Switch to 39004 for room presence

This commit is contained in:
mplorentz
2026-03-12 18:27:01 -04:00
parent b82ef3b387
commit b513982dc3
5 changed files with 124 additions and 89 deletions
+8 -4
View File
@@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import type {Readable} from "svelte/store"
import {readable} from "svelte/store"
import cx from "classnames" import cx from "classnames"
import {removeUndefined} from "@welshman/lib" import {ifLet, removeUndefined} from "@welshman/lib"
import {deriveProfile} from "@welshman/app" import {deriveProfile} from "@welshman/app"
import UserRounded from "@assets/icons/user-rounded.svg?dataurl" import UserRounded from "@assets/icons/user-rounded.svg?dataurl"
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
type Props = { type Props = {
pubkey: string pubkey?: string
class?: string class?: string
size?: number size?: number
url?: string url?: string
@@ -14,11 +16,13 @@
const {pubkey, url, size = 7, ...props}: Props = $props() const {pubkey, url, size = 7, ...props}: Props = $props()
const profile = deriveProfile(pubkey, removeUndefined([url])) const readableProfile = ifLet(pubkey, pk => deriveProfile(pk, removeUndefined([url])))
const emptyProfile = readable(undefined)
const profile: Readable<{picture?: string} | undefined> = readableProfile ?? emptyProfile
</script> </script>
<ImageIcon <ImageIcon
{size} {size}
alt="" alt=""
class={cx(props.class, "rounded-full")} class={cx(props.class, "rounded-full")}
src={$profile?.picture || UserRounded} /> src={$profile?.picture ?? UserRounded} />
+14 -17
View File
@@ -4,10 +4,10 @@
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.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 {deriveRoom, deriveShouldNotify, getRoomType, RoomType} from "@app/core/state"
import {notifications} from "@app/util/notifications" import {notifications} from "@app/util/notifications"
import {makeRoomPath} from "@app/util/routes" import {makeRoomPath} from "@app/util/routes"
import {joinVoiceRoom, currentVoiceSession} from "@app/voice"
interface Props { interface Props {
url: any url: any
@@ -24,21 +24,18 @@
const shouldNotifyForSpace = deriveShouldNotify(url) const shouldNotifyForSpace = deriveShouldNotify(url)
const shouldNotifyForRoom = deriveShouldNotify(url, h) const shouldNotifyForRoom = deriveShouldNotify(url, h)
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace) const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
const handleClick = () => {
if (roomType !== RoomType.Voice) return
if ($currentVoiceSession?.url === url && $currentVoiceSession?.h === h) return
void joinVoiceRoom(url, h)
}
</script> </script>
<SecondaryNavItem {#if roomType === RoomType.Voice}
href={path} <VoiceRoomItem {url} {h} />
{replaceState} {:else}
onclick={handleClick} <SecondaryNavItem
notification={notify ? $notifications.has(path) : false}> href={path}
<RoomNameWithImage {url} {h} /> {replaceState}
{#if showDifferenceIcon} notification={notify ? $notifications.has(path) : false}>
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" /> <RoomNameWithImage {url} {h} />
{/if} {#if showDifferenceIcon}
</SecondaryNavItem> <Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
{/if}
</SecondaryNavItem>
{/if}
+9 -7
View File
@@ -11,7 +11,9 @@
joinVoiceRoom, joinVoiceRoom,
leaveVoiceRoom, leaveVoiceRoom,
currentVoiceSession, currentVoiceSession,
speakingPubkeys, isParticipantSpeaking,
participantKey,
type VoiceParticipant,
} from "@app/voice" } from "@app/voice"
interface Props { interface Props {
@@ -46,8 +48,8 @@
} }
$effect(() => { $effect(() => {
for (const pk of $participants) { for (const p of $participants) {
loadProfile(pk) if (p.pubkey) loadProfile(p.pubkey)
} }
}) })
</script> </script>
@@ -65,17 +67,17 @@
<RoomName {url} {h} /> <RoomName {url} {h} />
</div> </div>
{#if $participants.length > 0} {#if $participants.length > 0}
{#each $participants as pk (pk)} {#each $participants as p (participantKey(p as VoiceParticipant))}
<div class="flex items-center gap-2 ml-6"> <div class="flex items-center gap-2 ml-6">
<div <div
class={cx( class={cx(
"inline-flex shrink-0 items-center justify-center rounded-full transition-shadow", "inline-flex shrink-0 items-center justify-center rounded-full transition-shadow",
isActive && $speakingPubkeys.has(pk) && "ring-2 ring-success", isActive && $isParticipantSpeaking(p) && "ring-2 ring-success",
)}> )}>
<ProfileCircle pubkey={pk} size={5} class="h-5 w-5" /> <ProfileCircle pubkey={p.pubkey} size={5} class="h-5 w-5" />
</div> </div>
<span class="ellipsize text-xs opacity-70"> <span class="ellipsize text-xs opacity-70">
{displayProfileByPubkey(pk)} {p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"}
</span> </span>
</div> </div>
{/each} {/each}
+2 -2
View File
@@ -55,7 +55,7 @@ import {
loadFeedsForPubkey, loadFeedsForPubkey,
} from "@app/core/state" } from "@app/core/state"
import {hasBlossomSupport} from "@app/core/commands" import {hasBlossomSupport} from "@app/core/commands"
import {ROOM_PRESENCE} from "@app/voice" import {LIVEKIT_PARTICIPANTS} from "@app/voice"
// Utils // Utils
@@ -320,7 +320,7 @@ const syncSpace = (url: string, rooms: string[]) => {
pullAndListen({ pullAndListen({
url, url,
signal: controller.signal, signal: controller.signal,
filters: [{kinds: [ROOM_PRESENCE]}], filters: [{kinds: [LIVEKIT_PARTICIPANTS]}],
}) })
return () => controller.abort() return () => controller.abort()
+91 -59
View File
@@ -4,21 +4,19 @@
*/ */
import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client" import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client"
import {derived, get, writable} from "svelte/store" import {derived, get, writable} from "svelte/store"
import {now} from "@welshman/lib" import {uniqBy} from "@welshman/lib"
import {makeEvent, makeHttpAuth, makeHttpAuthHeader, getTagValue} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {signer, publishThunk} from "@welshman/app" import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
import {signer} from "@welshman/app"
import {getLivekitEndpoint} from "$lib/livekit" import {getLivekitEndpoint} from "$lib/livekit"
import {AbortError, whenAborted, whenTimeout} from "$lib/util" 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" import {pushToast} from "@app/util/toast"
export const ROOM_PRESENCE = 10312 export const LIVEKIT_PARTICIPANTS = 39004
export {checkRelayHasLivekit} from "$lib/livekit" export {checkRelayHasLivekit} from "$lib/livekit"
const PRESENCE_INTERVAL_MS = 60_000
const PRESENCE_EXPIRY_S = 300
export type VoiceSession = { export type VoiceSession = {
url: string url: string
h: string h: string
@@ -26,9 +24,49 @@ export type VoiceSession = {
muted: boolean muted: boolean
} }
export type Pubkey = string
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined) export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
export const speakingPubkeys = writable(new Set<string>()) export const participantPubkeyMap = writable<Map<string, Pubkey>>(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<VoiceParticipant[]>([])
export const isParticipantSpeaking = derived(
speakingParticipants,
$participants => (p: VoiceParticipant) =>
$participants.some(sp => participantKey(sp) === participantKey(p)),
)
const fetchLivekitToken = async ( const fetchLivekitToken = async (
url: string, url: string,
@@ -60,54 +98,39 @@ const fetchLivekitToken = async (
} }
export const deriveVoiceParticipants = (url: string, h: string) => export const deriveVoiceParticipants = (url: string, h: string) =>
derived(deriveEventsForUrl(url, [{kinds: [ROOM_PRESENCE]}]), $events => { // We use the livekit identity list while in a call, and fall back to the list in kind 39004.
const cutoff = now() - PRESENCE_EXPIRY_S derived(
const pubkeys: string[] = [] [
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 (inCall) {
if (event.created_at < cutoff) continue const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity)
return uniqBy((p: VoiceParticipant) => participantKey(p), participants)
if (getTagValue("h", event.tags) === h) { } else {
pubkeys.push(event.pubkey) 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<typeof setInterval> | 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) => { const onRoomDisconnected = (reason?: DisconnectReason) => {
speakingPubkeys.set(new Set()) speakingParticipants.set([])
participantPubkeyMap.set(new Map())
currentVoiceSession.set(undefined) currentVoiceSession.set(undefined)
stopPresenceHeartbeat()
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
const message = const message =
reason === DisconnectReason.JOIN_FAILURE reason === DisconnectReason.JOIN_FAILURE
@@ -131,7 +154,7 @@ const onTrackUnsubscribed = (track: Track) => {
} }
const onActiveSpeakersChanged = (participants: {identity: string}[]) => { const onActiveSpeakersChanged = (participants: {identity: string}[]) => {
speakingPubkeys.set(new Set(participants.map(p => p.identity))) speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity)))
} }
const playJoinSound = () => { const playJoinSound = () => {
@@ -139,10 +162,15 @@ const playJoinSound = () => {
audio.play().catch(() => {}) audio.play().catch(() => {})
} }
const onParticipantConnected = () => { const onParticipantConnected = (participant: {identity: string}) => {
addParticipant(participant.identity)
playJoinSound() playJoinSound()
} }
const onParticipantDisconnected = (participant: {identity: string}) => {
deleteParticipant(participant.identity)
}
export const joinVoiceRoom = async ( export const joinVoiceRoom = async (
url: string, url: string,
h: string, h: string,
@@ -160,6 +188,7 @@ export const joinVoiceRoom = async (
room.on(RoomEvent.Disconnected, onRoomDisconnected) room.on(RoomEvent.Disconnected, onRoomDisconnected)
room.on(RoomEvent.ParticipantConnected, onParticipantConnected) room.on(RoomEvent.ParticipantConnected, onParticipantConnected)
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed) room.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
@@ -178,6 +207,12 @@ export const joinVoiceRoom = async (
throw e throw e
} }
participantPubkeyMap.set(new Map())
addParticipant(room.localParticipant.identity)
for (const p of room.remoteParticipants.values()) {
addParticipant(p.identity)
}
let muted = false let muted = false
try { try {
await room.localParticipant.setMicrophoneEnabled(true) await room.localParticipant.setMicrophoneEnabled(true)
@@ -188,8 +223,6 @@ export const joinVoiceRoom = async (
currentVoiceSession.set({url, h, room, muted}) currentVoiceSession.set({url, h, room, muted})
startPresenceHeartbeat(url, h)
playJoinSound() playJoinSound()
} }
@@ -200,10 +233,9 @@ export const leaveVoiceRoom = async () => {
const audio = new Audio("/leave-voice-room.mp3") const audio = new Audio("/leave-voice-room.mp3")
audio.play().catch(() => {}) audio.play().catch(() => {})
speakingPubkeys.set(new Set()) speakingParticipants.set([])
stopPresenceHeartbeat() participantPubkeyMap.set(new Map())
session.room.disconnect() session.room.disconnect()
deletePresence(session.url)
currentVoiceSession.set(undefined) currentVoiceSession.set(undefined)
} }