From bd0adfdffe9ceb0e04c149e1c937280103a8c983 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 27 Mar 2026 11:06:05 -0400 Subject: [PATCH 1/5] Add a dialog before joining voice rooms --- src/app/components/VoiceRoomItem.svelte | 21 +++- src/app/components/VoiceRoomJoinDialog.svelte | 116 ++++++++++++++++++ src/app/components/VoiceWidget.svelte | 68 +++++----- src/app/core/state.ts | 25 +++- src/app/voice.ts | 89 ++++++++++---- src/routes/spaces/[relay]/[h]/+page.svelte | 4 +- 6 files changed, 262 insertions(+), 61 deletions(-) create mode 100644 src/app/components/VoiceRoomJoinDialog.svelte diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 30c7fe9f..22253956 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -1,15 +1,17 @@ + + + + + Join voice room? + + + + {$room?.name || h} + · + {spaceLabel} + + + +

Select a microphone to join the call.

+
+
+ + +
+ + {#snippet label()} +

Microphone

+ {/snippet} + {#snippet input()} + + {/snippet} +
+
+
+ + + + +
diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 239ba8f4..6b528869 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -1,22 +1,7 @@ - - -{#if $currentVoiceRoom} +{#if $targetRoom}
- {#if $voiceState === "joining"} + {#if $voiceState === VoiceState.Joining} Joining... - {:else if $voiceState === "connected"} + {:else if $voiceState === VoiceState.Connected} Voice Connected {:else} Disconnected @@ -64,7 +72,7 @@
- {#if $voiceState === "joining"} + {#if $voiceState === VoiceState.Joining} - {:else if $voiceState === "connected" && $currentVoiceSession} + {:else if $voiceState === VoiceState.Connected && $currentVoiceSession} {/if} diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 7b133f24..16e3eca4 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -1,7 +1,9 @@ import twColors from "tailwindcss/colors" import {context as pomadeContext} from "@pomade/core" import {Capacitor} from "@capacitor/core" -import {derived, readable, writable} from "svelte/store" +import {page} from "$app/stores" +import type {Page} from "@sveltejs/kit" +import {derived, readable, writable, type Readable} from "svelte/store" import * as nip19 from "nostr-tools/nip19" import { on, @@ -583,6 +585,8 @@ export type Room = PublishedRoomMeta & { url: string } +export type RoomRef = {url: string; h: string} + export const getRoomType = (room: RoomMeta): RoomType => room.livekit ? RoomType.Voice : RoomType.Text @@ -805,6 +809,25 @@ export const deriveOtherRooms = (url: string) => }, ) +export const parseDisplayedRoomParams = (params: Page["params"]): RoomRef | undefined => { + const relay = params.relay + const h = params.h + if (!relay || !h || typeof h !== "string") return undefined + return {url: decodeRelay(relay), h} +} + +export const deriveDisplayedRoom = derived( + derived(page, $p => parseDisplayedRoomParams($p.params)), + ($p, set) => { + if (!$p) { + set(undefined) + return () => {} + } + const inner = deriveRoom($p.url, $p.h) + return inner.subscribe(set) + }, +) as Readable<(RoomMeta & {url: string; id: string}) | undefined> + // Space/room memberships export const deriveSpaceMembers = (url: string) => diff --git a/src/app/voice.ts b/src/app/voice.ts index 8856c2d2..091e2955 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -2,21 +2,26 @@ * Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox * (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS. */ -import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client" +import { + DisconnectReason, + Room, + RoomEvent, + Track, + type AudioCaptureOptions, + type LocalParticipant, +} from "livekit-client" import {derived, get, writable} from "svelte/store" import {map, removeUndefined, uniqBy} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" import {signer} from "@welshman/app" import {getLivekitEndpoint} from "$lib/livekit" -import {AbortError, whenAborted, whenTimeout} from "$lib/util" -import {deriveLatestEventForUrl} from "@app/core/state" +import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util" +import {deriveLatestEventForUrl, type RoomRef} from "@app/core/state" import {pushToast} from "@app/util/toast" export const LIVEKIT_PARTICIPANTS = 39004 -export {checkRelayHasLivekit} from "$lib/livekit" - export class VoiceJoinMembershipError extends Error { constructor() { super("Failed to join voice room: you must be a member.") @@ -24,6 +29,19 @@ export class VoiceJoinMembershipError extends Error { } } +const handleJoinError = (e: unknown) => { + if (e instanceof AbortError) return + console.error("Failed to join voice room", e) + let message = "Failed to join voice room" + if (e instanceof VoiceJoinMembershipError) message = e.message + else if (e instanceof TimeoutError) + message = "Connection timed out. Please check your network and try again." + else if (e instanceof Error && e.message === "No signer available") message = e.message + pushToast({theme: "error", message}) +} + +export {checkRelayHasLivekit} from "$lib/livekit" + export type VoiceSession = { url: string h: string @@ -35,13 +53,17 @@ export type Pubkey = string export type VoiceParticipant = {pubkey?: Pubkey; identity: string} -export type VoiceState = "joining" | "connected" | "disconnected" +export enum VoiceState { + Joining = "joining", + Connected = "connected", + Disconnected = "disconnected", +} export const currentVoiceSession = writable(undefined) -export const voiceState = writable("disconnected") +export const voiceState = writable(VoiceState.Disconnected) -export const currentVoiceRoom = writable<{url: string; h: string} | undefined>(undefined) +export const currentVoiceRoom = writable(undefined) export const participantPubkeyMap = writable>(new Map()) @@ -140,10 +162,33 @@ export const deriveVoiceParticipants = (url: string, h: string) => }, ) +const setUpMicrophone = async ( + startMuted: boolean, + preferredMicId: string | undefined, + participant: LocalParticipant, +): Promise => { + if (startMuted) { + return true + } + + let muted = true + let capture: AudioCaptureOptions | undefined = undefined + if (preferredMicId) { + capture = {deviceId: preferredMicId} + } + try { + await participant.setMicrophoneEnabled(true, capture) + muted = false + } catch (e) { + pushToast({theme: "error", message: "Could not access microphone"}) + } + return muted +} + const onRoomDisconnected = (reason?: DisconnectReason) => { currentVoiceSession.set(undefined) if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { - voiceState.set("disconnected") + voiceState.set(VoiceState.Disconnected) const message = reason === DisconnectReason.JOIN_FAILURE ? "Could not connect to voice room. Please try again." @@ -191,14 +236,19 @@ export const cancelJoinVoiceRoom = () => { joinAbortController?.abort() } -export const joinVoiceRoom = async (url: string, h: string): Promise => { +export const joinVoiceRoom = async ( + url: string, + h: string, + startMuted = true, + preferredMicId?: string, +): Promise => { cancelJoinVoiceRoom() const session = get(currentVoiceSession) if (session) await leaveVoiceRoom() currentVoiceRoom.set({url, h}) - voiceState.set("joining") + voiceState.set(VoiceState.Joining) const controller = new AbortController() joinAbortController = controller @@ -238,20 +288,15 @@ export const joinVoiceRoom = async (url: string, h: string): Promise => { addParticipant(p.identity) } - let muted = false - try { - await room.localParticipant.setMicrophoneEnabled(true) - } catch (e) { - muted = true - pushToast({theme: "error", message: "Could not access microphone"}) - } + const muted = await setUpMicrophone(startMuted, preferredMicId, room.localParticipant) currentVoiceSession.set({url, h, room, muted}) - voiceState.set("connected") + voiceState.set(VoiceState.Connected) playJoinSound() } catch (e) { - if (isActive()) voiceState.set("disconnected") - throw e + if (isActive()) voiceState.set(VoiceState.Disconnected) + if (e instanceof AbortError) return + handleJoinError(e) } finally { if (isActive()) joinAbortController = undefined } @@ -264,7 +309,7 @@ export const leaveVoiceRoom = async () => { const audio = new Audio("/leave-voice-room.mp3") audio.play().catch(() => {}) - voiceState.set("disconnected") + voiceState.set(VoiceState.Disconnected) currentVoiceSession.set(undefined) session.room.disconnect() speakingParticipants.set([]) diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index 2b139c4d..e85edaeb 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -50,7 +50,7 @@ userSettingsValues, } from "@app/core/state" import VoiceWidget from "@app/components/VoiceWidget.svelte" - import {voiceState} from "@app/voice" + import {VoiceState, voiceState} from "@app/voice" import {makeFeed} from "@app/core/requests" import {popKey} from "@lib/implicit" import {checked} from "@app/util/notifications" @@ -494,7 +494,7 @@ {/key} {/if}
- {#if isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"} + {#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
-- 2.52.0 From 51bd33556da732817497d497623af7b2d5ca03dc Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 27 Mar 2026 11:07:52 -0400 Subject: [PATCH 2/5] Revert changes from bugfix/voice-room-membership-error --- src/app/voice.ts | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/src/app/voice.ts b/src/app/voice.ts index 091e2955..6c7e791b 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -16,30 +16,12 @@ import type {TrustedEvent} from "@welshman/util" import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" import {signer} from "@welshman/app" import {getLivekitEndpoint} from "$lib/livekit" -import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util" +import {AbortError, whenAborted, whenTimeout} from "$lib/util" import {deriveLatestEventForUrl, type RoomRef} from "@app/core/state" import {pushToast} from "@app/util/toast" export const LIVEKIT_PARTICIPANTS = 39004 -export class VoiceJoinMembershipError extends Error { - constructor() { - super("Failed to join voice room: you must be a member.") - this.name = "VoiceJoinMembershipError" - } -} - -const handleJoinError = (e: unknown) => { - if (e instanceof AbortError) return - console.error("Failed to join voice room", e) - let message = "Failed to join voice room" - if (e instanceof VoiceJoinMembershipError) message = e.message - else if (e instanceof TimeoutError) - message = "Connection timed out. Please check your network and try again." - else if (e instanceof Error && e.message === "No signer available") message = e.message - pushToast({theme: "error", message}) -} - export {checkRelayHasLivekit} from "$lib/livekit" export type VoiceSession = { @@ -124,7 +106,6 @@ const fetchLivekitToken = async ( if (!response.ok) { const text = await response.text() - if (response.status === 403) throw new VoiceJoinMembershipError() throw new Error(`Token request failed (${response.status}): ${text}`) } @@ -296,7 +277,7 @@ export const joinVoiceRoom = async ( } catch (e) { if (isActive()) voiceState.set(VoiceState.Disconnected) if (e instanceof AbortError) return - handleJoinError(e) + throw e } finally { if (isActive()) joinAbortController = undefined } -- 2.52.0 From 02da8dc811650915eff242961e826e82ec2e553e Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 27 Mar 2026 11:23:32 -0400 Subject: [PATCH 3/5] Fix typo on VoiceRoomJoinDialog --- src/app/components/VoiceRoomJoinDialog.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/VoiceRoomJoinDialog.svelte b/src/app/components/VoiceRoomJoinDialog.svelte index 2f39d225..fb620691 100644 --- a/src/app/components/VoiceRoomJoinDialog.svelte +++ b/src/app/components/VoiceRoomJoinDialog.svelte @@ -70,7 +70,7 @@ -

Select a microphone to join the call.

+

Select a microphone to join the call:

Date: Fri, 27 Mar 2026 13:40:13 -0400 Subject: [PATCH 4/5] Address some PR feedback --- src/app/components/VoiceWidget.svelte | 56 ++++++++++++++------------- src/app/core/state.ts | 23 +---------- 2 files changed, 31 insertions(+), 48 deletions(-) diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 6b528869..1a4f196a 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -1,7 +1,8 @@ -{#if $targetRoom} +{#if targetRoom}
}, ) -export const parseDisplayedRoomParams = (params: Page["params"]): RoomRef | undefined => { - const relay = params.relay - const h = params.h - if (!relay || !h || typeof h !== "string") return undefined - return {url: decodeRelay(relay), h} -} - -export const deriveDisplayedRoom = derived( - derived(page, $p => parseDisplayedRoomParams($p.params)), - ($p, set) => { - if (!$p) { - set(undefined) - return () => {} - } - const inner = deriveRoom($p.url, $p.h) - return inner.subscribe(set) - }, -) as Readable<(RoomMeta & {url: string; id: string}) | undefined> - // Space/room memberships export const deriveSpaceMembers = (url: string) => -- 2.52.0 From 0b04076baa7de48df729f27ff9ec36758b514cff Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 27 Mar 2026 14:57:18 -0400 Subject: [PATCH 5/5] Refactor currentVoiceRoom and some related types to use Room instead of RoomRef. --- src/app/components/VoiceRoomItem.svelte | 9 ++-- src/app/components/VoiceRoomJoinDialog.svelte | 5 +-- src/app/components/VoiceWidget.svelte | 13 ++++-- src/app/core/state.ts | 4 +- src/app/voice.ts | 41 +++++++++---------- 5 files changed, 35 insertions(+), 37 deletions(-) diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 22253956..5de7178a 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -9,6 +9,7 @@ import {makeRoomPath} from "@app/util/routes" import {pushModal} from "@app/util/modal" import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte" + import {makeRoomId} from "@app/core/state" import { VoiceState, deriveVoiceParticipants, @@ -30,14 +31,10 @@ const participants = deriveVoiceParticipants(url, h) const isActive = $derived( - $voiceState === VoiceState.Connected && - $currentVoiceRoom?.url === url && - $currentVoiceRoom?.h === h, + $voiceState === VoiceState.Connected && $currentVoiceRoom?.id === makeRoomId(url, h), ) const isJoining = $derived( - $voiceState === VoiceState.Joining && - $currentVoiceRoom?.url === url && - $currentVoiceRoom?.h === h, + $voiceState === VoiceState.Joining && $currentVoiceRoom?.id === makeRoomId(url, h), ) const handleClick = async (e: MouseEvent) => { diff --git a/src/app/components/VoiceRoomJoinDialog.svelte b/src/app/components/VoiceRoomJoinDialog.svelte index fb620691..044fcd64 100644 --- a/src/app/components/VoiceRoomJoinDialog.svelte +++ b/src/app/components/VoiceRoomJoinDialog.svelte @@ -12,7 +12,7 @@ import ModalHeader from "@lib/components/ModalHeader.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte" - import {deriveRoom} from "@app/core/state" + import {displayRoom} from "@app/core/state" import {joinVoiceRoom} from "@app/voice" import {popModal} from "@app/util/modal" @@ -23,7 +23,6 @@ const {url, h}: Props = $props() - const room = deriveRoom(url, h) const spaceLabel = $derived(displayRelayUrl(url)) let audioInputs = $state([]) @@ -64,7 +63,7 @@ - {$room?.name || h} + {displayRoom(url, h)} · {spaceLabel} diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 1a4f196a..603d2efc 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -12,7 +12,14 @@ import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte" - import {decodeRelay, deriveRoom, displayRoom, getRoomType, RoomType} from "@app/core/state" + import { + decodeRelay, + deriveRoom, + displayRoom, + getRoomType, + RoomType, + type Room, + } from "@app/core/state" import {pushModal} from "@app/util/modal" import {makeRoomPath} from "@app/util/routes" import { @@ -32,14 +39,14 @@ ) const routeDisplayedRoom = $derived($displayedRoomStore) - const targetRoom = $derived.by(() => { + const targetRoom = $derived.by((): Room | undefined => { if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) { return $currentVoiceRoom } if ($voiceState === VoiceState.Disconnected) { if (routeDisplayedRoom) { if (getRoomType(routeDisplayedRoom) === RoomType.Voice) { - return {url: routeDisplayedRoom.url, h: routeDisplayedRoom.h} + return routeDisplayedRoom } return undefined } diff --git a/src/app/core/state.ts b/src/app/core/state.ts index f6d99caf..f6f2cdf5 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -583,8 +583,6 @@ export type Room = PublishedRoomMeta & { url: string } -export type RoomRef = {url: string; h: string} - export const getRoomType = (room: RoomMeta): RoomType => room.livekit ? RoomType.Voice : RoomType.Text @@ -671,7 +669,7 @@ export const deriveRoom = call(() => { return (url: string, h: string) => derived( _deriveRoom(makeRoomId(url, h)), - room => room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}, + room => (room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}) as Room, ) }) diff --git a/src/app/voice.ts b/src/app/voice.ts index 6c7e791b..a4453ed4 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -4,7 +4,7 @@ */ import { DisconnectReason, - Room, + Room as LiveKitRoom, RoomEvent, Track, type AudioCaptureOptions, @@ -17,7 +17,7 @@ import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" import {signer} from "@welshman/app" import {getLivekitEndpoint} from "$lib/livekit" import {AbortError, whenAborted, whenTimeout} from "$lib/util" -import {deriveLatestEventForUrl, type RoomRef} from "@app/core/state" +import {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} from "@app/core/state" import {pushToast} from "@app/util/toast" export const LIVEKIT_PARTICIPANTS = 39004 @@ -27,7 +27,7 @@ export {checkRelayHasLivekit} from "$lib/livekit" export type VoiceSession = { url: string h: string - room: Room + room: LiveKitRoom muted: boolean } @@ -45,7 +45,7 @@ export const currentVoiceSession = writable(undefined) export const voiceState = writable(VoiceState.Disconnected) -export const currentVoiceRoom = writable(undefined) +export const currentVoiceRoom = writable(undefined) export const participantPubkeyMap = writable>(new Map()) @@ -121,10 +121,7 @@ export const deriveVoiceParticipants = (url: string, h: string) => deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]), ], ([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => { - const inCall = - $participantPubkeyMap.size > 0 && - $currentVoiceRoom?.url === url && - $currentVoiceRoom?.h === h + const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h) if (inCall) { const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity) @@ -228,7 +225,7 @@ export const joinVoiceRoom = async ( const session = get(currentVoiceSession) if (session) await leaveVoiceRoom() - currentVoiceRoom.set({url, h}) + currentVoiceRoom.set(get(deriveRoom(url, h))) voiceState.set(VoiceState.Joining) const controller = new AbortController() @@ -241,37 +238,37 @@ export const joinVoiceRoom = async ( if (signal.aborted) throw new AbortError() - const room = new Room({adaptiveStream: true, dynacast: true}) + const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true}) - room.on(RoomEvent.Disconnected, onRoomDisconnected) - room.on(RoomEvent.ParticipantConnected, onParticipantConnected) - room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) - room.on(RoomEvent.TrackSubscribed, onTrackSubscribed) - room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) - room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) + liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected) + liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected) + liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) + liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed) + liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) + liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) try { await Promise.race([ - room.connect(server_url, participant_token, {maxRetries: 0}), + liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}), whenTimeout(5_000, { message: "Connection timed out. Please check your network and try again.", }), whenAborted(signal), ]) } catch (e) { - room.disconnect() + liveKitRoom.disconnect() throw e } participantPubkeyMap.set(new Map()) - addParticipant(room.localParticipant.identity) - for (const p of room.remoteParticipants.values()) { + addParticipant(liveKitRoom.localParticipant.identity) + for (const p of liveKitRoom.remoteParticipants.values()) { addParticipant(p.identity) } - const muted = await setUpMicrophone(startMuted, preferredMicId, room.localParticipant) + const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant) - currentVoiceSession.set({url, h, room, muted}) + currentVoiceSession.set({url, h, room: liveKitRoom, muted}) voiceState.set(VoiceState.Connected) playJoinSound() } catch (e) { -- 2.52.0