feat: show voice room participants before joining
This commit is contained in:
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user