Add a location field to the profile and a member map to the directory

This commit is contained in:
Coracle-Bot 2026-09-24 19:58:16 +00:00
parent c70a31a682
commit eef6c19129
20 changed files with 720 additions and 24 deletions

View file

@ -1558,6 +1558,21 @@ Acceptance:
- A member given "List banned members" finds "Banned Members" in the directory - A member given "List banned members" finds "Banned Members" in the directory
menu they had no menu in before, and still no "Admins". menu they had no menu in before, and still no "Admins".
### US-131 — Say where you are and find members on a map
As a member, I want to give a town, state or country on my profile and see where
the rest of a space is, so that I can find the people near me.
Acceptance:
- The location field on the profile form searches places by name, leaves out
towns of under a thousand people, and publishes what was picked to kind 0.
- Choosing a town publishes a geohash with it; choosing a state or a country
publishes the name alone, so nothing finer than the answer given goes out.
- The directory offers a map whenever a member has a geohash, pins each of them
with their avatar, and opens a pin's profile when it is clicked.
- The directory's search narrows the map as it narrows the list.
### US-098 — Browse and create hosted spaces ### US-098 — Browse and create hosted spaces
As a space owner, I want to see the spaces I host and spin up new ones, so that As a space owner, I want to see the spaces I host and spin up new ones, so that

View file

