Replace the Access class with plain join functions, move invite preparation into InviteLink and space notification opt-in into push

This commit is contained in:
Jon Staab 2026-09-25 22:45:34 -07:00
parent b924523455
commit f1946b9a55
8 changed files with 123 additions and 216 deletions

View file

@ -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=<relay>&c=<claim>`, 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.

View file

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

View file

@ -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<string | undefined>
loading = writable(true)
claim = writable("")
claimFailed = writable(false)
roomCode = writable<string | undefined>()
roomInviteError = writable<string | undefined>()
isExplicitAuthError: Readable<boolean>
isGenericError: Readable<boolean>
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)
}
}

View file

@ -1,6 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {Share} from "@capacitor/share"
import {randomId, sleep} from "@welshman/lib"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Upload from "@assets/icons/upload.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl"
@ -9,8 +10,10 @@
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import QRCode from "@app/components/QRCode.svelte"
import {Access, makeInviteLink} from "@app/access"
import {deriveRelayAuthError, makeInviteLink, publishRoomInvite} from "@app/access"
import {relayManagement} from "@app/core"
import {PLATFORM_NAME} from "@app/env"
import {spaceManagement} from "@app/management"
import {clip} from "@app/toast"
type Props = {
@ -20,10 +23,7 @@
const {url, h}: Props = $props()
const access = new Access(url)
const {claim, loading, roomCode} = access
const inviteStatus = access.createInviteStatus(Boolean(h))
const invite = $derived(makeInviteLink({url, claim: $claim, h, code: $roomCode}))
const authError = deriveRelayAuthError(url)
const copyInvite = () => clip(invite)
@ -35,8 +35,51 @@
}
}
const prepareInvite = async () => {
try {
const management = relayManagement.get().forUrl(url)
const [methods, roomInvite] = await Promise.all([
spaceManagement.get().loadSupportedMethods(url),
h ? publishRoomInvite(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]) {
claim = claims[0]
} else {
const newClaim = randomId()
const {error} = await management.createClaim(newClaim)
if (error) {
claimFailed = true
} else {
claim = newClaim
}
}
}
roomCode = roomInvite.code
roomInviteError = roomInvite.error
} finally {
loading = false
}
}
let loading = $state(true)
let claim = $state("")
let claimFailed = $state(false)
let roomCode = $state<string>()
let roomInviteError = $state<string>()
let canShare = $state(false)
const invite = $derived(makeInviteLink({url, claim, h, code: roomCode}))
onMount(async () => {
try {
const {value} = await Share.canShare()
@ -45,20 +88,20 @@
canShare = false
}
await access.prepareInvite(h)
await prepareInvite()
})
</script>
<div>
{#if $inviteStatus === "loading"}
{#if loading}
<p class="flex justify-center items-center">
<Spinner loading={$loading}>Requesting an invite link...</Spinner>
<Spinner {loading}>Requesting an invite link...</Spinner>
</p>
{:else if $inviteStatus === "network"}
{:else if $authError && /failed|timeout|network/i.test($authError)}
<p class="flex justify-center items-center">
Unable to reach the space. Please check your connection and try again.
</p>
{:else if $inviteStatus === "auth"}
{:else if $authError || roomInviteError}
<p class="flex justify-center items-center">
{#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}
</p>
{:else if $inviteStatus === "failed"}
{:else if h && !roomCode}
<p class="flex justify-center items-center">Unable to create a room invite code.</p>
{:else}
<div class="flex flex-col items-center gap-6">
@ -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}
</p>

View file

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

View file

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

View file

@ -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
})
</script>

View file

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