diff --git a/.agents/skills/flotilla-model/SKILL.md b/.agents/skills/flotilla-model/SKILL.md index e77319cc..9c980041 100644 --- a/.agents/skills/flotilla-model/SKILL.md +++ b/.agents/skills/flotilla-model/SKILL.md @@ -144,19 +144,23 @@ has no getter for them yet. Roles are only ever changed over NIP-86 (`createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole`), never by publishing 33534. -Joining a space (`attemptRelayAccess` and `Access` in `src/app/access.ts`): +Joining a space (`attemptRelayAccess` and `completeSpaceJoin` in `src/app/access.ts`): 1. Open the socket and drive NIP-42 auth, retrying up to three times. 2. Publish `RelayJoin` with the claim. The writer protects the event and requires a forced relay. -3. Translate refusals: "invite code" means rejected, "claim" means the space needs an invite. -4. `completeJoin`: `roomLists.addRelay(url)`, restart sync, and `Sync.push` the user's `RELAYS`, +3. Translate refusals: a `mute:` reply (`matchReason`) is ignored, and the free-text "invite code" + means rejected and "claim" means the space needs an invite. +4. `completeSpaceJoin`: opt into the space's notifications (`optInSpaceNotifications` in + `src/app/push`, which asks for push permission first when push is on), `roomLists.addRelay(url)`, + clear the relay from `relaysMostlyRestricted`, restart sync, and `Sync.push` the user's `RELAYS`, `MESSAGING_RELAYS`, `FOLLOWS` and `PROFILE` to the space so other members can see them. Invite links are `${PLATFORM_URL}/join?r=&c=`, plus `h` and `code` for a room (`makeInviteLink`; `parseInviteLink` also accepts a bare relay URL). `src/routes/join` renders -`SpaceInviteAccept`, which calls `Access.acceptInvite` to join the space and then the room. -`Access.prepareInvite` gets a claim over NIP-86 (`supportedmethods`, then `listclaims`, then -`createclaim`), and `InviteLink` renders the result for both `SpaceInvite` and `RoomInvite`; this replaced reading `RELAY_INVITE` events. Leaving (`SpaceExit`, +`SpaceInviteAccept`, which calls `acceptInvite` to join the space (skipped if already joined) and +then the room. `InviteLink`, rendered by both `SpaceInvite` and `RoomInvite`, gets a claim over +NIP-86 (`supportedmethods`, then `listclaims`, then `createclaim`), publishes a room invite code +for a room, and holds that state itself; this replaced reading `RELAY_INVITE` events. Leaving (`SpaceExit`, `SpaceAuthError`) is `roomLists.removeRelay(url)` plus `publishLeaveRequest(url)`. ## NIP-86 relay management @@ -171,7 +175,7 @@ relay's URL, each call signed with a fresh NIP-98 event. Every method resolves t | `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `ReportMenu`, `allowPubkeys` (`SpaceMembersAdd`, `RoleAddMembers`, `addRoomMembers`) | | `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenu`, `RoomJoinItem` (dismissing a join request) | | `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles` (via `syncAssignments`), `RoleAddMembers` | -| `listClaims`, `createClaim` | `Access.prepareInvite` | +| `listClaims`, `createClaim` | `InviteLink` | | `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` | A relay answers `supportedmethods` with what the authenticated pubkey may call, so every control @@ -179,7 +183,7 @@ is gated on the method behind it through `deriveSpacePermissions(url)`, a store (`$permissions.ban`, `$permissions.editRoles`). The composite gates a parent uses to decide whether to render a menu (`memberMenu`, `roleMenu`, `bannedMenu`, `directoryMenu`) are defined there too, so parent and menu can't disagree. The `SpaceManagement` plugin (`spaceManagement`) caches the -methods per app, re-checked at most every five minutes per URL, and `Access.prepareInvite` reads +methods per app, re-checked at most every five minutes per URL, and `InviteLink` reads them through `loadSupportedMethods`. It also holds the admin and ban lists, which `loadMethodAssignees`/`loadBannedPubkeys` refresh after a change. Still handle an error from the call, since a listed method can be refused for a particular event or target. diff --git a/.agents/skills/flotilla-state/SKILL.md b/.agents/skills/flotilla-state/SKILL.md index a65ebaeb..846ebef0 100644 --- a/.agents/skills/flotilla-state/SKILL.md +++ b/.agents/skills/flotilla-state/SKILL.md @@ -322,7 +322,7 @@ its composer has gone. ## Background sync (`src/app/sync.ts`) The root layout calls `syncApplicationData()` once the session is restored and storage is ready, -and again after every app swap. `Access.completeJoin` calls it after a space is joined. Each +and again after every app swap. `completeSpaceJoin` in `access.ts` calls it after a space is joined. Each call tears down the previous run. - `syncRelays` loads NIP-11 for the indexer relays, the current route's relay and the user's @@ -410,9 +410,8 @@ NIP-86 calls (`relayManagement.get().forUrl(url)`) are not thunks. They return - **Singleton session state.** `call.ts` keeps call state in plain writables (`callState`, `currentCallSession`, …). - **UI signals.** `toast` in `toast.ts`, and `relaysPendingTrust` in `policies.ts`. -- **A controller per flow.** `Access` (`access.ts`) and `Nip46Controller` (`nip46.ts`) are - classes a component instantiates (`new Access(url)`). They hold the writables and actions for - a multi-step flow. +- **A controller per flow.** `Nip46Controller` (`nip46.ts`) is a class a component instantiates. + It holds the writables and actions for a multi-step flow. - **Module-owned values.** `wallet` in `lightning.ts` is a `withGetter(writable(...))` that the root layout persists. diff --git a/src/app/access.ts b/src/app/access.ts index fac888f4..3a0f6e66 100644 --- a/src/app/access.ts +++ b/src/app/access.ts @@ -1,6 +1,6 @@ -import {derived, writable, type Readable} from "svelte/store" -import {dissoc, fromPairs, last, poll, randomId, sleep, tryCatch} from "@welshman/lib" -import {AuthStatus, SocketStatus} from "@welshman/net" +import {derived} from "svelte/store" +import {dissoc, fromPairs, last, poll, randomId, tryCatch} from "@welshman/lib" +import {AuthStatus, RelayReasonPrefix, SocketStatus, matchReason} from "@welshman/net" import type {Socket} from "@welshman/net" import { displayRelayUrl, @@ -16,13 +16,12 @@ import { import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain" import {Sync, User, publish} from "@welshman/app" import {stripPrefix} from "@lib/util" -import {app, command, relayManagement, roomLists, thunks, writer} from "@app/core" +import {app, command, roomLists, thunks, writer} from "@app/core" import {PLATFORM_URL} from "@app/env" -import {spaceManagement} from "@app/management" import {relaysMostlyRestricted} from "@app/policies" -import {Push} from "@app/push" +import {optInSpaceNotifications} from "@app/push" import {deriveSocket} from "@app/relays" -import {notificationSettings, removeTrustedRelay, setSpaceNotifications} from "@app/settings" +import {removeTrustedRelay} from "@app/settings" import {syncApplicationData} from "@app/sync" export const ROOM_CREATE_INVITE = 9009 @@ -34,12 +33,6 @@ export type InviteData = { code?: string } -export const isNetworkAuthError = (error: string) => { - const lower = error.toLowerCase() - - return lower.includes("failed") || lower.includes("timeout") || lower.includes("network") -} - export const parseInviteLink = (invite: string): InviteData | undefined => { if (invite.length < 3 || !invite.includes(".")) { return @@ -83,7 +76,7 @@ export const makeInviteLink = (data: InviteData) => { } export const shouldIgnoreError = (error: string) => { - const isIgnored = error.startsWith("mute: ") + const isIgnored = matchReason(RelayReasonPrefix.Mute, error) const isAborted = error.includes("Signing was aborted") const isStrictNip29Relay = error.includes("missing group (`h`) tag") @@ -249,177 +242,39 @@ export const attemptRelayAccess = async (url: string, claim = "") => { return stripPrefix(error) } -export class Access { - url: string - authError: Readable - loading = writable(true) - claim = writable("") - claimFailed = writable(false) - roomCode = writable() - roomInviteError = writable() - isExplicitAuthError: Readable - isGenericError: Readable - - constructor(url: string) { - this.url = url - this.authError = deriveRelayAuthError(url) - this.isExplicitAuthError = derived([this.authError], ([$authError]) => - Boolean($authError && !isNetworkAuthError($authError)), - ) - this.isGenericError = derived([this.authError], ([$authError]) => - Boolean($authError && isNetworkAuthError($authError)), - ) - } - - createInviteStatus(requireCode: boolean) { - return derived( - [ - this.loading, - this.isGenericError, - this.isExplicitAuthError, - this.roomInviteError, - this.roomCode, - this.claimFailed, +export const completeSpaceJoin = async (url: string, notifications: boolean) => { + await optInSpaceNotifications(url, notifications) + await roomLists.get().addRelay(url).then(publish) + relaysMostlyRestricted.update(dissoc(url)) + syncApplicationData() + app + .get() + .use(Sync) + .push({ + relays: [url], + filters: [ + { + kinds: [RELAYS, MESSAGING_RELAYS, FOLLOWS, PROFILE], + authors: [User.require(app.get()).pubkey], + }, ], - ([$loading, $isGeneric, $isExplicit, $roomInviteError, $roomCode, $claimFailed]) => { - if ($loading) { - return "loading" as const - } - if ($isGeneric) { - return "network" as const - } - if ($isExplicit || $roomInviteError) { - return "auth" as const - } - if (requireCode && !$roomCode) { - return "failed" as const - } - if ($claimFailed) { - return "noclaim" as const - } - - return "ready" as const - }, - ) - } - - clearRestricted() { - relaysMostlyRestricted.update(dissoc(this.url)) - } - - async attempt(claim = "") { - return attemptRelayAccess(this.url, claim) - } - - async configureNotifications(enabled: boolean) { - if (enabled) { - if (!notificationSettings.get().push) { - await setSpaceNotifications(this.url, true) - } else { - const permissions = await Push.request() - - if (permissions.startsWith("granted")) { - await setSpaceNotifications(this.url, true) - } - } - } else { - await setSpaceNotifications(this.url, false) - } - } - - async completeJoin(notifications: boolean) { - await this.configureNotifications(notifications) - await roomLists.get().addRelay(this.url).then(publish) - this.clearRestricted() - syncApplicationData() - app - .get() - .use(Sync) - .push({ - relays: [this.url], - filters: [ - { - kinds: [RELAYS, MESSAGING_RELAYS, FOLLOWS, PROFILE], - authors: [User.require(app.get()).pubkey], - }, - ], - }) - } - - async joinSpace({ - claim = "", - notifications, - alreadyJoined = false, - }: { - claim?: string - notifications: boolean - alreadyJoined?: boolean - }) { - if (!alreadyJoined) { - const error = await this.attempt(claim) - - if (error) { - return error - } - - await this.completeJoin(notifications) - } - } - - async acceptInvite(data: InviteData, notifications: boolean) { - const spaceUrls = roomLists.get().urls(User.require(app.get()).pubkey).get() - const error = await this.joinSpace({ - claim: data.claim, - notifications, - alreadyJoined: spaceUrls.includes(data.url), }) +} + +export const acceptInvite = async (data: InviteData, notifications: boolean) => { + const spaceUrls = roomLists.get().urls(User.require(app.get()).pubkey).get() + + if (!spaceUrls.includes(data.url)) { + const error = await attemptRelayAccess(data.url, data.claim) if (error) { return error } - if (data.h && data.code) { - return joinRoom(this.url, data.h, data.code) - } + await completeSpaceJoin(data.url, notifications) } - async prepareInvite(h?: string) { - this.loading.set(true) - - try { - const management = relayManagement.get().forUrl(this.url) - - const [methods, roomInviteResult] = await Promise.all([ - spaceManagement.get().loadSupportedMethods(this.url), - h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}), - // Keep the spinner up long enough that a fast relay doesn't make it flash - sleep(300), - ]) - - // A relay reporting methods relay-wide can still come back "blocked" for this user. - if (methods.includes("createclaim")) { - const {result: claims} = await management.listClaims() - - if (claims?.[0]) { - this.claim.set(claims[0]) - } else { - const claim = randomId() - const {error} = await management.createClaim(claim) - - if (error) { - this.claimFailed.set(true) - } else { - this.claim.set(claim) - } - } - } - - if (h) { - this.roomCode.set(roomInviteResult.code) - this.roomInviteError.set(roomInviteResult.error) - } - } finally { - this.loading.set(false) - } + if (data.h && data.code) { + return joinRoom(data.url, data.h, data.code) } } diff --git a/src/app/components/InviteLink.svelte b/src/app/components/InviteLink.svelte index 2a6988c4..9dee6915 100644 --- a/src/app/components/InviteLink.svelte +++ b/src/app/components/InviteLink.svelte @@ -1,6 +1,7 @@
- {#if $inviteStatus === "loading"} + {#if loading}

- Requesting an invite link... + Requesting an invite link...

- {:else if $inviteStatus === "network"} + {:else if $authError && /failed|timeout|network/i.test($authError)}

Unable to reach the space. Please check your connection and try again.

- {:else if $inviteStatus === "auth"} + {:else if $authError || roomInviteError}

{#if h} Oops! It looks like you're not allowed to create invites for this room. @@ -66,7 +109,7 @@ Oops! It looks like you're not a member of this space. {/if}

- {:else if $inviteStatus === "failed"} + {:else if h && !roomCode}

Unable to create a room invite code.

{:else}
@@ -101,10 +144,10 @@ {:else} This invite link can be used by clicking "Add Space" and pasting it there. {/if} - {#if $inviteStatus === "noclaim"} + {#if claimFailed} We weren't able to get an invite code from this space, so additional steps might be required. - {:else if !$claim} + {:else if !claim} This space did not issue a claim for this link, so additional steps might be required. {/if}

diff --git a/src/app/components/SpaceAccessRequest.svelte b/src/app/components/SpaceAccessRequest.svelte index 7d30e270..ed7d292d 100644 --- a/src/app/components/SpaceAccessRequest.svelte +++ b/src/app/components/SpaceAccessRequest.svelte @@ -15,7 +15,7 @@ import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte" import {pushToast} from "@app/toast" - import {Access, parseInviteLink} from "@app/access" + import {attemptRelayAccess, parseInviteLink} from "@app/access" type Props = { url: string @@ -24,8 +24,6 @@ const {url, callback}: Props = $props() - const access = new Access(url) - const back = () => history.back() const join = async () => { @@ -33,7 +31,7 @@ try { const claim = parseInviteLink(value)?.claim || value - const message = await access.attempt(claim) + const message = await attemptRelayAccess(url, claim) if (message) { return pushToast({theme: "error", message, timeout: 30_000}) diff --git a/src/app/components/SpaceInviteAccept.svelte b/src/app/components/SpaceInviteAccept.svelte index d6d34cce..474cc829 100644 --- a/src/app/components/SpaceInviteAccept.svelte +++ b/src/app/components/SpaceInviteAccept.svelte @@ -24,7 +24,7 @@ import SpaceJoinStatus from "@app/components/SpaceJoinStatus.svelte" import {pushToast} from "@app/toast" import {goToSpace, makeRoomPath} from "@app/routes" - import {Access, parseInviteLink} from "@app/access" + import {acceptInvite, parseInviteLink} from "@app/access" type Props = { invite: string @@ -45,9 +45,7 @@ const joinRelay = async () => { const data = inviteData! - const access = new Access(data.url) - - error = await access.acceptInvite(data, notifications) + error = await acceptInvite(data, notifications) if (error) { return diff --git a/src/app/components/SpaceJoin.svelte b/src/app/components/SpaceJoin.svelte index 51ed1a2e..217c3759 100644 --- a/src/app/components/SpaceJoin.svelte +++ b/src/app/components/SpaceJoin.svelte @@ -14,7 +14,7 @@ import SpaceAccessRequest from "@app/components/SpaceAccessRequest.svelte" import SpaceJoinNotifications from "@app/components/SpaceJoinNotifications.svelte" import SpaceJoinStatus from "@app/components/SpaceJoinStatus.svelte" - import {Access} from "@app/access" + import {attemptRelayAccess, completeSpaceJoin} from "@app/access" import {pushModal} from "@app/modal" import {pushToast} from "@app/toast" import {goToSpace} from "@app/routes" @@ -25,8 +25,6 @@ const {url}: Props = $props() - const access = new Access(url) - const back = () => history.back() const join = async () => { @@ -37,7 +35,7 @@ loading = true try { - await access.completeJoin(notifications) + await completeSpaceJoin(url, notifications) pushToast({message: "Welcome to the space!"}) await goToSpace(url, {replaceState: true}) @@ -54,7 +52,7 @@ let notifications = $state(true) onMount(async () => { - error = await access.attempt() + error = await attemptRelayAccess(url) loading = false }) diff --git a/src/app/push/index.ts b/src/app/push/index.ts index af695d67..7361c4ef 100644 --- a/src/app/push/index.ts +++ b/src/app/push/index.ts @@ -1,5 +1,5 @@ import {Capacitor} from "@capacitor/core" -import {notificationSettings} from "@app/settings" +import {notificationSettings, setSpaceNotifications} from "@app/settings" import {pushState} from "@app/push/adapters/common" import {ElectronNotifications} from "@app/push/adapters/electron" import {WebNotifications} from "@app/push/adapters/web" @@ -64,3 +64,15 @@ export class Push { }) } } + +export const optInSpaceNotifications = async (url: string, notify: boolean) => { + if (notify && notificationSettings.get().push) { + const permission = await Push.request() + + if (permission.startsWith("granted")) { + await setSpaceNotifications(url, true) + } + } else { + await setSpaceNotifications(url, notify) + } +}