@ -88,12 +88,14 @@ export {
getHosting, getHosting,
mockBlossom, mockBlossom,
mockDufflepud, mockDufflepud,
mockGeocoding,
mockLivekit, mockLivekit,
mockOpenRouterSpeech, mockOpenRouterSpeech,
mockOpenRouterTranscription, mockOpenRouterTranscription,
} from "./net/http" } from "./net/http"
export type { export type {
DufflepudFixtures, DufflepudFixtures,
GeocodedPlace,
HostingFixtures, HostingFixtures,
HostingHandle, HostingHandle,
HostingRecord, HostingRecord,

View file

@ -19,6 +19,9 @@ const PLAUSIBLE_ORIGIN = "https://plausible.coracle.social"
// Transcription and speech both go here, against whichever key the user has saved. // Transcription and speech both go here, against whichever key the user has saved.
const OPENROUTER_ORIGIN = "https://openrouter.ai" const OPENROUTER_ORIGIN = "https://openrouter.ai"
// The place search behind the profile's location field, in src/app/location.ts.
const GEOCODING_ORIGIN = "https://geocoding-api.open-meteo.com"
// .test resolves nowhere and the block-all aborts the navigation, so a spec sees the redirect. // .test resolves nowhere and the block-all aborts the navigation, so a spec sees the redirect.
const CHECKOUT_ORIGIN = "https://checkout.test" const CHECKOUT_ORIGIN = "https://checkout.test"
@ -615,6 +618,21 @@ export const mockLivekit = (context: BrowserContext, {serverUrl, token}: Livekit
}, },
) )
// One row of Open-Meteo's answer. `feature_code` is PCLI for a country and PPL* for a town.
export type GeocodedPlace = {
name: string
admin1?: string
country?: string
feature_code: string
population?: number
latitude: number
longitude: number
}
/** The place search, answering every term with the same places. */
export const mockGeocoding = (context: BrowserContext, results: GeocodedPlace[]) =>
context.route(`${GEOCODING_ORIGIN}/**`, route => route.fulfill({json: {results}}))
/** Serves a png for anything loaded as an image, so a fixture's avatar and blob urls are never fetched. */ /** Serves a png for anything loaded as an image, so a fixture's avatar and blob urls are never fetched. */
export const mockImages = (context: BrowserContext) => export const mockImages = (context: BrowserContext) =>
context.route( context.route(

View file

@ -2,7 +2,7 @@ import {createHash} from "node:crypto"
import type {Locator, Page} from "@playwright/test" import type {Locator, Page} from "@playwright/test"
import {npubEncode} from "nostr-tools/nip19" import {npubEncode} from "nostr-tools/nip19"
import {HOUR, MINUTE, MONTH, uniq} from "@welshman/lib" import {HOUR, MINUTE, MONTH, uniq} from "@welshman/lib"
import {NOTE, STATUS, makeEvent} from "@welshman/util" import {NOTE, PROFILE, STATUS, makeEvent} from "@welshman/util"
import type {SignedEvent} from "@welshman/util" import type {SignedEvent} from "@welshman/util"
import {FollowList, Note, PinList, Profile} from "@welshman/domain" import {FollowList, Note, PinList, Profile} from "@welshman/domain"
import { import {
@ -11,7 +11,9 @@ import {
WEBP, WEBP,
expect, expect,
getPublished, getPublished,
getPublishedEvents,
mockBlossom, mockBlossom,
mockGeocoding,
noteEditor, noteEditor,
profilePath, profilePath,
spacePath, spacePath,
@ -621,3 +623,128 @@ test("US-082 mute an account", async ({seed, as}) => {
await expect(pageContent(page).getByText("Salt on the rigging this morning")).toBeVisible() await expect(pageContent(page).getByText("Salt on the rigging this morning")).toBeVisible()
await expect(pageContent(page).getByText("The gulls have taken the mainsail")).toBeVisible() await expect(pageContent(page).getByText("The gulls have taken the mainsail")).toBeVisible()
}) })
test("US-131 say where you are and find members on a map", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anders"})
// Bob shares a town already, so the directory has a pin before alice adds hers.
space.event(user.bob, () =>
space
.kind(Profile)
.writer()
.update({name: "Bob Barnacle", location: "Wellington, New Zealand"})
.addTags(["g", "rbsm1"])
.renderTemplate(),
)
space.relayList(user.alice)
space.relayList(user.bob)
})
const {url} = scenario.space("space")
const page = await as(users.alice, profilePath(users.alice.pubkey))
await mockGeocoding(page.context(), [
{
name: "Portland",
admin1: "Oregon",
country: "United States",
feature_code: "PPLA2",
population: 652503,
latitude: 45.52345,
longitude: -122.67621,
},
{
name: "Portlaw",
admin1: "Munster",
country: "Ireland",
feature_code: "PPL",
population: 900,
latitude: 52.28833,
longitude: -7.32056,
},
])
const openEditForm = async () => {
await page.getByRole("button", {name: "Edit profile"}).click()
return topDialog(page)
}
const form = await openEditForm()
await form.getByPlaceholder("Search for a town").fill("portl")
// A town of under a thousand people is not somewhere to be from.
await expect(form.getByRole("button", {name: "Portlaw, Munster, Ireland"})).toHaveCount(0)
await form.getByRole("button", {name: "Portland, Oregon, United States"}).click()
await form.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Your profile has been updated!")
await expect(pageContent(page).getByText("Portland, Oregon, United States")).toBeVisible()
const published = getPublishedEvents(page.context(), PROFILE)
expect(JSON.parse(published[0].content).location).toBe("Portland, Oregon, United States")
expect(published[0].tags).toContainEqual(["g", "c20fb"])
await page.goto(`${spacePath(url)}/directory`)
const showMap = page.getByRole("button", {name: "Map"})
await expect(showMap).toBeVisible()
await showMap.click()
const pins = page.locator(".leaflet-marker-icon")
await expect(pins).toHaveCount(2)
await pins.and(page.locator('[title="Bob Barnacle"]')).click()
const profile = page.getByRole("dialog", {name: "Profile details"})
await expect(profile.getByText("Bob Barnacle")).toBeVisible()
await expect(profile.getByText("Wellington, New Zealand")).toBeVisible()
await profile.getByRole("button", {name: "Close dialog"}).click()
// Searching narrows the map the same way it narrows the list.
await page.getByPlaceholder("Search people or roles...").fill("Anders")
await expect(pins).toHaveCount(1)
await page.getByRole("button", {name: "List"}).click()
await expect(pins).toHaveCount(0)
// A country is not a pin, so choosing one takes alice off the map.
await page.goto(profilePath(users.alice.pubkey))
const secondForm = await openEditForm()
await expect(secondForm.getByText("Portland, Oregon, United States")).toBeVisible()
await secondForm.getByRole("button", {name: "Clear location"}).click()
await secondForm.getByPlaceholder("Search for a town").fill("portl")
await secondForm.getByRole("button", {name: "Portland, Oregon, United States"}).click()
await secondForm.getByLabel("How much of your location to show").selectOption({label: "Country"})
await expect(secondForm.getByText("United States", {exact: true})).toBeVisible()
await secondForm.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Your profile has been updated!")
const republished = getPublishedEvents(page.context(), PROFILE)
expect(JSON.parse(republished[1].content).location).toBe("United States")
expect(republished[1].tags.map(tag => tag[0])).not.toContain("g")
})

