forked from coracle/flotilla
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 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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user