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} + + {/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}

No members found.

{:else}