Add a dialog before joining voice rooms (#109)

After using the voice rooms more since we removed the option for voice-only rooms I think you were right to suggest a dialog box before joining rooms. It felt far to clunky to have to join the voice call any time you just wanted to try to view room members, edit room settings, or just view the recent text chat.

This adds a dialog that allows the user to decline to join the call but still access the text part of the room along with associated settings and controls. It also acts as another confirmation step before turning on the user's microphone, and allows them to choose an audio input so they don't have to mess with the (generally terrible) browser controls for doing so. We should probably have controls to change your audio input and output from within the call as well, but I think this is enough for an MVP.

![Screenshot 2026-03-27 at 11.10.53 AM.png](/attachments/3ac271a6-5d17-4063-9ac6-3e5bdef10ccf)

Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social>
Reviewed-on: coracle/flotilla#109
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
This commit is contained in:
2026-03-27 19:02:56 +00:00
committed by hodlbod
parent 82245d895c
commit 16a73f27c9
6 changed files with 246 additions and 83 deletions
+12 -6
View File
@@ -1,15 +1,18 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames" import cx from "classnames"
import {goto} from "$app/navigation"
import {loadProfile, displayProfileByPubkey} from "@welshman/app" import {loadProfile, displayProfileByPubkey} from "@welshman/app"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import RoomImage from "@app/components/RoomImage.svelte" import RoomImage from "@app/components/RoomImage.svelte"
import RoomName from "@app/components/RoomName.svelte" import RoomName from "@app/components/RoomName.svelte"
import {handleJoinError} from "@app/components/VoiceWidget.svelte"
import {makeRoomPath} from "@app/util/routes" 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 { import {
VoiceState,
deriveVoiceParticipants, deriveVoiceParticipants,
joinVoiceRoom,
cancelJoinVoiceRoom, cancelJoinVoiceRoom,
currentVoiceRoom, currentVoiceRoom,
voiceState, voiceState,
@@ -28,21 +31,24 @@
const participants = deriveVoiceParticipants(url, h) const participants = deriveVoiceParticipants(url, h)
const isActive = $derived( const isActive = $derived(
$voiceState === "connected" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h, $voiceState === VoiceState.Connected && $currentVoiceRoom?.id === makeRoomId(url, h),
) )
const isJoining = $derived( 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 (isActive) return
if (isJoining) { if (isJoining) {
e.preventDefault()
cancelJoinVoiceRoom() cancelJoinVoiceRoom()
return return
} }
await joinVoiceRoom(url, h).catch(handleJoinError) e.preventDefault()
await goto(makeRoomPath(url, h), {replaceState})
pushModal(VoiceRoomJoinDialog, {url, h})
} }
$effect(() => { $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"> <script lang="ts">
import {readable} from "svelte/store"
import {fly} from "svelte/transition" import {fly} from "svelte/transition"
import {goto} from "$app/navigation"
import {page} from "$app/stores"
import {displayRelayUrl} from "@welshman/util" import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.svg?dataurl" import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.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 CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import {displayRoom} from "@app/core/state" import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
import { 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, currentVoiceSession,
currentVoiceRoom, currentVoiceRoom,
voiceState, voiceState,
leaveVoiceRoom, leaveVoiceRoom,
toggleMute, toggleMute,
rejoinVoiceRoom,
cancelJoinVoiceRoom, cancelJoinVoiceRoom,
} from "@app/voice" } from "@app/voice"
const roomName = $derived( const {relay, h} = $derived($page.params)
$currentVoiceRoom ? displayRoom($currentVoiceRoom.url, $currentVoiceRoom.h) : "", 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 = () => { const targetRoom = $derived.by((): Room | undefined => {
void rejoinVoiceRoom().catch(handleJoinError) if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
return $currentVoiceRoom
}
if ($voiceState === VoiceState.Disconnected) {
if (routeDisplayedRoom) {
if (getRoomType(routeDisplayedRoom) === RoomType.Voice) {
return routeDisplayedRoom
}
return undefined
}
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> </script>
{#if $currentVoiceRoom} {#if targetRoom}
<div <div
in:fly={{y: 60, duration: 350}} in:fly={{y: 60, duration: 350}}
out:fly={{y: 60, duration: 250}} out:fly={{y: 60, duration: 250}}
class="flex flex-col gap-2 rounded-box bg-base-100 p-3"> class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
<div class="flex flex-col gap-0.5"> <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> <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> <span class="text-sm font-semibold text-success">Voice Connected</span>
{:else} {:else}
<span class="text-sm font-semibold text-neutral-content">Disconnected</span> <span class="text-sm font-semibold text-neutral-content">Disconnected</span>
@@ -64,7 +83,7 @@
</span> </span>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{#if $voiceState === "joining"} {#if $voiceState === VoiceState.Joining}
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
<Button <Button
data-tip="Cancel" data-tip="Cancel"
@@ -72,7 +91,7 @@
onclick={cancelJoinVoiceRoom}> onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={4} /> <Icon icon={CloseCircle} size={4} />
</Button> </Button>
{:else if $voiceState === "connected" && $currentVoiceSession} {:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
<Button <Button
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"} data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
@@ -91,7 +110,7 @@
<Button <Button
data-tip="Join Voice" data-tip="Join Voice"
class="center tooltip tooltip-top btn btn-sm btn-square btn-success" class="center tooltip tooltip-top btn btn-sm btn-square btn-success"
onclick={handleRejoin}> onclick={openJoinDialog}>
<Icon icon={PhoneCallingRounded} size={4} /> <Icon icon={PhoneCallingRounded} size={4} />
</Button> </Button>
{/if} {/if}
+1 -1
View File
@@ -669,7 +669,7 @@ export const deriveRoom = call(() => {
return (url: string, h: string) => return (url: string, h: string) =>
derived( derived(
_deriveRoom(makeRoomId(url, h)), _deriveRoom(makeRoomId(url, h)),
room => room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}, room => (room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}) as Room,
) )
}) })
+67 -44
View File
@@ -2,7 +2,14 @@
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox * Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS. * (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 {derived, get, writable} from "svelte/store"
import {map, removeUndefined, uniqBy} from "@welshman/lib" import {map, removeUndefined, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
@@ -10,24 +17,17 @@ import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app" import {signer} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit" import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, whenAborted, whenTimeout} from "$lib/util" 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" import {pushToast} from "@app/util/toast"
export const LIVEKIT_PARTICIPANTS = 39004 export const LIVEKIT_PARTICIPANTS = 39004
export {checkRelayHasLivekit} from "$lib/livekit" 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 = { export type VoiceSession = {
url: string url: string
h: string h: string
room: Room room: LiveKitRoom
muted: boolean muted: boolean
} }
@@ -35,13 +35,17 @@ export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: 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 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)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map()) export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
@@ -102,7 +106,6 @@ const fetchLivekitToken = async (
if (!response.ok) { if (!response.ok) {
const text = await response.text() const text = await response.text()
if (response.status === 403) throw new VoiceJoinMembershipError()
throw new Error(`Token request failed (${response.status}): ${text}`) 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]}]), deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
], ],
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => { ([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => {
const inCall = const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
$participantPubkeyMap.size > 0 &&
$currentVoiceRoom?.url === url &&
$currentVoiceRoom?.h === h
if (inCall) { if (inCall) {
const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity) 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) => { const onRoomDisconnected = (reason?: DisconnectReason) => {
currentVoiceSession.set(undefined) currentVoiceSession.set(undefined)
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
voiceState.set("disconnected") voiceState.set(VoiceState.Disconnected)
const message = const message =
reason === DisconnectReason.JOIN_FAILURE reason === DisconnectReason.JOIN_FAILURE
? "Could not connect to voice room. Please try again." ? "Could not connect to voice room. Please try again."
@@ -191,14 +214,19 @@ export const cancelJoinVoiceRoom = () => {
joinAbortController?.abort() 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() cancelJoinVoiceRoom()
const session = get(currentVoiceSession) const session = get(currentVoiceSession)
if (session) await leaveVoiceRoom() if (session) await leaveVoiceRoom()
currentVoiceRoom.set({url, h}) currentVoiceRoom.set(get(deriveRoom(url, h)))
voiceState.set("joining") voiceState.set(VoiceState.Joining)
const controller = new AbortController() const controller = new AbortController()
joinAbortController = controller joinAbortController = controller
@@ -210,47 +238,42 @@ export const joinVoiceRoom = async (url: string, h: string): Promise<void> => {
if (signal.aborted) throw new AbortError() 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) liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected)
room.on(RoomEvent.ParticipantConnected, onParticipantConnected) liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed) liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
try { try {
await Promise.race([ await Promise.race([
room.connect(server_url, participant_token, {maxRetries: 0}), liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
whenTimeout(5_000, { whenTimeout(5_000, {
message: "Connection timed out. Please check your network and try again.", message: "Connection timed out. Please check your network and try again.",
}), }),
whenAborted(signal), whenAborted(signal),
]) ])
} catch (e) { } catch (e) {
room.disconnect() liveKitRoom.disconnect()
throw e throw e
} }
participantPubkeyMap.set(new Map()) participantPubkeyMap.set(new Map())
addParticipant(room.localParticipant.identity) addParticipant(liveKitRoom.localParticipant.identity)
for (const p of room.remoteParticipants.values()) { for (const p of liveKitRoom.remoteParticipants.values()) {
addParticipant(p.identity) addParticipant(p.identity)
} }
let muted = false const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
try {
await room.localParticipant.setMicrophoneEnabled(true)
} catch (e) {
muted = true
pushToast({theme: "error", message: "Could not access microphone"})
}
currentVoiceSession.set({url, h, room, muted}) currentVoiceSession.set({url, h, room: liveKitRoom, muted})
voiceState.set("connected") voiceState.set(VoiceState.Connected)
playJoinSound() playJoinSound()
} catch (e) { } catch (e) {
if (isActive()) voiceState.set("disconnected") if (isActive()) voiceState.set(VoiceState.Disconnected)
if (e instanceof AbortError) return
throw e throw e
} finally { } finally {
if (isActive()) joinAbortController = undefined if (isActive()) joinAbortController = undefined
@@ -264,7 +287,7 @@ export const leaveVoiceRoom = async () => {
const audio = new Audio("/leave-voice-room.mp3") const audio = new Audio("/leave-voice-room.mp3")
audio.play().catch(() => {}) audio.play().catch(() => {})
voiceState.set("disconnected") voiceState.set(VoiceState.Disconnected)
currentVoiceSession.set(undefined) currentVoiceSession.set(undefined)
session.room.disconnect() session.room.disconnect()
speakingParticipants.set([]) speakingParticipants.set([])
+2 -2
View File
@@ -50,7 +50,7 @@
userSettingsValues, userSettingsValues,
} from "@app/core/state" } from "@app/core/state"
import VoiceWidget from "@app/components/VoiceWidget.svelte" 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 {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit" import {popKey} from "@lib/implicit"
import {checked} from "@app/util/notifications" import {checked} from "@app/util/notifications"
@@ -494,7 +494,7 @@
{/key} {/key}
{/if} {/if}
</div> </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"> <div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
<VoiceWidget /> <VoiceWidget />
</div> </div>