Add a location field to the profile and a member map to the directory
This commit is contained in:
parent
c70a31a682
commit
eef6c19129
20 changed files with 720 additions and 24 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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:",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
148
src/app/components/InputLocation.svelte
Normal file
148
src/app/components/InputLocation.svelte
Normal 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}
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
24
src/app/components/ProfileLocation.svelte
Normal file
24
src/app/components/ProfileLocation.svelte
Normal 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}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
106
src/app/components/SpaceMemberMap.svelte
Normal file
106
src/app/components/SpaceMemberMap.svelte
Normal 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
89
src/app/location.ts
Normal 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
|
||||||
|
|
@ -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
69
src/lib/geohash.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue