diff --git a/src/app/components/ProfileCircle.svelte b/src/app/components/ProfileCircle.svelte index 255cbffe..8d4561b6 100644 --- a/src/app/components/ProfileCircle.svelte +++ b/src/app/components/ProfileCircle.svelte @@ -1,12 +1,14 @@ + src={$profile?.picture ?? UserRounded} /> diff --git a/src/app/components/SpaceMenuRoomItem.svelte b/src/app/components/SpaceMenuRoomItem.svelte index 19d4d4e1..1ac59b01 100644 --- a/src/app/components/SpaceMenuRoomItem.svelte +++ b/src/app/components/SpaceMenuRoomItem.svelte @@ -4,10 +4,10 @@ import Icon from "@lib/components/Icon.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte" + import VoiceRoomItem from "@app/components/VoiceRoomItem.svelte" import {deriveRoom, deriveShouldNotify, getRoomType, RoomType} from "@app/core/state" import {notifications} from "@app/util/notifications" import {makeRoomPath} from "@app/util/routes" - import {joinVoiceRoom, currentVoiceSession} from "@app/voice" interface Props { url: any @@ -24,21 +24,18 @@ const shouldNotifyForSpace = deriveShouldNotify(url) const shouldNotifyForRoom = deriveShouldNotify(url, h) const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace) - - const handleClick = () => { - if (roomType !== RoomType.Voice) return - if ($currentVoiceSession?.url === url && $currentVoiceSession?.h === h) return - void joinVoiceRoom(url, h) - } - - - {#if showDifferenceIcon} - - {/if} - +{#if roomType === RoomType.Voice} + +{:else} + + + {#if showDifferenceIcon} + + {/if} + +{/if} diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index d77da0d1..6f37923e 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -11,7 +11,9 @@ joinVoiceRoom, leaveVoiceRoom, currentVoiceSession, - speakingPubkeys, + isParticipantSpeaking, + participantKey, + type VoiceParticipant, } from "@app/voice" interface Props { @@ -46,8 +48,8 @@ } $effect(() => { - for (const pk of $participants) { - loadProfile(pk) + for (const p of $participants) { + if (p.pubkey) loadProfile(p.pubkey) } }) @@ -65,17 +67,17 @@ {#if $participants.length > 0} - {#each $participants as pk (pk)} + {#each $participants as p (participantKey(p as VoiceParticipant))}
- +
- {displayProfileByPubkey(pk)} + {p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"}
{/each} diff --git a/src/app/core/sync.ts b/src/app/core/sync.ts index f5737d45..ebe46474 100644 --- a/src/app/core/sync.ts +++ b/src/app/core/sync.ts @@ -55,7 +55,7 @@ import { loadFeedsForPubkey, } from "@app/core/state" import {hasBlossomSupport} from "@app/core/commands" -import {ROOM_PRESENCE} from "@app/voice" +import {LIVEKIT_PARTICIPANTS} from "@app/voice" // Utils @@ -320,7 +320,7 @@ const syncSpace = (url: string, rooms: string[]) => { pullAndListen({ url, signal: controller.signal, - filters: [{kinds: [ROOM_PRESENCE]}], + filters: [{kinds: [LIVEKIT_PARTICIPANTS]}], }) return () => controller.abort() diff --git a/src/app/voice.ts b/src/app/voice.ts index 2a952474..7c9289b7 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -4,21 +4,19 @@ */ import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client" import {derived, get, writable} from "svelte/store" -import {now} from "@welshman/lib" -import {makeEvent, makeHttpAuth, makeHttpAuthHeader, getTagValue} from "@welshman/util" -import {signer, publishThunk} from "@welshman/app" +import {uniqBy} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" +import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" +import {signer} from "@welshman/app" import {getLivekitEndpoint} from "$lib/livekit" import {AbortError, whenAborted, whenTimeout} from "$lib/util" -import {deriveEventsForUrl} from "@app/core/state" +import {deriveLatestEventForUrl} from "@app/core/state" import {pushToast} from "@app/util/toast" -export const ROOM_PRESENCE = 10312 +export const LIVEKIT_PARTICIPANTS = 39004 export {checkRelayHasLivekit} from "$lib/livekit" -const PRESENCE_INTERVAL_MS = 60_000 -const PRESENCE_EXPIRY_S = 300 - export type VoiceSession = { url: string h: string @@ -26,9 +24,49 @@ export type VoiceSession = { muted: boolean } +export type Pubkey = string + +export type VoiceParticipant = {pubkey?: Pubkey; identity: string} + export const currentVoiceSession = writable(undefined) -export const speakingPubkeys = writable(new Set()) +export const participantPubkeyMap = writable>(new Map()) + +const addParticipant = (identity: string) => { + participantPubkeyMap.update(m => { + const next = new Map(m) + next.set(identity, pubkeyFromLiveKitIdentity(identity) ?? "") + return next + }) +} + +const deleteParticipant = (identity: string) => { + participantPubkeyMap.update(m => { + const next = new Map(m) + next.delete(identity) + return next + }) +} + +const currentVoiceRoom = derived(currentVoiceSession, s => (s ? {url: s.url, h: s.h} : undefined)) + +export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined => + /^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined + +export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => { + const pk = pubkeyFromLiveKitIdentity(identity) + return pk ? {pubkey: pk, identity} : {identity} +} + +export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity + +export const speakingParticipants = writable([]) + +export const isParticipantSpeaking = derived( + speakingParticipants, + $participants => (p: VoiceParticipant) => + $participants.some(sp => participantKey(sp) === participantKey(p)), +) const fetchLivekitToken = async ( url: string, @@ -60,54 +98,39 @@ const fetchLivekitToken = async ( } export const deriveVoiceParticipants = (url: string, h: string) => - derived(deriveEventsForUrl(url, [{kinds: [ROOM_PRESENCE]}]), $events => { - const cutoff = now() - PRESENCE_EXPIRY_S - const pubkeys: string[] = [] + // We use the livekit identity list while in a call, and fall back to the list in kind 39004. + derived( + [ + participantPubkeyMap, + currentVoiceRoom, + deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]), + ], + ([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => { + const inCall = + $participantPubkeyMap.size > 0 && + $currentVoiceRoom?.url === url && + $currentVoiceRoom?.h === h - for (const event of $events) { - if (event.created_at < cutoff) continue - - if (getTagValue("h", event.tags) === h) { - pubkeys.push(event.pubkey) + if (inCall) { + const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity) + return uniqBy((p: VoiceParticipant) => participantKey(p), participants) + } else { + const latestEvent = $publishedParticipantList as TrustedEvent | undefined + if (!latestEvent) return [] + const participants = getTags("participant", latestEvent.tags).map((tag: string[]) => { + const pubkey = tag[1] + const identity = tag[2] ?? pubkey + return pubkey ? {pubkey, identity} : {identity} + }) + return uniqBy((p: VoiceParticipant) => participantKey(p), participants) } - } - - return pubkeys - }) - -const publishPresence = (url: string, h: string) => { - const event = makeEvent(ROOM_PRESENCE, { - tags: [["h", h]], - }) - - return publishThunk({event, relays: [url]}) -} - -const deletePresence = (url: string) => { - const event = makeEvent(ROOM_PRESENCE, {tags: []}) - - return publishThunk({event, relays: [url]}) -} - -let presenceInterval: ReturnType | undefined - -const startPresenceHeartbeat = (url: string, h: string) => { - stopPresenceHeartbeat() - publishPresence(url, h) - presenceInterval = setInterval(() => publishPresence(url, h), PRESENCE_INTERVAL_MS) -} - -const stopPresenceHeartbeat = () => { - if (presenceInterval) { - clearInterval(presenceInterval) - presenceInterval = undefined - } -} + }, + ) const onRoomDisconnected = (reason?: DisconnectReason) => { - speakingPubkeys.set(new Set()) + speakingParticipants.set([]) + participantPubkeyMap.set(new Map()) currentVoiceSession.set(undefined) - stopPresenceHeartbeat() if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { const message = reason === DisconnectReason.JOIN_FAILURE @@ -131,7 +154,7 @@ const onTrackUnsubscribed = (track: Track) => { } const onActiveSpeakersChanged = (participants: {identity: string}[]) => { - speakingPubkeys.set(new Set(participants.map(p => p.identity))) + speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity))) } const playJoinSound = () => { @@ -139,10 +162,15 @@ const playJoinSound = () => { audio.play().catch(() => {}) } -const onParticipantConnected = () => { +const onParticipantConnected = (participant: {identity: string}) => { + addParticipant(participant.identity) playJoinSound() } +const onParticipantDisconnected = (participant: {identity: string}) => { + deleteParticipant(participant.identity) +} + export const joinVoiceRoom = async ( url: string, h: string, @@ -160,6 +188,7 @@ export const joinVoiceRoom = async ( room.on(RoomEvent.Disconnected, onRoomDisconnected) room.on(RoomEvent.ParticipantConnected, onParticipantConnected) + room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) room.on(RoomEvent.TrackSubscribed, onTrackSubscribed) room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) @@ -178,6 +207,12 @@ export const joinVoiceRoom = async ( throw e } + participantPubkeyMap.set(new Map()) + addParticipant(room.localParticipant.identity) + for (const p of room.remoteParticipants.values()) { + addParticipant(p.identity) + } + let muted = false try { await room.localParticipant.setMicrophoneEnabled(true) @@ -188,8 +223,6 @@ export const joinVoiceRoom = async ( currentVoiceSession.set({url, h, room, muted}) - startPresenceHeartbeat(url, h) - playJoinSound() } @@ -200,10 +233,9 @@ export const leaveVoiceRoom = async () => { const audio = new Audio("/leave-voice-room.mp3") audio.play().catch(() => {}) - speakingPubkeys.set(new Set()) - stopPresenceHeartbeat() + speakingParticipants.set([]) + participantPubkeyMap.set(new Map()) session.room.disconnect() - deletePresence(session.url) currentVoiceSession.set(undefined) }