Add a dialog before joining voice rooms #109

Merged
hodlbod merged 5 commits from feature/join-voice-room-dialog into dev 2026-03-27 19:02:57 +00:00
6 changed files with 246 additions and 83 deletions
+12 -6
View File
@@ -1,15 +1,18 @@
<script lang="ts">
import cx from "classnames"
import {goto} from "$app/navigation"
import {loadProfile, displayProfileByPubkey} from "@welshman/app"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {handleJoinError} from "@app/components/VoiceWidget.svelte"
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,
joinVoiceRoom,
cancelJoinVoiceRoom,
currentVoiceRoom,
voiceState,
@@ -28,21 +31,24 @@
const participants = deriveVoiceParticipants(url, h)
const isActive = $derived(
$voiceState === "connected" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
$voiceState === VoiceState.Connected && $currentVoiceRoom?.id === makeRoomId(url, h),
)
const isJoining = $derived(
$voiceState === "joining" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
$voiceState === VoiceState.Joining && $currentVoiceRoom?.id === makeRoomId(url, h),
)
const handleClick = async () => {
const handleClick = async (e: MouseEvent) => {
if (isActive) return
if (isJoining) {
e.preventDefault()
cancelJoinVoiceRoom()
return
}
await joinVoiceRoom(url, h).catch(handleJoinError)
e.preventDefault()
await goto(makeRoomPath(url, h), {replaceState})
pushModal(VoiceRoomJoinDialog, {url, h})
}
$effect(() => {
@@ -0,0 +1,115 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Volume from "@assets/icons/volume.svg?dataurl"
import Button from "@lib/components/Button.svelte"
import FieldInline from "@lib/components/FieldInline.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {displayRoom} from "@app/core/state"
import {joinVoiceRoom} from "@app/voice"
import {popModal} from "@app/util/modal"
type Props = {
url: string
h: string
}
const {url, h}: Props = $props()
const spaceLabel = $derived(displayRelayUrl(url))
let audioInputs = $state<MediaDeviceInfo[]>([])
let selectedDeviceId = $state("")
let startWithoutMic = $state(false)
const loadDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) return
try {
const devices = await navigator.mediaDevices.enumerateDevices()
audioInputs = devices.filter(d => d.kind === "audioinput")
} catch {
audioInputs = []
}
}
$effect(() => {
void loadDevices()
})
const goBack = () => history.back()
const joinVoice = async () => {
popModal()
await joinVoiceRoom(
url,
h,
startWithoutMic,
startWithoutMic ? undefined : selectedDeviceId || undefined,
)
}
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Join voice room?</ModalTitle>
<ModalSubtitle>
<span class="inline-flex flex-wrap items-center justify-center gap-x-1.5 gap-y-1">
<Icon icon={Volume} size={4} class="shrink-0" />
<span class="ellipsize min-w-0">{displayRoom(url, h)}</span>
<span>·</span>
<span>{spaceLabel}</span>
</span>
</ModalSubtitle>
</ModalHeader>
<p class="text-sm opacity-80">Select a microphone to join the call:</p>
<div class="flex flex-col gap-4 pt-2">
<div class="flex items-center gap-2">
<input
id="voice-start-without-mic"
type="checkbox"
class="checkbox"
bind:checked={startWithoutMic} />
<label for="voice-start-without-mic" class="text-sm cursor-pointer">
Join without microphone (you can unmute later)
</label>
</div>
<FieldInline>
{#snippet label()}
<p>Microphone</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered w-full"
bind:value={selectedDeviceId}
disabled={startWithoutMic}
aria-label="Microphone">
<option value="">Default microphone</option>
{#each audioInputs as d (d.deviceId)}
<option value={d.deviceId}>
{d.label || `Microphone ${d.deviceId.slice(0, 8)}`}
</option>
{/each}
</select>
{/snippet}
</FieldInline>
</div>
</ModalBody>
<ModalFooter>
<Button class="btn btn-link" onclick={goBack}>
<Icon icon={AltArrowLeft} />
Don't join
</Button>
<Button class="btn btn-primary" onclick={joinVoice}>
Join voice
<Icon icon={AltArrowRight} />
</Button>
</ModalFooter>
</Modal>
+49 -30
View File
@@ -1,22 +1,8 @@
<script module lang="ts">
import {AbortError, TimeoutError} from "$lib/util"
import {VoiceJoinMembershipError} from "@app/voice"
import {pushToast} from "@app/util/toast"
export function 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})
}
</script>
<script lang="ts">
import {readable} from "svelte/store"
import {fly} from "svelte/transition"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
@@ -25,36 +11,69 @@
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {displayRoom} from "@app/core/state"
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
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 {
VoiceState,
currentVoiceSession,
Outdated
Review

This logic got more complex. Basically we need incorporate the room the user is currently viewing now.

Detailed explanation: now that you can join the text room without joining the voice call I needed to handle the case where you join voice room A, then view the text chat for voice room B, then disconnect from the call. In this case the VoiceWidget should show a button to join the call for voice room B since that's the room you are currently viewing. It required some changes to state.ts which I'm not sure I'm happy with. More on that below.

This logic got more complex. Basically we need incorporate the room the user is currently viewing now. Detailed explanation: now that you can join the text room without joining the voice call I needed to handle the case where you join voice room A, then view the text chat for voice room B, then disconnect from the call. In this case the `VoiceWidget` should show a button to join the call for voice room B since that's the room you are currently viewing. It required some changes to state.ts which I'm not sure I'm happy with. More on that below.
currentVoiceRoom,
voiceState,
leaveVoiceRoom,
toggleMute,
rejoinVoiceRoom,
cancelJoinVoiceRoom,
} from "@app/voice"
const roomName = $derived(
$currentVoiceRoom ? displayRoom($currentVoiceRoom.url, $currentVoiceRoom.h) : "",
const {relay, h} = $derived($page.params)
const url = $derived(relay ? decodeRelay(relay) : undefined)
const displayedRoomStore = $derived(
url && h && typeof h === "string" ? deriveRoom(url, h) : readable(undefined),
)
const spaceName = $derived($currentVoiceRoom ? displayRelayUrl($currentVoiceRoom.url) : "")
const routeDisplayedRoom = $derived($displayedRoomStore)
const handleRejoin = () => {
void rejoinVoiceRoom().catch(handleJoinError)
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 routeDisplayedRoom
}
return undefined
Outdated
Review

Just use $targetRoom here, as long as you're in a svelte file you can avoid using get, even if you're not in a template

Just use `$targetRoom` here, as long as you're in a svelte file you can avoid using get, even if you're not in a template
}
return $currentVoiceRoom
}
return $currentVoiceRoom
})
const roomName = $derived(targetRoom ? displayRoom(targetRoom.url, targetRoom.h) : "")
const spaceName = $derived(targetRoom ? displayRelayUrl(targetRoom.url) : "")
const openJoinDialog = async () => {
if (!targetRoom) return
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
}
</script>
{#if $currentVoiceRoom}
{#if targetRoom}
<div
in:fly={{y: 60, duration: 350}}
out:fly={{y: 60, duration: 250}}
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
<div class="flex flex-col gap-0.5">
{#if $voiceState === "joining"}
{#if $voiceState === VoiceState.Joining}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $voiceState === "connected"}
{:else if $voiceState === VoiceState.Connected}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
@@ -64,7 +83,7 @@
</span>
</div>
<div class="flex items-center gap-1">
{#if $voiceState === "joining"}
{#if $voiceState === VoiceState.Joining}
<span class="loading loading-spinner loading-sm"></span>
<Button
data-tip="Cancel"
@@ -72,7 +91,7 @@
onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={4} />
</Button>
{:else if $voiceState === "connected" && $currentVoiceSession}
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
<Button
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
@@ -91,7 +110,7 @@
<Button
data-tip="Join Voice"
class="center tooltip tooltip-top btn btn-sm btn-square btn-success"
onclick={handleRejoin}>
onclick={openJoinDialog}>
<Icon icon={PhoneCallingRounded} size={4} />
</Button>
{/if}
+1 -1
View File
@@ -669,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,
)
})
2
+67 -44
View File
@@ -2,7 +2,14 @@
* 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 as LiveKitRoom,
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"
@@ -10,24 +17,17 @@ 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 {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} 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.")
this.name = "VoiceJoinMembershipError"
}
}
export type VoiceSession = {
url: string
h: string
room: Room
room: LiveKitRoom
muted: boolean
}
@@ -35,13 +35,17 @@ export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export type VoiceState = "joining" | "connected" | "disconnected"
export enum VoiceState {
hodlbod marked this conversation as resolved Outdated
Outdated
Review

idk why I didn't make this an enum before.

idk why I didn't make this an enum before.
Joining = "joining",
Connected = "connected",
Disconnected = "disconnected",
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
export const voiceState = writable<VoiceState>("disconnected")
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
export const currentVoiceRoom = writable<{url: string; h: string} | undefined>(undefined)
export const currentVoiceRoom = writable<Room | undefined>(undefined)
Outdated
Review

Any reason we can't just use Room from core/state? Or maybe deriveRoom doesn't return that type.

Any reason we can't just use `Room` from core/state? Or maybe deriveRoom doesn't return that type.
Outdated
Review

Yeah, I was able to do that.

Yeah, I was able to do that.
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
@@ -102,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}`)
}
@@ -118,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)
@@ -140,10 +140,33 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
},
)
const setUpMicrophone = async (
startMuted: boolean,
preferredMicId: string | undefined,
participant: LocalParticipant,
): Promise<boolean> => {
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 +214,19 @@ export const cancelJoinVoiceRoom = () => {
joinAbortController?.abort()
}
export const joinVoiceRoom = async (url: string, h: string): Promise<void> => {
export const joinVoiceRoom = async (
url: string,
h: string,
startMuted = true,
preferredMicId?: string,
): Promise<void> => {
cancelJoinVoiceRoom()
const session = get(currentVoiceSession)
if (session) await leaveVoiceRoom()
currentVoiceRoom.set({url, h})
voiceState.set("joining")
currentVoiceRoom.set(get(deriveRoom(url, h)))
voiceState.set(VoiceState.Joining)
const controller = new AbortController()
joinAbortController = controller
@@ -210,47 +238,42 @@ export const joinVoiceRoom = async (url: string, h: string): Promise<void> => {
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)
}
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, liveKitRoom.localParticipant)
currentVoiceSession.set({url, h, room, muted})
voiceState.set("connected")
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
voiceState.set(VoiceState.Connected)
playJoinSound()
} catch (e) {
if (isActive()) voiceState.set("disconnected")
if (isActive()) voiceState.set(VoiceState.Disconnected)
if (e instanceof AbortError) return
throw e
} finally {
if (isActive()) joinAbortController = undefined
@@ -264,7 +287,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([])
+2 -2
View File
@@ -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}
</div>
{#if isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"}
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
<div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
<VoiceWidget />
</div>