feature/23-voice-room/poc #93

Merged
hodlbod merged 68 commits from feature/23-voice-room/poc into dev 2026-03-16 20:38:06 +00:00
8 changed files with 377 additions and 1 deletions
Showing only changes of commit 0daab7a46c - Show all commits
+3
View File
@@ -18,5 +18,8 @@ VITE_DEFAULT_RELAYS=relay.damus.io,relay.primal.net,nostr.mom
VITE_DEFAULT_MESSAGING_RELAYS=auth.nostr1.com,nostr-01.uid.ovh,relay.keychat.io,relay.0xchat.com
VITE_SIGNER_RELAYS=relay.nsec.app,ephemeral.snowflare.cc,bucket.coracle.social
VITE_VAPID_PUBLIC_KEY=BIt2D4BdgdbCowD_0d3Np6GbrIGHxd7aIEUeZNe3hQuRlHz02OhzvDaai0XSFoJYVzSzdMjdyW-QhvW9_yq8j4Y
VITE_LIVEKIT_URL=
VITE_LIVEKIT_API_KEY=
VITE_LIVEKIT_API_SECRET=
VITE_GLITCHTIP_API_KEY=
GLITCHTIP_AUTH_TOKEN=
+2
View File
@@ -83,6 +83,8 @@
"fuse.js": "^7.1.0",
"husky": "^9.1.7",
"idb": "^8.0.3",
"jose": "^6.1.3",
"livekit-client": "^2.17.2",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.6.14",
+97
View File
@@ -134,6 +134,12 @@ importers:
idb:
specifier: ^8.0.3
version: 8.0.3
jose:
specifier: ^6.1.3
version: 6.1.3
livekit-client:
specifier: ^2.17.2
version: 2.17.2(@types/dom-mediacapture-record@1.0.22)
nostr-signer-capacitor-plugin:
specifier: github:coracle-social/nostr-signer-capacitor-plugin#main
version: https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1)
@@ -737,6 +743,9 @@ packages:
'@braintree/sanitize-url@7.1.1':
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
'@bufbuild/protobuf@1.10.1':
resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==}
'@canvas/image-data@1.1.0':
resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==}
@@ -1283,6 +1292,12 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@livekit/mutex@1.1.1':
resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==}
'@livekit/protocol@1.44.0':
resolution: {integrity: sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==}
'@noble/ciphers@0.5.3':
resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==}
@@ -1823,6 +1838,9 @@ packages:
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
'@types/dom-mediacapture-record@1.0.22':
resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==}
'@types/eslint@9.6.1':
resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==}
@@ -3334,6 +3352,9 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
@@ -3438,6 +3459,11 @@ packages:
linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
livekit-client@2.17.2:
resolution: {integrity: sha512-+67y2EtAWZabARlY7kANl/VT1Uu1EJYR5a8qwpT2ub/uBCltsEgEDOxCIMwE9HFR5w+z41HR6GL9hyEvW/y6CQ==}
peerDependencies:
'@types/dom-mediacapture-record': ^1
load-json-file@4.0.0:
resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==}
engines: {node: '>=4'}
@@ -3472,6 +3498,10 @@ packages:
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
loglevel@1.9.2:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -4277,6 +4307,9 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
@@ -4306,6 +4339,13 @@ packages:
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
engines: {node: '>=11.0.0'}
sdp-transform@2.15.0:
resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==}
hasBin: true
sdp@3.2.1:
resolution: {integrity: sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==}
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -4641,6 +4681,9 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
ts-debounce@4.0.0:
resolution: {integrity: sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==}
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
@@ -4707,6 +4750,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typed-emitter@2.1.0:
resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
typescript-eslint@8.53.1:
resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4851,6 +4897,10 @@ packages:
webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
webrtc-adapter@9.0.4:
resolution: {integrity: sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==}
engines: {node: '>=6.0.0', npm: '>=3.10.0'}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -5737,6 +5787,8 @@ snapshots:
'@braintree/sanitize-url@7.1.1': {}
'@bufbuild/protobuf@1.10.1': {}
'@canvas/image-data@1.1.0': {}
'@capacitor-community/safe-area@8.0.1(@capacitor/core@8.0.1)':
@@ -6302,6 +6354,12 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@livekit/mutex@1.1.1': {}
'@livekit/protocol@1.44.0':
dependencies:
'@bufbuild/protobuf': 1.10.1
'@noble/ciphers@0.5.3': {}
'@noble/ciphers@1.3.0': {}
@@ -6863,6 +6921,8 @@ snapshots:
'@types/cookie@0.6.0': {}
'@types/dom-mediacapture-record@1.0.22': {}
'@types/eslint@9.6.1':
dependencies:
'@types/estree': 1.0.8
@@ -8534,6 +8594,8 @@ snapshots:
jiti@1.21.7: {}
jose@6.1.3: {}
js-base64@3.7.8: {}
js-tokens@4.0.0: {}
@@ -8609,6 +8671,20 @@ snapshots:
linkifyjs@4.3.2: {}
livekit-client@2.17.2(@types/dom-mediacapture-record@1.0.22):
dependencies:
'@livekit/mutex': 1.1.1
'@livekit/protocol': 1.44.0
'@types/dom-mediacapture-record': 1.0.22
events: 3.3.0
jose: 6.1.3
loglevel: 1.9.2
sdp-transform: 2.15.0
ts-debounce: 4.0.0
tslib: 2.8.1
typed-emitter: 2.1.0
webrtc-adapter: 9.0.4
load-json-file@4.0.0:
dependencies:
graceful-fs: 4.2.11
@@ -8641,6 +8717,8 @@ snapshots:
lodash@4.17.23: {}
loglevel@1.9.2: {}
lru-cache@10.4.3: {}
lru-cache@11.2.4: {}
@@ -9440,6 +9518,11 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
optional: true
sade@1.8.1:
dependencies:
mri: 1.2.0
@@ -9471,6 +9554,10 @@ snapshots:
sax@1.4.4: {}
sdp-transform@2.15.0: {}
sdp@3.2.1: {}
semver@5.7.2: {}
semver@6.3.1: {}
@@ -9920,6 +10007,8 @@ snapshots:
dependencies:
typescript: 5.9.3
ts-debounce@4.0.0: {}
ts-interface-checker@0.1.13: {}
ts-node@10.9.2(@types/node@25.0.10)(typescript@5.9.3):
@@ -9995,6 +10084,10 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typed-emitter@2.1.0:
optionalDependencies:
rxjs: 7.8.2
typescript-eslint@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
@@ -10103,6 +10196,10 @@ snapshots:
webidl-conversions@4.0.2: {}
webrtc-adapter@9.0.4:
dependencies:
sdp: 3.2.1
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
+6 -1
View File
@@ -38,6 +38,8 @@
import SpaceReports from "@app/components/SpaceReports.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import VoiceRoomItem from "@app/components/VoiceRoomItem.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import {
ENABLE_ZAPS,
@@ -257,6 +259,7 @@
{/if}
{#each $userRooms as h, i (h)}
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
<VoiceRoomItem {url} {h} />
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
@@ -276,6 +279,7 @@
{/if}
{#each $roomSearch.searchValues(term) as h, i (h)}
<SpaceMenuRoomItem {replaceState} {url} {h} />
<VoiceRoomItem {url} {h} />
{/each}
{#if $canCreateRoom}
<SecondaryNavItem {replaceState} onclick={addRoom}>
@@ -286,7 +290,8 @@
{/if}
</div>
</SecondaryNavSection>
<div class="flex flex-col gap-2 pb-2 p-4 pt-0">
<div class="flex flex-col gap-2 p-4 pb-2 pt-0">
<VoiceWidget />
hodlbod marked this conversation as resolved Outdated
Outdated
Review

I think we talked about putting voice rooms below, separate from text rooms, is that right? I can't remember, but I think it would make sense for any room with livekit support to go there instead of in the text room area regardless of whether the room has text support.

I think we talked about putting voice rooms below, separate from text rooms, is that right? I can't remember, but I think it would make sense for any room with livekit support to go there instead of in the text room area regardless of whether the room has text support.
Outdated
Review

I don't love the labels I landed on but see what you think of the current take:
"Your Rooms"
{ favorited voice and text}
"Other Rooms"
{ text rooms}
Voice Rooms
{ voice rooms}

Or when the user has no favorited rooms it looks like:
"Rooms"
{ text rooms}
Voice Rooms
{ voice rooms}

Idk if you want to say "Text Rooms" or "Chat Rooms" or something. Those feel quite wordy for menu headings to me though.

I don't love the labels I landed on but see what you think of the current take: "Your Rooms" { favorited voice and text} "Other Rooms" { text rooms} Voice Rooms { voice rooms} Or when the user has no favorited rooms it looks like: "Rooms" { text rooms} Voice Rooms { voice rooms} Idk if you want to say "Text Rooms" or "Chat Rooms" or something. Those feel quite wordy for menu headings to me though.
Outdated
Review

This surfaces another weird consequence of rooms that are "livekit" but not "no-text": currently favoriting either the text or vioce channel pulls both into the "Your Rooms" section.

This surfaces another weird consequence of rooms that are "livekit" but not "no-text": currently favoriting either the text or vioce channel pulls both into the "Your Rooms" section.
Outdated
Review

That's probably ok, I like how the labels look.

That's probably ok, I like how the labels look.
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
<SocketStatusIndicator {url} />
</Button>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import {loadProfile, displayProfileByPubkey} from "@welshman/app"
import Volume from "@assets/icons/volume.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {deriveVoiceParticipants, joinVoiceRoom, currentVoiceSession} from "@app/voice"
interface Props {
url: string
h: string
}
const {url, h}: Props = $props()
const participants = deriveVoiceParticipants(url, h)
const isActive = $derived($currentVoiceSession?.url === url && $currentVoiceSession?.h === h)
const handleClick = () => joinVoiceRoom(url, h)
$effect(() => {
for (const pk of $participants) {
loadProfile(pk)
}
})
</script>
<div>
<SecondaryNavItem onclick={handleClick} class={isActive ? "!bg-base-100 !text-base-content" : ""}>
<Icon icon={Volume} size={4} class="opacity-70" />
<RoomName {url} {h} />
</SecondaryNavItem>
{#if $participants.length > 0}
<div class="flex flex-col gap-1 pb-1 pl-10">
hodlbod marked this conversation as resolved Outdated
Outdated
Review

Instead of this, we should just not render voice rooms on mobile

Instead of this, we should just not render voice rooms on mobile
Outdated
Review

I tried that, but realized that you could then join a call on desktop web, resize your browser to be small and then lose the UI to leave the call. As I type it out I realize that's too edgy of a case 😂 I will just hide it.

I tried that, but realized that you could then join a call on desktop web, resize your browser to be small and then lose the UI to leave the call. As I type it out I realize that's too edgy of a case 😂 I will just hide it.
{#each $participants as pk (pk)}
<div class="flex items-center gap-2">
<ProfileCircle pubkey={pk} size={5} class="h-5 w-5" />
<span class="ellipsize text-xs opacity-70">
{displayProfileByPubkey(pk)}
</span>
</div>
{/each}
</div>
{/if}
hodlbod marked this conversation as resolved Outdated
Outdated
Review

[nit] in situations like this, I like to overload the controller as the isJoining boolean and just check whether joinAbortController is undefined. One fewer variable to keep track of

[nit] in situations like this, I like to overload the controller as the isJoining boolean and just check whether joinAbortController is undefined. One fewer variable to keep track of
</div>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import VolumeCross from "@assets/icons/volume-cross.svg?dataurl"
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {displayRoom} from "@app/core/state"
import {currentVoiceSession, leaveVoiceRoom, toggleMute} from "@app/voice"
const roomName = $derived(
$currentVoiceSession ? displayRoom($currentVoiceSession.url, $currentVoiceSession.h) : "",
)
const spaceName = $derived($currentVoiceSession ? displayRelayUrl($currentVoiceSession.url) : "")
const handleDisconnect = () => leaveVoiceRoom()
const handleToggleMute = () => toggleMute()
</script>
{#if $currentVoiceSession}
<div class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
hodlbod marked this conversation as resolved Outdated
Outdated
Review

Don't do indirection like this, just call the actual function

Don't do indirection like this, just call the actual function
Outdated
Review

A stupid way to do this that I like would be ifLet($currentVoiceSession, ({url, h}) => pushModal(RoomDetail, {url, h})) but you don't have to do that

A stupid way to do this that I like would be `ifLet($currentVoiceSession, ({url, h}) => pushModal(RoomDetail, {url, h}))` but you don't have to do that
Outdated
Review

It is harder to read at first glance but ifLet brings warm feelings from better programming languages so it's done.

It is harder to read at first glance but `ifLet` brings warm feelings from better programming languages so it's done.
<div class="flex flex-col gap-0.5">
<span class="text-sm font-semibold text-success">Voice Connected</span>
<span class="ellipsize text-xs opacity-70">
hodlbod marked this conversation as resolved Outdated
Outdated
Review

$currentVoiceSession is better than get

$currentVoiceSession is better than `get`
{roomName} / {spaceName}
</span>
</div>
<div class="flex items-center gap-1">
<Button
class="btn btn-sm btn-square {$currentVoiceSession.muted ? 'btn-error' : 'btn-ghost'}"
onclick={handleToggleMute}>
<Icon icon={$currentVoiceSession.muted ? VolumeCross : Microphone} size={4} />
</Button>
<Button class="btn btn-sm btn-square btn-error" onclick={handleDisconnect}>
<Icon icon={PhoneRounded} size={4} />
</Button>
</div>
</div>
{/if}
+7
View File
@@ -55,6 +55,7 @@ import {
loadFeedsForPubkey,
} from "@app/core/state"
import {hasBlossomSupport} from "@app/core/commands"
import {LIVE_ACTIVITY, ROOM_PRESENCE} from "@app/voice"
// Utils
@@ -316,6 +317,12 @@ const syncSpace = (url: string, rooms: string[]) => {
})
}
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: [LIVE_ACTIVITY, ROOM_PRESENCE]}],
})
return () => controller.abort()
}
+177
View File
@@ -0,0 +1,177 @@
import {derived, get, writable} from "svelte/store"
import * as nip19 from "nostr-tools/nip19"
import {SignJWT} from "jose"
import {Room, RoomEvent} from "livekit-client"
import {now} from "@welshman/lib"
import {makeEvent, normalizeRelayUrl, getTag} from "@welshman/util"
import {pubkey, publishThunk} from "@welshman/app"
import {deriveEventsForUrl, displayRoom} from "@app/core/state"
export const LIVE_ACTIVITY = 30311
export const ROOM_PRESENCE = 10312
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || ""
const LIVEKIT_API_KEY = import.meta.env.VITE_LIVEKIT_API_KEY || ""
const LIVEKIT_API_SECRET = import.meta.env.VITE_LIVEKIT_API_SECRET || ""
const PRESENCE_INTERVAL_MS = 60_000
const PRESENCE_EXPIRY_S = 300
export type VoiceSession = {
url: string
h: string
room: Room
muted: boolean
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
const makeLivekitRoomName = (url: string, h: string) =>
`${normalizeRelayUrl(url)}:${h}`.replace(/[^a-zA-Z0-9_-]/g, "_")
const generateToken = async (roomName: string, identity: string) => {
const secret = new TextEncoder().encode(LIVEKIT_API_SECRET)
const jwt = await new SignJWT({
video: {roomJoin: true, room: roomName, canPublish: true, canSubscribe: true},
sub: identity,
iss: LIVEKIT_API_KEY,
jti: identity,
})
.setProtectedHeader({alg: "HS256"})
.setIssuedAt()
.setExpirationTime("6h")
.sign(secret)
return jwt
}
export const deriveVoiceParticipants = (url: string, h: string) =>
derived(deriveEventsForUrl(url, [{kinds: [ROOM_PRESENCE]}]), $events => {
const cutoff = now() - PRESENCE_EXPIRY_S
hodlbod marked this conversation as resolved Outdated
Outdated
Review

[nit] simpler would be writable(new Set<string>())

[nit] simpler would be `writable(new Set<string>())`
const pubkeys: string[] = []
for (const event of $events) {
if (event.created_at < cutoff) continue
const aTag = getTag("a", event.tags)
if (!aTag) continue
const [, , dTag] = aTag[1].split(":")
if (dTag === h) {
pubkeys.push(event.pubkey)
}
}
hodlbod marked this conversation as resolved Outdated
Outdated
Review

Use welshman's version instead (makeHttpAuth/makeHttpAuthHeader)

Use welshman's version instead (makeHttpAuth/makeHttpAuthHeader)
return pubkeys
})
const publishLiveActivity = (url: string, h: string, status: "live" | "ended") => {
const pk = get(pubkey)!
const title = displayRoom(url, h)
const event = makeEvent(LIVE_ACTIVITY, {
tags: [
["d", h],
["h", h],
["title", title],
["service", LIVEKIT_URL],
["status", status],
["starts", String(now())],
["p", pk, "", "Host"],
],
})
return publishThunk({event, relays: [url]})
}
hodlbod marked this conversation as resolved Outdated
Outdated
Review

If we really need to propagate these errors (probably not, they're either triggered by a user action and shouldn't notify the user, or technical in nature), we should return an error instead of throwing one. As it is, we should just assert signer and let errors fly

If we really need to propagate these errors (probably not, they're either triggered by a user action and shouldn't notify the user, or technical in nature), we should return an error instead of throwing one. As it is, we should just assert signer and let errors fly
Outdated
Review

I tried this several different ways. I think it turned out ok but I added some util functions. Let me know what you think.

I tried this several different ways. I think it turned out ok but I added some util functions. Let me know what you think.
const publishPresence = (url: string, h: string) => {
const pk = get(pubkey)!
const aTag = `${LIVE_ACTIVITY}:${pk}:${h}`
const event = makeEvent(ROOM_PRESENCE, {
tags: [["a", aTag, url, "root"]],
})
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
}
}
export const joinVoiceRoom = async (url: string, h: string) => {
const session = get(currentVoiceSession)
if (session) {
if (session.url === url && session.h === h) return
await leaveVoiceRoom()
}
const pk = get(pubkey)!
const identity = nip19.npubEncode(pk)
const roomName = makeLivekitRoomName(url, h)
const token = await generateToken(roomName, identity)
const room = new Room({
adaptiveStream: true,
dynacast: true,
})
room.on(RoomEvent.Disconnected, () => {
currentVoiceSession.set(undefined)
stopPresenceHeartbeat()
})
await room.connect(LIVEKIT_URL, token)
await room.localParticipant.setMicrophoneEnabled(true)
currentVoiceSession.set({url, h, room, muted: false})
publishLiveActivity(url, h, "live")
startPresenceHeartbeat(url, h)
}
export const leaveVoiceRoom = async () => {
const session = get(currentVoiceSession)
if (!session) return
stopPresenceHeartbeat()
session.room.disconnect()
deletePresence(session.url)
publishLiveActivity(session.url, session.h, "ended")
currentVoiceSession.set(undefined)
}
export const toggleMute = () => {
const session = get(currentVoiceSession)
if (!session) return
const muted = !session.muted
session.room.localParticipant.setMicrophoneEnabled(!muted)
currentVoiceSession.set({...session, muted})
}
export const toggleDeafen = () => {
const session = get(currentVoiceSession)
if (!session) return
for (const participant of session.room.remoteParticipants.values()) {
for (const pub of participant.audioTrackPublications.values()) {
pub.setEnabled(!pub.isEnabled)
}
}
}