diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md
index 5f15a169..a6ed3766 100644
--- a/e2e/USER_STORIES.md
+++ b/e2e/USER_STORIES.md
@@ -1558,6 +1558,21 @@ Acceptance:
- A member given "List banned members" finds "Banned Members" in the directory
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
As a space owner, I want to see the spaces I host and spin up new ones, so that
diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts
index ccf86465..e78ddb2a 100644
--- a/e2e/harness/index.ts
+++ b/e2e/harness/index.ts
@@ -88,12 +88,14 @@ export {
getHosting,
mockBlossom,
mockDufflepud,
+ mockGeocoding,
mockLivekit,
mockOpenRouterSpeech,
mockOpenRouterTranscription,
} from "./net/http"
export type {
DufflepudFixtures,
+ GeocodedPlace,
HostingFixtures,
HostingHandle,
HostingRecord,
diff --git a/e2e/harness/net/http.ts b/e2e/harness/net/http.ts
index 9697fd7c..89d3e07c 100644
--- a/e2e/harness/net/http.ts
+++ b/e2e/harness/net/http.ts
@@ -19,6 +19,9 @@ const PLAUSIBLE_ORIGIN = "https://plausible.coracle.social"
// Transcription and speech both go here, against whichever key the user has saved.
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.
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. */
export const mockImages = (context: BrowserContext) =>
context.route(
diff --git a/e2e/specs/people.spec.ts b/e2e/specs/people.spec.ts
index f0db3244..faadf8dd 100644
--- a/e2e/specs/people.spec.ts
+++ b/e2e/specs/people.spec.ts
@@ -2,7 +2,7 @@ import {createHash} from "node:crypto"
import type {Locator, Page} from "@playwright/test"
import {npubEncode} from "nostr-tools/nip19"
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 {FollowList, Note, PinList, Profile} from "@welshman/domain"
import {
@@ -11,7 +11,9 @@ import {
WEBP,
expect,
getPublished,
+ getPublishedEvents,
mockBlossom,
+ mockGeocoding,
noteEditor,
profilePath,
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("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")
+})
diff --git a/electron/main.ts b/electron/main.ts
index 01f5d80b..56affb7d 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -133,12 +133,12 @@ createCapacitorElectronApp({
csp: {
// SvelteKit's meta CSP owns scripts; the platform default would block its hashed bootstrap.
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: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.coracle.social",
"style-src 'self' 'unsafe-inline'",
- "img-src 'self' data: blob:",
+ "img-src 'self' data: blob: https://*.basemaps.cartocdn.com",
"font-src 'self' data:",
"media-src 'self' blob:",
"connect-src 'self' https: wss: ws: http:",
diff --git a/package.json b/package.json
index b0ff6865..5d89d4d7 100644
--- a/package.json
+++ b/package.json
@@ -93,6 +93,7 @@
"@tiptap/core": "^2.27.3",
"@tiptap/pm": "^2.27.3",
"@tiptap/suggestion": "^2.27.3",
+ "@types/leaflet": "^1.9.22",
"@types/qrcode": "^1.5.6",
"@types/throttle-debounce": "^5.0.2",
"@vite-pwa/assets-generator": "^1.0.2",
@@ -118,6 +119,7 @@
"hono": "^4.12.23",
"husky": "^9.1.7",
"idb": "^8.0.3",
+ "leaflet": "^1.9.4",
"livekit-client": "^2.17.2",
"marked": "^18.0.9",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9b752494..025e2bdf 100644
Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ
diff --git a/src/app/components/InputLocation.svelte b/src/app/components/InputLocation.svelte
new file mode 100644
index 00000000..e0031520
--- /dev/null
+++ b/src/app/components/InputLocation.svelte
@@ -0,0 +1,148 @@
+
+
+{#if location}
+
+
+
+ {location}
+
+
+ {#if place}
+
+ {/if}
+
+{:else}
+
+
+ {#if failed}
+
Location search is unavailable right now.
+ {:else if results.length > 0}
+
+ {#each results as result (result.latitude + "," + result.longitude)}
+
+
+
+ {/each}
+
+ {/if}
+
+{/if}
diff --git a/src/app/components/ProfileDetail.svelte b/src/app/components/ProfileDetail.svelte
index 701b7318..117a6b2d 100644
--- a/src/app/components/ProfileDetail.svelte
+++ b/src/app/components/ProfileDetail.svelte
@@ -18,6 +18,7 @@
import Profile from "@app/components/Profile.svelte"
import ProfileAbout from "@app/components/ProfileAbout.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte"
+ import ProfileLocation from "@app/components/ProfileLocation.svelte"
import ProfileMenu from "@app/components/ProfileMenu.svelte"
import ProfilePinnedNote from "@app/components/ProfilePinnedNote.svelte"
import ProfileStatus from "@app/components/ProfileStatus.svelte"
@@ -141,6 +142,7 @@
+
diff --git a/src/app/components/ProfileEdit.svelte b/src/app/components/ProfileEdit.svelte
index 95921a26..17bfdd59 100644
--- a/src/app/components/ProfileEdit.svelte
+++ b/src/app/components/ProfileEdit.svelte
@@ -1,4 +1,5 @@
@@ -27,6 +29,7 @@
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
+ import InputLocation from "@app/components/InputLocation.svelte"
import InputProfilePicture from "@app/components/InputProfilePicture.svelte"
import InfoHandle from "@app/components/InfoHandle.svelte"
import {pushModal} from "@app/modal"
@@ -100,6 +103,17 @@
Give a brief introduction to why you're here.
{/snippet}
+
+ {#snippet label()}
+
Location
+ {/snippet}
+ {#snippet input()}
+
+ {/snippet}
+ {#snippet info()}
+ A town, state or country. Only a town puts you on a space's member map.
+ {/snippet}
+
{#if !isSignup}
{#snippet label()}
diff --git a/src/app/components/ProfileLocation.svelte b/src/app/components/ProfileLocation.svelte
new file mode 100644
index 00000000..01e9d7d9
--- /dev/null
+++ b/src/app/components/ProfileLocation.svelte
@@ -0,0 +1,24 @@
+
+
+{#if location}
+
+
+ {location}
+
+{/if}
diff --git a/src/app/components/ProfilePage.svelte b/src/app/components/ProfilePage.svelte
index ee89f5be..c19ed9fc 100644
--- a/src/app/components/ProfilePage.svelte
+++ b/src/app/components/ProfilePage.svelte
@@ -16,6 +16,7 @@
import Spinner from "@lib/components/Spinner.svelte"
import ProfileAbout from "@app/components/ProfileAbout.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
+ import ProfileLocation from "@app/components/ProfileLocation.svelte"
import ProfileTrust from "@app/components/ProfileTrust.svelte"
import ProfileSharedSpaces from "@app/components/ProfileSharedSpaces.svelte"
import ProfileStatus from "@app/components/ProfileStatus.svelte"
@@ -202,6 +203,8 @@
+
+
{#if website}
= 3
@@ -44,6 +45,13 @@
// Join default spaces so the relay is warmed up before we publish below
await Promise.all(DEFAULT_SPACES.map(url => attemptRelayAccess(url)))
+ const geohash = getKey("signup.geohash")
+ const profileWriter = $domain.writer(Profile).update(getKey("signup.profile")!)
+
+ if (geohash) {
+ profileWriter.addTags(["g", geohash])
+ }
+
// Add default outbox/inbox/messaging relays, profile, spaces
const publications = await Promise.all([
$app.use(Publisher).publish({
@@ -55,10 +63,7 @@
relays: [...DEFAULT_RELAYS, ...DEFAULT_SPACES],
}),
$app.use(Publisher).publish({
- event: await $domain
- .writer(Profile)
- .update(getKey("signup.profile")!)
- .renderTemplate(),
+ event: await profileWriter.renderTemplate(),
relays: [...DEFAULT_RELAYS, ...DEFAULT_SPACES],
}),
$roomLists.setRelays(DEFAULT_SPACES).then(publish),
diff --git a/src/app/components/SignUpProfile.svelte b/src/app/components/SignUpProfile.svelte
index 7a7bbb4b..9ca06312 100644
--- a/src/app/components/SignUpProfile.svelte
+++ b/src/app/components/SignUpProfile.svelte
@@ -16,14 +16,16 @@
const {next, step, totalSteps}: Props = $props()
- const profile = getKey("signup.profile")!
-
- const initialValues = {profile}
+ const initialValues = {
+ profile: getKey("signup.profile")!,
+ geohash: getKey("signup.geohash"),
+ }
const back = () => history.back()
- const onsubmit = ({profile}: Values) => {
+ const onsubmit = ({profile, geohash}: Values) => {
setKey("signup.profile", profile)
+ setKey("signup.geohash", geohash)
next()
}
diff --git a/src/app/components/SpaceMemberMap.svelte b/src/app/components/SpaceMemberMap.svelte
new file mode 100644
index 00000000..3414354d
--- /dev/null
+++ b/src/app/components/SpaceMemberMap.svelte
@@ -0,0 +1,106 @@
+
+
+
+
diff --git a/src/app/location.ts b/src/app/location.ts
new file mode 100644
index 00000000..9931a3d9
--- /dev/null
+++ b/src/app/location.ts
@@ -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 = {
+ 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
diff --git a/src/app/social.ts b/src/app/social.ts
index e86d15dc..9560a1ba 100644
--- a/src/app/social.ts
+++ b/src/app/social.ts
@@ -32,7 +32,9 @@ import {withGetter} from "@welshman/store"
import {displayPubkey, getCommentTagValues, getReplyTagValues, getReplyTags} from "@welshman/domain"
import {Events, FollowLists, MuteLists, Network, Profiles, Router} from "@welshman/app"
import type {IApp} from "@welshman/app"
+import {decodeGeohash} from "@lib/geohash"
import {deriveUserItem, fromApp, profiles, user, usePlugin} from "@app/core"
+import {getProfileGeohash} from "@app/location"
import {DEFAULT_PUBKEYS} from "@app/env"
// 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 => {
const appPubkeys = DEFAULT_PUBKEYS.split(",")
const userPubkeys = shuffle($userFollowList?.pubkeys() ?? [])
diff --git a/src/lib/geohash.ts b/src/lib/geohash.ts
new file mode 100644
index 00000000..9d329650
--- /dev/null
+++ b/src/lib/geohash.ts
@@ -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,
+ }
+}
diff --git a/src/routes/spaces/[relay]/directory/+page.svelte b/src/routes/spaces/[relay]/directory/+page.svelte
index 832c5156..524faab5 100644
--- a/src/routes/spaces/[relay]/directory/+page.svelte
+++ b/src/routes/spaces/[relay]/directory/+page.svelte
@@ -4,18 +4,20 @@
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.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 Button from "@lib/components/Button.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import SpaceMember from "@app/components/SpaceMember.svelte"
+ import SpaceMemberMap from "@app/components/SpaceMemberMap.svelte"
import SpaceInvite from "@app/components/SpaceInvite.svelte"
import SpaceDirectoryMenu from "@app/components/SpaceDirectoryMenu.svelte"
import {deriveSpacePermissions} from "@app/management"
import {deriveSpaceMemberRoles} from "@app/roles"
import {relayMemberLists, relayRoles} from "@app/core"
- import {deriveDisplaysByPubkey} from "@app/social"
+ import {deriveDisplaysByPubkey, deriveMemberPins} from "@app/social"
import {decodeRelay} from "@app/relays"
import {pushModal} from "@app/modal"
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) =>
($displays.get(pubkey) ?? "").toLowerCase().includes(t) || pubkey.toLowerCase().includes(t)
@@ -71,6 +80,16 @@
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)
@@ -97,15 +116,25 @@
-
- {#if visibleMembers.length === 0}
+
+
+ {#if $pins.length > 0}
+
+ {/if}
+
+ {#if showMap}
+
+ {:else if visibleMembers.length === 0}