diff --git a/src/app/access.ts b/src/app/access.ts index c396e194..d459dfee 100644 --- a/src/app/access.ts +++ b/src/app/access.ts @@ -42,7 +42,7 @@ export type InviteData = { code?: string } -export type InviteCreateStatus = "loading" | "network" | "auth" | "failed" | "ready" +export type InviteCreateStatus = "loading" | "network" | "auth" | "failed" | "noclaim" | "ready" export type JoinRequestParams = { url: string @@ -55,9 +55,6 @@ export const isNetworkAuthError = (error: string) => { return lower.includes("failed") || lower.includes("timeout") || lower.includes("network") } -export const isRequestNetworkError = (error: unknown) => - error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError") - export const parseInviteLink = (invite: string): InviteData | undefined => { if (invite.length < 3 || !invite.includes(".")) { return @@ -108,22 +105,16 @@ export const shouldIgnoreError = (error: string) => { return isIgnored || isAborted || isStrictNip29Relay } -export const deriveRelayAuthError = (url: string) => { - Pool.get().get(url).auth.attemptAuth(sign) +export const deriveRelayAuthError = (url: string) => + derived([relaysMostlyRestricted, deriveSocket(url)], ([$relaysMostlyRestricted, $socket]) => { + if ($socket.auth.status === AuthStatus.Forbidden && $socket.auth.details) { + return stripPrefix($socket.auth.details) + } - return derived( - [relaysMostlyRestricted, deriveSocket(url)], - ([$relaysMostlyRestricted, $socket]) => { - if ($socket.auth.status === AuthStatus.Forbidden && $socket.auth.details) { - return stripPrefix($socket.auth.details) - } - - if ($relaysMostlyRestricted[url]) { - return stripPrefix($relaysMostlyRestricted[url]) - } - }, - ) -} + if ($relaysMostlyRestricted[url]) { + return stripPrefix($relaysMostlyRestricted[url]) + } + }) export const requestRelayClaim = async (url: string) => { const filters = [{kinds: [RELAY_INVITE], limit: 1}] @@ -229,12 +220,16 @@ export const attemptRelayAccess = async (url: string, claim = "") => { return `Failed to connect` } + // Relays send their challenge right after the socket opens, so if none shows up + // shortly the relay doesn't use auth and we can go ahead and publish. await poll({ - signal: AbortSignal.timeout(3000), + signal: AbortSignal.timeout(800), condition: () => socket.auth.status === AuthStatus.Requested, }) - for (let i = 0; i < 3 && ![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status); i++) { + // Stop at any terminal status — retrying a denied signature or a forbidden response + // just re-prompts the user's signer for an answer we already have. + for (let i = 0; i < 3 && !authTerminalStatuses.includes(socket.auth.status); i++) { await socket.auth.retryAuth(sign) await waitForAuth(socket) } @@ -261,9 +256,9 @@ export const attemptRelayAccess = async (url: string, claim = "") => { export class Access { url: string authError: Readable - networkError = writable(false) loading = writable(true) claim = writable("") + claimFailed = writable(false) roomCode = writable() roomInviteError = writable() isExplicitAuthError: Readable @@ -275,10 +270,8 @@ export class Access { this.isExplicitAuthError = derived([this.authError], ([$authError]) => Boolean($authError && !isNetworkAuthError($authError)), ) - this.isGenericError = derived( - [this.authError, this.networkError, this.isExplicitAuthError], - ([$authError, $networkError, $isExplicitAuthError]) => - $networkError || Boolean($authError && !$isExplicitAuthError), + this.isGenericError = derived([this.authError], ([$authError]) => + Boolean($authError && isNetworkAuthError($authError)), ) } @@ -290,12 +283,14 @@ export class Access { this.isExplicitAuthError, this.roomInviteError, this.roomCode, + this.claimFailed, ], - ([$loading, $isGeneric, $isExplicit, $roomInviteError, $roomCode]) => { + ([$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 }, @@ -383,35 +378,29 @@ export class Access { async prepareInvite(h?: string) { this.loading.set(true) - this.networkError.set(false) - try { - const [[event], roomInviteResult] = await Promise.all([ - request({ - relays: [this.url], - autoClose: true, - signal: AbortSignal.timeout(10000), - filters: [{kinds: [RELAY_INVITE]}], - }), - h ? publishRoomInvite(this.url, h) : Promise.resolve({code: undefined, error: undefined}), - sleep(2000), - ]) + // A request that times out or hits a closed socket resolves with no events, so an + // empty result is the only signal we get that the relay never answered. + const [events, roomInviteResult] = await Promise.all([ + request({ + relays: [this.url], + autoClose: true, + signal: AbortSignal.timeout(10000), + filters: [{kinds: [RELAY_INVITE]}], + }), + 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), + ]) - this.claim.set(getTagValue("claim", event?.tags || []) || "") + this.claim.set(getTagValue("claim", events[0]?.tags || []) || "") + this.claimFailed.set(events.length === 0) - if (h) { - this.roomCode.set(roomInviteResult.code) - this.roomInviteError.set(roomInviteResult.error) - } - } catch (error) { - this.claim.set("") - this.roomCode.set(undefined) - - if (isRequestNetworkError(error)) { - this.networkError.set(true) - } - } finally { - this.loading.set(false) + if (h) { + this.roomCode.set(roomInviteResult.code) + this.roomInviteError.set(roomInviteResult.error) } + + this.loading.set(false) } } diff --git a/src/app/components/RoomInvite.svelte b/src/app/components/RoomInvite.svelte index 08962662..4afee99f 100644 --- a/src/app/components/RoomInvite.svelte +++ b/src/app/components/RoomInvite.svelte @@ -118,7 +118,10 @@

This invite link includes access to the space and room. Anyone with the link can join by opening it in {PLATFORM_NAME}. - {#if !$claim} + {#if $inviteStatus === "noclaim"} + We weren't able to get an invite code from this space, so additional steps might + be required. + {: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/SpaceInvite.svelte b/src/app/components/SpaceInvite.svelte index cca1eaed..f69b6bb1 100644 --- a/src/app/components/SpaceInvite.svelte +++ b/src/app/components/SpaceInvite.svelte @@ -136,7 +136,10 @@ {#snippet info()}

This invite link can be used by clicking "Add Space" and pasting it there. - {#if !$claim} + {#if $inviteStatus === "noclaim"} + We weren't able to get an invite code from this space, so additional steps might + be required. + {: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/members.ts b/src/app/members.ts index 326cdce6..ca051aba 100644 --- a/src/app/members.ts +++ b/src/app/members.ts @@ -20,7 +20,7 @@ import { sortEventsAsc, } from "@welshman/util" import type {Filter, PublishedRoomMeta, TrustedEvent} from "@welshman/util" -import {first, sortBy, spec, uniq} from "@welshman/lib" +import {first, simpleCache, sortBy, spec, uniq} from "@welshman/lib" import {addRoomMember, manageRelay, pubkey, waitForThunkError} from "@welshman/app" import {load} from "@welshman/net" import {get} from "svelte/store" @@ -266,14 +266,24 @@ export enum MembershipStatus { Granted, } -export const deriveUserIsSpaceAdmin = (url?: string) => - readable(false, set => { - if (url) { - manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}).then(res => - set(Boolean(res.result?.length)), - ) +// This costs a signature and an http round trip, so share one store per url and only +// re-check when the answer has had time to go stale (or when a check failed). +export const deriveUserIsSpaceAdmin = simpleCache(([url]: [string | undefined]) => { + let checkedAt = 0 + + return readable(false, set => { + if (url && checkedAt < Date.now() - 300_000) { + checkedAt = Date.now() + + manageRelay(url, {method: ManagementMethod.SupportedMethods, params: []}) + .then(({result}) => set(Boolean(result?.length))) + .catch(error => { + checkedAt = 0 + console.error(error) + }) } }) +}) export const deriveUserSpaceMembershipStatus = (url: string) => { // Fetch member list and user add/remove events directly in this derivation.