Name the group-space, platform-flag and active-space rules once, and keep makeEventPath to in-app paths

This commit is contained in:
Jon Staab 2026-09-26 00:00:18 -07:00
parent 0e01055855
commit 602ac09293
18 changed files with 134 additions and 135 deletions

View file

@ -185,7 +185,7 @@ One web build runs in several shells:
Platform checks call Capacitor directly. There is no wrapper module: Platform checks call Capacitor directly. There is no wrapper module:
```ts ```ts
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios" // src/app/env.ts export const ENABLE_ZAPS = Capacitor.getPlatform() !== "ios" // src/app/env.ts
export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios" // src/app/hosting.ts export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios" // src/app/hosting.ts
if (!Capacitor.isPluginAvailable("Keyboard")) return noop // src/app/keyboard.ts if (!Capacitor.isPluginAvailable("Keyboard")) return noop // src/app/keyboard.ts

View file

@ -31,7 +31,8 @@ carries `["h", h]`; content with no `h` belongs to the whole space. `/spaces/[re
`deriveUserRooms(url)` in `src/app/rooms.ts` read it. "Joined" in the UI means "in this list", `deriveUserRooms(url)` in `src/app/rooms.ts` read it. "Joined" in the UI means "in this list",
which is separate from NIP-43 membership on the relay: `src/routes/spaces/[relay]/+layout.svelte` which is separate from NIP-43 membership on the relay: `src/routes/spaces/[relay]/+layout.svelte`
prompts `SpaceJoin` for any URL not in `userSpaceUrls`. A deployment with `VITE_PLATFORM_RELAYS` prompts `SpaceJoin` for any URL not in `userSpaceUrls`. A deployment with `VITE_PLATFORM_RELAYS`
uses `PLATFORM_RELAYS` in place of the list for sync and navigation. uses `PLATFORM_RELAYS` in place of the list for sync and navigation: `activeSpaceUrls` in
`src/app/rooms.ts` is that choice, read by background sync, notifications and editor suggestions.
### NIP-11 relay info ### NIP-11 relay info
@ -40,7 +41,7 @@ domain `Relay`. These fields drive protocol decisions:
| Read | Decides | | Read | Decides |
|---|---| |---|---|
| `hasNip(29)` | whether the space has rooms. Without it everything lives in the space chat: `makeSpaceEntryPath` (`src/app/routes.ts`), `shareEvent` (`src/app/share.ts`), room search, notification grouping, `SpaceMenuRooms` | | `hasNip(29)` | whether the space has rooms, read through `isGroupSpace(url)` / `deriveIsGroupSpace(url)` in `src/app/relays.ts`. Without it everything lives in the space chat (`makeSpaceChatPath`): `shareEvent` (`src/app/share.ts`), room search, notification grouping, `SpaceMenuNavItems`, `SpaceMenuRooms` |
| `hasNip(70)` | whether space content is marked protected (below) | | `hasNip(70)` | whether space content is marked protected (below) |
| `self` | the relay's own pubkey, the trust anchor for relay-signed state | | `self` | the relay's own pubkey, the trust anchor for relay-signed state |
| `pubkey` | the space's owner, who writes space-wide content that has no other author (`deriveUserIsSpaceOwner`) | | `pubkey` | the space's owner, who writes space-wide content that has no other author (`deriveUserIsSpaceOwner`) |
@ -104,9 +105,9 @@ user holds a method for — reports under `banevent`, join requests under `allow
- `deriveUserRoomMembershipStatus`: an admin is always `Granted`. - `deriveUserRoomMembershipStatus`: an admin is always `Granted`.
- `addRoomMembers`: allows each non-member at the relay (NIP-86 `allowPubkey`) before publishing - `addRoomMembers`: allows each non-member at the relay (NIP-86 `allowPubkey`) before publishing
9000, because a room member the relay won't serve can't read the room. 9000, because a room member the relay won't serve can't read the room.
- `deriveUserRooms`, `deriveOtherRooms`, `deriveOtherVoiceRooms`: rooms from the user's 10009 - `deriveUserRooms`, `deriveOtherRooms(url, type)`: rooms from the user's 10009 list and the rest
list and the rest of the space, limited to rooms the relay still advertises. `meta.hasLivekit()` of the space by `RoomType`, limited to rooms the relay still advertises. `meta.hasLivekit()`
marks a voice room. marks a voice room (`getRoomType`).
### Room flows ### Room flows

View file

