feat: show voice room participants before joining #294

Merged
hodlbod merged 1 commits from userAdityaa/flotilla:272-show-call-participants into dev 2026-06-09 00:04:55 +00:00
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 {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app"
import {load} from "@welshman/net"
import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, TimeoutError, whenAborted, whenTimeout} from "$lib/util"
import {
@@ -154,6 +155,12 @@ const fetchLivekitToken = async (
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) =>
// We use the livekit identity list while in a call, and fall back to the list in kind 39004.
derived(
@@ -173,7 +180,7 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
if (!latestEvent) return []
const participants = removeUndefined(
map(
(tag: string[]) => (tag[1] ? {pubkey: tag[1], identity: tag[1]} : undefined),
(tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined),
getTags("participant", latestEvent.tags),
),
)
+35 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import cx from "classnames"
import {getProfile, loadProfile} from "@welshman/app"
import {isMobile} from "@lib/html"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
@@ -6,10 +7,20 @@
type Props = {
pubkeys: string[]
size?: number
limit?: number
class?: string
}
const {pubkeys, size = 7}: Props = $props()
const limit = isMobile ? 7 : 10
const {pubkeys, size = 7, limit, class: className}: Props = $props()
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) {
loadProfile(pubkey)
@@ -20,13 +31,31 @@
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>
<div class="flex pr-3">
{#each visiblePubkeys.toSorted().slice(0, limit) as pubkey (pubkey)}
<div class={cx("flex", size <= 5 ? "pr-2" : "pr-3", className)}>
{#each displayPubkeys as pubkey (pubkey)}
<div
class="z-feature -mr-3 inline-block flex h-8 w-8 items-center justify-center rounded-full bg-base-100">
<ProfileCircle class="h-8 w-8 bg-base-300" {pubkey} {size} />
class={cx(
"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>
{/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>
+32 -18
View File
@@ -4,6 +4,7 @@
import {loadProfile, displayProfileByPubkey} from "@welshman/app"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {makeRoomPath} from "@app/util/routes"
@@ -20,7 +21,11 @@
voiceState,
type VoiceParticipant,
} from "@app/call/stores"
import {cancelJoinVoiceRoom, deriveVoiceParticipants} from "@app/call/voice"
import {
cancelJoinVoiceRoom,
deriveVoiceParticipants,
loadVoiceParticipants,
} from "@app/call/voice"
interface Props {
url: string
@@ -32,6 +37,7 @@
const {url, h, replaceState = false, notification = false}: Props = $props()
const participants = deriveVoiceParticipants(url, h)
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
const isActive = $derived(
$voiceState === VoiceState.Connected && $currentVoiceRoom?.id === makeRoomId(url, h),
)
@@ -53,6 +59,10 @@
pushModal(VoiceRoomJoinDialog, {url, h})
}
$effect(() => {
void loadVoiceParticipants(url, h)
})
$effect(() => {
for (const p of $participants) {
if (p.pubkey) loadProfile(p.pubkey)
@@ -75,29 +85,33 @@
{/if}
<RoomName {url} {h} />
</div>
{#if $participants.length > 0}
{#each $participants as p (participantKey(p as VoiceParticipant))}
<div class="flex items-center gap-2 ml-6">
<div
class={cx(
"inline-flex shrink-0 items-center justify-center rounded-full transition-shadow",
isActive && $isParticipantSpeaking(p) && "ring-2 ring-success",
)}>
<ProfileCircle pubkey={p.pubkey} size={5} class="h-5 w-5" />
</div>
<span class="ellipsize min-w-0 flex-1 text-xs opacity-70">
{p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"}
</span>
{#if isActive}
{@const media = $mediaStateByIdentity(p.identity)}
{#if participantPubkeys.length > 0}
{#if isActive}
{#each $participants as p (participantKey(p as VoiceParticipant))}
{@const media = $mediaStateByIdentity(p.identity)}
<div class="flex items-center gap-2 ml-6">
<div
class={cx(
"inline-flex shrink-0 items-center justify-center rounded-full transition-shadow",
$isParticipantSpeaking(p) && "ring-2 ring-success",
)}>
<ProfileCircle pubkey={p.pubkey} size={5} class="h-5 w-5" />
</div>
<span class="ellipsize min-w-0 flex-1 text-xs opacity-70">
{p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"}
</span>
<VoiceParticipantMediaBadges
muted={media.muted}
cameraOn={media.cameraOn}
size={3}
class="shrink-0" />
{/if}
</div>
{/each}
{:else}
<div class="ml-6">
<ProfileCircles pubkeys={participantPubkeys} size={5} limit={3} />
</div>
{/each}
{/if}
{/if}
</div>
</SecondaryNavItem>
+10 -1
View File
@@ -13,8 +13,9 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import {AbortError, TimeoutError} from "$lib/util"
import ProfileCircles from "@app/components/ProfileCircles.svelte"
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 {pushToast} from "@app/util/toast"
@@ -26,6 +27,8 @@
const {url, h}: Props = $props()
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 selectedDeviceId = $state("")
@@ -42,6 +45,7 @@
}
$effect(() => {
void loadVoiceParticipants(url, h)
void loadDevices()
})
@@ -81,6 +85,11 @@
</span>
</ModalSubtitle>
</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>
<div class="flex flex-col gap-4 pt-2">
<div class="flex items-center gap-2">