diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 71dc7002..7974e612 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -5,6 +5,7 @@ import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte" import RoomName from "@app/components/RoomName.svelte" + import {pushToast} from "@app/util/toast" import {deriveVoiceParticipants, joinVoiceRoom, currentVoiceSession} from "@app/voice" interface Props { @@ -17,7 +18,14 @@ const participants = deriveVoiceParticipants(url, h) const isActive = $derived($currentVoiceSession?.url === url && $currentVoiceSession?.h === h) - const handleClick = () => joinVoiceRoom(url, h) + const handleClick = async () => { + try { + await joinVoiceRoom(url, h) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + pushToast({theme: "error", message: `Failed to join voice room: ${message}`}) + } + } $effect(() => { for (const pk of $participants) { diff --git a/src/app/voice.ts b/src/app/voice.ts index 8013a654..365be1e2 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -1,9 +1,11 @@ +import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client" +import {getToken} from "nostr-tools/nip98" import {derived, get, writable} from "svelte/store" -import {Room, RoomEvent, Track} from "livekit-client" import {now} from "@welshman/lib" import {makeEvent, getTagValue} from "@welshman/util" import {signer, publishThunk} from "@welshman/app" import {deriveEventsForUrl} from "@app/core/state" +import {pushToast} from "@app/util/toast" export const ROOM_PRESENCE = 10312 @@ -19,32 +21,34 @@ export type VoiceSession = { export const currentVoiceSession = writable(undefined) -const buildNip98AuthEvent = async (url: string, method: string) => { - const $signer = signer.get() - if (!$signer) throw new Error("No signer available") - - const event = makeEvent(27235, { - tags: [ - ["u", url], - ["method", method], - ], - }) - - return $signer.sign(event) -} - const fetchLivekitToken = async ( url: string, groupId: string, ): Promise<{server_url: string; participant_token: string}> => { - const httpUrl = url.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://") + const httpUrl = url + .replace(/^wss:\/\//, "https://") + .replace(/^ws:\/\//, "http://") + .replace(/\/$/, "") const endpoint = `${httpUrl}/.well-known/nip29/livekit/${groupId}` - const authEvent = await buildNip98AuthEvent(endpoint, "GET") - const encoded = btoa(JSON.stringify(authEvent)) + const $signer = signer.get() + if (!$signer) throw new Error("No signer available") + + const authHeader = await getToken( + endpoint, + "GET", + template => + $signer.sign( + makeEvent(template.kind, { + tags: template.tags, + content: template.content ?? "", + }), + ), + true, + ) const response = await fetch(endpoint, { - headers: {Authorization: `Nostr ${encoded}`}, + headers: {Authorization: authHeader}, }) if (!response.ok) { @@ -115,9 +119,16 @@ export const joinVoiceRoom = async (url: string, h: string) => { dynacast: true, }) - room.on(RoomEvent.Disconnected, () => { + room.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => { currentVoiceSession.set(undefined) stopPresenceHeartbeat() + if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { + const message = + reason === DisconnectReason.JOIN_FAILURE + ? "Could not connect to voice room. Please try again." + : "Voice connection lost." + pushToast({theme: "error", message}) + } }) room.on(RoomEvent.TrackSubscribed, (track, _publication, _participant) => { @@ -133,7 +144,22 @@ export const joinVoiceRoom = async (url: string, h: string) => { track.detach().forEach(el => el.remove()) }) - await room.connect(server_url, participant_token) + const CONNECT_TIMEOUT_MS = 5_000 + + try { + await Promise.race([ + room.connect(server_url, participant_token, {maxRetries: 0}), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("Connection timed out. Please check your network and try again.")), + CONNECT_TIMEOUT_MS, + ), + ), + ]) + } catch (e) { + room.disconnect() + throw e + } await room.localParticipant.setMicrophoneEnabled(true) currentVoiceSession.set({url, h, room, muted: false})