feat: show voice room participants before joining

This commit is contained in:
2026-06-08 23:16:59 +05:30
parent b6b8145901
commit 57d2f61ff4
4 changed files with 85 additions and 26 deletions
+8 -1
View File
@@ -19,6 +19,7 @@ import {map, not, nthEq, reject, removeUndefined, uniqBy} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app" import {signer} from "@welshman/app"
import {load} from "@welshman/net"
import {getLivekitEndpoint} from "$lib/livekit" import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util" import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
import { import {
@@ -154,6 +155,12 @@ const fetchLivekitToken = async (
return response.json() return response.json()
} }
export const loadVoiceParticipants = (url: string, h: string) =>
load({
relays: [url],
filters: [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}],
})
export const deriveVoiceParticipants = (url: string, h: string) => export const deriveVoiceParticipants = (url: string, h: string) =>
// We use the livekit identity list while in a call, and fall back to the list in kind 39004. // We use the livekit identity list while in a call, and fall back to the list in kind 39004.
derived( derived(
@@ -173,7 +180,7 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
if (!latestEvent) return [] if (!latestEvent) return []
const participants = removeUndefined( const participants = removeUndefined(
map( map(
(tag: string[]) => (tag[1] ? {pubkey: tag[1], identity: tag[1]} : undefined), (tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
getTags("participant", latestEvent.tags), getTags("participant", latestEvent.tags),
), ),
) )
+35 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import cx from "classnames"
import {getProfile, loadProfile} from "@welshman/app" import {getProfile, loadProfile} from "@welshman/app"
import {isMobile} from "@lib/html" import {isMobile} from "@lib/html"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
@@ -6,10 +7,20 @@
type Props = { type Props = {
pubkeys: string[] pubkeys: string[]
size?: number size?: number
limit?: number
class?: string
} }
const {pubkeys, size = 7}: Props = $props() const {pubkeys, size = 7, limit, class: className}: Props = $props()
const limit = isMobile ? 7 : 10 const effectiveLimit = $derived(limit ?? (isMobile ? 7 : 10))
const dimensions = $derived(
size <= 5
? {box: "h-5 w-5", overlap: "-mr-2", overflow: "text-[9px]"}
: size <= 6
? {box: "h-6 w-6", overlap: "-mr-2.5", overflow: "text-[10px]"}
: {box: "h-8 w-8", overlap: "-mr-3", overflow: "text-xs"},
)
for (const pubkey of pubkeys) { for (const pubkey of pubkeys) {
loadProfile(pubkey) loadProfile(pubkey)
@@ -20,13 +31,31 @@
return filtered.length > 0 ? filtered : pubkeys.slice(0, 1) return filtered.length > 0 ? filtered : pubkeys.slice(0, 1)
}) })
const displayPubkeys = $derived(visiblePubkeys.toSorted().slice(0, effectiveLimit))
const overflowCount = $derived(Math.max(0, pubkeys.length - effectiveLimit))
</script> </script>
<div class="flex pr-3"> <div class={cx("flex", size <= 5 ? "pr-2" : "pr-3", className)}>
{#each visiblePubkeys.toSorted().slice(0, limit) as pubkey (pubkey)} {#each displayPubkeys as pubkey (pubkey)}
<div <div
class="z-feature -mr-3 inline-block flex h-8 w-8 items-center justify-center rounded-full bg-base-100"> class={cx(
<ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {size} /> "z-feature inline-block flex items-center justify-center rounded-full bg-base-100",
dimensions.box,
dimensions.overlap,
)}>
<ProfileCircle class={cx(dimensions.box, "bg-base-300")} {pubkey} {size} />
</div> </div>
{/each} {/each}
{#if overflowCount > 0}
<div
class={cx(
"z-feature inline-flex items-center justify-center rounded-full bg-neutral font-medium text-neutral-content",
dimensions.box,
dimensions.overlap,
dimensions.overflow,
)}>
+{overflowCount}
</div>
{/if}
</div> </div>
+32 -18
View File
@@ -4,6 +4,7 @@
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 ProfileCircles from "@app/components/ProfileCircles.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 {makeRoomPath} from "@app/util/routes" import {makeRoomPath} from "@app/util/routes"
@@ -20,7 +21,11 @@
voiceState, voiceState,
type VoiceParticipant, type VoiceParticipant,
} from "@app/call/stores" } from "@app/call/stores"
import {cancelJoinVoiceRoom, deriveVoiceParticipants} from "@app/call/voice" import {
cancelJoinVoiceRoom,
deriveVoiceParticipants,
loadVoiceParticipants,
} from "@app/call/voice"
interface Props { interface Props {
url: string url: string
@@ -32,6 +37,7 @@
const {url, h, replaceState = false, notification = false}: Props = $props() const {url, h, replaceState = false, notification = false}: Props = $props()
const participants = deriveVoiceParticipants(url, h) const participants = deriveVoiceParticipants(url, h)
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
const isActive = $derived( const isActive = $derived(
$voiceState === VoiceState.Connected && $currentVoiceRoom?.id === makeRoomId(url, h), $voiceState === VoiceState.Connected && $currentVoiceRoom?.id === makeRoomId(url, h),
) )
@@ -53,6 +59,10 @@
pushModal(VoiceRoomJoinDialog, {url, h}) pushModal(VoiceRoomJoinDialog, {url, h})
} }
$effect(() => {
void loadVoiceParticipants(url, h)
})
$effect(() => { $effect(() => {
for (const p of $participants) { for (const p of $participants) {
if (p.pubkey) loadProfile(p.pubkey) if (p.pubkey) loadProfile(p.pubkey)
@@ -75,29 +85,33 @@
{/if} {/if}
<RoomName {url} {h} /> <RoomName {url} {h} />
</div> </div>
{#if $participants.length > 0} {#if participantPubkeys.length > 0}
{#each $participants as p (participantKey(p as VoiceParticipant))} {#if isActive}
<div class="flex items-center gap-2 ml-6"> {#each $participants as p (participantKey(p as VoiceParticipant))}
<div {@const media = $mediaStateByIdentity(p.identity)}
class={cx( <div class="flex items-center gap-2 ml-6">
"inline-flex shrink-0 items-center justify-center rounded-full transition-shadow", <div
isActive && $isParticipantSpeaking(p) && "ring-2 ring-success", class={cx(
)}> "inline-flex shrink-0 items-center justify-center rounded-full transition-shadow",
<ProfileCircle pubkey={p.pubkey} size={5} class="h-5 w-5" /> $isParticipantSpeaking(p) && "ring-2 ring-success",
</div> )}>
<span class="ellipsize min-w-0 flex-1 text-xs opacity-70"> <ProfileCircle pubkey={p.pubkey} size={5} class="h-5 w-5" />
{p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"} </div>
</span> <span class="ellipsize min-w-0 flex-1 text-xs opacity-70">
{#if isActive} {p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"}
{@const media = $mediaStateByIdentity(p.identity)} </span>
<VoiceParticipantMediaBadges <VoiceParticipantMediaBadges
muted={media.muted} muted={media.muted}
cameraOn={media.cameraOn} cameraOn={media.cameraOn}
size={3} size={3}
class="shrink-0" /> class="shrink-0" />
{/if} </div>
{/each}
{:else}
<div class="ml-6">
<ProfileCircles pubkeys={participantPubkeys} size={5} limit={3} />
</div> </div>
{/each} {/if}
{/if} {/if}
</div> </div>
</SecondaryNavItem> </SecondaryNavItem>
+10 -1
View File
@@ -13,8 +13,9 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import {AbortError, TimeoutError} from "$lib/util" import {AbortError, TimeoutError} from "$lib/util"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import {displayRoom} from "@app/core/state" import {displayRoom} from "@app/core/state"
import {joinVoiceRoom} from "@app/call/voice" import {deriveVoiceParticipants, joinVoiceRoom, loadVoiceParticipants} from "@app/call/voice"
import {popModal} from "@app/util/modal" import {popModal} from "@app/util/modal"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
@@ -26,6 +27,8 @@
const {url, h}: Props = $props() const {url, h}: Props = $props()
const spaceLabel = $derived(displayRelayUrl(url)) const spaceLabel = $derived(displayRelayUrl(url))
const participants = deriveVoiceParticipants(url, h)
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
let audioInputs = $state<MediaDeviceInfo[]>([]) let audioInputs = $state<MediaDeviceInfo[]>([])
let selectedDeviceId = $state("") let selectedDeviceId = $state("")
@@ -42,6 +45,7 @@
} }
$effect(() => { $effect(() => {
void loadVoiceParticipants(url, h)
void loadDevices() void loadDevices()
}) })
@@ -81,6 +85,11 @@
</span> </span>
</ModalSubtitle> </ModalSubtitle>
</ModalHeader> </ModalHeader>
{#if participantPubkeys.length > 0}
<div class="flex justify-center py-2">
<ProfileCircles pubkeys={participantPubkeys} size={5} limit={3} />
</div>
{/if}
<p class="text-sm opacity-80">Select a microphone to join the call:</p> <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 flex-col gap-4 pt-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">