diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte
index 912cd2c4..ca81dd61 100644
--- a/src/app/components/VoiceRoomItem.svelte
+++ b/src/app/components/VoiceRoomItem.svelte
@@ -7,6 +7,7 @@
import {isMobileViewport} from "@lib/html"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import RoomName from "@app/components/RoomName.svelte"
+ import {errorMessage} from "@lib/util"
import {pushToast} from "@app/util/toast"
import {
deriveVoiceParticipants,
@@ -46,10 +47,7 @@
try {
await joinVoiceRoom(url, h, joinAbortController.signal)
} catch (e) {
- if (e instanceof Error && e.message === "Join cancelled") return
- if (e instanceof DOMException && e.name === "AbortError") return
- const message = e instanceof Error ? e.message : String(e)
- pushToast({theme: "error", message: `Failed to join voice room: ${message}`})
+ pushToast({theme: "error", message: `Failed to join voice room: ${errorMessage(e)}`})
} finally {
isJoining = false
joinAbortController = undefined
diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte
index 3148f2ca..0be11aa1 100644
--- a/src/app/components/VoiceWidget.svelte
+++ b/src/app/components/VoiceWidget.svelte
@@ -1,5 +1,4 @@
@@ -41,10 +38,10 @@
-
diff --git a/src/app/core/state.ts b/src/app/core/state.ts
index 8978797d..69a0d479 100644
--- a/src/app/core/state.ts
+++ b/src/app/core/state.ts
@@ -103,6 +103,7 @@ import {
getListTags,
getPubkeyTagValues,
getRelayTagValues,
+ getTag,
getTagValues,
isRelayUrl,
normalizeRelayUrl,
@@ -668,7 +669,7 @@ export const deriveRoomsWithLivekit = (url: string) =>
derived(roomsById, $roomsById => {
const set = new Set()
for (const room of $roomsById.values()) {
- if (room.url === url && room.event?.tags?.some(t => t[0] === "livekit")) {
+ if (room.url === url && getTag("livekit", room.event?.tags ?? [])) {
set.add(room.h)
}
}
@@ -679,7 +680,7 @@ export const deriveRoomsNoText = (url: string) =>
derived(roomsById, $roomsById => {
const set = new Set()
for (const room of $roomsById.values()) {
- if (room.url === url && room.event?.tags?.some(t => t[0] === "no-text")) {
+ if (room.url === url && getTag("no-text", room.event?.tags ?? [])) {
set.add(room.h)
}
}
@@ -688,12 +689,12 @@ export const deriveRoomsNoText = (url: string) =>
export const roomHasLivekit = (url: string, h: string) => {
const room = getRoom(makeRoomId(url, h))
- return room?.event?.tags?.some(t => t[0] === "livekit") ?? false
+ return !!getTag("livekit", room?.event?.tags ?? [])
}
export const roomIsNoText = (url: string, h: string) => {
const room = getRoom(makeRoomId(url, h))
- return room?.event?.tags?.some(t => t[0] === "no-text") ?? false
+ return !!getTag("no-text", room?.event?.tags ?? [])
}
// User space/room lists
diff --git a/src/app/voice.ts b/src/app/voice.ts
index 7dfefc9b..4ac68189 100644
--- a/src/app/voice.ts
+++ b/src/app/voice.ts
@@ -3,12 +3,12 @@
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
*/
import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client"
-import {getToken} from "nostr-tools/nip98"
import {derived, get, writable} from "svelte/store"
import {now} from "@welshman/lib"
-import {makeEvent, getTagValue} from "@welshman/util"
+import {makeEvent, makeHttpAuth, makeHttpAuthHeader, getTagValue} from "@welshman/util"
import {signer, publishThunk} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit"
+import {AbortError, whenAborted, whenTimeout} from "$lib/util"
import {deriveEventsForUrl} from "@app/core/state"
import {pushToast} from "@app/util/toast"
@@ -28,7 +28,7 @@ export type VoiceSession = {
export const currentVoiceSession = writable(undefined)
-export const speakingPubkeys = writable>(new Set())
+export const speakingPubkeys = writable(new Set())
const fetchLivekitToken = async (
url: string,
@@ -40,31 +40,16 @@ const fetchLivekitToken = async (
const $signer = signer.get()
if (!$signer) throw new Error("No signer available")
- if (signal?.aborted) throw new Error("Join cancelled")
+ if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
- const authHeader = await getToken(
- endpoint,
- "GET",
- template =>
- $signer.sign(
- makeEvent(template.kind, {
- tags: template.tags,
- content: template.content ?? "",
- }),
- ),
- true,
- )
+ const template = await makeHttpAuth(endpoint, "GET")
+ const signedEvent = await $signer.sign(template)
+ const authHeader = makeHttpAuthHeader(signedEvent)
- let response: Response
- try {
- response = await fetch(endpoint, {
- headers: {Authorization: authHeader},
- signal,
- })
- } catch (e) {
- if (e instanceof DOMException && e.name === "AbortError") throw new Error("Join cancelled")
- throw e
- }
+ const response = await fetch(endpoint, {
+ headers: {Authorization: authHeader},
+ signal,
+ })
if (!response.ok) {
const text = await response.text()
@@ -119,6 +104,36 @@ const stopPresenceHeartbeat = () => {
}
}
+const onRoomDisconnected = (reason?: DisconnectReason) => {
+ speakingPubkeys.set(new Set())
+ 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})
+ }
+}
+
+const onTrackSubscribed = (track: Track) => {
+ if (track.kind === Track.Kind.Audio) {
+ const element = track.attach()
+ element.style.display = "none"
+ document.body.appendChild(element)
+ element.play().catch(() => {})
+ }
+}
+
+const onTrackUnsubscribed = (track: Track) => {
+ track.detach().forEach(el => el.remove())
+}
+
+const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
+ speakingPubkeys.set(new Set(participants.map(p => p.identity)))
+}
+
export const joinVoiceRoom = async (
url: string,
h: string,
@@ -126,83 +141,33 @@ export const joinVoiceRoom = async (
): Promise => {
const session = get(currentVoiceSession)
- if (session) {
- if (session.url === url && session.h === h) return
- await leaveVoiceRoom()
- }
+ if (session) await leaveVoiceRoom()
const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
- if (signal?.aborted) throw new Error("Join cancelled")
+ if (signal?.aborted) return
- const room = new Room({
- adaptiveStream: true,
- dynacast: true,
+ const room = new Room({adaptiveStream: true, dynacast: true})
+
+ room.on(RoomEvent.Disconnected, onRoomDisconnected)
+ room.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
+ room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
+ room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
+
+ const connect = room.connect(server_url, participant_token, {maxRetries: 0})
+ const timeout = whenTimeout(5_000, {
+ message: "Connection timed out. Please check your network and try again.",
})
-
- room.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {
- speakingPubkeys.set(new Set())
- 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) => {
- if (track.kind === Track.Kind.Audio) {
- const element = track.attach()
- element.style.display = "none"
- document.body.appendChild(element)
- element.play().catch(() => {})
- }
- })
-
- room.on(RoomEvent.TrackUnsubscribed, track => {
- track.detach().forEach(el => el.remove())
- })
-
- room.on(RoomEvent.ActiveSpeakersChanged, participants => {
- speakingPubkeys.set(new Set(participants.map(p => p.identity)))
- })
-
- const onAbort = () => {
- room.disconnect()
- }
- if (signal) {
- if (signal.aborted) {
- room.disconnect()
- throw new Error("Join cancelled")
- }
- signal.addEventListener("abort", onAbort, {once: true})
- }
-
- const CONNECT_TIMEOUT_MS = 5_000
+ const abort = whenAborted(signal)
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,
- ),
- ),
- ])
+ await Promise.race([connect, timeout, abort])
} catch (e) {
room.disconnect()
- if (signal?.aborted) {
- throw new Error("Join cancelled")
- }
+ if (e instanceof AbortError) return
throw e
- } finally {
- signal?.removeEventListener("abort", onAbort)
}
- if (signal?.aborted) throw new Error("Join cancelled")
+
await room.localParticipant.setMicrophoneEnabled(true)
currentVoiceSession.set({url, h, room, muted: false})
diff --git a/src/lib/util.ts b/src/lib/util.ts
index e22c36db..59f9fb21 100644
--- a/src/lib/util.ts
+++ b/src/lib/util.ts
@@ -19,6 +19,36 @@ export const ucFirst = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1)
export const errorMessage = (err: unknown) => String(err).replace(/^.*Error: /, "")
+export class AbortError extends Error {
+ constructor() {
+ super("Aborted")
+ this.name = "AbortError"
+ }
+}
+
+export class TimeoutError extends Error {
+ constructor(message = "Timed out") {
+ super(message)
+ this.name = "TimeoutError"
+ }
+}
+
+/** Returns a promise that rejects with AbortError when signal aborts. Use with Promise.race. */
+export const whenAborted = (signal?: AbortSignal) => {
+ if (!signal) return new Promise(() => {})
+
+ return new Promise((_, reject) => {
+ const onAborted = () => reject(new AbortError())
+ if (signal.aborted) onAborted()
+ else signal.addEventListener("abort", onAborted, {once: true})
+ })
+}
+
+/** Returns a promise that rejects with TimeoutError after ms. Use with Promise.race. */
+export const whenTimeout = (ms: number, opts: {message?: string} = {}) => {
+ return new Promise((_, reject) => setTimeout(() => reject(new TimeoutError()), ms))
+}
+
export const buildUrl = (base: string | URL, ...pathname: string[]) => {
const url = new URL(base)