Switch to 39004 for room presence
This commit is contained in:
@@ -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} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user