feature/23-voice-room/poc #93
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
|
||||
<SocketStatusIndicator {url} />
|
||||
</Button>
|
||||
|
||||
@@ -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
hodlbod
commented
Instead of this, we should just not render voice rooms on mobile Instead of this, we should just not render voice rooms on mobile
mplorentz
commented
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
hodlbod
commented
[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>
|
||||
@@ -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
hodlbod
commented
Don't do indirection like this, just call the actual function Don't do indirection like this, just call the actual function
hodlbod
commented
A stupid way to do this that I like would be 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
mplorentz
commented
It is harder to read at first glance but 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
hodlbod
commented
$currentVoiceSession is better than $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}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
hodlbod
commented
[nit] simpler would be [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
hodlbod
commented
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
hodlbod
commented
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
mplorentz
commented
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 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.
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.
That's probably ok, I like how the labels look.