@ -99,7 +99,9 @@ mobile. Pages outside a space use `Page`, `PageBar` and `PageContent` themselves
`makeCalendarPath`, `makeGoalPath`, `makePollPath`, `makeLibraryPath`, `makeArticleCreatePath`). `makeCalendarPath`, `makeGoalPath`, `makePollPath`, `makeLibraryPath`, `makeArticleCreatePath`).
- `makeContentPath(url, kind, idOrAddress)` maps a kind to its page. `makeEventPath` builds on it - `makeContentPath(url, kind, idOrAddress)` maps a kind to its page. `makeEventPath` builds on it
for any event, covering DMs, room messages (`?at=`) and comments (through the parent's `K`, for any event, covering DMs, room messages (`?at=`) and comments (through the parent's `K`,
`A` and `E` tags), and falls back to `entityLink` from `src/app/env.ts`, an external link. `A` and `E` tags), and returns `undefined` for an event with no page in the app. A caller that
wants an external link then calls `entityLink` from `src/app/env.ts` itself, as
`makeEventPermalink` does.
- `goToEvent(event)` scrolls to the event if it is already rendered with a `data-event` attribute - `goToEvent(event)` scrolls to the event if it is already rendered with a `data-event` attribute
and navigates otherwise; `makeEventPermalink` is the shareable form. and navigates otherwise; `makeEventPermalink` is the shareable form.
- `goToSpace(url)` goes to `makeSpaceEntryPath(url)`: the last page visited in that space, which - `goToSpace(url)` goes to `makeSpaceEntryPath(url)`: the last page visited in that space, which

