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
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

View file

@ -88,12 +88,14 @@ export {
getHosting,
mockBlossom,
mockDufflepud,
mockGeocoding,
mockLivekit,
mockOpenRouterSpeech,
mockOpenRouterTranscription,
} from "./net/http"
export type {
DufflepudFixtures,
GeocodedPlace,
HostingFixtures,
HostingHandle,
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.
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(

View file

@ -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")
})

View file

@ -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:",

View file

@ -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",

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 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 @@
<MenuButton component={ProfileMenu} componentProps={{pubkey, url, customActions}} />
</div>
<ProfileStatus {pubkey} {url} />
<ProfileLocation {pubkey} {url} />
<ProfileAbout {pubkey} {url} />
<ProfileBadges {pubkey} {url} />
<ProfilePinnedNote {pubkey} {url} />

View file

@ -1,4 +1,5 @@
<script lang="ts">
import {spec} from "@welshman/lib"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import {errorMessage} from "@lib/util"
import Icon from "@lib/components/Icon.svelte"
@ -9,16 +10,26 @@
import {clearModals} from "@app/modal"
import {pushToast} from "@app/toast"
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 onsubmit = async ({profile}: Values) => {
const onsubmit = async ({profile, geohash}: Values) => {
loading = true
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()
if (error) {

View file

@ -5,6 +5,7 @@
picture?: string
banner?: string
website?: string
location?: string
nip05?: string
lud06?: string
lud16?: string
@ -12,6 +13,7 @@
export type Values = {
profile: ProfileValues
geohash?: string
}
</script>
@ -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}
</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}
<Field>
{#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 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 @@
<ProfileStatus pubkey={target} />
<ProfileLocation pubkey={target} />
{#if website}
<Link
external

View file

@ -34,6 +34,7 @@
setKey("signup.email", "")
setKey("signup.secret", makeSecret())
setKey("signup.profile", {})
setKey("signup.geohash", undefined)
setKey("signup.clientOptions", undefined)
const hasPomade = POMADE_SIGNERS.length >= 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<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
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<ProfileValues>("signup.profile")!)
.renderTemplate(),
event: await profileWriter.renderTemplate(),
relays: [...DEFAULT_RELAYS, ...DEFAULT_SPACES],
}),
$roomLists.setRelays(DEFAULT_SPACES).then(publish),

View file

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

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 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)
</script>
<SpaceBar>
@ -97,15 +116,25 @@
</SpaceBar>
<PageContent class="@container flex flex-col gap-4 p-4">
<label class="input input-group flex w-full items-center gap-2 card">
<Icon size={4} icon={Magnifier} />
<input
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search people or roles..." />
</label>
{#if visibleMembers.length === 0}
<div class="flex items-center gap-2">
<label class="input input-group flex w-full items-center gap-2 card">
<Icon size={4} icon={Magnifier} />
<input
bind:value={term}
class="min-w-0 grow"
type="text"
placeholder="Search people or roles..." />
</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>
{:else}
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-2 @5xl:grid-cols-3">