View file

@ -133,12 +133,12 @@ createCapacitorElectronApp({
csp: { csp: {
// SvelteKit's meta CSP owns scripts; the platform default would block its hashed bootstrap. // SvelteKit's meta CSP owns scripts; the platform default would block its hashed bootstrap.
policy: "base-uri 'self'; object-src 'none'", policy: "base-uri 'self'; object-src 'none'",
// Capawesome 0.1.1's dev policy, plus Flotilla's analytics origin and blob workers. // Capawesome 0.1.1's dev policy, plus Flotilla's analytics origin, blob workers and map tiles.
devPolicy: [ devPolicy: [
"default-src 'self'", "default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.coracle.social", "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.coracle.social",
"style-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:", "img-src 'self' data: blob: https://*.basemaps.cartocdn.com",
"font-src 'self' data:", "font-src 'self' data:",
"media-src 'self' blob:", "media-src 'self' blob:",
"connect-src 'self' https: wss: ws: http:", "connect-src 'self' https: wss: ws: http:",

View file

@ -93,6 +93,7 @@
"@tiptap/core": "^2.27.3", "@tiptap/core": "^2.27.3",
"@tiptap/pm": "^2.27.3", "@tiptap/pm": "^2.27.3",
"@tiptap/suggestion": "^2.27.3", "@tiptap/suggestion": "^2.27.3",
"@types/leaflet": "^1.9.22",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2", "@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
@ -118,6 +119,7 @@
"hono": "^4.12.23", "hono": "^4.12.23",
"husky": "^9.1.7", "husky": "^9.1.7",
"idb": "^8.0.3", "idb": "^8.0.3",
"leaflet": "^1.9.4",
"livekit-client": "^2.17.2", "livekit-client": "^2.17.2",
"marked": "^18.0.9", "marked": "^18.0.9",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main", "nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",

Binary file not shown.

View file

@ -0,0 +1,148 @@
<script lang="ts">
import {debounce} from "throttle-debounce"
import MapPoint from "@assets/icons/map-point.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import {
displayPlace,
granularityLabels,
placeGeohash,
placeGranularities,
searchPlaces,
} from "@app/location"
import type {Granularity, Place} from "@app/location"
type Props = {
location?: string
geohash?: string
}
let {location = $bindable(), geohash = $bindable()}: Props = $props()
const fullName = (candidate: Place) => displayPlace(candidate, placeGranularities(candidate)[0])
const apply = (picked: Place) => {
location = displayPlace(picked, granularity)
geohash = placeGeohash(picked, granularity)
}
const search = debounce(400, async (value: string) => {
try {
const places = await searchPlaces(value)
if (value === term.trim()) {
results = places
failed = false
}
} catch (error) {
console.error(error)
failed = true
} finally {
loading = false
}
})
const onInput = () => {
const value = term.trim()
if (value.length > 1) {
loading = true
failed = false
search(value)
} else {
results = []
loading = false
}
}
const pick = (picked: Place) => {
place = picked
granularity = placeGranularities(picked)[0]
term = ""
results = []
apply(picked)
}
const changeGranularity = () => {
if (place) {
apply(place)
}
}
const clear = () => {
place = undefined
location = undefined
geohash = undefined
}
let term = $state("")
let results: Place[] = $state([])
let loading = $state(false)
let failed = $state(false)
let place: Place | undefined = $state()
let granularity: Granularity = $state("city")
let list: HTMLElement | undefined = $state()
// The field sits deep enough in the form that results can open below the fold.
$effect(() => {
if (results.length > 0) {
list?.scrollIntoView({block: "nearest"})
}
})
</script>
{#if location}
<div class="flex flex-col gap-2">
<div class="input flex w-full items-center gap-2">
<Icon icon={MapPoint} />
<span class="min-w-0 grow truncate">{location}</span>
<Button
class="button button-ghost button-xs button-square"
aria-label="Clear location"
onclick={clear}>
<Icon size={4} icon={CloseCircle} />
</Button>
</div>
{#if place}
<select
class="select input"
aria-label="How much of your location to show"
bind:value={granularity}
onchange={changeGranularity}>
{#each placeGranularities(place) as option (option)}
<option value={option}>{granularityLabels[option]}</option>
{/each}
</select>
{/if}
</div>
{:else}
<div class="flex flex-col gap-2">
<label class="input flex w-full items-center gap-2">
<Icon icon={Magnifier} />
<input
bind:value={term}
oninput={onInput}
class="min-w-0 grow"
type="text"
placeholder="Search for a town, city, or country..." />
<Spinner {loading} />
</label>
{#if failed}
<p class="text-sm text-error">Location search is unavailable right now.</p>
{:else if results.length > 0}
<ul bind:this={list} class="menu bg-surface max-h-60 gap-1 overflow-auto rounded-2xl p-2">
{#each results as result (result.latitude + "," + result.longitude)}
<li>
<Button class="w-full text-left" onclick={() => pick(result)}>
<Icon size={4} icon={MapPoint} />
{fullName(result)}
</Button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}

View file

@ -18,6 +18,7 @@
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import ProfileAbout from "@app/components/ProfileAbout.svelte" import ProfileAbout from "@app/components/ProfileAbout.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte"
import ProfileLocation from "@app/components/ProfileLocation.svelte"
import ProfileMenu from "@app/components/ProfileMenu.svelte" import ProfileMenu from "@app/components/ProfileMenu.svelte"
import ProfilePinnedNote from "@app/components/ProfilePinnedNote.svelte" import ProfilePinnedNote from "@app/components/ProfilePinnedNote.svelte"
import ProfileStatus from "@app/components/ProfileStatus.svelte" import ProfileStatus from "@app/components/ProfileStatus.svelte"
@ -141,6 +142,7 @@
<MenuButton component={ProfileMenu} componentProps={{pubkey, url, customActions}} /> <MenuButton component={ProfileMenu} componentProps={{pubkey, url, customActions}} />
</div> </div>
<ProfileStatus {pubkey} {url} /> <ProfileStatus {pubkey} {url} />
<ProfileLocation {pubkey} {url} />
<ProfileAbout {pubkey} {url} /> <ProfileAbout {pubkey} {url} />
<ProfileBadges {pubkey} {url} /> <ProfileBadges {pubkey} {url} />
<ProfilePinnedNote {pubkey} {url} /> <ProfilePinnedNote {pubkey} {url} />

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import {spec} from "@welshman/lib"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import {errorMessage} from "@lib/util" import {errorMessage} from "@lib/util"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@ -9,16 +10,26 @@
import {clearModals} from "@app/modal" import {clearModals} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {profiles, user} from "@app/core" import {profiles, user} from "@app/core"
import {getProfileGeohash} from "@app/location"
const initialValues = {profile: {...$profiles.get($user.pubkey)?.values}} const existingProfile = $profiles.get($user.pubkey)
const initialValues = {
profile: {...existingProfile?.values},
geohash: getProfileGeohash(existingProfile),
}
const back = () => history.back() const back = () => history.back()
const onsubmit = async ({profile}: Values) => { const onsubmit = async ({profile, geohash}: Values) => {
loading = true loading = true
try { try {
const command = await $profiles.update(writer => writer.update(profile)) const command = await $profiles.update(writer => {
writer.update(profile).dropTags(spec(["g"]))
return geohash ? writer.addTags(["g", geohash]) : writer
})
const error = (await command.publish().settled()).getError() const error = (await command.publish().settled()).getError()
if (error) { if (error) {

View file

@ -5,6 +5,7 @@
picture?: string picture?: string
banner?: string banner?: string
website?: string website?: string
location?: string
nip05?: string nip05?: string
lud06?: string lud06?: string
lud16?: string lud16?: string
@ -12,6 +13,7 @@
export type Values = { export type Values = {
profile: ProfileValues profile: ProfileValues
geohash?: string
} }
</script> </script>
@ -27,6 +29,7 @@
import Modal from "@lib/components/Modal.svelte" import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import InputLocation from "@app/components/InputLocation.svelte"
import InputProfilePicture from "@app/components/InputProfilePicture.svelte" import InputProfilePicture from "@app/components/InputProfilePicture.svelte"
import InfoHandle from "@app/components/InfoHandle.svelte" import InfoHandle from "@app/components/InfoHandle.svelte"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@ -100,6 +103,17 @@
Give a brief introduction to why you're here. Give a brief introduction to why you're here.
{/snippet} {/snippet}
</Field> </Field>
<Field>
{#snippet label()}
<p>Location</p>
{/snippet}
{#snippet input()}
<InputLocation bind:location={values.profile.location} bind:geohash={values.geohash} />
{/snippet}
{#snippet info()}
A town, state or country. Only a town puts you on a space's member map.
{/snippet}
</Field>
{#if !isSignup} {#if !isSignup}
<Field> <Field>
{#snippet label()} {#snippet label()}

View file

@ -0,0 +1,24 @@
<script lang="ts">
import {removeUndefined} from "@welshman/lib"
import MapPoint from "@assets/icons/map-point.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import {profiles} from "@app/core"
import {getProfileLocation} from "@app/location"
type Props = {
pubkey: string
url?: string
}
const {pubkey, url}: Props = $props()
const profile = $profiles.one(pubkey, removeUndefined([url]))
const location = $derived(getProfileLocation($profile))
</script>
{#if location}
<p class="flex w-fit items-center gap-2 text-sm opacity-75">
<Icon icon={MapPoint} size={4} />
{location}
</p>
{/if}

View file

@ -16,6 +16,7 @@
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import ProfileAbout from "@app/components/ProfileAbout.svelte" import ProfileAbout from "@app/components/ProfileAbout.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileLocation from "@app/components/ProfileLocation.svelte"
import ProfileTrust from "@app/components/ProfileTrust.svelte" import ProfileTrust from "@app/components/ProfileTrust.svelte"
import ProfileSharedSpaces from "@app/components/ProfileSharedSpaces.svelte" import ProfileSharedSpaces from "@app/components/ProfileSharedSpaces.svelte"
import ProfileStatus from "@app/components/ProfileStatus.svelte" import ProfileStatus from "@app/components/ProfileStatus.svelte"
@ -202,6 +203,8 @@
<ProfileStatus pubkey={target} /> <ProfileStatus pubkey={target} />
<ProfileLocation pubkey={target} />
{#if website} {#if website}
<Link <Link
external external

View file

@ -34,6 +34,7 @@
setKey("signup.email", "") setKey("signup.email", "")
setKey("signup.secret", makeSecret()) setKey("signup.secret", makeSecret())
setKey("signup.profile", {}) setKey("signup.profile", {})
setKey("signup.geohash", undefined)
setKey("signup.clientOptions", undefined) setKey("signup.clientOptions", undefined)
const hasPomade = POMADE_SIGNERS.length >= 3 const hasPomade = POMADE_SIGNERS.length >= 3
@ -44,6 +45,13 @@
// Join default spaces so the relay is warmed up before we publish below // Join default spaces so the relay is warmed up before we publish below
await Promise.all(DEFAULT_SPACES.map(url => attemptRelayAccess(url))) await Promise.all(DEFAULT_SPACES.map(url => attemptRelayAccess(url)))
const geohash = getKey<string>("signup.geohash")
const profileWriter = $domain.writer(Profile).update(getKey<ProfileValues>("signup.profile")!)
if (geohash) {
profileWriter.addTags(["g", geohash])
}
// Add default outbox/inbox/messaging relays, profile, spaces // Add default outbox/inbox/messaging relays, profile, spaces
const publications = await Promise.all([ const publications = await Promise.all([
$app.use(Publisher).publish({ $app.use(Publisher).publish({
@ -55,10 +63,7 @@
relays: [...DEFAULT_RELAYS, ...DEFAULT_SPACES], relays: [...DEFAULT_RELAYS, ...DEFAULT_SPACES],
}), }),
$app.use(Publisher).publish({ $app.use(Publisher).publish({
event: await $domain event: await profileWriter.renderTemplate(),
.writer(Profile)
.update(getKey<ProfileValues>("signup.profile")!)
.renderTemplate(),
relays: [...DEFAULT_RELAYS, ...DEFAULT_SPACES], relays: [...DEFAULT_RELAYS, ...DEFAULT_SPACES],
}), }),
$roomLists.setRelays(DEFAULT_SPACES).then(publish), $roomLists.setRelays(DEFAULT_SPACES).then(publish),

View file

@ -16,14 +16,16 @@
const {next, step, totalSteps}: Props = $props() const {next, step, totalSteps}: Props = $props()
const profile = getKey<Values["profile"]>("signup.profile")! const initialValues = {
profile: getKey<Values["profile"]>("signup.profile")!,
const initialValues = {profile} geohash: getKey<Values["geohash"]>("signup.geohash"),
}
const back = () => history.back() const back = () => history.back()
const onsubmit = ({profile}: Values) => { const onsubmit = ({profile, geohash}: Values) => {
setKey("signup.profile", profile) setKey("signup.profile", profile)
setKey("signup.geohash", geohash)
next() next()
} }
</script> </script>

View file

@ -0,0 +1,106 @@
<script lang="ts">
import "leaflet/dist/leaflet.css"
import {mount, onMount, unmount} from "svelte"
import type {Map as LeafletMap, Marker} from "leaflet"
import type {Maybe} from "@welshman/lib"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {pushModal} from "@app/modal"
import {activeTheme} from "@app/theme"
import type {MemberPin} from "@app/social"
type Props = {
url: string
pins: MemberPin[]
}
const {url, pins}: Props = $props()
const tileUrl = $derived(
`https://{s}.basemaps.cartocdn.com/${$activeTheme === "dark" ? "dark" : "light"}_all/{z}/{x}/{y}{r}.png`,
)
const openProfile = (pubkey: string) => pushModal(ProfileDetail, {pubkey, url})
let element: HTMLElement
let leaflet: Maybe<typeof import("leaflet")> = $state()
let map: Maybe<LeafletMap> = $state()
$effect(() => {
if (leaflet) {
const created = leaflet
.map(element, {worldCopyJump: true, zoomControl: false})
.setView([25, 0], 2)
leaflet.control.zoom({position: "bottomright"}).addTo(created)
map = created
return () => created.remove()
}
})
$effect(() => {
if (leaflet && map) {
const tiles = leaflet
.tileLayer(tileUrl, {
maxZoom: 12,
attribution:
'<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, <a href="https://carto.com/attributions">CARTO</a>',
})
.addTo(map)
return () => tiles.remove()
}
})
$effect(() => {
const markers: Marker[] = []
const avatars: Record<string, unknown>[] = []
if (leaflet && map) {
for (const pin of pins) {
const target = document.createElement("div")
avatars.push(
mount(ProfileCircle, {
target,
props: {
pubkey: pin.pubkey,
url,
size: 10,
class: "bg-surface ring-2 ring-surface shadow-lg",
},
}),
)
markers.push(
leaflet
.marker([pin.latitude, pin.longitude], {
title: pin.display,
// An empty className replaces the white box leaflet gives a div icon.
icon: leaflet.divIcon({html: target, className: "", iconSize: [40, 40]}),
})
.on("click", () => openProfile(pin.pubkey))
.addTo(map),
)
}
if (markers.length > 0) {
map.fitBounds(leaflet.featureGroup(markers).getBounds(), {maxZoom: 8, padding: [40, 40]})
}
}
return () => {
markers.forEach(marker => marker.remove())
avatars.forEach(avatar => unmount(avatar))
}
})
onMount(async () => {
leaflet = await import("leaflet")
})
</script>
<!-- Leaflet's panes sit at z-index 400, which covers a modal without a stacking context here. -->
<div bind:this={element} class="isolate min-h-96 flex-1 overflow-hidden rounded-2xl"></div>

89
src/app/location.ts Normal file
View file

@ -0,0 +1,89 @@
import {removeUndefined} from "@welshman/lib"
import {tagSpec, tagValue} from "@welshman/util"
import type {ProfileReader} from "@welshman/domain"
import {encodeGeohash} from "@lib/geohash"
export type Place = {
city?: string
region?: string
country: string
latitude: number
longitude: number
}
export type Granularity = "city" | "region" | "country"
export const granularityLabels: Record<Granularity, string> = {
city: "City",
region: "State or region",
country: "Country",
}
type GeocodingResult = {
name: string
admin1?: string
country?: string
feature_code: string
population?: number
latitude: number
longitude: number
}
// Open-Meteo indexes GeoNames' populated places and countries, never a state on its own.
export const searchPlaces = async (term: string) => {
const url = new URL("https://geocoding-api.open-meteo.com/v1/search")
url.searchParams.set("name", term)
url.searchParams.set("count", "20")
url.searchParams.set("language", "en")
url.searchParams.set("format", "json")
const response = await fetch(url)
const {results = []}: {results?: GeocodingResult[]} = await response.json()
const places: Place[] = []
for (const result of results) {
const {name, admin1, country, feature_code, population, latitude, longitude} = result
if (feature_code === "PCLI") {
places.push({country: name, latitude, longitude})
} else if (country && population && population >= 1000) {
places.push({city: name, region: admin1, country, latitude, longitude})
}
}
return places
}
export const placeGranularities = (place: Place) => {
const granularities: Granularity[] = []
if (place.city) {
granularities.push("city")
}
if (place.region) {
granularities.push("region")
}
granularities.push("country")
return granularities
}
export const displayPlace = (place: Place, granularity: Granularity) =>
removeUndefined([
granularity === "city" ? place.city : undefined,
granularity === "country" ? undefined : place.region,
place.country,
]).join(", ")
// A pin is finer than a state or a country asked to be, so only a city publishes one.
export const placeGeohash = (place: Place, granularity: Granularity) =>
granularity === "city" ? encodeGeohash(place.latitude, place.longitude, 5) : undefined
export const getProfileLocation = (profile?: ProfileReader): string | undefined =>
profile?.values.location
export const getProfileGeohash = (profile?: ProfileReader) =>
profile ? tagValue(tagSpec("g"), profile.tags()) : undefined

View file

@ -32,7 +32,9 @@ import {withGetter} from "@welshman/store"
import {displayPubkey, getCommentTagValues, getReplyTagValues, getReplyTags} from "@welshman/domain" import {displayPubkey, getCommentTagValues, getReplyTagValues, getReplyTags} from "@welshman/domain"
import {Events, FollowLists, MuteLists, Network, Profiles, Router} from "@welshman/app" import {Events, FollowLists, MuteLists, Network, Profiles, Router} from "@welshman/app"
import type {IApp} from "@welshman/app" import type {IApp} from "@welshman/app"
import {decodeGeohash} from "@lib/geohash"
import {deriveUserItem, fromApp, profiles, user, usePlugin} from "@app/core" import {deriveUserItem, fromApp, profiles, user, usePlugin} from "@app/core"
import {getProfileGeohash} from "@app/location"
import {DEFAULT_PUBKEYS} from "@app/env" import {DEFAULT_PUBKEYS} from "@app/env"
// People // People
@ -62,6 +64,34 @@ export const deriveDisplaysByPubkey = (pubkeys: string[], url?: string) => {
) )
} }
export type MemberPin = {
pubkey: string
display: string
latitude: number
longitude: number
}
export const deriveMemberPins = (pubkeys: string[], url?: string) => {
const relays = removeUndefined([url])
for (const pubkey of pubkeys) {
profiles.get().load(pubkey, relays).catch(noop)
}
return derived(profileIndex, $index =>
removeUndefined(
pubkeys.map(pubkey => {
const profile = $index.get(pubkey)
const coordinates = decodeGeohash(getProfileGeohash(profile))
return coordinates
? {pubkey, display: profile?.display() ?? displayPubkey(pubkey), ...coordinates}
: undefined
}),
),
)
}
export const bootstrapPubkeys = derived(deriveUserItem(FollowLists), $userFollowList => { export const bootstrapPubkeys = derived(deriveUserItem(FollowLists), $userFollowList => {
const appPubkeys = DEFAULT_PUBKEYS.split(",") const appPubkeys = DEFAULT_PUBKEYS.split(",")
const userPubkeys = shuffle($userFollowList?.pubkeys() ?? []) const userPubkeys = shuffle($userFollowList?.pubkeys() ?? [])

69
src/lib/geohash.ts Normal file
View file

@ -0,0 +1,69 @@
export const encodeGeohash = (latitude: number, longitude: number, precision: number) => {
const latitudes = [-90, 90]
const longitudes = [-180, 180]
let hash = ""
let bits = 0
let index = 0
let even = true
while (hash.length < precision) {
const bounds = even ? longitudes : latitudes
const middle = (bounds[0] + bounds[1]) / 2
if ((even ? longitude : latitude) > middle) {
index = index * 2 + 1
bounds[0] = middle
} else {
index = index * 2
bounds[1] = middle
}
even = !even
bits += 1
if (bits === 5) {
hash += "0123456789bcdefghjkmnpqrstuvwxyz"[index]
bits = 0
index = 0
}
}
return hash
}
export const decodeGeohash = (hash?: string) => {
const indexes = (hash ?? "")
.toLowerCase()
.split("")
.map(character => "0123456789bcdefghjkmnpqrstuvwxyz".indexOf(character))
if (indexes.length === 0 || indexes.includes(-1)) {
return undefined
}
const latitudes = [-90, 90]
const longitudes = [-180, 180]
let even = true
for (const index of indexes) {
for (const mask of [16, 8, 4, 2, 1]) {
const bounds = even ? longitudes : latitudes
const middle = (bounds[0] + bounds[1]) / 2
if (index & mask) {
bounds[0] = middle
} else {
bounds[1] = middle
}
even = !even
}
}
return {
latitude: (latitudes[0] + latitudes[1]) / 2,
longitude: (longitudes[0] + longitudes[1]) / 2,
}
}

View file

@ -4,18 +4,20 @@
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl" import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import MapIcon from "@assets/icons/map.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte" import MenuButton from "@lib/components/MenuButton.svelte"
import PageContent from "@lib/components/PageContent.svelte" import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import SpaceMember from "@app/components/SpaceMember.svelte" import SpaceMember from "@app/components/SpaceMember.svelte"
import SpaceMemberMap from "@app/components/SpaceMemberMap.svelte"
import SpaceInvite from "@app/components/SpaceInvite.svelte" import SpaceInvite from "@app/components/SpaceInvite.svelte"
import SpaceDirectoryMenu from "@app/components/SpaceDirectoryMenu.svelte" import SpaceDirectoryMenu from "@app/components/SpaceDirectoryMenu.svelte"
import {deriveSpacePermissions} from "@app/management" import {deriveSpacePermissions} from "@app/management"
import {deriveSpaceMemberRoles} from "@app/roles" import {deriveSpaceMemberRoles} from "@app/roles"
import {relayMemberLists, relayRoles} from "@app/core" import {relayMemberLists, relayRoles} from "@app/core"
import {deriveDisplaysByPubkey} from "@app/social" import {deriveDisplaysByPubkey, deriveMemberPins} from "@app/social"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import type {PageProps} from "./$types" import type {PageProps} from "./$types"
@ -54,6 +56,13 @@
), ),
) )
const pins = $derived(
deriveMemberPins(
$memberList.map(m => m.pubkey),
url,
),
)
const matchesTerm = (pubkey: string, t: string) => const matchesTerm = (pubkey: string, t: string) =>
($displays.get(pubkey) ?? "").toLowerCase().includes(t) || pubkey.toLowerCase().includes(t) ($displays.get(pubkey) ?? "").toLowerCase().includes(t) || pubkey.toLowerCase().includes(t)
@ -71,6 +80,16 @@
roleList.some(role => (role.label() ?? "").toLowerCase().includes(t)), roleList.some(role => (role.label() ?? "").toLowerCase().includes(t)),
) )
}) })
const visiblePins = $derived.by(() => {
const visible = new Set(visibleMembers.map(m => m.pubkey))
return $pins.filter(pin => visible.has(pin.pubkey))
})
const toggleMap = () => (showMap = !showMap)
let showMap = $state(false)
</script> </script>
<SpaceBar> <SpaceBar>
@ -97,15 +116,25 @@
</SpaceBar> </SpaceBar>
<PageContent class="@container flex flex-col gap-4 p-4"> <PageContent class="@container flex flex-col gap-4 p-4">
<label class="input input-group flex w-full items-center gap-2 card"> <div class="flex items-center gap-2">
<Icon size={4} icon={Magnifier} /> <label class="input input-group flex w-full items-center gap-2 card">
<input <Icon size={4} icon={Magnifier} />
bind:value={term} <input
class="min-w-0 grow" bind:value={term}
type="text" class="min-w-0 grow"
placeholder="Search people or roles..." /> type="text"
</label> placeholder="Search people or roles..." />
{#if visibleMembers.length === 0} </label>
{#if $pins.length > 0}
<Button class="button button-neutral shrink-0" onclick={toggleMap}>
<Icon size={4} icon={showMap ? UsersGroup : MapIcon} />
{showMap ? "List" : "Map"}
</Button>
{/if}
</div>
{#if showMap}
<SpaceMemberMap {url} pins={visiblePins} />
{:else if visibleMembers.length === 0}
<p class="flex flex-col items-center py-20 text-center">No members found.</p> <p class="flex flex-col items-center py-20 text-center">No members found.</p>
{:else} {:else}
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-2 @5xl:grid-cols-3"> <div class="grid grid-cols-1 gap-4 @3xl:grid-cols-2 @5xl:grid-cols-3">