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 NIP-86 (`createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole`), never by publishing
33534. 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. 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. 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. 3. Translate refusals: a `mute:` reply (`matchReason`) is ignored, and the free-text "invite code"
4. `completeJoin`: `roomLists.addRelay(url)`, restart sync, and `Sync.push` the user's `RELAYS`, 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. `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 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 (`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. `SpaceInviteAccept`, which calls `acceptInvite` to join the space (skipped if already joined) and
`Access.prepareInvite` gets a claim over NIP-86 (`supportedmethods`, then `listclaims`, then then the room. `InviteLink`, rendered by both `SpaceInvite` and `RoomInvite`, gets a claim over
`createclaim`), and `InviteLink` renders the result for both `SpaceInvite` and `RoomInvite`; this replaced reading `RELAY_INVITE` events. Leaving (`SpaceExit`, 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)`. `SpaceAuthError`) is `roomLists.removeRelay(url)` plus `publishLeaveRequest(url)`.
## NIP-86 relay management ## 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`) | | `banPubkey`, `unbanPubkey`, `allowPubkey`, `unallowPubkey`, `listBannedPubkeys` | `ProfileDetail`, `SpaceMemberMenu`, `SpaceMemberBannedMenu`, `ReportMenu`, `allowPubkeys` (`SpaceMembersAdd`, `RoleAddMembers`, `addRoomMembers`) |
| `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenu`, `RoomJoinItem` (dismissing a join request) | | `banEvent` | `RoomItemMenu`, `EventMenu`, `ReportMenu`, `RoomJoinItem` (dismissing a join request) |
| `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles` (via `syncAssignments`), `RoleAddMembers` | | `createRole`, `editRole`, `deleteRole`, `assignRole`, `unassignRole` | `RoleCreate`, `RoleEdit`, `SpaceRoleMenu`, `SpaceMemberRoles` (via `syncAssignments`), `RoleAddMembers` |
| `listClaims`, `createClaim` | `Access.prepareInvite` | | `listClaims`, `createClaim` | `InviteLink` |
| `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` | | `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` |
A relay answers `supportedmethods` with what the authenticated pubkey may call, so every control 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 (`$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, 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 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 them through `loadSupportedMethods`. It also holds the admin and ban lists, which
`loadMethodAssignees`/`loadBannedPubkeys` refresh after a change. Still handle an error from the `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. 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`) ## Background sync (`src/app/sync.ts`)
The root layout calls `syncApplicationData()` once the session is restored and storage is ready, 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. call tears down the previous run.
- `syncRelays` loads NIP-11 for the indexer relays, the current route's relay and the user's - `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`, - **Singleton session state.** `call.ts` keeps call state in plain writables (`callState`,
`currentCallSession`, …). `currentCallSession`, …).
- **UI signals.** `toast` in `toast.ts`, and `relaysPendingTrust` in `policies.ts`. - **UI signals.** `toast` in `toast.ts`, and `relaysPendingTrust` in `policies.ts`.
- **A controller per flow.** `Access` (`access.ts`) and `Nip46Controller` (`nip46.ts`) are - **A controller per flow.** `Nip46Controller` (`nip46.ts`) is a class a component instantiates.
classes a component instantiates (`new Access(url)`). They hold the writables and actions for It holds the writables and actions for a multi-step flow.
a multi-step flow.
- **Module-owned values.** `wallet` in `lightning.ts` is a `withGetter(writable(...))` that the - **Module-owned values.** `wallet` in `lightning.ts` is a `withGetter(writable(...))` that the
root layout persists. root layout persists.

View file

@ -1,6 +1,6 @@
import {derived, writable, type Readable} from "svelte/store" import {derived} from "svelte/store"
import {dissoc, fromPairs, last, poll, randomId, sleep, tryCatch} from "@welshman/lib" import {dissoc, fromPairs, last, poll, randomId, tryCatch} from "@welshman/lib"
import {AuthStatus, SocketStatus} from "@welshman/net" import {AuthStatus, RelayReasonPrefix, SocketStatus, matchReason} from "@welshman/net"
import type {Socket} from "@welshman/net" import type {Socket} from "@welshman/net"
import { import {
displayRelayUrl, displayRelayUrl,
@ -16,13 +16,12 @@ import {
import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain" import {RelayJoin, RelayLeave, RoomJoin, RoomLeave} from "@welshman/domain"
import {Sync, User, publish} from "@welshman/app" import {Sync, User, publish} from "@welshman/app"
import {stripPrefix} from "@lib/util" 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 {PLATFORM_URL} from "@app/env"
import {spaceManagement} from "@app/management"
import {relaysMostlyRestricted} from "@app/policies" import {relaysMostlyRestricted} from "@app/policies"
import {Push} from "@app/push" import {optInSpaceNotifications} from "@app/push"
import {deriveSocket} from "@app/relays" import {deriveSocket} from "@app/relays"
import {notificationSettings, removeTrustedRelay, setSpaceNotifications} from "@app/settings" import {removeTrustedRelay} from "@app/settings"
import {syncApplicationData} from "@app/sync" import {syncApplicationData} from "@app/sync"
export const ROOM_CREATE_INVITE = 9009 export const ROOM_CREATE_INVITE = 9009
@ -34,12 +33,6 @@ export type InviteData = {
code?: string 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 => { export const parseInviteLink = (invite: string): InviteData | undefined => {
if (invite.length < 3 || !invite.includes(".")) { if (invite.length < 3 || !invite.includes(".")) {
return return
@ -83,7 +76,7 @@ export const makeInviteLink = (data: InviteData) => {
} }
export const shouldIgnoreError = (error: string) => { export const shouldIgnoreError = (error: string) => {
const isIgnored = error.startsWith("mute: ") const isIgnored = matchReason(RelayReasonPrefix.Mute, error)
const isAborted = error.includes("Signing was aborted") const isAborted = error.includes("Signing was aborted")
const isStrictNip29Relay = error.includes("missing group (`h`) tag") const isStrictNip29Relay = error.includes("missing group (`h`) tag")
@ -249,94 +242,16 @@ export const attemptRelayAccess = async (url: string, claim = "") => {
return stripPrefix(error) return stripPrefix(error)
} }
export class Access { export const completeSpaceJoin = async (url: string, notifications: boolean) => {
url: string await optInSpaceNotifications(url, notifications)
authError: Readable<string | undefined> await roomLists.get().addRelay(url).then(publish)
loading = writable(true) relaysMostlyRestricted.update(dissoc(url))
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,
],
([$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() syncApplicationData()
app app
.get() .get()
.use(Sync) .use(Sync)
.push({ .push({
relays: [this.url], relays: [url],
filters: [ filters: [
{ {
kinds: [RELAYS, MESSAGING_RELAYS, FOLLOWS, PROFILE], kinds: [RELAYS, MESSAGING_RELAYS, FOLLOWS, PROFILE],
@ -346,80 +261,20 @@ export class Access {
}) })
} }
async joinSpace({ export const acceptInvite = async (data: InviteData, notifications: boolean) => {
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 spaceUrls = roomLists.get().urls(User.require(app.get()).pubkey).get()
const error = await this.joinSpace({
claim: data.claim, if (!spaceUrls.includes(data.url)) {
notifications, const error = await attemptRelayAccess(data.url, data.claim)
alreadyJoined: spaceUrls.includes(data.url),
})
if (error) { if (error) {
return error return error
} }
await completeSpaceJoin(data.url, notifications)
}
if (data.h && data.code) { if (data.h && data.code) {
return joinRoom(this.url, data.h, data.code) return joinRoom(data.url, data.h, data.code)
}
}
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)
}
} }
} }

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import {Share} from "@capacitor/share" import {Share} from "@capacitor/share"
import {randomId, sleep} from "@welshman/lib"
import LinkRound from "@assets/icons/link-round.svg?dataurl" import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Upload from "@assets/icons/upload.svg?dataurl" import Upload from "@assets/icons/upload.svg?dataurl"
import Copy from "@assets/icons/copy.svg?dataurl" import Copy from "@assets/icons/copy.svg?dataurl"
@ -9,8 +10,10 @@
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"
import QRCode from "@app/components/QRCode.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 {PLATFORM_NAME} from "@app/env"
import {spaceManagement} from "@app/management"
import {clip} from "@app/toast" import {clip} from "@app/toast"
type Props = { type Props = {
@ -20,10 +23,7 @@
const {url, h}: Props = $props() const {url, h}: Props = $props()
const access = new Access(url) const authError = deriveRelayAuthError(url)
const {claim, loading, roomCode} = access
const inviteStatus = access.createInviteStatus(Boolean(h))
const invite = $derived(makeInviteLink({url, claim: $claim, h, code: $roomCode}))
const copyInvite = () => clip(invite) 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) let canShare = $state(false)
const invite = $derived(makeInviteLink({url, claim, h, code: roomCode}))
onMount(async () => { onMount(async () => {
try { try {
const {value} = await Share.canShare() const {value} = await Share.canShare()
@ -45,20 +88,20 @@
canShare = false canShare = false
} }
await access.prepareInvite(h) await prepareInvite()
}) })
</script> </script>
<div> <div>
{#if $inviteStatus === "loading"} {#if loading}
<p class="flex justify-center items-center"> <p class="flex justify-center items-center">
<Spinner loading={$loading}>Requesting an invite link...</Spinner> <Spinner {loading}>Requesting an invite link...</Spinner>
</p> </p>
{:else if $inviteStatus === "network"} {:else if $authError && /failed|timeout|network/i.test($authError)}
<p class="flex justify-center items-center"> <p class="flex justify-center items-center">
Unable to reach the space. Please check your connection and try again. Unable to reach the space. Please check your connection and try again.
</p> </p>
{:else if $inviteStatus === "auth"} {:else if $authError || roomInviteError}
<p class="flex justify-center items-center"> <p class="flex justify-center items-center">
{#if h} {#if h}
Oops! It looks like you're not allowed to create invites for this room. 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. Oops! It looks like you're not a member of this space.
{/if} {/if}
</p> </p>
{:else if $inviteStatus === "failed"} {:else if h && !roomCode}
<p class="flex justify-center items-center">Unable to create a room invite code.</p> <p class="flex justify-center items-center">Unable to create a room invite code.</p>
{:else} {:else}
<div class="flex flex-col items-center gap-6"> <div class="flex flex-col items-center gap-6">
@ -101,10 +144,10 @@
{:else} {:else}
This invite link can be used by clicking "Add Space" and pasting it there. This invite link can be used by clicking "Add Space" and pasting it there.
{/if} {/if}
{#if $inviteStatus === "noclaim"} {#if claimFailed}
We weren't able to get an invite code from this space, so additional steps might be We weren't able to get an invite code from this space, so additional steps might be
required. required.
{:else if !$claim} {:else if !claim}
This space did not issue a claim for this link, so additional steps might be required. This space did not issue a claim for this link, so additional steps might be required.
{/if} {/if}
</p> </p>

View file

@ -15,7 +15,7 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {Access, parseInviteLink} from "@app/access" import {attemptRelayAccess, parseInviteLink} from "@app/access"
type Props = { type Props = {
url: string url: string
@ -24,8 +24,6 @@
const {url, callback}: Props = $props() const {url, callback}: Props = $props()
const access = new Access(url)
const back = () => history.back() const back = () => history.back()
const join = async () => { const join = async () => {
@ -33,7 +31,7 @@
try { try {
const claim = parseInviteLink(value)?.claim || value const claim = parseInviteLink(value)?.claim || value
const message = await access.attempt(claim) const message = await attemptRelayAccess(url, claim)
if (message) { if (message) {
return pushToast({theme: "error", message, timeout: 30_000}) return pushToast({theme: "error", message, timeout: 30_000})

View file

@ -24,7 +24,7 @@
import SpaceJoinStatus from "@app/components/SpaceJoinStatus.svelte" import SpaceJoinStatus from "@app/components/SpaceJoinStatus.svelte"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {goToSpace, makeRoomPath} from "@app/routes" import {goToSpace, makeRoomPath} from "@app/routes"
import {Access, parseInviteLink} from "@app/access" import {acceptInvite, parseInviteLink} from "@app/access"
type Props = { type Props = {
invite: string invite: string
@ -45,9 +45,7 @@
const joinRelay = async () => { const joinRelay = async () => {
const data = inviteData! const data = inviteData!
const access = new Access(data.url) error = await acceptInvite(data, notifications)
error = await access.acceptInvite(data, notifications)
if (error) { if (error) {
return return

View file

@ -14,7 +14,7 @@
import SpaceAccessRequest from "@app/components/SpaceAccessRequest.svelte" import SpaceAccessRequest from "@app/components/SpaceAccessRequest.svelte"
import SpaceJoinNotifications from "@app/components/SpaceJoinNotifications.svelte" import SpaceJoinNotifications from "@app/components/SpaceJoinNotifications.svelte"
import SpaceJoinStatus from "@app/components/SpaceJoinStatus.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 {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {goToSpace} from "@app/routes" import {goToSpace} from "@app/routes"
@ -25,8 +25,6 @@
const {url}: Props = $props() const {url}: Props = $props()
const access = new Access(url)
const back = () => history.back() const back = () => history.back()
const join = async () => { const join = async () => {
@ -37,7 +35,7 @@
loading = true loading = true
try { try {
await access.completeJoin(notifications) await completeSpaceJoin(url, notifications)
pushToast({message: "Welcome to the space!"}) pushToast({message: "Welcome to the space!"})
await goToSpace(url, {replaceState: true}) await goToSpace(url, {replaceState: true})
@ -54,7 +52,7 @@
let notifications = $state(true) let notifications = $state(true)
onMount(async () => { onMount(async () => {
error = await access.attempt() error = await attemptRelayAccess(url)
loading = false loading = false
}) })
</script> </script>

View file

@ -1,5 +1,5 @@
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import {notificationSettings} from "@app/settings" import {notificationSettings, setSpaceNotifications} from "@app/settings"
import {pushState} from "@app/push/adapters/common" import {pushState} from "@app/push/adapters/common"
import {ElectronNotifications} from "@app/push/adapters/electron" import {ElectronNotifications} from "@app/push/adapters/electron"
import {WebNotifications} from "@app/push/adapters/web" 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)
}
}