feat: add room invite links with combined space and room join flow (#319)
Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/319 Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social> Co-committed-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
This commit is contained in:
parent
16a446095a
commit
1eebe1698a
16 changed files with 646 additions and 322 deletions
417
src/app/access.ts
Normal file
417
src/app/access.ts
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
import {derived, get, writable, type Readable} from "svelte/store"
|
||||||
|
import {
|
||||||
|
dissoc,
|
||||||
|
filterVals,
|
||||||
|
fromPairs,
|
||||||
|
isDefined,
|
||||||
|
last,
|
||||||
|
poll,
|
||||||
|
randomId,
|
||||||
|
sleep,
|
||||||
|
tryCatch,
|
||||||
|
} from "@welshman/lib"
|
||||||
|
import {AuthStatus, load, Pool, request, SocketStatus} from "@welshman/net"
|
||||||
|
import {
|
||||||
|
displayRelayUrl,
|
||||||
|
getTagValue,
|
||||||
|
isRelayUrl,
|
||||||
|
makeEvent,
|
||||||
|
normalizeRelayUrl,
|
||||||
|
RELAY_INVITE,
|
||||||
|
RELAY_JOIN,
|
||||||
|
RELAY_LEAVE,
|
||||||
|
ROOM_JOIN,
|
||||||
|
} from "@welshman/util"
|
||||||
|
import {publishThunk, sign, waitForThunkError} from "@welshman/app"
|
||||||
|
import {stripPrefix} from "@lib/util"
|
||||||
|
import {PLATFORM_URL} from "@app/env"
|
||||||
|
import {addSpace, userSpaceUrls} from "@app/groups"
|
||||||
|
import {relaysMostlyRestricted} from "@app/policies"
|
||||||
|
import {broadcastUserData} from "@app/profiles"
|
||||||
|
import {Push} from "@app/push"
|
||||||
|
import {resyncApplicationData} from "@app/sync"
|
||||||
|
import {notificationSettings, setSpaceNotifications} from "@app/settings"
|
||||||
|
import {deriveSocket} from "@app/relays"
|
||||||
|
|
||||||
|
export const ROOM_CREATE_INVITE = 9009
|
||||||
|
|
||||||
|
export type InviteData = {
|
||||||
|
url: string
|
||||||
|
claim: string
|
||||||
|
h?: string
|
||||||
|
code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InviteCreateStatus = "loading" | "network" | "auth" | "failed" | "ready"
|
||||||
|
|
||||||
|
export type JoinRequestParams = {
|
||||||
|
url: string
|
||||||
|
claim: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isNetworkAuthError = (error: string) => {
|
||||||
|
const lower = error.toLowerCase()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
tryCatch(() => {
|
||||||
|
const params = fromPairs(Array.from(new URL(invite).searchParams))
|
||||||
|
const {r: relay = "", c: claim = "", h = "", code = ""} = params
|
||||||
|
const url = normalizeRelayUrl(relay)
|
||||||
|
|
||||||
|
if (isRelayUrl(url)) {
|
||||||
|
return {url, claim, h: h || undefined, code: code || undefined}
|
||||||
|
}
|
||||||
|
}) ||
|
||||||
|
tryCatch(() => {
|
||||||
|
const url = normalizeRelayUrl(invite)
|
||||||
|
|
||||||
|
if (isRelayUrl(url)) {
|
||||||
|
return {url, claim: ""}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeInviteLink = (data: InviteData) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
r: displayRelayUrl(data.url),
|
||||||
|
c: data.claim,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data.h) {
|
||||||
|
params.set("h", data.h)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.code) {
|
||||||
|
params.set("code", data.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
return PLATFORM_URL + "/join?" + params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shouldIgnoreError = (error: string) => {
|
||||||
|
const isIgnored = error.startsWith("mute: ")
|
||||||
|
const isAborted = error.includes("Signing was aborted")
|
||||||
|
const isStrictNip29Relay = error.includes("missing group (`h`) tag")
|
||||||
|
|
||||||
|
return isIgnored || isAborted || isStrictNip29Relay
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deriveRelayAuthError = (url: string) => {
|
||||||
|
Pool.get().get(url).auth.attemptAuth(sign)
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestRelayClaim = async (url: string) => {
|
||||||
|
const filters = [{kinds: [RELAY_INVITE], limit: 1}]
|
||||||
|
const events = await load({filters, relays: [url]})
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
return getTagValue("claim", events[0].tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestRelayClaims = async (urls: string[]) =>
|
||||||
|
filterVals(
|
||||||
|
isDefined,
|
||||||
|
fromPairs(await Promise.all(urls.map(async url => [url, await requestRelayClaim(url)]))),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const makeJoinRequest = (params: JoinRequestParams) =>
|
||||||
|
makeEvent(RELAY_JOIN, {tags: [["claim", params.claim]]})
|
||||||
|
|
||||||
|
export const publishJoinRequest = (params: JoinRequestParams) =>
|
||||||
|
publishThunk({event: makeJoinRequest(params), relays: [params.url]})
|
||||||
|
|
||||||
|
export type LeaveRequestParams = {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publishLeaveRequest = (params: LeaveRequestParams) =>
|
||||||
|
publishThunk({event: makeEvent(RELAY_LEAVE), relays: [params.url]})
|
||||||
|
|
||||||
|
export const publishRoomInvite = async (url: string, h: string) => {
|
||||||
|
const code = randomId()
|
||||||
|
const error = await waitForThunkError(
|
||||||
|
publishThunk({
|
||||||
|
event: makeEvent(ROOM_CREATE_INVITE, {
|
||||||
|
tags: [
|
||||||
|
["h", h],
|
||||||
|
["code", code],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
relays: [url],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return {error, code: undefined}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {error: undefined, code}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publishRoomJoinRequest = (url: string, h: string, code?: string) => {
|
||||||
|
const tags = [["h", h]]
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
tags.push(["code", code])
|
||||||
|
}
|
||||||
|
|
||||||
|
return publishThunk({
|
||||||
|
event: makeEvent(ROOM_JOIN, {tags}),
|
||||||
|
relays: [url],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const authTerminalStatuses = [
|
||||||
|
AuthStatus.None,
|
||||||
|
AuthStatus.Ok,
|
||||||
|
AuthStatus.Forbidden,
|
||||||
|
AuthStatus.DeniedSignature,
|
||||||
|
]
|
||||||
|
|
||||||
|
const waitForAuth = async (socket: ReturnType<ReturnType<typeof Pool.get>["get"]>) => {
|
||||||
|
await poll({
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
condition: () => authTerminalStatuses.includes(socket.auth.status),
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatAuthError = (status: AuthStatus, details?: string) => {
|
||||||
|
if (status === AuthStatus.DeniedSignature) {
|
||||||
|
return "Failed to authenticate — check your signer"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === AuthStatus.PendingSignature) {
|
||||||
|
return "Failed to authenticate — approve the signing request from your extension or signer"
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = details || last(status.split(":"))
|
||||||
|
|
||||||
|
return `Failed to authenticate (${message})`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const attemptRelayAccess = async (url: string, claim = "") => {
|
||||||
|
const socket = Pool.get().get(url)
|
||||||
|
|
||||||
|
socket.attemptToOpen()
|
||||||
|
|
||||||
|
await poll({
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
condition: () => socket.status === SocketStatus.Open,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (socket.status !== SocketStatus.Open) {
|
||||||
|
return `Failed to connect`
|
||||||
|
}
|
||||||
|
|
||||||
|
await poll({
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
condition: () => socket.auth.status === AuthStatus.Requested,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let i = 0; i < 3 && ![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status); i++) {
|
||||||
|
await socket.auth.retryAuth(sign)
|
||||||
|
await waitForAuth(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status)) {
|
||||||
|
return formatAuthError(socket.auth.status, socket.auth.details)
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = await waitForThunkError(publishJoinRequest({url, claim}))
|
||||||
|
|
||||||
|
if (shouldIgnoreError(error)) return
|
||||||
|
|
||||||
|
if (claim) {
|
||||||
|
if (error.includes("invite code")) {
|
||||||
|
return "join request rejected"
|
||||||
|
}
|
||||||
|
} else if (error.includes("invite code")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripPrefix(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Access {
|
||||||
|
url: string
|
||||||
|
authError: Readable<string | undefined>
|
||||||
|
networkError = writable(false)
|
||||||
|
loading = writable(true)
|
||||||
|
claim = writable("")
|
||||||
|
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, this.networkError, this.isExplicitAuthError],
|
||||||
|
([$authError, $networkError, $isExplicitAuthError]) =>
|
||||||
|
$networkError || Boolean($authError && !$isExplicitAuthError),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createInviteStatus(requireCode: boolean) {
|
||||||
|
return derived(
|
||||||
|
[
|
||||||
|
this.loading,
|
||||||
|
this.isGenericError,
|
||||||
|
this.isExplicitAuthError,
|
||||||
|
this.roomInviteError,
|
||||||
|
this.roomCode,
|
||||||
|
],
|
||||||
|
([$loading, $isGeneric, $isExplicit, $roomInviteError, $roomCode]) => {
|
||||||
|
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
|
||||||
|
|
||||||
|
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 addSpace(this.url)
|
||||||
|
this.clearRestricted()
|
||||||
|
resyncApplicationData()
|
||||||
|
broadcastUserData([this.url])
|
||||||
|
}
|
||||||
|
|
||||||
|
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 joinRoom(h: string, code?: string) {
|
||||||
|
if (!code) return
|
||||||
|
|
||||||
|
const message = await waitForThunkError(publishRoomJoinRequest(this.url, h, code))
|
||||||
|
|
||||||
|
if (message && !message.startsWith("duplicate:")) {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptInvite(data: InviteData, notifications: boolean) {
|
||||||
|
const alreadyInSpace = get(userSpaceUrls).includes(data.url)
|
||||||
|
const error = await this.joinSpace({
|
||||||
|
claim: data.claim,
|
||||||
|
notifications,
|
||||||
|
alreadyJoined: alreadyInSpace,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.h && data.code) {
|
||||||
|
return this.joinRoom(data.h, data.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
])
|
||||||
|
|
||||||
|
this.claim.set(getTagValue("claim", event?.tags || []) || "")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
import EyeClosed from "@assets/icons/eye-closed.svg?dataurl"
|
import EyeClosed from "@assets/icons/eye-closed.svg?dataurl"
|
||||||
import Eye from "@assets/icons/eye.svg?dataurl"
|
import Eye from "@assets/icons/eye.svg?dataurl"
|
||||||
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
|
||||||
|
|
@ -18,10 +19,11 @@
|
||||||
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
||||||
import RoomMembers from "@app/components/RoomMembers.svelte"
|
import RoomMembers from "@app/components/RoomMembers.svelte"
|
||||||
import RoomDetailMenu from "@app/components/RoomDetailMenu.svelte"
|
import RoomDetailMenu from "@app/components/RoomDetailMenu.svelte"
|
||||||
|
import RoomInvite from "@app/components/RoomInvite.svelte"
|
||||||
import RoomName from "@app/components/RoomName.svelte"
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
import RoomImage from "@app/components/RoomImage.svelte"
|
import RoomImage from "@app/components/RoomImage.svelte"
|
||||||
import {deriveRoom, deriveUserRooms, addRoom, removeRoom} from "@app/groups"
|
import {deriveRoom, deriveUserRooms, addRoom, removeRoom} from "@app/groups"
|
||||||
import {deriveRoomMembers} from "@app/members"
|
import {deriveRoomMembers, deriveUserIsRoomAdmin} from "@app/members"
|
||||||
import {deriveShouldNotify, toggleRoomNotifications} from "@app/settings"
|
import {deriveShouldNotify, toggleRoomNotifications} from "@app/settings"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
||||||
|
|
@ -35,6 +37,7 @@
|
||||||
const room = deriveRoom(url, h)
|
const room = deriveRoom(url, h)
|
||||||
const members = deriveRoomMembers(url, h)
|
const members = deriveRoomMembers(url, h)
|
||||||
const userRooms = deriveUserRooms(url)
|
const userRooms = deriveUserRooms(url)
|
||||||
|
const userIsAdmin = deriveUserIsRoomAdmin(url, h)
|
||||||
|
|
||||||
const isFavorite = $derived($userRooms.includes(h))
|
const isFavorite = $derived($userRooms.includes(h))
|
||||||
const shouldNotify = deriveShouldNotify(url, h)
|
const shouldNotify = deriveShouldNotify(url, h)
|
||||||
|
|
@ -43,6 +46,8 @@
|
||||||
|
|
||||||
const showMembers = () => pushModal(RoomMembers, {url, h})
|
const showMembers = () => pushModal(RoomMembers, {url, h})
|
||||||
|
|
||||||
|
const createInvite = () => pushModal(RoomInvite, {url, h})
|
||||||
|
|
||||||
const toggleFavorite = () => {
|
const toggleFavorite = () => {
|
||||||
if (isFavorite) {
|
if (isFavorite) {
|
||||||
removeRoom(url, h)
|
removeRoom(url, h)
|
||||||
|
|
@ -114,13 +119,21 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if $members !== undefined && $members.length > 0}
|
{#if $members !== undefined && $members.length > 0}
|
||||||
<div class="card card-sm flex items-center justify-between gap-4">
|
<div class="card card-sm flex flex-col gap-3">
|
||||||
<div class="flex min-w-0 items-center gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<span class="shrink-0">Members:</span>
|
<div class="flex min-w-0 items-center gap-4">
|
||||||
<ProfileCircles pubkeys={$members} class="min-w-0 overflow-hidden" />
|
<span class="shrink-0">Members:</span>
|
||||||
|
<ProfileCircles pubkeys={$members} class="min-w-0 overflow-hidden" />
|
||||||
|
</div>
|
||||||
|
<Button class="button button-neutral button-sm shrink-0" onclick={showMembers}
|
||||||
|
>View All</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button class="button button-neutral button-sm shrink-0" onclick={showMembers}
|
{#if $userIsAdmin}
|
||||||
>View All</Button>
|
<Button class="button button-neutral" onclick={createInvite}>
|
||||||
|
<Icon icon={LinkRound} />
|
||||||
|
Create invite
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if $members === undefined}
|
{:else if $members === undefined}
|
||||||
<div class="card card-sm bg-surface flex items-center gap-4">
|
<div class="card card-sm bg-surface flex items-center gap-4">
|
||||||
|
|
|
||||||
135
src/app/components/RoomInvite.svelte
Normal file
135
src/app/components/RoomInvite.svelte
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from "svelte"
|
||||||
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
|
import {Share} from "@capacitor/share"
|
||||||
|
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"
|
||||||
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import QRCode from "@app/components/QRCode.svelte"
|
||||||
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
|
import {Access, makeInviteLink} from "@app/access"
|
||||||
|
import {PLATFORM_NAME} from "@app/env"
|
||||||
|
import {clip} from "@app/toast"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
h: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, h}: Props = $props()
|
||||||
|
|
||||||
|
const access = new Access(url)
|
||||||
|
const {claim, loading, roomCode} = access
|
||||||
|
const inviteStatus = access.createInviteStatus(true)
|
||||||
|
|
||||||
|
const back = () => history.back()
|
||||||
|
const copyInvite = () => clip(invite)
|
||||||
|
|
||||||
|
const shareInvite = async () => {
|
||||||
|
if (!canShare) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Share.share({url: invite})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let canShare = $state(false)
|
||||||
|
let invite = $state("")
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
invite = makeInviteLink({url, claim: $claim, h, code: $roomCode})
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const {value} = await Share.canShare()
|
||||||
|
canShare = value
|
||||||
|
} catch {
|
||||||
|
canShare = false
|
||||||
|
}
|
||||||
|
|
||||||
|
await access.prepareInvite(h)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Create a Room Invite</ModalTitle>
|
||||||
|
<ModalSubtitle>
|
||||||
|
Get a link to invite people to
|
||||||
|
<RoomName {url} {h} class="text-primary" />
|
||||||
|
in <span class="text-primary">{displayRelayUrl(url)}</span>
|
||||||
|
</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<div>
|
||||||
|
{#if $inviteStatus === "loading"}
|
||||||
|
<p class="flex justify-center items-center">
|
||||||
|
<Spinner loading={$loading}>Requesting an invite link...</Spinner>
|
||||||
|
</p>
|
||||||
|
{:else if $inviteStatus === "network"}
|
||||||
|
<p class="flex justify-center items-center">
|
||||||
|
Unable to reach the space. Please check your connection and try again.
|
||||||
|
</p>
|
||||||
|
{:else if $inviteStatus === "auth"}
|
||||||
|
<p class="flex justify-center items-center">
|
||||||
|
Oops! It looks like you're not allowed to create invites for this room.
|
||||||
|
</p>
|
||||||
|
{:else if $inviteStatus === "failed"}
|
||||||
|
<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">
|
||||||
|
<div class="w-48">
|
||||||
|
<QRCode code={invite} />
|
||||||
|
</div>
|
||||||
|
<Field>
|
||||||
|
{#snippet input()}
|
||||||
|
<div class="flex w-full gap-2">
|
||||||
|
{#if canShare}
|
||||||
|
<Button
|
||||||
|
class="input flex w-12 shrink-0 items-center justify-center p-0"
|
||||||
|
onclick={shareInvite}>
|
||||||
|
<Icon icon={Upload} />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<label class="input flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<Icon icon={LinkRound} class="shrink-0" />
|
||||||
|
<input bind:value={invite} class="min-w-0 flex-1 truncate" type="text" readonly />
|
||||||
|
<Button class="shrink-0" onclick={copyInvite}>
|
||||||
|
<Icon icon={Copy} />
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet info()}
|
||||||
|
<p>
|
||||||
|
This invite link includes access to the space and room. Anyone with the link can
|
||||||
|
join by opening it in {PLATFORM_NAME}.
|
||||||
|
{#if !$claim}
|
||||||
|
This space did not issue a claim for this link, so additional steps might be
|
||||||
|
required.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/snippet}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="button button-primary grow" onclick={back}>Done</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
import SignUpEmail from "@app/components/SignUpEmail.svelte"
|
import SignUpEmail from "@app/components/SignUpEmail.svelte"
|
||||||
import SignUpProfile from "@app/components/SignUpProfile.svelte"
|
import SignUpProfile from "@app/components/SignUpProfile.svelte"
|
||||||
import SignUpComplete from "@app/components/SignUpComplete.svelte"
|
import SignUpComplete from "@app/components/SignUpComplete.svelte"
|
||||||
import {attemptRelayAccess} from "@app/relays"
|
import {attemptRelayAccess} from "@app/access"
|
||||||
import {
|
import {
|
||||||
POMADE_SIGNERS,
|
POMADE_SIGNERS,
|
||||||
PLATFORM_NAME,
|
PLATFORM_NAME,
|
||||||
|
|
|
||||||
|
|
@ -15,8 +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 {attemptRelayAccess} from "@app/relays"
|
import {Access, parseInviteLink} from "@app/access"
|
||||||
import {parseInviteLink} from "@app/invites"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -25,6 +24,8 @@
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|
@ -32,7 +33,7 @@
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const claim = parseInviteLink(value)?.claim || value
|
const claim = parseInviteLink(value)?.claim || value
|
||||||
const message = await attemptRelayAccess(url, claim)
|
const message = await access.attempt(claim)
|
||||||
|
|
||||||
if (message) {
|
if (message) {
|
||||||
return pushToast({theme: "error", message, timeout: 30_000})
|
return pushToast({theme: "error", message, timeout: 30_000})
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@
|
||||||
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 SpaceAccessRequest from "@app/components/SpaceAccessRequest.svelte"
|
import SpaceAccessRequest from "@app/components/SpaceAccessRequest.svelte"
|
||||||
|
import {publishLeaveRequest} from "@app/access"
|
||||||
import {pushModal, clearModals} from "@app/modal"
|
import {pushModal, clearModals} from "@app/modal"
|
||||||
import {removeSpace} from "@app/groups"
|
import {removeSpace} from "@app/groups"
|
||||||
import {publishLeaveRequest} from "@app/relays"
|
|
||||||
import {removeTrustedRelay} from "@app/settings"
|
import {removeTrustedRelay} from "@app/settings"
|
||||||
|
|
||||||
const {url, error} = $props()
|
const {url, error} = $props()
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@
|
||||||
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import {publishLeaveRequest} from "@app/access"
|
||||||
import {removeSpace} from "@app/groups"
|
import {removeSpace} from "@app/groups"
|
||||||
import {publishLeaveRequest} from "@app/relays"
|
|
||||||
import {removeTrustedRelay} from "@app/settings"
|
import {removeTrustedRelay} from "@app/settings"
|
||||||
|
|
||||||
const {url} = $props()
|
const {url} = $props()
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {sleep} from "@welshman/lib"
|
import {displayRelayUrl, ManagementMethod} from "@welshman/util"
|
||||||
import {request} from "@welshman/net"
|
|
||||||
import {displayRelayUrl, getTagValue, ManagementMethod, RELAY_INVITE} from "@welshman/util"
|
|
||||||
import {Share} from "@capacitor/share"
|
import {Share} from "@capacitor/share"
|
||||||
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"
|
||||||
|
|
@ -22,26 +20,17 @@
|
||||||
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
|
import ProfileMultiSelect from "@app/components/ProfileMultiSelect.svelte"
|
||||||
import QRCode from "@app/components/QRCode.svelte"
|
import QRCode from "@app/components/QRCode.svelte"
|
||||||
import {clip, pushToast} from "@app/toast"
|
import {clip, pushToast} from "@app/toast"
|
||||||
import {PLATFORM_URL} from "@app/env"
|
import {deriveSupportedMethods} from "@app/relays"
|
||||||
import {deriveRelayAuthError, deriveSupportedMethods} from "@app/relays"
|
|
||||||
import {addSpaceMembers} from "@app/members"
|
import {addSpaceMembers} from "@app/members"
|
||||||
|
import {Access, makeInviteLink} from "@app/access"
|
||||||
|
|
||||||
const {url} = $props()
|
const {url} = $props()
|
||||||
|
|
||||||
|
const access = new Access(url)
|
||||||
|
const {claim, loading} = access
|
||||||
|
const inviteStatus = access.createInviteStatus(false)
|
||||||
const supportedMethods = deriveSupportedMethods(url)
|
const supportedMethods = deriveSupportedMethods(url)
|
||||||
const canAddMembers = $derived($supportedMethods.includes(ManagementMethod.AllowPubkey))
|
const canAddMembers = $derived($supportedMethods.includes(ManagementMethod.AllowPubkey))
|
||||||
const authError = deriveRelayAuthError(url)
|
|
||||||
|
|
||||||
let networkError = $state(false)
|
|
||||||
const isExplicitAuthError = $derived(
|
|
||||||
$authError &&
|
|
||||||
!(
|
|
||||||
$authError.toLowerCase().includes("failed") ||
|
|
||||||
$authError.toLowerCase().includes("timeout") ||
|
|
||||||
$authError.toLowerCase().includes("network")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const isGenericError = $derived(networkError || ($authError && !isExplicitAuthError))
|
|
||||||
|
|
||||||
const back = () => history.back()
|
const back = () => history.back()
|
||||||
const copyInvite = () => clip(invite)
|
const copyInvite = () => clip(invite)
|
||||||
|
|
@ -57,8 +46,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let canShare = $state(false)
|
let canShare = $state(false)
|
||||||
let claim = $state("")
|
|
||||||
let loading = $state(true)
|
|
||||||
let invite = $state("")
|
let invite = $state("")
|
||||||
|
|
||||||
let adding = $state(false)
|
let adding = $state(false)
|
||||||
|
|
@ -84,9 +71,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const relay = displayRelayUrl(url)
|
invite = makeInviteLink({url, claim: $claim})
|
||||||
const params = new URLSearchParams({r: relay, c: claim}).toString()
|
|
||||||
invite = PLATFORM_URL + "/join?" + params
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
|
@ -97,29 +82,7 @@
|
||||||
canShare = false
|
canShare = false
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await access.prepareInvite()
|
||||||
const [[event]] = await Promise.all([
|
|
||||||
request({
|
|
||||||
relays: [url],
|
|
||||||
autoClose: true,
|
|
||||||
signal: AbortSignal.timeout(10000),
|
|
||||||
filters: [{kinds: [RELAY_INVITE]}],
|
|
||||||
}),
|
|
||||||
sleep(2000),
|
|
||||||
])
|
|
||||||
|
|
||||||
claim = getTagValue("claim", event?.tags || []) || ""
|
|
||||||
} catch (err) {
|
|
||||||
claim = ""
|
|
||||||
if (
|
|
||||||
(err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError")) ||
|
|
||||||
!navigator.onLine
|
|
||||||
) {
|
|
||||||
networkError = true
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -133,15 +96,15 @@
|
||||||
</ModalSubtitle>
|
</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<div>
|
<div>
|
||||||
{#if loading}
|
{#if $inviteStatus === "loading"}
|
||||||
<p class="flex justify-center items-center">
|
<p class="flex justify-center items-center">
|
||||||
<Spinner {loading}>Requesting an invite link...</Spinner>
|
<Spinner loading={$loading}>Requesting an invite link...</Spinner>
|
||||||
</p>
|
</p>
|
||||||
{:else if isGenericError}
|
{:else if $inviteStatus === "network"}
|
||||||
<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 isExplicitAuthError}
|
{:else if $inviteStatus === "auth"}
|
||||||
<p class="flex justify-center items-center">
|
<p class="flex justify-center items-center">
|
||||||
Oops! It looks like you're not a member of this space.
|
Oops! It looks like you're not a member of this space.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -173,7 +136,7 @@
|
||||||
{#snippet info()}
|
{#snippet info()}
|
||||||
<p>
|
<p>
|
||||||
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 !claim}
|
{#if !$claim}
|
||||||
This space did not issue a claim for this link, so additional steps might be
|
This space did not issue a claim for this link, so additional steps might be
|
||||||
required.
|
required.
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {debounce} from "throttle-debounce"
|
import {debounce} from "throttle-debounce"
|
||||||
import {dissoc, maybe} from "@welshman/lib"
|
import {maybe} from "@welshman/lib"
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
import {slideAndFade} from "@lib/transition"
|
import {slideAndFade} from "@lib/transition"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
|
|
@ -22,15 +23,8 @@
|
||||||
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 {pushToast} from "@app/toast"
|
import {pushToast} from "@app/toast"
|
||||||
import {goToSpace} from "@app/routes"
|
import {goToSpace, makeRoomPath} from "@app/routes"
|
||||||
import {resyncApplicationData} from "@app/sync"
|
import {Access, parseInviteLink} from "@app/access"
|
||||||
import {relaysMostlyRestricted} from "@app/policies"
|
|
||||||
import {notificationSettings, setSpaceNotifications} from "@app/settings"
|
|
||||||
import {parseInviteLink} from "@app/invites"
|
|
||||||
import {Push} from "@app/push"
|
|
||||||
import {attemptRelayAccess} from "@app/relays"
|
|
||||||
import {addSpace} from "@app/groups"
|
|
||||||
import {broadcastUserData} from "@app/profiles"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
invite: string
|
invite: string
|
||||||
|
|
@ -50,34 +44,26 @@
|
||||||
})
|
})
|
||||||
|
|
||||||
const joinRelay = async () => {
|
const joinRelay = async () => {
|
||||||
const {url, claim} = inviteData!
|
const data = inviteData!
|
||||||
|
const access = new Access(data.url)
|
||||||
|
|
||||||
error = await attemptRelayAccess(url, claim)
|
error = await access.acceptInvite(data, notifications)
|
||||||
|
|
||||||
if (!error) {
|
if (error) {
|
||||||
if (notifications) {
|
return
|
||||||
if (!notificationSettings.get().push) {
|
|
||||||
await setSpaceNotifications(url, true)
|
|
||||||
} else {
|
|
||||||
const permissions = await Push.request()
|
|
||||||
|
|
||||||
if (permissions.startsWith("granted")) {
|
|
||||||
await setSpaceNotifications(url, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await setSpaceNotifications(url, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
await addSpace(url)
|
|
||||||
|
|
||||||
pushToast({message: "Welcome to the space!"})
|
|
||||||
relaysMostlyRestricted.update(dissoc(url))
|
|
||||||
resyncApplicationData()
|
|
||||||
broadcastUserData([url])
|
|
||||||
|
|
||||||
await goToSpace(url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.h) {
|
||||||
|
const path = makeRoomPath(data.url, data.h)
|
||||||
|
const qp = data.code ? `?code=${encodeURIComponent(data.code)}` : ""
|
||||||
|
|
||||||
|
pushToast({message: "Welcome to the room!"})
|
||||||
|
await goto(path + qp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pushToast({message: "Welcome to the space!"})
|
||||||
|
await goToSpace(data.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const join = async () => {
|
const join = async () => {
|
||||||
|
|
@ -96,15 +82,21 @@
|
||||||
let notifications = $state(true)
|
let notifications = $state(true)
|
||||||
|
|
||||||
const inviteData = $derived(parseInviteLink(invite))
|
const inviteData = $derived(parseInviteLink(invite))
|
||||||
|
const isRoomInvite = $derived(Boolean(inviteData?.h))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal tag="form" onsubmit={preventDefault(join)}>
|
<Modal tag="form" onsubmit={preventDefault(join)}>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
{#if !hideHeader}
|
{#if !hideHeader}
|
||||||
<ModalHeader>
|
<ModalHeader>
|
||||||
<ModalTitle>Join a Space</ModalTitle>
|
<ModalTitle>{isRoomInvite ? "Join a Room" : "Join a Space"}</ModalTitle>
|
||||||
<ModalSubtitle
|
<ModalSubtitle>
|
||||||
>Enter a relay URL or invite link below to join an existing space.</ModalSubtitle>
|
{#if isRoomInvite}
|
||||||
|
Enter a room invite link below to join an existing room.
|
||||||
|
{:else}
|
||||||
|
Enter a relay URL or invite link below to join an existing space.
|
||||||
|
{/if}
|
||||||
|
</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<Field>
|
<Field>
|
||||||
{#snippet label()}
|
{#snippet label()}
|
||||||
|
|
@ -145,7 +137,7 @@
|
||||||
Go back
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" class="button button-primary" disabled={!inviteData || loading}>
|
<Button type="submit" class="button button-primary" disabled={!inviteData || loading}>
|
||||||
<Spinner {loading}>Join Space</Spinner>
|
<Spinner {loading}>{isRoomInvite ? "Join Room" : "Join Space"}</Spinner>
|
||||||
<Icon icon={AltArrowRight} />
|
<Icon icon={AltArrowRight} />
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount} from "svelte"
|
import {onMount} from "svelte"
|
||||||
import {dissoc, maybe} from "@welshman/lib"
|
import {maybe} from "@welshman/lib"
|
||||||
import {preventDefault} from "@lib/html"
|
import {preventDefault} from "@lib/html"
|
||||||
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"
|
||||||
|
|
@ -14,17 +14,10 @@
|
||||||
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 {attemptRelayAccess} from "@app/relays"
|
import {Access} from "@app/access"
|
||||||
import {addSpace} from "@app/groups"
|
|
||||||
import {resyncApplicationData} from "@app/sync"
|
|
||||||
import {broadcastUserData} from "@app/profiles"
|
|
||||||
import {setSpaceNotifications} from "@app/settings"
|
|
||||||
import {relaysMostlyRestricted} from "@app/policies"
|
|
||||||
import {notificationSettings} from "@app/settings"
|
|
||||||
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"
|
||||||
import {Push} from "@app/push"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: string
|
url: string
|
||||||
|
|
@ -32,6 +25,8 @@
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|
@ -42,27 +37,9 @@
|
||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (notifications) {
|
await access.completeJoin(notifications)
|
||||||
if (!notificationSettings.get().push) {
|
|
||||||
await setSpaceNotifications(url, true)
|
|
||||||
} else {
|
|
||||||
const permissions = await Push.request()
|
|
||||||
|
|
||||||
if (permissions.startsWith("granted")) {
|
|
||||||
await setSpaceNotifications(url, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await setSpaceNotifications(url, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
await addSpace(url)
|
|
||||||
|
|
||||||
pushToast({message: "Welcome to the space!"})
|
pushToast({message: "Welcome to the space!"})
|
||||||
relaysMostlyRestricted.update(dissoc(url))
|
|
||||||
resyncApplicationData()
|
|
||||||
broadcastUserData([url])
|
|
||||||
|
|
||||||
await goToSpace(url)
|
await goToSpace(url)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to join space:", e)
|
console.error("Failed to join space:", e)
|
||||||
|
|
@ -77,7 +54,7 @@
|
||||||
let notifications = $state(true)
|
let notifications = $state(true)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
error = await attemptRelayAccess(url)
|
error = await access.attempt()
|
||||||
loading = false
|
loading = false
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import {fromPairs, tryCatch} from "@welshman/lib"
|
|
||||||
import {isRelayUrl, normalizeRelayUrl} from "@welshman/util"
|
|
||||||
export type InviteData = {url: string; claim: string}
|
|
||||||
|
|
||||||
export const parseInviteLink = (invite: string): InviteData | undefined => {
|
|
||||||
if (invite.length < 3 || !invite.includes(".")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
tryCatch(() => {
|
|
||||||
const {r: relay = "", c: claim = ""} = fromPairs(Array.from(new URL(invite).searchParams))
|
|
||||||
const url = normalizeRelayUrl(relay)
|
|
||||||
|
|
||||||
if (isRelayUrl(url)) {
|
|
||||||
return {url, claim}
|
|
||||||
}
|
|
||||||
}) ||
|
|
||||||
tryCatch(() => {
|
|
||||||
const url = normalizeRelayUrl(invite)
|
|
||||||
|
|
||||||
if (isRelayUrl(url)) {
|
|
||||||
return {url, claim: ""}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +1,16 @@
|
||||||
import {
|
import {
|
||||||
RELAY_INVITE,
|
|
||||||
RELAY_JOIN,
|
|
||||||
RELAY_LEAVE,
|
|
||||||
ManagementMethod,
|
ManagementMethod,
|
||||||
getRelaysFromList,
|
getRelaysFromList,
|
||||||
getTagValue,
|
|
||||||
isShareableRelayUrl,
|
isShareableRelayUrl,
|
||||||
isSignedEvent,
|
isSignedEvent,
|
||||||
makeEvent,
|
|
||||||
normalizeRelayUrl,
|
normalizeRelayUrl,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {List, RelayProfile} from "@welshman/util"
|
import type {List, RelayProfile} from "@welshman/util"
|
||||||
import {AuthStateEvent, AuthStatus, Pool, SocketEvent, SocketStatus, load} from "@welshman/net"
|
import {AuthStateEvent, AuthStatus, Pool, SocketEvent, SocketStatus} from "@welshman/net"
|
||||||
import {derived, readable} from "svelte/store"
|
import {derived, readable} from "svelte/store"
|
||||||
import {
|
import {call, on, simpleCache, uniq} from "@welshman/lib"
|
||||||
call,
|
|
||||||
filterVals,
|
|
||||||
fromPairs,
|
|
||||||
isDefined,
|
|
||||||
last,
|
|
||||||
on,
|
|
||||||
poll,
|
|
||||||
simpleCache,
|
|
||||||
uniq,
|
|
||||||
} from "@welshman/lib"
|
|
||||||
import {throttled} from "@welshman/store"
|
import {throttled} from "@welshman/store"
|
||||||
import {
|
import {getRelay, loadRelay, manageRelay, publishThunk, waitForThunkError} from "@welshman/app"
|
||||||
getRelay,
|
|
||||||
loadRelay,
|
|
||||||
manageRelay,
|
|
||||||
publishThunk,
|
|
||||||
sign,
|
|
||||||
waitForThunkError,
|
|
||||||
} from "@welshman/app"
|
|
||||||
import {checkRelayHasLivekit} from "$lib/livekit"
|
import {checkRelayHasLivekit} from "$lib/livekit"
|
||||||
import {stripPrefix} from "@lib/util"
|
import {stripPrefix} from "@lib/util"
|
||||||
import {relaysMostlyRestricted} from "@app/policies"
|
import {relaysMostlyRestricted} from "@app/policies"
|
||||||
|
|
@ -145,14 +123,6 @@ export const deriveHasLivekit = simpleCache(([url]: [string]) =>
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const shouldIgnoreError = (error: string) => {
|
|
||||||
const isIgnored = error.startsWith("mute: ")
|
|
||||||
const isAborted = error.includes("Signing was aborted")
|
|
||||||
const isStrictNip29Relay = error.includes("missing group (`h`) tag")
|
|
||||||
|
|
||||||
return isIgnored || isAborted || isStrictNip29Relay
|
|
||||||
}
|
|
||||||
|
|
||||||
export const discoverRelays = (lists: List[]) =>
|
export const discoverRelays = (lists: List[]) =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
uniq(lists.flatMap($l => getRelaysFromList($l)))
|
uniq(lists.flatMap($l => getRelaysFromList($l)))
|
||||||
|
|
@ -160,125 +130,4 @@ export const discoverRelays = (lists: List[]) =>
|
||||||
.map(url => loadRelay(url)),
|
.map(url => loadRelay(url)),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const requestRelayClaim = async (url: string) => {
|
|
||||||
const filters = [{kinds: [RELAY_INVITE], limit: 1}]
|
|
||||||
const events = await load({filters, relays: [url]})
|
|
||||||
|
|
||||||
if (events.length > 0) {
|
|
||||||
return getTagValue("claim", events[0].tags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const requestRelayClaims = async (urls: string[]) =>
|
|
||||||
filterVals(
|
|
||||||
isDefined,
|
|
||||||
fromPairs(await Promise.all(urls.map(async url => [url, await requestRelayClaim(url)]))),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const canEnforceNip70 = (url: string) => hasNip70(getRelay(url))
|
export const canEnforceNip70 = (url: string) => hasNip70(getRelay(url))
|
||||||
|
|
||||||
const authTerminalStatuses = [
|
|
||||||
AuthStatus.None,
|
|
||||||
AuthStatus.Ok,
|
|
||||||
AuthStatus.Forbidden,
|
|
||||||
AuthStatus.DeniedSignature,
|
|
||||||
]
|
|
||||||
|
|
||||||
const waitForAuth = async (socket: ReturnType<ReturnType<typeof Pool.get>["get"]>) => {
|
|
||||||
await poll({
|
|
||||||
signal: AbortSignal.timeout(10_000),
|
|
||||||
condition: () => authTerminalStatuses.includes(socket.auth.status),
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatAuthError = (status: AuthStatus, details?: string) => {
|
|
||||||
if (status === AuthStatus.DeniedSignature) {
|
|
||||||
return "Failed to authenticate — check your signer"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === AuthStatus.PendingSignature) {
|
|
||||||
return "Failed to authenticate — approve the signing request from your extension or signer"
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = details || last(status.split(":"))
|
|
||||||
|
|
||||||
return `Failed to authenticate (${message})`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const attemptRelayAccess = async (url: string, claim = "") => {
|
|
||||||
const socket = Pool.get().get(url)
|
|
||||||
|
|
||||||
socket.attemptToOpen()
|
|
||||||
|
|
||||||
await poll({
|
|
||||||
signal: AbortSignal.timeout(3000),
|
|
||||||
condition: () => socket.status === SocketStatus.Open,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (socket.status !== SocketStatus.Open) {
|
|
||||||
return `Failed to connect`
|
|
||||||
}
|
|
||||||
|
|
||||||
await poll({
|
|
||||||
signal: AbortSignal.timeout(3000),
|
|
||||||
condition: () => socket.auth.status === AuthStatus.Requested,
|
|
||||||
})
|
|
||||||
|
|
||||||
for (let i = 0; i < 3 && ![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status); i++) {
|
|
||||||
await socket.auth.retryAuth(sign)
|
|
||||||
await waitForAuth(socket)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (![AuthStatus.None, AuthStatus.Ok].includes(socket.auth.status)) {
|
|
||||||
return formatAuthError(socket.auth.status, socket.auth.details)
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = await waitForThunkError(publishJoinRequest({url, claim}))
|
|
||||||
|
|
||||||
if (shouldIgnoreError(error)) return
|
|
||||||
|
|
||||||
if (claim) {
|
|
||||||
if (error.includes("invite code")) {
|
|
||||||
return "join request rejected"
|
|
||||||
}
|
|
||||||
} else if (error.includes("invite code")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return stripPrefix(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deriveRelayAuthError = (url: string) => {
|
|
||||||
Pool.get().get(url).auth.attemptAuth(sign)
|
|
||||||
|
|
||||||
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])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type JoinRequestParams = {
|
|
||||||
url: string
|
|
||||||
claim: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeJoinRequest = (params: JoinRequestParams) =>
|
|
||||||
makeEvent(RELAY_JOIN, {tags: [["claim", params.claim]]})
|
|
||||||
|
|
||||||
export const publishJoinRequest = (params: JoinRequestParams) =>
|
|
||||||
publishThunk({event: makeJoinRequest(params), relays: [params.url]})
|
|
||||||
|
|
||||||
export type LeaveRequestParams = {
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const publishLeaveRequest = (params: LeaveRequestParams) =>
|
|
||||||
publishThunk({event: makeEvent(RELAY_LEAVE), relays: [params.url]})
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
import * as appDevice from "@app/device"
|
import * as appDevice from "@app/device"
|
||||||
import * as actionItems from "@app/actionItems"
|
import * as actionItems from "@app/actionItems"
|
||||||
import * as appFeeds from "@app/feeds"
|
import * as appFeeds from "@app/feeds"
|
||||||
import * as invites from "@app/invites"
|
import * as access from "@app/access"
|
||||||
import * as healthChecks from "@app/healthChecks"
|
import * as healthChecks from "@app/healthChecks"
|
||||||
import {theme} from "@app/theme"
|
import {theme} from "@app/theme"
|
||||||
import {toast, pushToast} from "@app/toast"
|
import {toast, pushToast} from "@app/toast"
|
||||||
|
|
@ -112,7 +112,7 @@
|
||||||
...appDevice,
|
...appDevice,
|
||||||
...actionItems,
|
...actionItems,
|
||||||
...appFeeds,
|
...appFeeds,
|
||||||
...invites,
|
...access,
|
||||||
...healthChecks,
|
...healthChecks,
|
||||||
...comments,
|
...comments,
|
||||||
...deletes,
|
...deletes,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
import {userSpaceUrls, loadUserGroupList, groupListPubkeysByUrl, setSpaces} from "@app/groups"
|
import {userSpaceUrls, loadUserGroupList, groupListPubkeysByUrl, setSpaces} from "@app/groups"
|
||||||
import {PLATFORM_RELAYS, DEFAULT_RELAYS} from "@app/env"
|
import {PLATFORM_RELAYS, DEFAULT_RELAYS} from "@app/env"
|
||||||
import {bootstrapPubkeys} from "@app/social"
|
import {bootstrapPubkeys} from "@app/social"
|
||||||
import {parseInviteLink} from "@app/invites"
|
import {parseInviteLink} from "@app/access"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
import {goToSpace, makeSpacePath} from "@app/routes"
|
import {goToSpace, makeSpacePath} from "@app/routes"
|
||||||
import {notifications} from "@app/notifications"
|
import {notifications} from "@app/notifications"
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,12 @@
|
||||||
import SpaceAuthError from "@app/components/SpaceAuthError.svelte"
|
import SpaceAuthError from "@app/components/SpaceAuthError.svelte"
|
||||||
import SpaceTrustRelay from "@app/components/SpaceTrustRelay.svelte"
|
import SpaceTrustRelay from "@app/components/SpaceTrustRelay.svelte"
|
||||||
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
import SpaceJoin from "@app/components/SpaceJoin.svelte"
|
||||||
import {modal, pushModal} from "@app/modal"
|
import {deriveRelayAuthError} from "@app/access"
|
||||||
import {makeSpacePath} from "@app/routes"
|
|
||||||
import {decodeRelay, deriveRelayAuthError} from "@app/relays"
|
|
||||||
import {loadUserGroupList, userSpaceUrls} from "@app/groups"
|
import {loadUserGroupList, userSpaceUrls} from "@app/groups"
|
||||||
|
import {modal, pushModal} from "@app/modal"
|
||||||
import {relaysPendingTrust} from "@app/policies"
|
import {relaysPendingTrust} from "@app/policies"
|
||||||
|
import {decodeRelay} from "@app/relays"
|
||||||
|
import {makeSpacePath} from "@app/routes"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: Snippet
|
children?: Snippet
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,11 @@
|
||||||
import ThunkToast from "@app/components/ThunkToast.svelte"
|
import ThunkToast from "@app/components/ThunkToast.svelte"
|
||||||
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
import RoomItemAddMember from "@src/app/components/RoomItemAddMember.svelte"
|
||||||
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
|
import RoomComposeEdit from "@src/app/components/RoomComposeEdit.svelte"
|
||||||
import {canEnforceNip70} from "@app/relays"
|
import {publishRoomJoinRequest} from "@app/access"
|
||||||
import {prependParent, deriveRoom, getRoomType, PROTECTED, RoomType} from "@app/groups"
|
|
||||||
import {publishDelete} from "@app/deletes"
|
import {publishDelete} from "@app/deletes"
|
||||||
import {decodeRelay} from "@app/relays"
|
import {prependParent, deriveRoom, getRoomType, PROTECTED, RoomType} from "@app/groups"
|
||||||
import {deriveUserRoomMembershipStatus, MembershipStatus} from "@app/members"
|
import {deriveUserRoomMembershipStatus, MembershipStatus} from "@app/members"
|
||||||
|
import {canEnforceNip70, decodeRelay} from "@app/relays"
|
||||||
import {userSettingsValues} from "@app/settings"
|
import {userSettingsValues} from "@app/settings"
|
||||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||||
import VideoCallContent from "@app/components/VideoCallContent.svelte"
|
import VideoCallContent from "@app/components/VideoCallContent.svelte"
|
||||||
|
|
@ -121,6 +121,7 @@
|
||||||
const shouldProtect = canEnforceNip70(url)
|
const shouldProtect = canEnforceNip70(url)
|
||||||
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
||||||
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
||||||
|
const inviteCode = $derived($page.url.searchParams.get("code") || "")
|
||||||
|
|
||||||
const showRoomDetail = () => pushModal(RoomDetail, {url, h})
|
const showRoomDetail = () => pushModal(RoomDetail, {url, h})
|
||||||
|
|
||||||
|
|
@ -128,7 +129,9 @@
|
||||||
joining = true
|
joining = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = await waitForThunkError(joinRoom(url, makeRoomMeta({h})))
|
const message = await waitForThunkError(
|
||||||
|
inviteCode ? publishRoomJoinRequest(url, h, inviteCode) : joinRoom(url, makeRoomMeta({h})),
|
||||||
|
)
|
||||||
|
|
||||||
if (message && !message.startsWith("duplicate:")) {
|
if (message && !message.startsWith("duplicate:")) {
|
||||||
return pushToast({theme: "error", message})
|
return pushToast({theme: "error", message})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue