Add a dialog before joining voice rooms

This commit is contained in:
mplorentz
2026-03-27 11:06:05 -04:00
parent 82245d895c
commit bd0adfdffe
6 changed files with 262 additions and 61 deletions
+15 -6
View File
@@ -1,15 +1,17 @@
<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 {
VoiceState,
deriveVoiceParticipants,
joinVoiceRoom,
cancelJoinVoiceRoom,
currentVoiceRoom,
voiceState,
@@ -28,21 +30,28 @@
const participants = deriveVoiceParticipants(url, h)
const isActive = $derived(
$voiceState === "connected" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
$voiceState === VoiceState.Connected &&
$currentVoiceRoom?.url === url &&
$currentVoiceRoom?.h === h,
)
const isJoining = $derived(
$voiceState === "joining" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
$voiceState === VoiceState.Joining &&
$currentVoiceRoom?.url === url &&
$currentVoiceRoom?.h === 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,116 @@
<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 {deriveRoom} 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 room = deriveRoom(url, h)
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">{$room?.name || 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>
+38 -30
View File
@@ -1,22 +1,7 @@
<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 {derived, get} from "svelte/store"
import {fly} from "svelte/transition"
import {goto} from "$app/navigation"
import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
@@ -25,36 +10,59 @@
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 {deriveDisplayedRoom, displayRoom, getRoomType, RoomType} from "@app/core/state"
import {pushModal} from "@app/util/modal"
import {makeRoomPath} from "@app/util/routes"
import {
VoiceState,
currentVoiceSession,
currentVoiceRoom,
voiceState,
leaveVoiceRoom,
toggleMute,
rejoinVoiceRoom,
cancelJoinVoiceRoom,
} from "@app/voice"
const roomName = $derived(
$currentVoiceRoom ? displayRoom($currentVoiceRoom.url, $currentVoiceRoom.h) : "",
const targetRoom = derived(
[voiceState, currentVoiceRoom, deriveDisplayedRoom],
([voiceState, currentVoiceRoom, routeDisplayedRoom]) => {
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 undefined
}
return currentVoiceRoom
}
return currentVoiceRoom
},
)
const spaceName = $derived($currentVoiceRoom ? displayRelayUrl($currentVoiceRoom.url) : "")
const handleRejoin = () => {
void rejoinVoiceRoom().catch(handleJoinError)
const roomName = $derived($targetRoom ? displayRoom($targetRoom.url, $targetRoom.h) : "")
const spaceName = $derived($targetRoom ? displayRelayUrl($targetRoom.url) : "")
const openJoinDialog = async () => {
const target = get(targetRoom)
if (!target) return
await goto(makeRoomPath(target.url, target.h))
pushModal(VoiceRoomJoinDialog, {url: target.url, h: target.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 +72,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 +80,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 +99,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}
+24 -1
View File
@@ -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) =>
+67 -22
View File
@@ -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<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<RoomRef | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
@@ -140,10 +162,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 +236,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")
voiceState.set(VoiceState.Joining)
const controller = new AbortController()
joinAbortController = controller
@@ -238,20 +288,15 @@ export const joinVoiceRoom = async (url: string, h: string): Promise<void> => {
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([])
+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>