View file

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import {Capacitor} from "@capacitor/core"
import UserCircle from "@assets/icons/user-circle.svg?dataurl" import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import ServerPath from "@assets/icons/server-path.svg?dataurl" import ServerPath from "@assets/icons/server-path.svg?dataurl"
import GalleryMinimalistic from "@assets/icons/gallery-minimalistic.svg?dataurl" import GalleryMinimalistic from "@assets/icons/gallery-minimalistic.svg?dataurl"
@ -14,6 +13,8 @@
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import LogOut from "@app/components/LogOut.svelte" import LogOut from "@app/components/LogOut.svelte"
import {user} from "@app/core" import {user} from "@app/core"
import {ENABLE_ZAPS} from "@app/env"
import {HOSTING_ENABLED} from "@app/hosting"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {makeProfilePath} from "@app/routes" import {makeProfilePath} from "@app/routes"
@ -39,7 +40,7 @@
<Icon icon={Bell} size={5} /> <Icon icon={Bell} size={5} />
Alerts Alerts
</Link> </Link>
{#if Capacitor.getPlatform() !== "ios"} {#if ENABLE_ZAPS}
<Link <Link
href="/settings/wallet" href="/settings/wallet"
class="aspect-square button button-neutral h-[unset] flex flex-col gap-2 text-center"> class="aspect-square button button-neutral h-[unset] flex flex-col gap-2 text-center">
@ -47,12 +48,14 @@
Wallet Wallet
</Link> </Link>
{/if} {/if}
<Link {#if HOSTING_ENABLED}
href="/settings/hosting" <Link
class="aspect-square button button-neutral h-[unset] flex flex-col gap-2 text-center"> href="/settings/hosting"
<Icon icon={ServerPath} size={5} /> class="aspect-square button button-neutral h-[unset] flex flex-col gap-2 text-center">
Hosting <Icon icon={ServerPath} size={5} />
</Link> Hosting
</Link>
{/if}
<Link <Link
href="/settings/content" href="/settings/content"
class="aspect-square button button-neutral h-[unset] flex flex-col gap-2 text-center"> class="aspect-square button button-neutral h-[unset] flex flex-col gap-2 text-center">

View file

@ -3,6 +3,7 @@
import {remove} from "@welshman/lib" import {remove} from "@welshman/lib"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl" import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@ -21,7 +22,7 @@
import {chatSearch} from "@app/chats" import {chatSearch} from "@app/chats"
import {user} from "@app/core" import {user} from "@app/core"
import {deriveUserRoomSearch} from "@app/rooms" import {deriveUserRoomSearch} from "@app/rooms"
import {makeChatPath, makeRoomPath} from "@app/routes" import {makeChatPath} from "@app/routes"
import {shareTo, type Share} from "@app/share" import {shareTo, type Share} from "@app/share"
type Props = { type Props = {
@ -65,8 +66,7 @@
{#if roomResults.length > 0} {#if roomResults.length > 0}
<p class="text-xs uppercase tracking-wide opacity-60">Rooms</p> <p class="text-xs uppercase tracking-wide opacity-60">Rooms</p>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each roomResults as { url, h } (makeRoomPath(url, h))} {#each roomResults as { url, h, path } (path)}
{@const path = makeRoomPath(url, h)}
<Button <Button
type="button" type="button"
class={cx( class={cx(
@ -74,7 +74,14 @@
selection === path ? "button-primary" : "button-neutral", selection === path ? "button-primary" : "button-neutral",
)} )}
onclick={() => toggle(path)}> onclick={() => toggle(path)}>
<RoomNameWithImage {url} {h} /> {#if h}
<RoomNameWithImage {url} {h} />
{:else}
<div class="inline-flex min-w-0 items-center gap-2">
<Icon icon={Hashtag} size={5} />
<span class="truncate min-w-0">chat</span>
</div>
{/if}
<RelayName {url} class="min-w-0 truncate text-xs opacity-60" /> <RelayName {url} class="min-w-0 truncate text-xs opacity-60" />
</Button> </Button>
{/each} {/each}

View file

@ -17,10 +17,11 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import SpaceSearch from "@app/components/SpaceSearch.svelte" import SpaceSearch from "@app/components/SpaceSearch.svelte"
import {events, relays} from "@app/core" import {events} from "@app/core"
import {ENABLE_ZAPS} from "@app/env" import {ENABLE_ZAPS} from "@app/env"
import {CONTENT_KINDS} from "@app/content" import {CONTENT_KINDS} from "@app/content"
import {makeSpacePath} from "@app/routes" import {deriveIsGroupSpace} from "@app/relays"
import {makeSpaceChatPath, makeSpacePath} from "@app/routes"
import {allNotifications, notifications} from "@app/notifications" import {allNotifications, notifications} from "@app/notifications"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@ -30,8 +31,8 @@
const {url}: Props = $props() const {url}: Props = $props()
const relay = $relays.one(url) const isGroupSpace = deriveIsGroupSpace(url)
const chatPath = makeSpacePath(url, "chat") const chatPath = makeSpaceChatPath(url)
const goalsPath = makeSpacePath(url, "goals") const goalsPath = makeSpacePath(url, "goals")
const threadsPath = makeSpacePath(url, "threads") const threadsPath = makeSpacePath(url, "threads")
const classifiedsPath = makeSpacePath(url, "classifieds") const classifiedsPath = makeSpacePath(url, "classifieds")
@ -53,8 +54,6 @@
const showSection = (kind: number, path: string) => const showSection = (kind: number, path: string) =>
$spaceKinds.has(kind) || $allNotifications.has(path) || $page.url.pathname.startsWith(path) $spaceKinds.has(kind) || $allNotifications.has(path) || $page.url.pathname.startsWith(path)
const hasNip29 = $derived($relay?.hasNip(29) ?? false)
const openSearch = () => pushModal(SpaceSearch, {url}) const openSearch = () => pushModal(SpaceSearch, {url})
$effect(() => setJson(spaceKindsKey, [...$spaceKinds])) $effect(() => setJson(spaceKindsKey, [...$spaceKinds]))
@ -63,7 +62,7 @@
<SecondaryNavItem href={makeSpacePath(url, "about")}> <SecondaryNavItem href={makeSpacePath(url, "about")}>
<Icon icon={Home} /> Space Details <Icon icon={Home} /> Space Details
</SecondaryNavItem> </SecondaryNavItem>
{#if !hasNip29} {#if !$isGroupSpace}
<SecondaryNavItem href={chatPath} notification={$notifications.has(chatPath)}> <SecondaryNavItem href={chatPath} notification={$notifications.has(chatPath)}>
<Icon icon={ChatRound} /> Chat <Icon icon={ChatRound} /> Chat
</SecondaryNavItem> </SecondaryNavItem>
@ -106,7 +105,7 @@
<Icon icon={Revote} /> Polls <Icon icon={Revote} /> Polls
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{#if hasNip29} {#if $isGroupSpace}
<SecondaryNavItem onclick={openSearch}> <SecondaryNavItem onclick={openSearch}>
<Icon icon={Magnifier} /> Search <Icon icon={Magnifier} /> Search
</SecondaryNavItem> </SecondaryNavItem>

View file

@ -8,9 +8,9 @@
import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte" import SecondaryNavHeader from "@lib/components/SecondaryNavHeader.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte" import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte" import RoomCreate from "@app/components/RoomCreate.svelte"
import {relays} from "@app/core"
import {deriveUserCanCreateRoom} from "@app/management" import {deriveUserCanCreateRoom} from "@app/management"
import {deriveUserRooms, deriveOtherRooms, deriveOtherVoiceRooms, displayRoom} from "@app/rooms" import {deriveIsGroupSpace} from "@app/relays"
import {RoomType, deriveUserRooms, deriveOtherRooms, displayRoom} from "@app/rooms"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type Props = { type Props = {
@ -20,10 +20,10 @@
const {url, mobile = false}: Props = $props() const {url, mobile = false}: Props = $props()
const relay = $relays.one(url) const isGroupSpace = deriveIsGroupSpace(url)
const userRooms = deriveUserRooms(url) const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url) const otherRooms = deriveOtherRooms(url, RoomType.Text)
const otherVoiceRooms = deriveOtherVoiceRooms(url) const otherVoiceRooms = deriveOtherRooms(url, RoomType.Voice)
const canCreateRoom = deriveUserCanCreateRoom(url) const canCreateRoom = deriveUserCanCreateRoom(url)
const roomSearch = derived(otherRooms, $otherRooms => const roomSearch = derived(otherRooms, $otherRooms =>
@ -95,7 +95,7 @@
{/if} {/if}
{/snippet} {/snippet}
{#if ($relay?.hasNip(29) ?? false) && ($userRooms.length > 0 || $otherRooms.length > 0 || $otherVoiceRooms.length > 0 || $canCreateRoom)} {#if $isGroupSpace && ($userRooms.length > 0 || $otherRooms.length > 0 || $otherVoiceRooms.length > 0 || $canCreateRoom)}
{#if mobile} {#if mobile}
<div class="card space-menu__card flex flex-col gap-1"> <div class="card space-menu__card flex flex-col gap-1">
{@render content()} {@render content()}

View file

@ -32,8 +32,7 @@ import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension"
import RoomSuggestion from "@app/editor/RoomSuggestion.svelte" import RoomSuggestion from "@app/editor/RoomSuggestion.svelte"
import {NativeClipboardPasteExtension} from "@app/editor/clipboard" import {NativeClipboardPasteExtension} from "@app/editor/clipboard"
import {compressFileForUpload, makeImetaTag, uploadFile} from "@app/uploads" import {compressFileForUpload, makeImetaTag, uploadFile} from "@app/uploads"
import {userSpaceUrls} from "@app/rooms" import {activeSpaceUrls} from "@app/rooms"
import {PLATFORM_RELAYS} from "@app/env"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
const isEmpty = (editor: Editor) => editor.getText({blockSeparator: "\n"}).trim() === "" const isEmpty = (editor: Editor) => editor.getText({blockSeparator: "\n"}).trim() === ""
@ -79,13 +78,9 @@ export const makeEditor = async ({
) )
const roomReferenceSearch = derived( const roomReferenceSearch = derived(
[throttled(800, userSpaceUrls), throttled(800, rooms.get().byUrl.$)], [throttled(800, activeSpaceUrls), throttled(800, rooms.get().byUrl.$)],
([$userSpaceUrls, $roomsByUrl]) => { ([$activeSpaceUrls, $roomsByUrl]) => {
// Platform relays, where configured, are the only spaces suggestions come from. const spaceUrls = uniq(url ? [url, ...$activeSpaceUrls] : $activeSpaceUrls)
const spaceUrls =
PLATFORM_RELAYS.length > 0
? PLATFORM_RELAYS
: uniq(url ? [url, ...$userSpaceUrls] : $userSpaceUrls)
const options = spaceUrls.flatMap(spaceUrl => const options = spaceUrls.flatMap(spaceUrl =>
($roomsByUrl.get(spaceUrl) ?? []).map((room: Room) => ({ ($roomsByUrl.get(spaceUrl) ?? []).map((room: Room) => ({

View file

@ -14,7 +14,7 @@ export const PUSH_SERVER = env("VITE_PUSH_SERVER")
export const PUSH_BRIDGE = normalizeRelayUrl(env("VITE_PUSH_BRIDGE")) export const PUSH_BRIDGE = normalizeRelayUrl(env("VITE_PUSH_BRIDGE"))
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios" export const ENABLE_ZAPS = Capacitor.getPlatform() !== "ios"
export const SIGNER_RELAYS = fromCsv(env("VITE_SIGNER_RELAYS")).map(normalizeRelayUrl) export const SIGNER_RELAYS = fromCsv(env("VITE_SIGNER_RELAYS")).map(normalizeRelayUrl)

View file

@ -15,13 +15,15 @@ import {
} from "@welshman/util" } from "@welshman/util"
import {Comment} from "@welshman/domain" import {Comment} from "@welshman/domain"
import {synced, throttled, withGetter} from "@welshman/store" import {synced, throttled, withGetter} from "@welshman/store"
import {Events, Relays, RoomLists} from "@welshman/app" import {Events, Relays} from "@welshman/app"
import {app, fromApp, reader} from "@app/core" import {app, fromApp, reader} from "@app/core"
import {makeRoomPath, makeSpaceChatPath, makeChatPath, makeContentPath} from "@app/routes" import {makeRoomPath, makeSpaceChatPath, makeChatPath, makeContentPath} from "@app/routes"
import {CONTENT_KINDS, makeCommentFilter} from "@app/content" import {CONTENT_KINDS, makeCommentFilter} from "@app/content"
import {getIsMuted, notificationSettings, userSettingsValues} from "@app/settings" import {getIsMuted, notificationSettings, userSettingsValues} from "@app/settings"
import {Chats} from "@app/chats" import {Chats} from "@app/chats"
import {dufflepud, DUFFLEPUD_URL, PLATFORM_RELAYS} from "@app/env" import {dufflepud, DUFFLEPUD_URL} from "@app/env"
import {isGroupSpace} from "@app/relays"
import {activeSpaceUrls} from "@app/rooms"
import {kv} from "@app/storage" import {kv} from "@app/storage"
// Checked state // Checked state
@ -231,10 +233,10 @@ export const latestActivityByPath = derived(
1000, 1000,
derived( derived(
[ [
app, activeSpaceUrls,
fromApp($app => $app.use(Chats).index.$), fromApp($app => $app.use(Chats).index.$),
// Only here to recompute once a relay's NIP-11 says whether it's a group space
fromApp($app => $app.use(Relays).index.$), fromApp($app => $app.use(Relays).index.$),
fromApp($app => $app.use(RoomLists).index.$),
fromApp( fromApp(
$app => $app =>
$app $app
@ -247,7 +249,7 @@ export const latestActivityByPath = derived(
identity, identity,
), ),
), ),
([$app, $chatsById, $relays, $roomLists, eventsByIdByUrl, $settings]) => { ([$activeSpaceUrls, $chatsById, , eventsByIdByUrl, $settings]) => {
const activity = new Map<string, Activity>() const activity = new Map<string, Activity>()
for (const {pubkeys, messages} of $chatsById.values()) { for (const {pubkeys, messages} of $chatsById.values()) {
@ -256,13 +258,10 @@ export const latestActivityByPath = derived(
activity.set(path, {path, pubkeys, event: messages[0]}) activity.set(path, {path, pubkeys, event: messages[0]})
} }
const roomList = $app.user?.pubkey ? $roomLists.get($app.user.pubkey) : undefined for (const url of $activeSpaceUrls) {
const urls = PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : (roomList?.urls() ?? [])
for (const url of urls) {
const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values()) const events = sortEventsDesc((eventsByIdByUrl.get(url) || new Map()).values())
if ($relays.get(url)?.hasNip(29)) { if (isGroupSpace(url)) {
for (const [h, [event]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) { for (const [h, [event]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) {
// A muted room is left out entirely, so it can't light up its own badge or the space's // A muted room is left out entirely, so it can't light up its own badge or the space's
if (h && !getIsMuted($settings, url, h)) { if (h && !getIsMuted($settings, url, h)) {

View file

@ -137,8 +137,7 @@ export const onPushNotificationAction = async (action: {
const target = event?.kind === WRAP ? await loadNotificationRumor(event) : event const target = event?.kind === WRAP ? await loadNotificationRumor(event) : event
const path = target && makeEventPath(target, [relay]) const path = target && makeEventPath(target, [relay])
// Kinds we have no route for get a coracle.social permalink, which navigate refuses if (path) {
if (path && !path.includes("://")) {
navigate(path) navigate(path)
} else if (event?.kind === WRAP) { } else if (event?.kind === WRAP) {
goToChat() goToChat()

View file

@ -3,7 +3,7 @@ import {call, on, simpleCache} from "@welshman/lib"
import {normalizeRelayUrl} from "@welshman/util" import {normalizeRelayUrl} from "@welshman/util"
import {AuthStateEvent, AuthStatus, SocketEvent, SocketStatus} from "@welshman/net" import {AuthStateEvent, AuthStatus, SocketEvent, SocketStatus} from "@welshman/net"
import {throttled} from "@welshman/store" import {throttled} from "@welshman/store"
import {app} from "@app/core" import {app, relays} from "@app/core"
import {relaysMostlyRestricted} from "@app/policies" import {relaysMostlyRestricted} from "@app/policies"
import {colorFor} from "@app/theme" import {colorFor} from "@app/theme"
@ -95,6 +95,12 @@ export const deriveSocketStatus = (url: string) =>
}), }),
) )
// A space that isn't a NIP-29 relay has no rooms, only one space-wide chat.
export const isGroupSpace = (url: string) => Boolean(relays.get().get(url)?.hasNip(29))
export const deriveIsGroupSpace = (url: string) =>
derived(relays.get().one(url), $relay => Boolean($relay?.hasNip(29)))
const livekitUrl = (url: string) => const livekitUrl = (url: string) =>
url.replace(/^ws/, "http").replace(/\/$/, "") + "/.well-known/nip29/livekit" url.replace(/^ws/, "http").replace(/\/$/, "") + "/.well-known/nip29/livekit"

View file

@ -30,8 +30,10 @@ import {
user, user,
writer, writer,
} from "@app/core" } from "@app/core"
import {PLATFORM_RELAYS} from "@app/env"
import {allowPubkeys, deriveSpacePermissions, deriveUserIsSpaceStaff} from "@app/management" import {allowPubkeys, deriveSpacePermissions, deriveUserIsSpaceStaff} from "@app/management"
import {makeRoomPath} from "@app/routes" import {isGroupSpace} from "@app/relays"
import {makeRoomPath, makeSpaceChatPath} from "@app/routes"
import {loadSendDelay} from "@app/settings" import {loadSendDelay} from "@app/settings"
export const PROTECTED = ["-"] export const PROTECTED = ["-"]
@ -180,6 +182,11 @@ export const userRoomList = deriveUserItem(RoomLists)
export const userSpaceUrls = derived(userRoomList, $userRoomList => $userRoomList?.urls() ?? []) export const userSpaceUrls = derived(userRoomList, $userRoomList => $userRoomList?.urls() ?? [])
// Platform relays, where configured, stand in for the spaces the user has joined.
export const activeSpaceUrls = derived(userSpaceUrls, $userSpaceUrls =>
PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : $userSpaceUrls,
)
// Spaces get reordered from lists showing only some of them, so urls go back in the slots they replace. // Spaces get reordered from lists showing only some of them, so urls go back in the slots they replace.
export const reorderSpaceUrls = (urls: string[]) => { export const reorderSpaceUrls = (urls: string[]) => {
let index = 0 let index = 0
@ -200,19 +207,10 @@ export const deriveUserRooms = (url: string) =>
}, },
) )
export const deriveOtherRooms = (url: string) => export const deriveOtherRooms = (url: string, type: RoomType) =>
derived([deriveUserRooms(url), rooms.get().forUrl(url).$], ([$userRooms, $spaceRooms]) => { derived([deriveUserRooms(url), rooms.get().forUrl(url).$], ([$userRooms, $spaceRooms]) => {
const result = $spaceRooms const result = $spaceRooms
.filter(room => !room.meta?.hasLivekit() && !$userRooms.includes(room.h)) .filter(room => getRoomType(room) === type && !$userRooms.includes(room.h))
.map(room => room.h)
return sortBy(roomComparator(url), uniq(result))
})
export const deriveOtherVoiceRooms = (url: string) =>
derived([deriveUserRooms(url), rooms.get().forUrl(url).$], ([$userRooms, $spaceRooms]) => {
const result = $spaceRooms
.filter(room => room.meta?.hasLivekit() && !$userRooms.includes(room.h))
.map(room => room.h) .map(room => room.h)
return sortBy(roomComparator(url), uniq(result)) return sortBy(roomComparator(url), uniq(result))
@ -310,23 +308,26 @@ export const deriveUserRoomSearch = () =>
derived( derived(
[userSpaceUrls, userRoomList, rooms.get().byUrl.$], [userSpaceUrls, userRoomList, rooms.get().byUrl.$],
([$userSpaceUrls, $userRoomList, $roomsByUrl]) => { ([$userSpaceUrls, $userRoomList, $roomsByUrl]) => {
const options = $userSpaceUrls.flatMap(url => { type Option = {url: string; h?: string; path: string; name: string; isFavorite: boolean}
const favorites = new Set($userRoomList?.roomsForUrl(url) ?? [])
// Spaces that aren't NIP-29 relays keep all their messages in the space-wide chat
const roomIds = relays.get().get(url)?.hasNip(29)
? ($roomsByUrl.get(url) ?? []).map(room => room.h)
: ["chat"]
return roomIds.map(h => ({ const options = $userSpaceUrls.flatMap((url): Option[] => {
url, if (isGroupSpace(url)) {
h, const favorites = new Set($userRoomList?.roomsForUrl(url) ?? [])
name: displayRoom(url, h),
isFavorite: favorites.has(h), return ($roomsByUrl.get(url) ?? []).map(({h}) => ({
})) url,
h,
path: makeRoomPath(url, h),
name: displayRoom(url, h),
isFavorite: favorites.has(h),
}))
} else {
return [{url, path: makeSpaceChatPath(url), name: "chat", isFavorite: false}]
}
}) })
return createSearch(options, { return createSearch(options, {
getValue: option => makeRoomPath(option.url, option.h), getValue: option => option.path,
fuseOptions: {keys: ["name", "url"]}, fuseOptions: {keys: ["name", "url"]},
sortFn: ({item}) => (item.isFavorite ? 0 : 1), sortFn: ({item}) => (item.isFavorite ? 0 : 1),
}) })

View file

@ -2,6 +2,7 @@ import {get, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19" import * as nip19 from "nostr-tools/nip19"
import {page} from "$app/stores" import {page} from "$app/stores"
import {identity, tryCatch} from "@welshman/lib" import {identity, tryCatch} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import { import {
CLASSIFIED, CLASSIFIED,
@ -203,7 +204,7 @@ export const makeContentPath = (url: string, kind: number, idOrAddress?: string)
} }
} }
export const makeEventPath = (event: TrustedEvent, urls: string[]) => { export const makeEventPath = (event: TrustedEvent, urls: string[]): Maybe<string> => {
if (DM_KINDS.includes(event.kind)) { if (DM_KINDS.includes(event.kind)) {
return makeChatPath([event.pubkey, ...tagValues(hexTags("p"), event.tags)]) return makeChatPath([event.pubkey, ...tagValues(hexTags("p"), event.tags)])
} }
@ -245,21 +246,18 @@ export const makeEventPath = (event: TrustedEvent, urls: string[]) => {
return `/notes/${nip19.neventEncode({id, author, kind, relays: urls.slice(0, 3)})}` return `/notes/${nip19.neventEncode({id, author, kind, relays: urls.slice(0, 3)})}`
} }
return entityLink(nip19.neventEncode({id: event.id, relays: urls}))
} }
export const makeEventPermalink = (event: TrustedEvent, url?: string) => { export const makeEventPermalink = (event: TrustedEvent, url?: string) => {
const urls = url ? [url] : Array.from(app.get().tracker.getRelays(event.id)) const urls = url ? [url] : Array.from(app.get().tracker.getRelays(event.id))
const path = makeEventPath(event, urls) const path = makeEventPath(event, urls)
if (path.includes("://")) {
return path
}
const pointer = nip19.neventEncode({id: event.id, relays: urls}) const pointer = nip19.neventEncode({id: event.id, relays: urls})
return `${PLATFORM_URL}${path}${path.includes("?") ? "&" : "?"}event=${pointer}` if (path) {
return `${PLATFORM_URL}${path}${path.includes("?") ? "&" : "?"}event=${pointer}`
} else {
return entityLink(pointer)
}
} }
export const getPermalinkTarget = (url: URL) => { export const getPermalinkTarget = (url: URL) => {
@ -299,11 +297,13 @@ export const goToEvent = (event: TrustedEvent, options: Record<string, any> = {}
const path = makeEventPath(event, urls) const path = makeEventPath(event, urls)
if (path.includes("://")) { if (path) {
window.open(path) if (!scrollToEvent(event.id)) {
} else if (!scrollToEvent(event.id)) { const replaceState = path.replace(/\?.*$/, "") === get(page).url.pathname
const replaceState = path.replace(/\?.*$/, "") === get(page).url.pathname
navigate(path, {replaceState, ...options}) navigate(path, {replaceState, ...options})
}
} else {
window.open(entityLink(nip19.neventEncode({id: event.id, relays: urls})))
} }
} }

View file

@ -4,8 +4,9 @@ import type {PluginListenerHandle} from "@capacitor/core"
import {noop} from "@welshman/lib" import {noop} from "@welshman/lib"
import type {Maybe} from "@welshman/lib" import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {app, relays} from "@app/core" import {app} from "@app/core"
import {navigate, pushModal} from "@app/modal" import {navigate, pushModal} from "@app/modal"
import {isGroupSpace} from "@app/relays"
import {makeSpaceChatPath} from "@app/routes" import {makeSpaceChatPath} from "@app/routes"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import ShareDialog from "@app/components/Share.svelte" import ShareDialog from "@app/components/Share.svelte"
@ -30,7 +31,7 @@ export const shareTo = async (
} }
export const shareEvent = (url: string, noun: string, event: TrustedEvent) => { export const shareEvent = (url: string, noun: string, event: TrustedEvent) => {
if (relays.get().get(url)?.hasNip(29)) { if (isGroupSpace(url)) {
pushModal(ShareEvent, {url, noun, event}) pushModal(ShareEvent, {url, noun, event})
} else { } else {
shareTo(makeSpaceChatPath(url), {type: "event", value: event}) shareTo(makeSpaceChatPath(url), {type: "event", value: event})

View file

@ -30,16 +30,9 @@ import {
unionFilters, unionFilters,
} from "@welshman/util" } from "@welshman/util"
import type {Filter} from "@welshman/util" import type {Filter} from "@welshman/util"
import type {FollowListReader, RoomListReader} from "@welshman/domain" import type {FollowListReader} from "@welshman/domain"
import {merged, synced, throttled, withGetter} from "@welshman/store" import {merged, synced, throttled, withGetter} from "@welshman/store"
import { import {FollowLists, MessagingRelayLists, RelayLists, SearchRelayLists, Sync} from "@welshman/app"
FollowLists,
MessagingRelayLists,
RelayLists,
RoomLists,
SearchRelayLists,
Sync,
} from "@welshman/app"
import { import {
app, app,
blockedRelayLists, blockedRelayLists,
@ -59,9 +52,10 @@ import {
} from "@app/core" } from "@app/core"
import {LIVEKIT_PARTICIPANTS} from "@app/call" import {LIVEKIT_PARTICIPANTS} from "@app/call"
import {CONTENT_KINDS, REACTION_KINDS, makeCommentFilter} from "@app/content" import {CONTENT_KINDS, REACTION_KINDS, makeCommentFilter} from "@app/content"
import {INDEXER_RELAYS, PLATFORM_RELAYS} from "@app/env" import {INDEXER_RELAYS} from "@app/env"
import {FEATURED_CONTENT_D} from "@app/featured" import {FEATURED_CONTENT_D} from "@app/featured"
import {decodeRelay} from "@app/relays" import {decodeRelay} from "@app/relays"
import {activeSpaceUrls, userSpaceUrls} from "@app/rooms"
import {Settings} from "@app/settings" import {Settings} from "@app/settings"
import {kv} from "@app/storage" import {kv} from "@app/storage"
import {hasBlossomSupport} from "@app/uploads" import {hasBlossomSupport} from "@app/uploads"
@ -95,8 +89,6 @@ const pullAndListen = ({url, signal, filters}: SyncOpts) => {
}) })
} }
const userRoomList = deriveUserItem(RoomLists)
const userRelayList = deriveUserItem(RelayLists) const userRelayList = deriveUserItem(RelayLists)
const userFollowList = deriveUserItem(FollowLists) const userFollowList = deriveUserItem(FollowLists)
@ -105,9 +97,6 @@ const userMessagingRelayList = deriveUserItem(MessagingRelayLists)
const userSearchRelayList = deriveUserItem(SearchRelayLists) const userSearchRelayList = deriveUserItem(SearchRelayLists)
const getSpaceUrls = ($roomList: Maybe<RoomListReader>) =>
PLATFORM_RELAYS.length > 0 ? PLATFORM_RELAYS : ($roomList?.urls() ?? [])
// Relays // Relays
const syncRelays = () => { const syncRelays = () => {
@ -124,8 +113,8 @@ const syncRelays = () => {
} }
}) })
const unsubscribeSpaceUrls = userRoomList.subscribe($roomList => { const unsubscribeSpaceUrls = userSpaceUrls.subscribe($userSpaceUrls => {
for (const url of $roomList?.urls() ?? []) { for (const url of $userSpaceUrls) {
relays.get().load(url) relays.get().load(url)
} }
}) })
@ -169,21 +158,19 @@ const syncUserData = () => {
const unsubscribersByUrl = new Map<string, Unsubscriber>() const unsubscribersByUrl = new Map<string, Unsubscriber>()
const $pubkey = app.get().user?.pubkey const $pubkey = app.get().user?.pubkey
const syncRoomList = ($roomList: Maybe<RoomListReader>) => { const syncSpaceMembership = ($activeSpaceUrls: string[]) => {
if ($roomList) { const urls = new Set($activeSpaceUrls)
const urls = new Set(getSpaceUrls($roomList))
for (const url of urls) { for (const url of urls) {
if (!unsubscribersByUrl.has(url)) { if (!unsubscribersByUrl.has(url)) {
unsubscribersByUrl.set(url, syncUserSpaceMembership(url)) unsubscribersByUrl.set(url, syncUserSpaceMembership(url))
}
} }
}
for (const [url, unsubscribe] of unsubscribersByUrl.entries()) { for (const [url, unsubscribe] of unsubscribersByUrl.entries()) {
if (!urls.has(url)) { if (!urls.has(url)) {
unsubscribersByUrl.delete(url) unsubscribersByUrl.delete(url)
unsubscribe() unsubscribe()
}
} }
} }
} }
@ -218,13 +205,13 @@ const syncUserData = () => {
relayLists.get().load($pubkey) relayLists.get().load($pubkey)
} }
const unsubscribeRoomList = userRoomList.subscribe(syncRoomList) const unsubscribeSpaceMembership = activeSpaceUrls.subscribe(syncSpaceMembership)
const unsubscribeRelayList = userRelayList.subscribe(syncUserLists) const unsubscribeRelayList = userRelayList.subscribe(syncUserLists)
const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork) const unsubscribeFollowList = userFollowList.subscribe(syncFollowNetwork)
return () => { return () => {
unsubscribersByUrl.forEach(call) unsubscribersByUrl.forEach(call)
unsubscribeRoomList() unsubscribeSpaceMembership()
unsubscribeRelayList() unsubscribeRelayList()
unsubscribeFollowList() unsubscribeFollowList()
} }
@ -304,8 +291,8 @@ const syncSpaces = () => {
let detailUrl: Maybe<string> let detailUrl: Maybe<string>
let unsubscribeDetail: Unsubscriber = noop let unsubscribeDetail: Unsubscriber = noop
const unsubscribe = merged([userRoomList, page]).subscribe(([$roomList, $page]) => { const unsubscribe = merged([activeSpaceUrls, page]).subscribe(([$activeSpaceUrls, $page]) => {
const urls = new Set(getSpaceUrls($roomList)) const urls = new Set($activeSpaceUrls)
const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined const currentUrl = $page.params.relay ? decodeRelay($page.params.relay) : undefined
if (currentUrl) { if (currentUrl) {

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type {Snippet} from "svelte" import type {Snippet} from "svelte"
import {Capacitor} from "@capacitor/core"
import UserCircle from "@assets/icons/user-circle.svg?dataurl" import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import Wallet from "@assets/icons/wallet.svg?dataurl" import Wallet from "@assets/icons/wallet.svg?dataurl"
import ServerPath from "@assets/icons/server-path.svg?dataurl" import ServerPath from "@assets/icons/server-path.svg?dataurl"
@ -15,6 +14,7 @@
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte" import SecondaryNavSection from "@lib/components/SecondaryNavSection.svelte"
import LogOut from "@app/components/LogOut.svelte" import LogOut from "@app/components/LogOut.svelte"
import {ENABLE_ZAPS} from "@app/env"
import {HOSTING_ENABLED} from "@app/hosting" import {HOSTING_ENABLED} from "@app/hosting"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
@ -38,7 +38,7 @@
<SecondaryNavItem href="/settings/alerts"> <SecondaryNavItem href="/settings/alerts">
<Icon icon={Bell} /> Alerts <Icon icon={Bell} /> Alerts
</SecondaryNavItem> </SecondaryNavItem>
{#if Capacitor.getPlatform() !== "ios"} {#if ENABLE_ZAPS}
<SecondaryNavItem href="/settings/wallet"> <SecondaryNavItem href="/settings/wallet">
<Icon icon={Wallet} /> Wallet <Icon icon={Wallet} /> Wallet
</SecondaryNavItem> </SecondaryNavItem>

View file

@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import {Capacitor} from "@capacitor/core"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@ -10,7 +9,7 @@
import HeadphonesRound from "@assets/icons/headphones-round.svg?dataurl" import HeadphonesRound from "@assets/icons/headphones-round.svg?dataurl"
import Zap from "@app/components/Zap.svelte" import Zap from "@app/components/Zap.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte" import ProfileDetail from "@app/components/ProfileDetail.svelte"
import {PLATFORM_NAME, PLATFORM_ABOUT} from "@app/env" import {ENABLE_ZAPS, PLATFORM_NAME, PLATFORM_ABOUT} from "@app/env"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
@ -27,7 +26,7 @@
<p class="text-center text-2xl">Thanks for using</p> <p class="text-center text-2xl">Thanks for using</p>
<h1 class="mb-4 text-center text-5xl font-bold uppercase">{PLATFORM_NAME}</h1> <h1 class="mb-4 text-center text-5xl font-bold uppercase">{PLATFORM_NAME}</h1>
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2"> <div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
{#if Capacitor.getPlatform() !== "ios"} {#if ENABLE_ZAPS}
<Card class="flex flex-col gap-2 text-center"> <Card class="flex flex-col gap-2 text-center">
<h3 class="text-2xl sm:h-12">Donate</h3> <h3 class="text-2xl sm:h-12">Donate</h3>
<p class="sm:h-16">Funds will be used to support development.</p> <p class="sm:h-16">Funds will be used to support development.</p>