From 9fe155c11808060f8dfde28ab8e043e5d8d8091d Mon Sep 17 00:00:00 2001 From: Matt Lorentz Date: Tue, 3 Mar 2026 19:52:22 +0000 Subject: [PATCH 01/68] Fix a docker rebuild issue (#88) The Docker build wasn't making use of docker's cache because the .git directory was being copied into the build context. This means that even if the app did not change, if anything in git changed then docker would rebuild the entire app. This excludes the .git folder from the docker build, instead relying on the user to pass in the build hash at build time. Which is annoying but I don't think there's a better way around it. This was annoying me because I am deploying a self-hosted version of flotilla from a git branch via ansible and it was rebuilding flotilla every time. Co-authored-by: mplorentz Reviewed-on: https://gitea.coracle.social/coracle/flotilla/pulls/88 Co-authored-by: Matt Lorentz Co-committed-by: Matt Lorentz --- Dockerfile | 1 + build.sh | 4 ++-- pnpm-lock.yaml | 13 +++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 18f909db..1d62f67d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ ENV VITE_BUILD_HASH=${VITE_BUILD_HASH} ENV NODE_OPTIONS=--max_old_space_size=16384 RUN pnpm run build +# Stage 2: Runtime FROM node:20-alpine WORKDIR /app diff --git a/build.sh b/build.sh index dc48ea9d..1b632f25 100755 --- a/build.sh +++ b/build.sh @@ -5,8 +5,8 @@ temp_env=$(declare -p -x) if [ -f .env.template ]; then source .env.template fi -if [ -f .env.local ]; then - source .env.local +if [ -f .env ]; then + source .env fi # Avoid overwriting env vars provided directly diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c48e9fa4..028a3ad2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3510,6 +3510,10 @@ packages: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -8665,6 +8669,15 @@ snapshots: markdown-it-task-lists@2.1.1: {} + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-it@14.1.1: dependencies: argparse: 2.0.1 -- 2.52.0 From 0daab7a46c18648576d7d708588ca52972b449ee Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 24 Feb 2026 13:49:13 -0500 Subject: [PATCH 02/68] WIP voice channels --- .env.template | 3 + package.json | 2 + pnpm-lock.yaml | 97 +++++++++++++ src/app/components/SpaceMenu.svelte | 7 +- src/app/components/VoiceRoomItem.svelte | 46 ++++++ src/app/components/VoiceWidget.svelte | 39 ++++++ src/app/core/sync.ts | 7 + src/app/voice.ts | 177 ++++++++++++++++++++++++ 8 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 src/app/components/VoiceRoomItem.svelte create mode 100644 src/app/components/VoiceWidget.svelte create mode 100644 src/app/voice.ts diff --git a/.env.template b/.env.template index 5067aedd..9d7cf686 100644 --- a/.env.template +++ b/.env.template @@ -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= diff --git a/package.json b/package.json index 6e91a8fc..3f3b2cd4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 028a3ad2..bdf0d005 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 540fa88c..086c6298 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -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)} + {/each} {#if $otherRooms.length > 0}
@@ -276,6 +279,7 @@ {/if} {#each $roomSearch.searchValues(term) as h, i (h)} + {/each} {#if $canCreateRoom} @@ -286,7 +290,8 @@ {/if} -
+
+ diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte new file mode 100644 index 00000000..71dc7002 --- /dev/null +++ b/src/app/components/VoiceRoomItem.svelte @@ -0,0 +1,46 @@ + + +
+ + + + + {#if $participants.length > 0} +
+ {#each $participants as pk (pk)} +
+ + + {displayProfileByPubkey(pk)} + +
+ {/each} +
+ {/if} +
diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte new file mode 100644 index 00000000..7d740394 --- /dev/null +++ b/src/app/components/VoiceWidget.svelte @@ -0,0 +1,39 @@ + + +{#if $currentVoiceSession} +
+
+ Voice Connected + + {roomName} / {spaceName} + +
+
+ + +
+
+{/if} diff --git a/src/app/core/sync.ts b/src/app/core/sync.ts index 96eab24f..82ee874d 100644 --- a/src/app/core/sync.ts +++ b/src/app/core/sync.ts @@ -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() } diff --git a/src/app/voice.ts b/src/app/voice.ts new file mode 100644 index 00000000..9d9f7426 --- /dev/null +++ b/src/app/voice.ts @@ -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(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 + 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) + } + } + + 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]}) +} + +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 | 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) + } + } +} -- 2.52.0 From ca259b119bc466a364e42af1ebebd11a22eb1d87 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 25 Feb 2026 09:30:55 -0500 Subject: [PATCH 03/68] ignore pnpm store --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e65071e4..e49b0404 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ android/app/src/main/assets/public/ # Web/JavaScript node_modules/ +.pnpm-store/ build/ .svelte-kit/ -- 2.52.0 From fa4afe0c01eed2074ad0da93261736cc40d45f24 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 25 Feb 2026 09:31:37 -0500 Subject: [PATCH 04/68] Add android microphoen permissions --- android/app/src/main/AndroidManifest.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 64ff6fe5..243166be 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -42,4 +42,6 @@ + + -- 2.52.0 From 0ab1cfadde4391ccfb88eb2e1a07150f36116826 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 25 Feb 2026 09:32:33 -0500 Subject: [PATCH 05/68] Auto-play voice track when joining a voice room --- src/app/voice.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/app/voice.ts b/src/app/voice.ts index 9d9f7426..4e0fc9a5 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -1,7 +1,7 @@ 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 {Room, RoomEvent, Track} from "livekit-client" import {now} from "@welshman/lib" import {makeEvent, normalizeRelayUrl, getTag} from "@welshman/util" import {pubkey, publishThunk} from "@welshman/app" @@ -136,6 +136,19 @@ export const joinVoiceRoom = async (url: string, h: string) => { stopPresenceHeartbeat() }) + room.on(RoomEvent.TrackSubscribed, (track, _publication, _participant) => { + if (track.kind === Track.Kind.Audio) { + const element = track.attach() + element.style.display = "none" + document.body.appendChild(element) + element.play().catch(() => {}) + } + }) + + room.on(RoomEvent.TrackUnsubscribed, track => { + track.detach().forEach(el => el.remove()) + }) + await room.connect(LIVEKIT_URL, token) await room.localParticipant.setMicrophoneEnabled(true) -- 2.52.0 From 7d3176da78ba221b2fc04a704685feb57382269e Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 26 Feb 2026 11:07:41 -0500 Subject: [PATCH 06/68] Serve in SPA mode --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 1d62f67d..7602d311 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,4 +28,5 @@ WORKDIR /app # Copy only the built output - no source, no .env, no dev deps COPY --from=builder /app/build ./build +# Serve in SPA mode CMD ["npx", "serve", "-s", "build"] -- 2.52.0 From 73fb411122a0848c2518980fc5ca3274acde3e0c Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 26 Feb 2026 11:18:03 -0500 Subject: [PATCH 07/68] Try rewrites to get SPA mode working --- static/serve.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 static/serve.json diff --git a/static/serve.json b/static/serve.json new file mode 100644 index 00000000..686b6074 --- /dev/null +++ b/static/serve.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "!(.well-known)/**/!(*.*)", + "destination": "/index.html" + } + ] +} -- 2.52.0 From 0cbcbfc47b3535fe882a62b17fb055fc1e7fdc74 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 26 Feb 2026 11:23:47 -0500 Subject: [PATCH 08/68] Try just serving 404.html --- static/serve.json | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 static/serve.json diff --git a/static/serve.json b/static/serve.json deleted file mode 100644 index 686b6074..00000000 --- a/static/serve.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "rewrites": [ - { - "source": "!(.well-known)/**/!(*.*)", - "destination": "/index.html" - } - ] -} -- 2.52.0 From eeaad7b9b3a87c61792628a45f1686c913b13712 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 27 Feb 2026 12:44:34 -0500 Subject: [PATCH 09/68] Fix issue where docker build would rebuild when app did not change --- .dockerignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index ae84fa66..e3c99575 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,7 @@ build .git .gitignore -# Env files (keep .env for build; exclude local overrides) +# Env files (use build args instead; .env.template is copied for defaults) +.env .env.local .env.*.local \ No newline at end of file -- 2.52.0 From df9f3a707a572c53d1d09e1412c0925acc07d407 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 27 Feb 2026 13:35:11 -0500 Subject: [PATCH 10/68] Source .env explicitly during build --- .dockerignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index e3c99575..ae84fa66 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,7 +7,6 @@ build .git .gitignore -# Env files (use build args instead; .env.template is copied for defaults) -.env +# Env files (keep .env for build; exclude local overrides) .env.local .env.*.local \ No newline at end of file -- 2.52.0 From 3049efe8896a3cb68addcfaad003b2bcd99e97fb Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 27 Feb 2026 13:58:56 -0500 Subject: [PATCH 11/68] Fix logo download during docker build --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 7602d311..5ecab84e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,9 @@ FROM node:20-bookworm AS builder +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + RUN npm install -g pnpm@latest WORKDIR /app -- 2.52.0 From 52f2f31ce6602cf96b0e104512c28af2f06a5db3 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 2 Mar 2026 17:00:19 -0500 Subject: [PATCH 12/68] Move livekit auth to relay --- .../voice_rooms_production_f5249058.plan.md | 320 ++++++++++++++++++ .env.template | 3 - package.json | 1 - pnpm-lock.yaml | 3 - src/app/components/SpaceMenu.svelte | 22 +- src/app/core/state.ts | 10 + src/app/core/sync.ts | 4 +- src/app/voice.ts | 105 +++--- 8 files changed, 386 insertions(+), 82 deletions(-) create mode 100644 .cursor/plans/voice_rooms_production_f5249058.plan.md diff --git a/.cursor/plans/voice_rooms_production_f5249058.plan.md b/.cursor/plans/voice_rooms_production_f5249058.plan.md new file mode 100644 index 00000000..d6a10a19 --- /dev/null +++ b/.cursor/plans/voice_rooms_production_f5249058.plan.md @@ -0,0 +1,320 @@ +--- +name: Voice Rooms Production +overview: Implement production voice rooms by moving LiveKit token generation to the zooid relay (NIP-29 extension), making voice rooms conditional on group metadata tags, and updating flotilla to fetch tokens from the relay via NIP-98 authenticated HTTP requests. +todos: + - id: zooid-config + content: "Zooid: Add [livekit] config section (server_url, api_key, api_secret) to config.go" + status: pending + - id: zooid-livekit-go + content: "Zooid: Create livekit.go with token generation (livekit/protocol/auth), room creation (Twirp API), and response type" + status: pending + - id: zooid-endpoint + content: "Zooid: Register GET /.well-known/nip29/livekit/{groupId} endpoint on instance router with NIP-98 auth, membership check, livekit tag check" + status: pending + - id: zooid-nip98-util + content: "Zooid: Extract/reuse NIP-98 auth validation for the livekit endpoint (from api.go pattern)" + status: pending + - id: zooid-notext + content: "Zooid: Add no-text enforcement in CheckWrite -- reject non-moderation events for groups with no-text tag" + status: pending + - id: zooid-test-cp1 + content: "Zooid: Test checkpoint 1+2 -- curl the token endpoint, verify livekit/no-text tag behavior" + status: pending + - id: flotilla-remove-poc + content: "Flotilla: Remove jose dependency, VITE_LIVEKIT_* env vars, client-side JWT generation, live activity (kind:30311) publishing" + status: completed + - id: flotilla-nip98-fetch + content: "Flotilla: Implement NIP-98 auth header construction and relay token HTTP fetch in voice.ts" + status: completed + - id: flotilla-update-join + content: "Flotilla: Update joinVoiceRoom/leaveVoiceRoom to use relay-provided token and server_url, use hex pubkey identity" + status: completed + - id: flotilla-presence-fix + content: "Flotilla: Simplify presence events to use h tag directly (drop a tag referencing live activity)" + status: completed + - id: flotilla-conditional-ui + content: "Flotilla: Add roomHasLivekit/roomIsNoText helpers; update SpaceMenu to conditionally render voice/text rooms" + status: completed + - id: flotilla-test-e2e + content: "Flotilla: End-to-end test -- join voice room via relay token, verify audio, verify conditional display" + status: completed +isProject: false +--- + +# Voice Rooms Production Implementation + +This plan implements the proposed [NIP-29 LiveKit extension](https://github.com/nostr-protocol/nips/pull/2238) across two repositories: **zooid** (relay) and **flotilla** (client). Changes are organized into checkpoints so each can be tested independently. Reference implementation: [pyramid commit 4ac5418](https://github.com/fiatjaf/pyramid/commit/4ac5418175c7ea7b53e81858aa69be6f8f56c691). + +--- + +## Architecture + +```mermaid +sequenceDiagram + participant F as Flotilla Client + participant Z as Zooid Relay + participant LK as LiveKit Server + + F->>Z: Subscribe kind:39000 (room metadata) + Z-->>F: Room with ["livekit"] tag + + F->>F: Show voice room in sidebar + + Note over F: User clicks voice room + + F->>F: Build NIP-98 auth event (kind 27235) + F->>Z: GET /.well-known/nip29/livekit/{groupId}
Authorization: Nostr base64(event) + Z->>Z: Verify signature, membership, livekit tag + Z->>LK: Create room if needed (Twirp API) + Z-->>F: { server_url, participant_token } + F->>LK: Connect with JWT token + F->>Z: Publish kind:10312 presence event +``` + +--- + +## Zooid Changes (relay) + +### Checkpoint 1: LiveKit config and token endpoint + +**Goal**: Zooid serves LiveKit JWTs to authenticated group members. + +**Config changes** ([zooid/config.go](zooid/config.go)): + +- Add a `[livekit]` section to the TOML config struct: + +```go + Livekit struct { + ServerURL string `toml:"server_url" json:"server_url"` + APIKey string `toml:"api_key" json:"api_key"` + APISecret string `toml:"api_secret" json:"api_secret"` + } `toml:"livekit" json:"livekit"` + + +``` + +**New file** `zooid/livekit.go`: + +- `generateLivekitToken(apiKey, apiSecret, room string, pubkey nostr.PubKey) string` -- Uses `github.com/livekit/protocol/auth` to create a JWT with `RoomJoin` grant and `SetIdentity(pubkey.Hex())` (hex pubkey per the NIP). +- `ensureLivekitRoom(apiKey, apiSecret, serverURL, roomName string)` -- Calls LiveKit's Twirp API to lazily create rooms. Cache known rooms in a `map[string]bool` with a mutex (same pattern as pyramid). +- `TokenEndpointResponse` struct: `{ ServerURL string, ParticipantToken string }`. + +**HTTP endpoint** ([zooid/instance.go](zooid/instance.go)): + +- In the router setup (after the existing `/static/` handler), register: + +```go + router.HandleFunc("GET /.well-known/nip29/livekit/{groupId}", instance.livekitTokenHandler) + + +``` + +- The handler (`livekitTokenHandler` on `*Instance`): + 1. Check `instance.Config.Livekit.APIKey` is configured (return 404 if not). + 2. Parse `{groupId}` from the path. + 3. Validate NIP-98 auth header: `Authorization: Nostr `. Reuse the same pattern from `api.go`'s `authenticateNIP98`. Verify: signature, kind == 27235, `u` tag matches the request URL, timestamp is recent. + 4. Check the group exists: call `instance.Groups.GetMetadata(groupId)`. + 5. Check the group's kind:39000 event has a `livekit` tag (use `HasTag(meta.Tags, "livekit")`). + 6. Check the user is a group member: `instance.Groups.HasAccess(groupId, pubkey)`. + 7. Ensure the LiveKit room exists. + 8. Generate a token and return JSON `{ "server_url": "...", "participant_token": "..." }`. + +**NIP-98 validation**: Extract from `api.go`'s `authenticateNIP98` into a shared utility in `zooid/util.go` (or call it directly), since both the REST API and the livekit endpoint need it. The key difference: the API validates `method` tag too; the livekit endpoint should also validate method is "GET". + +**Dependency**: Add `github.com/livekit/protocol` to `go.mod`. + +**Test**: With a zooid instance running with `[livekit]` config and a group that has a `livekit` tag on its kind:39000 event: + +```bash +# Manually test with a NIP-98 auth header (use nak or a script to create the auth event) +curl -H "Authorization: Nostr " \ + https://your-relay/.well-known/nip29/livekit/ +# Expect: { "server_url": "wss://...", "participant_token": "eyJ..." } +``` + +### Checkpoint 2: livekit and no-text tag support + +**Goal**: Admins can set `livekit` and `no-text` tags on groups; `no-text` groups reject text events. + +**Tag passthrough** -- Zooid's `UpdateMetadata` in [zooid/groups.go](zooid/groups.go) already copies all tags from edit events to the kind:39000 metadata event (converting `h` to `d`). The `livekit` and `no-text` tags will pass through naturally when an admin sends a `KindSimpleGroupEditMetadata` event containing them. No code change needed for this. + +**no-text enforcement** -- In `CheckWrite` in [zooid/groups.go](zooid/groups.go), after existing access checks, add: + +```go +if HasTag(meta.Tags, "no-text") && + !slices.Contains(nip29.ModerationEventKinds, event.Kind) && + event.Kind != nostr.KindSimpleGroupJoinRequest && + event.Kind != nostr.KindSimpleGroupLeaveRequest { + return "blocked: this group does not allow text events" +} +``` + +This mirrors pyramid's `reject-event.go` logic. + +**Test**: + +- Send a `KindSimpleGroupEditMetadata` event with `["livekit"]` and `["no-text"]` tags. Query kind:39000 and verify the tags appear. +- Try sending a regular message to a `no-text` group; expect rejection. +- Try the token endpoint for a group with vs without the `livekit` tag; expect 403 for non-livekit groups. + +--- + +## Flotilla Changes (client) + +### Checkpoint 3: NIP-98 token fetching from relay + +**Goal**: Replace client-side JWT generation with relay HTTP request. + +**Remove POC artifacts** from [src/app/voice.ts](src/app/voice.ts): + +- Remove the `jose` import and the `generateToken` function. +- Remove `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` constants. +- Remove `LIVE_ACTIVITY` constant and `publishLiveActivity` function (dropping kind:30311). +- Keep `ROOM_PRESENCE` and presence-related code (kind:10312). + +**Remove env vars** from [.env.template](.env.template): + +- Remove `VITE_LIVEKIT_URL`, `VITE_LIVEKIT_API_KEY`, `VITE_LIVEKIT_API_SECRET`. + +**Remove dependency**: Run `pnpm remove jose`. + +**Implement NIP-98 auth + token fetch** in [src/app/voice.ts](src/app/voice.ts): + +```typescript +const fetchLivekitToken = async ( + url: string, + groupId: string, +): Promise<{server_url: string; participant_token: string}> => { + const httpUrl = url.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://") + const endpoint = `${httpUrl}/.well-known/nip29/livekit/${groupId}` + + const authEvent = await buildNip98AuthEvent(endpoint, "GET") + const encoded = btoa(JSON.stringify(authEvent)) + + const response = await fetch(endpoint, { + headers: {Authorization: `Nostr ${encoded}`}, + }) + + if (!response.ok) throw new Error(`Token request failed: ${response.status}`) + return response.json() +} +``` + +**Build NIP-98 auth event**: Create a helper that uses the welshman signer to sign a kind 27235 event: + +```typescript +const buildNip98AuthEvent = async (url: string, method: string) => { + const event = makeEvent(27235, { + tags: [ + ["u", url], + ["method", method], + ], + }) + // Sign using welshman's signer + return signEvent(event) +} +``` + +Look at how `publishThunk` signs events for the pattern. The signer is available via `@welshman/app`. The event must be a complete signed event (with id, pubkey, sig). + +**Update `joinVoiceRoom`**: + +- Replace `generateToken(roomName, identity)` with `fetchLivekitToken(url, h)`. +- Use the returned `server_url` for `room.connect()` instead of the hardcoded `LIVEKIT_URL`. +- Change identity from `nip19.npubEncode(pk)` to just `pk` (hex pubkey) since the relay now sets it in the JWT. The `nip19` import can be removed. +- Remove the `publishLiveActivity` call; keep `startPresenceHeartbeat`. +- Remove `makeLivekitRoomName` since the relay now determines the room name. + +**Update `leaveVoiceRoom`**: + +- Remove `publishLiveActivity(session.url, session.h, "ended")` call. + +**Update sync** in [src/app/core/sync.ts](src/app/core/sync.ts): + +- Remove `LIVE_ACTIVITY` from the import and the subscription filter. Change `[LIVE_ACTIVITY, ROOM_PRESENCE]` to just `[ROOM_PRESENCE]`. + +**Update presence derivation** in [src/app/voice.ts](src/app/voice.ts): + +- Remove the reference to `LIVE_ACTIVITY` in the `a` tag matching inside `deriveVoiceParticipants`. The presence event currently references an `a` tag containing the live activity address. Since we're dropping live activities, switch to using the `h` tag directly on the presence event: + +```typescript +const event = makeEvent(ROOM_PRESENCE, { + tags: [["h", h]], +}) +``` + +And in deriveVoiceParticipants, match on the `h` tag instead of the `a` tag. + +**Test**: With zooid running the checkpoint 1+2 changes, and a group with the `livekit` tag: + +- Click a voice room in flotilla. +- Verify the NIP-98 HTTP request is sent to the relay. +- Verify the token is received and the LiveKit connection succeeds. +- Verify audio works between two clients. + +### Checkpoint 4: Conditional voice room display + +**Goal**: Only show voice rooms for groups with the `livekit` tag; show `no-text` groups as voice-only. + +**Read tags from room metadata** -- The `Room` type in [src/app/core/state.ts](src/app/core/state.ts) extends `PublishedRoomMeta`, which has an `event: TrustedEvent` field. The raw kind:39000 event tags are accessible via `room.event.tags`. Check for the `livekit` tag: + +```typescript +export const roomHasLivekit = (room: Room) => + room.event?.tags?.some(t => t[0] === "livekit") ?? false + +export const roomIsNoText = (room: Room) => room.event?.tags?.some(t => t[0] === "no-text") ?? false +``` + +Add these helpers to [src/app/core/state.ts](src/app/core/state.ts). + +**Update SpaceMenu** ([src/app/components/SpaceMenu.svelte](src/app/components/SpaceMenu.svelte)): + +- Currently, every room gets a `` unconditionally. Change this to only render `` when the room has the `livekit` tag. +- For `no-text` rooms, do NOT render `` (the text channel). Only render ``. +- This requires deriving the room metadata for each `h` value. Use `getRoom(makeRoomId(url, h))` to access the room data and check tags: + +```svelte +{#each $userRooms as h (h)} + {@const room = getRoom(makeRoomId(url, h))} + {#if !roomIsNoText(room)} + + {/if} + {#if roomHasLivekit(room)} + + {/if} +{/each} +``` + +Apply the same pattern to the `$roomSearch.searchValues(term)` loop. + +**Test**: + +- Create a group WITHOUT `livekit` tag -- verify no voice room appears. +- Add `livekit` tag to a group -- verify voice room appears alongside text channel. +- Create a group with both `livekit` and `no-text` tags -- verify only voice room appears (no text channel). + +--- + +## Summary of changes by repo + +### Zooid (relay) + +| File | Change | +| ------------------------ | -------------------------------------------------------- | +| `zooid/config.go` | Add `[livekit]` config section | +| `zooid/livekit.go` (new) | Token generation, room creation, response type | +| `zooid/instance.go` | Register HTTP endpoint, add handler method | +| `zooid/util.go` | Extract NIP-98 validation from api.go (or add alongside) | +| `zooid/groups.go` | Add `no-text` enforcement in `CheckWrite` | +| `go.mod` | Add `github.com/livekit/protocol` dependency | + +### Flotilla (client) + +| File | Change | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `src/app/voice.ts` | Replace client-side JWT with relay HTTP fetch; add NIP-98 auth; drop live activity; simplify presence | +| `src/app/core/state.ts` | Add `roomHasLivekit()` and `roomIsNoText()` helpers | +| `src/app/core/sync.ts` | Remove `LIVE_ACTIVITY` from sync filters | +| `src/app/components/SpaceMenu.svelte` | Conditionally show voice/text rooms based on tags | +| `.env.template` | Remove `VITE_LIVEKIT_` vars | +| `package.json` | Remove `jose` dependency | diff --git a/.env.template b/.env.template index 9d7cf686..5067aedd 100644 --- a/.env.template +++ b/.env.template @@ -18,8 +18,5 @@ 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= diff --git a/package.json b/package.json index 3f3b2cd4..d2369b2d 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,6 @@ "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdf0d005..08b3cf33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,9 +134,6 @@ 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) diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 086c6298..dccff979 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -55,6 +55,8 @@ notificationSettings, deriveShouldNotify, displayRoom, + roomHasLivekit, + roomIsNoText, } from "@app/core/state" import {setSpaceNotifications} from "@app/core/commands" import {pushModal} from "@app/util/modal" @@ -257,9 +259,13 @@
Your Rooms {/if} - {#each $userRooms as h, i (h)} - - + {#each $userRooms as h (h)} + {#if !roomIsNoText(url, h)} + + {/if} + {#if roomHasLivekit(url, h)} + + {/if} {/each} {#if $otherRooms.length > 0}
@@ -277,9 +283,13 @@ {/if} - {#each $roomSearch.searchValues(term) as h, i (h)} - - + {#each $roomSearch.searchValues(term) as h (h)} + {#if !roomIsNoText(url, h)} + + {/if} + {#if roomHasLivekit(url, h)} + + {/if} {/each} {#if $canCreateRoom} diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 7e64a12f..8b25bf7e 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -663,6 +663,16 @@ export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase() +export const roomHasLivekit = (url: string, h: string) => { + const room = getRoom(makeRoomId(url, h)) + return room?.event?.tags?.some(t => t[0] === "livekit") ?? false +} + +export const roomIsNoText = (url: string, h: string) => { + const room = getRoom(makeRoomId(url, h)) + return room?.event?.tags?.some(t => t[0] === "no-text") ?? false +} + // User space/room lists export const groupListsByPubkey = deriveItemsByKey({ diff --git a/src/app/core/sync.ts b/src/app/core/sync.ts index 82ee874d..f5737d45 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 {LIVE_ACTIVITY, ROOM_PRESENCE} from "@app/voice" +import {ROOM_PRESENCE} from "@app/voice" // Utils @@ -320,7 +320,7 @@ const syncSpace = (url: string, rooms: string[]) => { pullAndListen({ url, signal: controller.signal, - filters: [{kinds: [LIVE_ACTIVITY, ROOM_PRESENCE]}], + filters: [{kinds: [ROOM_PRESENCE]}], }) return () => controller.abort() diff --git a/src/app/voice.ts b/src/app/voice.ts index 4e0fc9a5..8013a654 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -1,18 +1,12 @@ import {derived, get, writable} from "svelte/store" -import * as nip19 from "nostr-tools/nip19" -import {SignJWT} from "jose" import {Room, RoomEvent, Track} 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" +import {makeEvent, getTagValue} from "@welshman/util" +import {signer, publishThunk} from "@welshman/app" +import {deriveEventsForUrl} 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 @@ -25,23 +19,40 @@ export type VoiceSession = { export const currentVoiceSession = writable(undefined) -const makeLivekitRoomName = (url: string, h: string) => - `${normalizeRelayUrl(url)}:${h}`.replace(/[^a-zA-Z0-9_-]/g, "_") +const buildNip98AuthEvent = async (url: string, method: string) => { + const $signer = signer.get() + if (!$signer) throw new Error("No signer available") -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, + const event = makeEvent(27235, { + tags: [ + ["u", url], + ["method", method], + ], }) - .setProtectedHeader({alg: "HS256"}) - .setIssuedAt() - .setExpirationTime("6h") - .sign(secret) - return jwt + return $signer.sign(event) +} + +const fetchLivekitToken = async ( + url: string, + groupId: string, +): Promise<{server_url: string; participant_token: string}> => { + const httpUrl = url.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://") + const endpoint = `${httpUrl}/.well-known/nip29/livekit/${groupId}` + + const authEvent = await buildNip98AuthEvent(endpoint, "GET") + const encoded = btoa(JSON.stringify(authEvent)) + + const response = await fetch(endpoint, { + headers: {Authorization: `Nostr ${encoded}`}, + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Token request failed (${response.status}): ${text}`) + } + + return response.json() } export const deriveVoiceParticipants = (url: string, h: string) => @@ -52,11 +63,7 @@ export const deriveVoiceParticipants = (url: string, h: 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) { + if (getTagValue("h", event.tags) === h) { pubkeys.push(event.pubkey) } } @@ -64,29 +71,9 @@ export const deriveVoiceParticipants = (url: string, h: string) => 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]}) -} - 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"]], + tags: [["h", h]], }) return publishThunk({event, relays: [url]}) @@ -121,10 +108,7 @@ export const joinVoiceRoom = async (url: string, h: string) => { await leaveVoiceRoom() } - const pk = get(pubkey)! - const identity = nip19.npubEncode(pk) - const roomName = makeLivekitRoomName(url, h) - const token = await generateToken(roomName, identity) + const {server_url, participant_token} = await fetchLivekitToken(url, h) const room = new Room({ adaptiveStream: true, @@ -149,12 +133,11 @@ export const joinVoiceRoom = async (url: string, h: string) => { track.detach().forEach(el => el.remove()) }) - await room.connect(LIVEKIT_URL, token) + await room.connect(server_url, participant_token) await room.localParticipant.setMicrophoneEnabled(true) currentVoiceSession.set({url, h, room, muted: false}) - publishLiveActivity(url, h, "live") startPresenceHeartbeat(url, h) } @@ -165,7 +148,6 @@ export const leaveVoiceRoom = async () => { stopPresenceHeartbeat() session.room.disconnect() deletePresence(session.url) - publishLiveActivity(session.url, session.h, "ended") currentVoiceSession.set(undefined) } @@ -177,14 +159,3 @@ export const toggleMute = () => { 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) - } - } -} -- 2.52.0 From 9a2fcec3f6db8dcf95f693ee66f538d8a1983d80 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 2 Mar 2026 17:32:48 -0500 Subject: [PATCH 13/68] Remove plan --- .../voice_rooms_production_f5249058.plan.md | 320 ------------------ 1 file changed, 320 deletions(-) delete mode 100644 .cursor/plans/voice_rooms_production_f5249058.plan.md diff --git a/.cursor/plans/voice_rooms_production_f5249058.plan.md b/.cursor/plans/voice_rooms_production_f5249058.plan.md deleted file mode 100644 index d6a10a19..00000000 --- a/.cursor/plans/voice_rooms_production_f5249058.plan.md +++ /dev/null @@ -1,320 +0,0 @@ ---- -name: Voice Rooms Production -overview: Implement production voice rooms by moving LiveKit token generation to the zooid relay (NIP-29 extension), making voice rooms conditional on group metadata tags, and updating flotilla to fetch tokens from the relay via NIP-98 authenticated HTTP requests. -todos: - - id: zooid-config - content: "Zooid: Add [livekit] config section (server_url, api_key, api_secret) to config.go" - status: pending - - id: zooid-livekit-go - content: "Zooid: Create livekit.go with token generation (livekit/protocol/auth), room creation (Twirp API), and response type" - status: pending - - id: zooid-endpoint - content: "Zooid: Register GET /.well-known/nip29/livekit/{groupId} endpoint on instance router with NIP-98 auth, membership check, livekit tag check" - status: pending - - id: zooid-nip98-util - content: "Zooid: Extract/reuse NIP-98 auth validation for the livekit endpoint (from api.go pattern)" - status: pending - - id: zooid-notext - content: "Zooid: Add no-text enforcement in CheckWrite -- reject non-moderation events for groups with no-text tag" - status: pending - - id: zooid-test-cp1 - content: "Zooid: Test checkpoint 1+2 -- curl the token endpoint, verify livekit/no-text tag behavior" - status: pending - - id: flotilla-remove-poc - content: "Flotilla: Remove jose dependency, VITE_LIVEKIT_* env vars, client-side JWT generation, live activity (kind:30311) publishing" - status: completed - - id: flotilla-nip98-fetch - content: "Flotilla: Implement NIP-98 auth header construction and relay token HTTP fetch in voice.ts" - status: completed - - id: flotilla-update-join - content: "Flotilla: Update joinVoiceRoom/leaveVoiceRoom to use relay-provided token and server_url, use hex pubkey identity" - status: completed - - id: flotilla-presence-fix - content: "Flotilla: Simplify presence events to use h tag directly (drop a tag referencing live activity)" - status: completed - - id: flotilla-conditional-ui - content: "Flotilla: Add roomHasLivekit/roomIsNoText helpers; update SpaceMenu to conditionally render voice/text rooms" - status: completed - - id: flotilla-test-e2e - content: "Flotilla: End-to-end test -- join voice room via relay token, verify audio, verify conditional display" - status: completed -isProject: false ---- - -# Voice Rooms Production Implementation - -This plan implements the proposed [NIP-29 LiveKit extension](https://github.com/nostr-protocol/nips/pull/2238) across two repositories: **zooid** (relay) and **flotilla** (client). Changes are organized into checkpoints so each can be tested independently. Reference implementation: [pyramid commit 4ac5418](https://github.com/fiatjaf/pyramid/commit/4ac5418175c7ea7b53e81858aa69be6f8f56c691). - ---- - -## Architecture - -```mermaid -sequenceDiagram - participant F as Flotilla Client - participant Z as Zooid Relay - participant LK as LiveKit Server - - F->>Z: Subscribe kind:39000 (room metadata) - Z-->>F: Room with ["livekit"] tag - - F->>F: Show voice room in sidebar - - Note over F: User clicks voice room - - F->>F: Build NIP-98 auth event (kind 27235) - F->>Z: GET /.well-known/nip29/livekit/{groupId}
Authorization: Nostr base64(event) - Z->>Z: Verify signature, membership, livekit tag - Z->>LK: Create room if needed (Twirp API) - Z-->>F: { server_url, participant_token } - F->>LK: Connect with JWT token - F->>Z: Publish kind:10312 presence event -``` - ---- - -## Zooid Changes (relay) - -### Checkpoint 1: LiveKit config and token endpoint - -**Goal**: Zooid serves LiveKit JWTs to authenticated group members. - -**Config changes** ([zooid/config.go](zooid/config.go)): - -- Add a `[livekit]` section to the TOML config struct: - -```go - Livekit struct { - ServerURL string `toml:"server_url" json:"server_url"` - APIKey string `toml:"api_key" json:"api_key"` - APISecret string `toml:"api_secret" json:"api_secret"` - } `toml:"livekit" json:"livekit"` - - -``` - -**New file** `zooid/livekit.go`: - -- `generateLivekitToken(apiKey, apiSecret, room string, pubkey nostr.PubKey) string` -- Uses `github.com/livekit/protocol/auth` to create a JWT with `RoomJoin` grant and `SetIdentity(pubkey.Hex())` (hex pubkey per the NIP). -- `ensureLivekitRoom(apiKey, apiSecret, serverURL, roomName string)` -- Calls LiveKit's Twirp API to lazily create rooms. Cache known rooms in a `map[string]bool` with a mutex (same pattern as pyramid). -- `TokenEndpointResponse` struct: `{ ServerURL string, ParticipantToken string }`. - -**HTTP endpoint** ([zooid/instance.go](zooid/instance.go)): - -- In the router setup (after the existing `/static/` handler), register: - -```go - router.HandleFunc("GET /.well-known/nip29/livekit/{groupId}", instance.livekitTokenHandler) - - -``` - -- The handler (`livekitTokenHandler` on `*Instance`): - 1. Check `instance.Config.Livekit.APIKey` is configured (return 404 if not). - 2. Parse `{groupId}` from the path. - 3. Validate NIP-98 auth header: `Authorization: Nostr `. Reuse the same pattern from `api.go`'s `authenticateNIP98`. Verify: signature, kind == 27235, `u` tag matches the request URL, timestamp is recent. - 4. Check the group exists: call `instance.Groups.GetMetadata(groupId)`. - 5. Check the group's kind:39000 event has a `livekit` tag (use `HasTag(meta.Tags, "livekit")`). - 6. Check the user is a group member: `instance.Groups.HasAccess(groupId, pubkey)`. - 7. Ensure the LiveKit room exists. - 8. Generate a token and return JSON `{ "server_url": "...", "participant_token": "..." }`. - -**NIP-98 validation**: Extract from `api.go`'s `authenticateNIP98` into a shared utility in `zooid/util.go` (or call it directly), since both the REST API and the livekit endpoint need it. The key difference: the API validates `method` tag too; the livekit endpoint should also validate method is "GET". - -**Dependency**: Add `github.com/livekit/protocol` to `go.mod`. - -**Test**: With a zooid instance running with `[livekit]` config and a group that has a `livekit` tag on its kind:39000 event: - -```bash -# Manually test with a NIP-98 auth header (use nak or a script to create the auth event) -curl -H "Authorization: Nostr " \ - https://your-relay/.well-known/nip29/livekit/ -# Expect: { "server_url": "wss://...", "participant_token": "eyJ..." } -``` - -### Checkpoint 2: livekit and no-text tag support - -**Goal**: Admins can set `livekit` and `no-text` tags on groups; `no-text` groups reject text events. - -**Tag passthrough** -- Zooid's `UpdateMetadata` in [zooid/groups.go](zooid/groups.go) already copies all tags from edit events to the kind:39000 metadata event (converting `h` to `d`). The `livekit` and `no-text` tags will pass through naturally when an admin sends a `KindSimpleGroupEditMetadata` event containing them. No code change needed for this. - -**no-text enforcement** -- In `CheckWrite` in [zooid/groups.go](zooid/groups.go), after existing access checks, add: - -```go -if HasTag(meta.Tags, "no-text") && - !slices.Contains(nip29.ModerationEventKinds, event.Kind) && - event.Kind != nostr.KindSimpleGroupJoinRequest && - event.Kind != nostr.KindSimpleGroupLeaveRequest { - return "blocked: this group does not allow text events" -} -``` - -This mirrors pyramid's `reject-event.go` logic. - -**Test**: - -- Send a `KindSimpleGroupEditMetadata` event with `["livekit"]` and `["no-text"]` tags. Query kind:39000 and verify the tags appear. -- Try sending a regular message to a `no-text` group; expect rejection. -- Try the token endpoint for a group with vs without the `livekit` tag; expect 403 for non-livekit groups. - ---- - -## Flotilla Changes (client) - -### Checkpoint 3: NIP-98 token fetching from relay - -**Goal**: Replace client-side JWT generation with relay HTTP request. - -**Remove POC artifacts** from [src/app/voice.ts](src/app/voice.ts): - -- Remove the `jose` import and the `generateToken` function. -- Remove `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` constants. -- Remove `LIVE_ACTIVITY` constant and `publishLiveActivity` function (dropping kind:30311). -- Keep `ROOM_PRESENCE` and presence-related code (kind:10312). - -**Remove env vars** from [.env.template](.env.template): - -- Remove `VITE_LIVEKIT_URL`, `VITE_LIVEKIT_API_KEY`, `VITE_LIVEKIT_API_SECRET`. - -**Remove dependency**: Run `pnpm remove jose`. - -**Implement NIP-98 auth + token fetch** in [src/app/voice.ts](src/app/voice.ts): - -```typescript -const fetchLivekitToken = async ( - url: string, - groupId: string, -): Promise<{server_url: string; participant_token: string}> => { - const httpUrl = url.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://") - const endpoint = `${httpUrl}/.well-known/nip29/livekit/${groupId}` - - const authEvent = await buildNip98AuthEvent(endpoint, "GET") - const encoded = btoa(JSON.stringify(authEvent)) - - const response = await fetch(endpoint, { - headers: {Authorization: `Nostr ${encoded}`}, - }) - - if (!response.ok) throw new Error(`Token request failed: ${response.status}`) - return response.json() -} -``` - -**Build NIP-98 auth event**: Create a helper that uses the welshman signer to sign a kind 27235 event: - -```typescript -const buildNip98AuthEvent = async (url: string, method: string) => { - const event = makeEvent(27235, { - tags: [ - ["u", url], - ["method", method], - ], - }) - // Sign using welshman's signer - return signEvent(event) -} -``` - -Look at how `publishThunk` signs events for the pattern. The signer is available via `@welshman/app`. The event must be a complete signed event (with id, pubkey, sig). - -**Update `joinVoiceRoom`**: - -- Replace `generateToken(roomName, identity)` with `fetchLivekitToken(url, h)`. -- Use the returned `server_url` for `room.connect()` instead of the hardcoded `LIVEKIT_URL`. -- Change identity from `nip19.npubEncode(pk)` to just `pk` (hex pubkey) since the relay now sets it in the JWT. The `nip19` import can be removed. -- Remove the `publishLiveActivity` call; keep `startPresenceHeartbeat`. -- Remove `makeLivekitRoomName` since the relay now determines the room name. - -**Update `leaveVoiceRoom`**: - -- Remove `publishLiveActivity(session.url, session.h, "ended")` call. - -**Update sync** in [src/app/core/sync.ts](src/app/core/sync.ts): - -- Remove `LIVE_ACTIVITY` from the import and the subscription filter. Change `[LIVE_ACTIVITY, ROOM_PRESENCE]` to just `[ROOM_PRESENCE]`. - -**Update presence derivation** in [src/app/voice.ts](src/app/voice.ts): - -- Remove the reference to `LIVE_ACTIVITY` in the `a` tag matching inside `deriveVoiceParticipants`. The presence event currently references an `a` tag containing the live activity address. Since we're dropping live activities, switch to using the `h` tag directly on the presence event: - -```typescript -const event = makeEvent(ROOM_PRESENCE, { - tags: [["h", h]], -}) -``` - -And in deriveVoiceParticipants, match on the `h` tag instead of the `a` tag. - -**Test**: With zooid running the checkpoint 1+2 changes, and a group with the `livekit` tag: - -- Click a voice room in flotilla. -- Verify the NIP-98 HTTP request is sent to the relay. -- Verify the token is received and the LiveKit connection succeeds. -- Verify audio works between two clients. - -### Checkpoint 4: Conditional voice room display - -**Goal**: Only show voice rooms for groups with the `livekit` tag; show `no-text` groups as voice-only. - -**Read tags from room metadata** -- The `Room` type in [src/app/core/state.ts](src/app/core/state.ts) extends `PublishedRoomMeta`, which has an `event: TrustedEvent` field. The raw kind:39000 event tags are accessible via `room.event.tags`. Check for the `livekit` tag: - -```typescript -export const roomHasLivekit = (room: Room) => - room.event?.tags?.some(t => t[0] === "livekit") ?? false - -export const roomIsNoText = (room: Room) => room.event?.tags?.some(t => t[0] === "no-text") ?? false -``` - -Add these helpers to [src/app/core/state.ts](src/app/core/state.ts). - -**Update SpaceMenu** ([src/app/components/SpaceMenu.svelte](src/app/components/SpaceMenu.svelte)): - -- Currently, every room gets a `` unconditionally. Change this to only render `` when the room has the `livekit` tag. -- For `no-text` rooms, do NOT render `` (the text channel). Only render ``. -- This requires deriving the room metadata for each `h` value. Use `getRoom(makeRoomId(url, h))` to access the room data and check tags: - -```svelte -{#each $userRooms as h (h)} - {@const room = getRoom(makeRoomId(url, h))} - {#if !roomIsNoText(room)} - - {/if} - {#if roomHasLivekit(room)} - - {/if} -{/each} -``` - -Apply the same pattern to the `$roomSearch.searchValues(term)` loop. - -**Test**: - -- Create a group WITHOUT `livekit` tag -- verify no voice room appears. -- Add `livekit` tag to a group -- verify voice room appears alongside text channel. -- Create a group with both `livekit` and `no-text` tags -- verify only voice room appears (no text channel). - ---- - -## Summary of changes by repo - -### Zooid (relay) - -| File | Change | -| ------------------------ | -------------------------------------------------------- | -| `zooid/config.go` | Add `[livekit]` config section | -| `zooid/livekit.go` (new) | Token generation, room creation, response type | -| `zooid/instance.go` | Register HTTP endpoint, add handler method | -| `zooid/util.go` | Extract NIP-98 validation from api.go (or add alongside) | -| `zooid/groups.go` | Add `no-text` enforcement in `CheckWrite` | -| `go.mod` | Add `github.com/livekit/protocol` dependency | - -### Flotilla (client) - -| File | Change | -| ------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `src/app/voice.ts` | Replace client-side JWT with relay HTTP fetch; add NIP-98 auth; drop live activity; simplify presence | -| `src/app/core/state.ts` | Add `roomHasLivekit()` and `roomIsNoText()` helpers | -| `src/app/core/sync.ts` | Remove `LIVE_ACTIVITY` from sync filters | -| `src/app/components/SpaceMenu.svelte` | Conditionally show voice/text rooms based on tags | -| `.env.template` | Remove `VITE_LIVEKIT_` vars | -| `package.json` | Remove `jose` dependency | -- 2.52.0 From 140440ca4c83b4452aa9dc57a7dc01ea7536a252 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 2 Mar 2026 17:48:06 -0500 Subject: [PATCH 14/68] Allow user to configure room for voice, text, or both. --- src/app/components/RoomForm.svelte | 40 +++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/app/components/RoomForm.svelte b/src/app/components/RoomForm.svelte index 358d5ffc..615dd4cc 100644 --- a/src/app/components/RoomForm.svelte +++ b/src/app/components/RoomForm.svelte @@ -16,6 +16,24 @@ import {pushToast} from "@app/util/toast" import {uploadFile} from "@app/core/commands" + type RoomMode = "text" | "voice" | "both" + + const getRoomModeFromEvent = (event?: {tags?: string[][]}): RoomMode => { + const tags = event?.tags ?? [] + const hasLivekit = tags.some(t => t[0] === "livekit") + const hasNoText = tags.some(t => t[0] === "no-text") + if (hasLivekit && hasNoText) return "voice" + if (hasLivekit) return "both" + return "text" + } + + const buildTagsWithRoomMode = (existingTags: string[][], roomMode: RoomMode): string[][] => { + const filtered = existingTags.filter(t => t[0] !== "livekit" && t[0] !== "no-text") + if (roomMode === "both") return [...filtered, ["livekit"]] + if (roomMode === "voice") return [...filtered, ["livekit"], ["no-text"]] + return filtered + } + type Props = { url: string header: Snippet @@ -27,12 +45,16 @@ const {url, header, footer, onsubmit, initialValues = makeRoomMeta()}: Props = $props() const values = $state(initialValues) + let roomMode = $state(getRoomModeFromEvent(initialValues.event)) const submit = async () => { const room = $state.snapshot(values) if (imageFile) { - const {error, result} = await uploadFile(imageFile, {maxWidth: 256, maxHeight: 256}) + const {error, result} = await uploadFile(imageFile, { + maxWidth: 256, + maxHeight: 256, + }) if (error) { return pushToast({theme: "error", message: error}) @@ -42,6 +64,10 @@ room.pictureMeta = result.tags } + const existingTags = room.event?.tags ?? [] + const tags = buildTagsWithRoomMode(existingTags, roomMode) + room.event = room.event ? {...room.event, tags} : ({tags} as RoomMeta["event"]) + const createMessage = await waitForThunkError(createRoom(url, room)) if (createMessage && !createMessage.includes("already")) { @@ -178,6 +204,18 @@ Ignore requests to join
+ + {#snippet label()} +

Room type

+ {/snippet} + {#snippet input()} + + {/snippet} +
{@render footer({loading})} -- 2.52.0 From 108a9fef06ea33f1e4ebcca51fef40550949edea Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 2 Mar 2026 17:48:41 -0500 Subject: [PATCH 15/68] Get rid of volume icon next to text rooms --- src/app/components/SpaceMenuRoomItem.svelte | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/app/components/SpaceMenuRoomItem.svelte b/src/app/components/SpaceMenuRoomItem.svelte index 992f5142..fe0cd4f2 100644 --- a/src/app/components/SpaceMenuRoomItem.svelte +++ b/src/app/components/SpaceMenuRoomItem.svelte @@ -1,12 +1,8 @@ - {#if showDifferenceIcon} - - {/if} -- 2.52.0 From de76cfd208afab6a43713d326d0a8fcc7c78e1bd Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 2 Mar 2026 17:49:13 -0500 Subject: [PATCH 16/68] Make voice rooms in sidebar reactive --- src/app/components/SpaceMenu.svelte | 14 ++++++++------ src/app/core/state.ts | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index dccff979..a4f402fc 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -47,6 +47,8 @@ deriveSpaceMembers, deriveUserRooms, deriveOtherRooms, + deriveRoomsWithLivekit, + deriveRoomsNoText, userSpaceUrls, hasNip29, deriveUserCanCreateRoom, @@ -55,8 +57,6 @@ notificationSettings, deriveShouldNotify, displayRoom, - roomHasLivekit, - roomIsNoText, } from "@app/core/state" import {setSpaceNotifications} from "@app/core/commands" import {pushModal} from "@app/util/modal" @@ -72,6 +72,8 @@ const calendarPath = makeSpacePath(url, "calendar") const userRooms = deriveUserRooms(url) const otherRooms = deriveOtherRooms(url) + const roomsWithLivekit = deriveRoomsWithLivekit(url) + const roomsNoText = deriveRoomsNoText(url) const members = deriveSpaceMembers(url) const userIsAdmin = deriveUserIsSpaceAdmin(url) const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}]) @@ -260,10 +262,10 @@ Your Rooms {/if} {#each $userRooms as h (h)} - {#if !roomIsNoText(url, h)} + {#if !$roomsNoText.has(h)} {/if} - {#if roomHasLivekit(url, h)} + {#if $roomsWithLivekit.has(h)} {/if} {/each} @@ -284,10 +286,10 @@ {/if} {#each $roomSearch.searchValues(term) as h (h)} - {#if !roomIsNoText(url, h)} + {#if !$roomsNoText.has(h)} {/if} - {#if roomHasLivekit(url, h)} + {#if $roomsWithLivekit.has(h)} {/if} {/each} diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 8b25bf7e..365bc63b 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -663,6 +663,28 @@ export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase() +export const deriveRoomsWithLivekit = (url: string) => + derived(roomsById, $roomsById => { + const set = new Set() + for (const room of $roomsById.values()) { + if (room.url === url && room.event?.tags?.some(t => t[0] === "livekit")) { + set.add(room.h) + } + } + return set + }) + +export const deriveRoomsNoText = (url: string) => + derived(roomsById, $roomsById => { + const set = new Set() + for (const room of $roomsById.values()) { + if (room.url === url && room.event?.tags?.some(t => t[0] === "no-text")) { + set.add(room.h) + } + } + return set + }) + export const roomHasLivekit = (url: string, h: string) => { const room = getRoom(makeRoomId(url, h)) return room?.event?.tags?.some(t => t[0] === "livekit") ?? false -- 2.52.0 From 652461fffce690b234f4ba0b8e81fcdaa19a9db2 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 3 Mar 2026 08:41:25 -0500 Subject: [PATCH 17/68] Add error toast on connection failure. --- src/app/components/VoiceRoomItem.svelte | 10 +++- src/app/voice.ts | 68 +++++++++++++++++-------- 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 71dc7002..7974e612 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -5,6 +5,7 @@ import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte" import RoomName from "@app/components/RoomName.svelte" + import {pushToast} from "@app/util/toast" import {deriveVoiceParticipants, joinVoiceRoom, currentVoiceSession} from "@app/voice" interface Props { @@ -17,7 +18,14 @@ const participants = deriveVoiceParticipants(url, h) const isActive = $derived($currentVoiceSession?.url === url && $currentVoiceSession?.h === h) - const handleClick = () => joinVoiceRoom(url, h) + const handleClick = async () => { + try { + await joinVoiceRoom(url, h) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + pushToast({theme: "error", message: `Failed to join voice room: ${message}`}) + } + } $effect(() => { for (const pk of $participants) { diff --git a/src/app/voice.ts b/src/app/voice.ts index 8013a654..365be1e2 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -1,9 +1,11 @@ +import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client" +import {getToken} from "nostr-tools/nip98" import {derived, get, writable} from "svelte/store" -import {Room, RoomEvent, Track} from "livekit-client" import {now} from "@welshman/lib" import {makeEvent, getTagValue} from "@welshman/util" import {signer, publishThunk} from "@welshman/app" import {deriveEventsForUrl} from "@app/core/state" +import {pushToast} from "@app/util/toast" export const ROOM_PRESENCE = 10312 @@ -19,32 +21,34 @@ export type VoiceSession = { export const currentVoiceSession = writable(undefined) -const buildNip98AuthEvent = async (url: string, method: string) => { - const $signer = signer.get() - if (!$signer) throw new Error("No signer available") - - const event = makeEvent(27235, { - tags: [ - ["u", url], - ["method", method], - ], - }) - - return $signer.sign(event) -} - const fetchLivekitToken = async ( url: string, groupId: string, ): Promise<{server_url: string; participant_token: string}> => { - const httpUrl = url.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://") + const httpUrl = url + .replace(/^wss:\/\//, "https://") + .replace(/^ws:\/\//, "http://") + .replace(/\/$/, "") const endpoint = `${httpUrl}/.well-known/nip29/livekit/${groupId}` - const authEvent = await buildNip98AuthEvent(endpoint, "GET") - const encoded = btoa(JSON.stringify(authEvent)) + const $signer = signer.get() + if (!$signer) throw new Error("No signer available") + + const authHeader = await getToken( + endpoint, + "GET", + template => + $signer.sign( + makeEvent(template.kind, { + tags: template.tags, + content: template.content ?? "", + }), + ), + true, + ) const response = await fetch(endpoint, { - headers: {Authorization: `Nostr ${encoded}`}, + headers: {Authorization: authHeader}, }) if (!response.ok) { @@ -115,9 +119,16 @@ export const joinVoiceRoom = async (url: string, h: string) => { dynacast: true, }) - room.on(RoomEvent.Disconnected, () => { + room.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => { currentVoiceSession.set(undefined) stopPresenceHeartbeat() + if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { + const message = + reason === DisconnectReason.JOIN_FAILURE + ? "Could not connect to voice room. Please try again." + : "Voice connection lost." + pushToast({theme: "error", message}) + } }) room.on(RoomEvent.TrackSubscribed, (track, _publication, _participant) => { @@ -133,7 +144,22 @@ export const joinVoiceRoom = async (url: string, h: string) => { track.detach().forEach(el => el.remove()) }) - await room.connect(server_url, participant_token) + const CONNECT_TIMEOUT_MS = 5_000 + + try { + await Promise.race([ + room.connect(server_url, participant_token, {maxRetries: 0}), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("Connection timed out. Please check your network and try again.")), + CONNECT_TIMEOUT_MS, + ), + ), + ]) + } catch (e) { + room.disconnect() + throw e + } await room.localParticipant.setMicrophoneEnabled(true) currentVoiceSession.set({url, h, room, muted: false}) -- 2.52.0 From 9bd57b0caa3a6c9d1a3a5a396e0578ca6404b266 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 3 Mar 2026 08:44:23 -0500 Subject: [PATCH 18/68] Add loading indicator while joining --- src/app/components/VoiceRoomItem.svelte | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 7974e612..5df903d5 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -17,13 +17,18 @@ const participants = deriveVoiceParticipants(url, h) const isActive = $derived($currentVoiceSession?.url === url && $currentVoiceSession?.h === h) + let isJoining = $state(false) const handleClick = async () => { + if (isJoining) return + isJoining = true try { await joinVoiceRoom(url, h) } catch (e) { const message = e instanceof Error ? e.message : String(e) pushToast({theme: "error", message: `Failed to join voice room: ${message}`}) + } finally { + isJoining = false } } @@ -35,8 +40,15 @@
- - + + {#if isJoining} + + {:else} + + {/if} {#if $participants.length > 0} -- 2.52.0 From 559df4b948fdffa857303964ab4a3fdff1c8f8b5 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 3 Mar 2026 08:54:42 -0500 Subject: [PATCH 19/68] Add ability to joining a voice room while it's in progress --- src/app/components/VoiceRoomItem.svelte | 28 ++++++++++++---- src/app/voice.ts | 43 ++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 5df903d5..2fd4f9b7 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -6,7 +6,12 @@ import ProfileCircle from "@app/components/ProfileCircle.svelte" import RoomName from "@app/components/RoomName.svelte" import {pushToast} from "@app/util/toast" - import {deriveVoiceParticipants, joinVoiceRoom, currentVoiceSession} from "@app/voice" + import { + deriveVoiceParticipants, + joinVoiceRoom, + leaveVoiceRoom, + currentVoiceSession, + } from "@app/voice" interface Props { url: string @@ -18,17 +23,29 @@ const participants = deriveVoiceParticipants(url, h) const isActive = $derived($currentVoiceSession?.url === url && $currentVoiceSession?.h === h) let isJoining = $state(false) + let joinAbortController: AbortController | undefined const handleClick = async () => { - if (isJoining) return + if (isActive) { + await leaveVoiceRoom() + return + } + if (isJoining) { + joinAbortController?.abort() + return + } + joinAbortController = new AbortController() isJoining = true try { - await joinVoiceRoom(url, h) + await joinVoiceRoom(url, h, joinAbortController.signal) } catch (e) { + if (e instanceof Error && e.message === "Join cancelled") return + if (e instanceof DOMException && e.name === "AbortError") return const message = e instanceof Error ? e.message : String(e) pushToast({theme: "error", message: `Failed to join voice room: ${message}`}) } finally { isJoining = false + joinAbortController = undefined } } @@ -40,10 +57,7 @@
- + {#if isJoining} {:else} diff --git a/src/app/voice.ts b/src/app/voice.ts index 365be1e2..b3db81c7 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -24,6 +24,7 @@ export const currentVoiceSession = writable(undefined) const fetchLivekitToken = async ( url: string, groupId: string, + signal?: AbortSignal, ): Promise<{server_url: string; participant_token: string}> => { const httpUrl = url .replace(/^wss:\/\//, "https://") @@ -34,6 +35,8 @@ const fetchLivekitToken = async ( const $signer = signer.get() if (!$signer) throw new Error("No signer available") + if (signal?.aborted) throw new Error("Join cancelled") + const authHeader = await getToken( endpoint, "GET", @@ -47,9 +50,16 @@ const fetchLivekitToken = async ( true, ) - const response = await fetch(endpoint, { - headers: {Authorization: authHeader}, - }) + let response: Response + try { + response = await fetch(endpoint, { + headers: {Authorization: authHeader}, + signal, + }) + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") throw new Error("Join cancelled") + throw e + } if (!response.ok) { const text = await response.text() @@ -104,7 +114,11 @@ const stopPresenceHeartbeat = () => { } } -export const joinVoiceRoom = async (url: string, h: string) => { +export const joinVoiceRoom = async ( + url: string, + h: string, + signal?: AbortSignal, +): Promise => { const session = get(currentVoiceSession) if (session) { @@ -112,7 +126,9 @@ export const joinVoiceRoom = async (url: string, h: string) => { await leaveVoiceRoom() } - const {server_url, participant_token} = await fetchLivekitToken(url, h) + const {server_url, participant_token} = await fetchLivekitToken(url, h, signal) + + if (signal?.aborted) throw new Error("Join cancelled") const room = new Room({ adaptiveStream: true, @@ -144,6 +160,17 @@ export const joinVoiceRoom = async (url: string, h: string) => { track.detach().forEach(el => el.remove()) }) + const onAbort = () => { + room.disconnect() + } + if (signal) { + if (signal.aborted) { + room.disconnect() + throw new Error("Join cancelled") + } + signal.addEventListener("abort", onAbort, {once: true}) + } + const CONNECT_TIMEOUT_MS = 5_000 try { @@ -158,8 +185,14 @@ export const joinVoiceRoom = async (url: string, h: string) => { ]) } catch (e) { room.disconnect() + if (signal?.aborted) { + throw new Error("Join cancelled") + } throw e + } finally { + signal?.removeEventListener("abort", onAbort) } + if (signal?.aborted) throw new Error("Join cancelled") await room.localParticipant.setMicrophoneEnabled(true) currentVoiceSession.set({url, h, room, muted: false}) -- 2.52.0 From 6330150c663d6e9be01a2527cb05d12057e35f60 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 3 Mar 2026 15:37:55 -0500 Subject: [PATCH 20/68] Check if livekit is configured on the relay during room creation/edit --- src/app/components/RoomForm.svelte | 28 +++++++++++++++++++++++++-- src/app/voice.ts | 31 +++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/app/components/RoomForm.svelte b/src/app/components/RoomForm.svelte index 615dd4cc..07ea28b8 100644 --- a/src/app/components/RoomForm.svelte +++ b/src/app/components/RoomForm.svelte @@ -15,6 +15,7 @@ import ModalBody from "@lib/components/ModalBody.svelte" import {pushToast} from "@app/util/toast" import {uploadFile} from "@app/core/commands" + import {checkRelayHasLivekit} from "@app/voice" type RoomMode = "text" | "voice" | "both" @@ -46,10 +47,29 @@ const values = $state(initialValues) let roomMode = $state(getRoomModeFromEvent(initialValues.event)) + let relayHasLivekit = $state(undefined) + + $effect(() => { + const u = url + let cancelled = false + checkRelayHasLivekit(u).then(has => { + if (!cancelled) relayHasLivekit = has + }) + return () => { + cancelled = true + } + }) const submit = async () => { const room = $state.snapshot(values) + if ((roomMode === "voice" || roomMode === "both") && !relayHasLivekit) { + return pushToast({ + theme: "error", + message: "This relay does not support voice rooms.", + }) + } + if (imageFile) { const {error, result} = await uploadFile(imageFile, { maxWidth: 256, @@ -211,8 +231,12 @@ {#snippet input()} {/snippet} diff --git a/src/app/voice.ts b/src/app/voice.ts index b3db81c7..ff7b7f74 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -1,3 +1,7 @@ +/** + * Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox + * (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS. + */ import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client" import {getToken} from "nostr-tools/nip98" import {derived, get, writable} from "svelte/store" @@ -9,6 +13,27 @@ import {pushToast} from "@app/util/toast" export const ROOM_PRESENCE = 10312 +const livekitEndpoint = (url: string, groupId: string) => { + const httpUrl = url + .replace(/^wss:\/\//, "https://") + .replace(/^ws:\/\//, "http://") + .replace(/\/$/, "") + return `${httpUrl}/.well-known/nip29/livekit/${groupId}` +} + +export const checkRelayHasLivekit = async (url: string): Promise => { + const endpoint = livekitEndpoint(url, "nop") + + try { + // Currently we are hitting the API with no auth because zooid returns a 401 livekit + // is configured and 404 if it is not. But we need a standardized solution in the NIP. + const response = await fetch(endpoint) + return response.status === 401 + } catch { + return false + } +} + const PRESENCE_INTERVAL_MS = 60_000 const PRESENCE_EXPIRY_S = 300 @@ -26,11 +51,7 @@ const fetchLivekitToken = async ( groupId: string, signal?: AbortSignal, ): Promise<{server_url: string; participant_token: string}> => { - const httpUrl = url - .replace(/^wss:\/\//, "https://") - .replace(/^ws:\/\//, "http://") - .replace(/\/$/, "") - const endpoint = `${httpUrl}/.well-known/nip29/livekit/${groupId}` + const endpoint = livekitEndpoint(url, groupId) const $signer = signer.get() if (!$signer) throw new Error("No signer available") -- 2.52.0 From 1b99ed67045f9e349cbd66b7f71a11309d975d8e Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 3 Mar 2026 16:04:42 -0500 Subject: [PATCH 21/68] Disable rooms on mobile temporarily --- src/app/components/VoiceRoomItem.svelte | 5 +++++ src/lib/html.ts | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 2fd4f9b7..2ba3edf9 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -5,6 +5,7 @@ import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte" import RoomName from "@app/components/RoomName.svelte" + import {isMobileViewport} from "@lib/html" import {pushToast} from "@app/util/toast" import { deriveVoiceParticipants, @@ -26,6 +27,10 @@ let joinAbortController: AbortController | undefined const handleClick = async () => { + if (isMobileViewport()) { + pushToast({theme: "error", message: "Voice rooms are not yet supported on mobile."}) + return + } if (isActive) { await leaveVoiceRoom() return diff --git a/src/lib/html.ts b/src/lib/html.ts index f736178a..255033fe 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -80,6 +80,9 @@ export const createScroller = ({ export const isMobile = "ontouchstart" in document.documentElement +// Remove this when we implement voice rooms on mobile +export const isMobileViewport = () => window.innerWidth <= 768 + export const downloadText = (filename: string, text: string) => { const blob = new Blob([text], {type: "text/plain"}) const url = URL.createObjectURL(blob) -- 2.52.0 From 6e72bc4b008ca7d3099085b9e4b0f99f0c10e9bd Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 3 Mar 2026 16:08:48 -0500 Subject: [PATCH 22/68] Move room type field up in the RoomForm --- src/app/components/RoomForm.svelte | 32 +++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/app/components/RoomForm.svelte b/src/app/components/RoomForm.svelte index 07ea28b8..4ad705ad 100644 --- a/src/app/components/RoomForm.svelte +++ b/src/app/components/RoomForm.svelte @@ -207,6 +207,22 @@ {/snippet} + + {#snippet label()} +

Room type

+ {/snippet} + {#snippet input()} + + {/snippet} +
Permissions
@@ -224,22 +240,6 @@ Ignore requests to join
- - {#snippet label()} -

Room type

- {/snippet} - {#snippet input()} - - {/snippet} -
{@render footer({loading})} -- 2.52.0 From 87b37bf0d8d6f6eb940ce3849c35aa8c89d5f523 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 3 Mar 2026 16:50:54 -0500 Subject: [PATCH 23/68] Add a right around user avatar when speaking --- src/app/components/VoiceRoomItem.svelte | 12 ++++++++++-- src/app/voice.ts | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 2ba3edf9..94e43697 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -1,4 +1,5 @@ {#if $currentVoiceSession}
-
- Voice Connected - - {roomName} / {spaceName} - +
+
+ Voice Connected + + {roomName} / {spaceName} + +
+
-
diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 8978797d..69a0d479 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -103,6 +103,7 @@ import { getListTags, getPubkeyTagValues, getRelayTagValues, + getTag, getTagValues, isRelayUrl, normalizeRelayUrl, @@ -668,7 +669,7 @@ export const deriveRoomsWithLivekit = (url: string) => derived(roomsById, $roomsById => { const set = new Set() for (const room of $roomsById.values()) { - if (room.url === url && room.event?.tags?.some(t => t[0] === "livekit")) { + if (room.url === url && getTag("livekit", room.event?.tags ?? [])) { set.add(room.h) } } @@ -679,7 +680,7 @@ export const deriveRoomsNoText = (url: string) => derived(roomsById, $roomsById => { const set = new Set() for (const room of $roomsById.values()) { - if (room.url === url && room.event?.tags?.some(t => t[0] === "no-text")) { + if (room.url === url && getTag("no-text", room.event?.tags ?? [])) { set.add(room.h) } } @@ -688,12 +689,12 @@ export const deriveRoomsNoText = (url: string) => export const roomHasLivekit = (url: string, h: string) => { const room = getRoom(makeRoomId(url, h)) - return room?.event?.tags?.some(t => t[0] === "livekit") ?? false + return !!getTag("livekit", room?.event?.tags ?? []) } export const roomIsNoText = (url: string, h: string) => { const room = getRoom(makeRoomId(url, h)) - return room?.event?.tags?.some(t => t[0] === "no-text") ?? false + return !!getTag("no-text", room?.event?.tags ?? []) } // User space/room lists diff --git a/src/app/voice.ts b/src/app/voice.ts index 7dfefc9b..4ac68189 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -3,12 +3,12 @@ * (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS. */ import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client" -import {getToken} from "nostr-tools/nip98" import {derived, get, writable} from "svelte/store" import {now} from "@welshman/lib" -import {makeEvent, getTagValue} from "@welshman/util" +import {makeEvent, makeHttpAuth, makeHttpAuthHeader, getTagValue} from "@welshman/util" import {signer, publishThunk} from "@welshman/app" import {getLivekitEndpoint} from "$lib/livekit" +import {AbortError, whenAborted, whenTimeout} from "$lib/util" import {deriveEventsForUrl} from "@app/core/state" import {pushToast} from "@app/util/toast" @@ -28,7 +28,7 @@ export type VoiceSession = { export const currentVoiceSession = writable(undefined) -export const speakingPubkeys = writable>(new Set()) +export const speakingPubkeys = writable(new Set()) const fetchLivekitToken = async ( url: string, @@ -40,31 +40,16 @@ const fetchLivekitToken = async ( const $signer = signer.get() if (!$signer) throw new Error("No signer available") - if (signal?.aborted) throw new Error("Join cancelled") + if (signal?.aborted) throw new DOMException("Aborted", "AbortError") - const authHeader = await getToken( - endpoint, - "GET", - template => - $signer.sign( - makeEvent(template.kind, { - tags: template.tags, - content: template.content ?? "", - }), - ), - true, - ) + const template = await makeHttpAuth(endpoint, "GET") + const signedEvent = await $signer.sign(template) + const authHeader = makeHttpAuthHeader(signedEvent) - let response: Response - try { - response = await fetch(endpoint, { - headers: {Authorization: authHeader}, - signal, - }) - } catch (e) { - if (e instanceof DOMException && e.name === "AbortError") throw new Error("Join cancelled") - throw e - } + const response = await fetch(endpoint, { + headers: {Authorization: authHeader}, + signal, + }) if (!response.ok) { const text = await response.text() @@ -119,6 +104,36 @@ const stopPresenceHeartbeat = () => { } } +const onRoomDisconnected = (reason?: DisconnectReason) => { + speakingPubkeys.set(new Set()) + currentVoiceSession.set(undefined) + stopPresenceHeartbeat() + if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { + const message = + reason === DisconnectReason.JOIN_FAILURE + ? "Could not connect to voice room. Please try again." + : "Voice connection lost." + pushToast({theme: "error", message}) + } +} + +const onTrackSubscribed = (track: Track) => { + if (track.kind === Track.Kind.Audio) { + const element = track.attach() + element.style.display = "none" + document.body.appendChild(element) + element.play().catch(() => {}) + } +} + +const onTrackUnsubscribed = (track: Track) => { + track.detach().forEach(el => el.remove()) +} + +const onActiveSpeakersChanged = (participants: {identity: string}[]) => { + speakingPubkeys.set(new Set(participants.map(p => p.identity))) +} + export const joinVoiceRoom = async ( url: string, h: string, @@ -126,83 +141,33 @@ export const joinVoiceRoom = async ( ): Promise => { const session = get(currentVoiceSession) - if (session) { - if (session.url === url && session.h === h) return - await leaveVoiceRoom() - } + if (session) await leaveVoiceRoom() const {server_url, participant_token} = await fetchLivekitToken(url, h, signal) - if (signal?.aborted) throw new Error("Join cancelled") + if (signal?.aborted) return - const room = new Room({ - adaptiveStream: true, - dynacast: true, + const room = new Room({adaptiveStream: true, dynacast: true}) + + room.on(RoomEvent.Disconnected, onRoomDisconnected) + room.on(RoomEvent.TrackSubscribed, onTrackSubscribed) + room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) + room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) + + const connect = room.connect(server_url, participant_token, {maxRetries: 0}) + const timeout = whenTimeout(5_000, { + message: "Connection timed out. Please check your network and try again.", }) - - room.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => { - speakingPubkeys.set(new Set()) - currentVoiceSession.set(undefined) - stopPresenceHeartbeat() - if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { - const message = - reason === DisconnectReason.JOIN_FAILURE - ? "Could not connect to voice room. Please try again." - : "Voice connection lost." - pushToast({theme: "error", message}) - } - }) - - room.on(RoomEvent.TrackSubscribed, (track, _publication, _participant) => { - if (track.kind === Track.Kind.Audio) { - const element = track.attach() - element.style.display = "none" - document.body.appendChild(element) - element.play().catch(() => {}) - } - }) - - room.on(RoomEvent.TrackUnsubscribed, track => { - track.detach().forEach(el => el.remove()) - }) - - room.on(RoomEvent.ActiveSpeakersChanged, participants => { - speakingPubkeys.set(new Set(participants.map(p => p.identity))) - }) - - const onAbort = () => { - room.disconnect() - } - if (signal) { - if (signal.aborted) { - room.disconnect() - throw new Error("Join cancelled") - } - signal.addEventListener("abort", onAbort, {once: true}) - } - - const CONNECT_TIMEOUT_MS = 5_000 + const abort = whenAborted(signal) try { - await Promise.race([ - room.connect(server_url, participant_token, {maxRetries: 0}), - new Promise((_, reject) => - setTimeout( - () => reject(new Error("Connection timed out. Please check your network and try again.")), - CONNECT_TIMEOUT_MS, - ), - ), - ]) + await Promise.race([connect, timeout, abort]) } catch (e) { room.disconnect() - if (signal?.aborted) { - throw new Error("Join cancelled") - } + if (e instanceof AbortError) return throw e - } finally { - signal?.removeEventListener("abort", onAbort) } - if (signal?.aborted) throw new Error("Join cancelled") + await room.localParticipant.setMicrophoneEnabled(true) currentVoiceSession.set({url, h, room, muted: false}) diff --git a/src/lib/util.ts b/src/lib/util.ts index e22c36db..59f9fb21 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -19,6 +19,36 @@ export const ucFirst = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1) export const errorMessage = (err: unknown) => String(err).replace(/^.*Error: /, "") +export class AbortError extends Error { + constructor() { + super("Aborted") + this.name = "AbortError" + } +} + +export class TimeoutError extends Error { + constructor(message = "Timed out") { + super(message) + this.name = "TimeoutError" + } +} + +/** Returns a promise that rejects with AbortError when signal aborts. Use with Promise.race. */ +export const whenAborted = (signal?: AbortSignal) => { + if (!signal) return new Promise(() => {}) + + return new Promise((_, reject) => { + const onAborted = () => reject(new AbortError()) + if (signal.aborted) onAborted() + else signal.addEventListener("abort", onAborted, {once: true}) + }) +} + +/** Returns a promise that rejects with TimeoutError after ms. Use with Promise.race. */ +export const whenTimeout = (ms: number, opts: {message?: string} = {}) => { + return new Promise((_, reject) => setTimeout(() => reject(new TimeoutError()), ms)) +} + export const buildUrl = (base: string | URL, ...pathname: string[]) => { const url = new URL(base) -- 2.52.0 From 703d573f969e062214878ac78409e31acd37254d Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 5 Mar 2026 16:52:53 -0500 Subject: [PATCH 31/68] Revert changes to dockerfile --- Dockerfile | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5ecab84e..efeb724a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,7 @@ FROM node:20-bookworm AS builder -RUN apt-get update && apt-get install -y --no-install-recommends curl \ - && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends curl RUN npm install -g pnpm@latest @@ -31,5 +30,4 @@ WORKDIR /app # Copy only the built output - no source, no .env, no dev deps COPY --from=builder /app/build ./build -# Serve in SPA mode -CMD ["npx", "serve", "-s", "build"] +CMD ["npx", "serve", "build"] -- 2.52.0 From c18154915d10d08ff75e4ce7c316d7c0e82b2ff4 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 5 Mar 2026 16:54:57 -0500 Subject: [PATCH 32/68] Don't show technical error message to the user --- src/app/components/VoiceRoomItem.svelte | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index ca81dd61..658b38fb 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -7,7 +7,6 @@ import {isMobileViewport} from "@lib/html" import ProfileCircle from "@app/components/ProfileCircle.svelte" import RoomName from "@app/components/RoomName.svelte" - import {errorMessage} from "@lib/util" import {pushToast} from "@app/util/toast" import { deriveVoiceParticipants, @@ -47,7 +46,7 @@ try { await joinVoiceRoom(url, h, joinAbortController.signal) } catch (e) { - pushToast({theme: "error", message: `Failed to join voice room: ${errorMessage(e)}`}) + pushToast({theme: "error", message: "Failed to join voice room"}) } finally { isJoining = false joinAbortController = undefined -- 2.52.0 From 3e14b31a09b62727c0b3ba6eae8e206214f18e43 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 5 Mar 2026 14:56:30 -0800 Subject: [PATCH 33/68] Tweak voice room display --- src/app/components/VoiceRoomItem.svelte | 31 +++++++++++++------------ 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 658b38fb..7bef530c 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -2,6 +2,7 @@ import cx from "classnames" import {loadProfile, displayProfileByPubkey} from "@welshman/app" import Volume from "@assets/icons/volume.svg?dataurl" + import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import {isMobileViewport} from "@lib/html" @@ -60,19 +61,19 @@ }) -
- - {#if isJoining} - - {:else} - - {/if} - - - {#if $participants.length > 0} -
+ +
+
+ {#if isJoining} + + {:else} + + {/if} + +
+ {#if $participants.length > 0} {#each $participants as pk (pk)} -
+
{/each} -
- {/if} -
+ {/if} +
+
-- 2.52.0 From ea8a9652c965fe0af850d93f911be8234829a131 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 5 Mar 2026 14:57:22 -0800 Subject: [PATCH 34/68] Bump welshman --- pnpm-lock.yaml | 35 ++++++++--------------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08b3cf33..4bd063e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,7 +136,7 @@ importers: version: 8.0.3 livekit-client: specifier: ^2.17.2 - version: 2.17.2(@types/dom-mediacapture-record@1.0.22) + version: 2.17.3(@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) @@ -3349,8 +3349,8 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true - jose@6.1.3: - resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + jose@6.2.1: + resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} @@ -3456,8 +3456,8 @@ packages: linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} - livekit-client@2.17.2: - resolution: {integrity: sha512-+67y2EtAWZabARlY7kANl/VT1Uu1EJYR5a8qwpT2ub/uBCltsEgEDOxCIMwE9HFR5w+z41HR6GL9hyEvW/y6CQ==} + livekit-client@2.17.3: + resolution: {integrity: sha512-htwsAL/BMylY/zwdcT/z00U789csbi9DldSW7DO+5tz7Q15pwu++E1X+ZdtZDfkmlysfQLLibdcqlyg9FY7veQ==} peerDependencies: '@types/dom-mediacapture-record': ^1 @@ -3537,10 +3537,6 @@ packages: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true - markdown-it@14.1.1: - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} - hasBin: true - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4678,9 +4674,6 @@ 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==} @@ -8591,7 +8584,7 @@ snapshots: jiti@1.21.7: {} - jose@6.1.3: {} + jose@6.2.1: {} js-base64@3.7.8: {} @@ -8668,16 +8661,15 @@ snapshots: linkifyjs@4.3.2: {} - livekit-client@2.17.2(@types/dom-mediacapture-record@1.0.22): + livekit-client@2.17.3(@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 + jose: 6.2.1 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 @@ -8744,15 +8736,6 @@ snapshots: markdown-it-task-lists@2.1.1: {} - markdown-it@14.1.1: - dependencies: - argparse: 2.0.1 - entities: 4.5.0 - linkify-it: 5.0.0 - mdurl: 2.0.0 - punycode.js: 2.3.1 - uc.micro: 2.1.0 - markdown-it@14.1.1: dependencies: argparse: 2.0.1 @@ -10004,8 +9987,6 @@ 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): -- 2.52.0 From b1e52c29b465d301d6902437dea7c619262acfc3 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 6 Mar 2026 11:00:21 -0500 Subject: [PATCH 35/68] include room participants inside VoiceRoomItem border --- src/app/components/VoiceRoomItem.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 7bef530c..8fb8e21c 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -61,8 +61,10 @@ }) - -
+ +
{#if isJoining} -- 2.52.0 From 89d11dccd37f9e66c82b0c5e77c11ceb252abc6b Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 6 Mar 2026 15:50:09 -0500 Subject: [PATCH 36/68] Log join errors --- src/app/components/VoiceRoomItem.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 8fb8e21c..465ebd86 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -47,6 +47,7 @@ try { await joinVoiceRoom(url, h, joinAbortController.signal) } catch (e) { + console.error("Failed to join voice room", e) pushToast({theme: "error", message: "Failed to join voice room"}) } finally { isJoining = false -- 2.52.0 From 84be1ae47b180df20cc742e33b4cae7a7ba0570d Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 6 Mar 2026 16:37:05 -0500 Subject: [PATCH 37/68] Allow joining without a microphone --- src/app/voice.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/voice.ts b/src/app/voice.ts index 4ac68189..1b1aef89 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -168,7 +168,11 @@ export const joinVoiceRoom = async ( throw e } - await room.localParticipant.setMicrophoneEnabled(true) + try { + await room.localParticipant.setMicrophoneEnabled(true) + } catch (e) { + pushToast({theme: "error", message: "Could not access microphone"}) + } currentVoiceSession.set({url, h, room, muted: false}) -- 2.52.0 From a691f7b80af69ca0efbb6844c1b74e372dc162b7 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 9 Mar 2026 10:42:46 -0400 Subject: [PATCH 38/68] use new livekit welshman properties --- src/app/components/RoomForm.svelte | 31 +++++++++--------------------- src/app/core/state.ts | 15 ++------------- 2 files changed, 11 insertions(+), 35 deletions(-) diff --git a/src/app/components/RoomForm.svelte b/src/app/components/RoomForm.svelte index 8b2740db..eb803004 100644 --- a/src/app/components/RoomForm.svelte +++ b/src/app/components/RoomForm.svelte @@ -1,9 +1,8 @@ -- 2.52.0 From 0b09b63d85ab2fdbcd4d4ae49428f4ebc7583fb6 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 9 Mar 2026 11:00:04 -0400 Subject: [PATCH 40/68] Move unfavorited voice rooms into a new section in the SpaceMenu --- src/app/components/SpaceMenu.svelte | 12 +++++++++--- src/app/core/state.ts | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 742495e7..8029a073 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -49,6 +49,7 @@ deriveOtherRooms, deriveRoomsWithLivekit, deriveRoomsNoText, + deriveOtherVoiceRooms, userSpaceUrls, hasNip29, deriveUserCanCreateRoom, @@ -74,6 +75,7 @@ const otherRooms = deriveOtherRooms(url) const roomsWithLivekit = deriveRoomsWithLivekit(url) const roomsNoText = deriveRoomsNoText(url) + const otherVoiceRooms = deriveOtherVoiceRooms(url) const members = deriveSpaceMembers(url) const userIsAdmin = deriveUserIsSpaceAdmin(url) const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}]) @@ -289,9 +291,13 @@ {#if !$roomsNoText.has(h)} {/if} - {#if $roomsWithLivekit.has(h)} - - {/if} + {/each} + {#if $otherVoiceRooms.length > 0} +
+ Voice Rooms + {/if} + {#each $otherVoiceRooms as h (h)} + {/each} {#if $canCreateRoom} diff --git a/src/app/core/state.ts b/src/app/core/state.ts index cad9bb4c..d8f13f2d 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -686,6 +686,22 @@ export const deriveRoomsNoText = (url: string) => return set }) +export const deriveOtherVoiceRooms = (url: string) => + derived( + [deriveRoomsWithLivekit(url), deriveUserRooms(url)], + ([$roomsWithLivekit, $userRooms]) => { + const rooms: string[] = [] + + for (const h of $roomsWithLivekit) { + if (!$userRooms.includes(h)) { + rooms.push(h) + } + } + + return sortBy(roomComparator(url), uniq(rooms)) + }, + ) + // User space/room lists export const groupListsByPubkey = deriveItemsByKey({ -- 2.52.0 From 05de3dd7e02f41aace519b1be8f437fc520ce8a6 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 9 Mar 2026 11:19:10 -0400 Subject: [PATCH 41/68] Hide voice rooms on mobile --- src/app/components/SpaceMenu.svelte | 16 ++++++++++------ src/app/components/VoiceRoomItem.svelte | 5 ----- src/lib/html.ts | 3 --- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 8029a073..55b2f8e2 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -268,7 +268,9 @@ {/if} {#if $roomsWithLivekit.has(h)} - + {/if} {/each} {#if $otherRooms.length > 0} @@ -293,12 +295,14 @@ {/if} {/each} {#if $otherVoiceRooms.length > 0} -
- Voice Rooms + {/if} - {#each $otherVoiceRooms as h (h)} - - {/each} {#if $canCreateRoom} diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 465ebd86..e3969d0b 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -5,7 +5,6 @@ import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" - import {isMobileViewport} from "@lib/html" import ProfileCircle from "@app/components/ProfileCircle.svelte" import RoomName from "@app/components/RoomName.svelte" import {pushToast} from "@app/util/toast" @@ -30,10 +29,6 @@ let joinAbortController: AbortController | undefined const handleClick = async () => { - if (isMobileViewport()) { - pushToast({theme: "error", message: "Voice rooms are not yet supported on mobile."}) - return - } if (isActive) { await leaveVoiceRoom() return diff --git a/src/lib/html.ts b/src/lib/html.ts index 255033fe..f736178a 100644 --- a/src/lib/html.ts +++ b/src/lib/html.ts @@ -80,9 +80,6 @@ export const createScroller = ({ export const isMobile = "ontouchstart" in document.documentElement -// Remove this when we implement voice rooms on mobile -export const isMobileViewport = () => window.innerWidth <= 768 - export const downloadText = (filename: string, text: string) => { const blob = new Blob([text], {type: "text/plain"}) const url = URL.createObjectURL(blob) -- 2.52.0 From 697c116956d343aeee773933b2279b91fa7e2eb8 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 9 Mar 2026 11:21:50 -0400 Subject: [PATCH 42/68] Use joinAbortController as isJoining --- src/app/components/VoiceRoomItem.svelte | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index e3969d0b..187e77c4 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -25,27 +25,24 @@ const participants = deriveVoiceParticipants(url, h) const isActive = $derived($currentVoiceSession?.url === url && $currentVoiceSession?.h === h) - let isJoining = $state(false) - let joinAbortController: AbortController | undefined + let joinAbortController = $state(undefined) const handleClick = async () => { if (isActive) { await leaveVoiceRoom() return } - if (isJoining) { - joinAbortController?.abort() + if (joinAbortController) { + joinAbortController.abort() return } joinAbortController = new AbortController() - isJoining = true try { await joinVoiceRoom(url, h, joinAbortController.signal) } catch (e) { console.error("Failed to join voice room", e) pushToast({theme: "error", message: "Failed to join voice room"}) } finally { - isJoining = false joinAbortController = undefined } } @@ -62,7 +59,7 @@ class={cx("!items-start", isActive && "!bg-base-100 !text-base-content")}>
- {#if isJoining} + {#if joinAbortController} {:else} -- 2.52.0 From 63d9adef197c64b68e0c8a7e36b809915a5c686d Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 9 Mar 2026 11:28:12 -0400 Subject: [PATCH 43/68] Add lables on hover for voice room controls --- src/app/components/VoiceWidget.svelte | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte index 9a517e50..373ab5e0 100644 --- a/src/app/components/VoiceWidget.svelte +++ b/src/app/components/VoiceWidget.svelte @@ -40,11 +40,17 @@
-
-- 2.52.0 From 298e419b02e1df414f993c326de1b9cd0ecb7a60 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 10 Mar 2026 11:51:14 -0400 Subject: [PATCH 44/68] Remove voice-only rooms from "Other rooms" --- src/app/components/SpaceMenu.svelte | 4 +--- src/app/core/state.ts | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 55b2f8e2..81f15ec6 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -290,9 +290,7 @@ {/if} {#each $roomSearch.searchValues(term) as h (h)} - {#if !$roomsNoText.has(h)} - - {/if} + {/each} {#if $otherVoiceRooms.length > 0} -- 2.52.0 From 25228f785de565da37a49dd54fc064bfd424fe09 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Tue, 10 Mar 2026 12:13:05 -0400 Subject: [PATCH 46/68] Request microphone permissions when unmuting --- src/app/voice.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/app/voice.ts b/src/app/voice.ts index 1b1aef89..3978adff 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -168,13 +168,15 @@ export const joinVoiceRoom = async ( throw e } + let muted = false try { await room.localParticipant.setMicrophoneEnabled(true) } catch (e) { + muted = true pushToast({theme: "error", message: "Could not access microphone"}) } - currentVoiceSession.set({url, h, room, muted: false}) + currentVoiceSession.set({url, h, room, muted}) startPresenceHeartbeat(url, h) } @@ -190,11 +192,22 @@ export const leaveVoiceRoom = async () => { currentVoiceSession.set(undefined) } -export const toggleMute = () => { +export const toggleMute = async () => { const session = get(currentVoiceSession) if (!session) return const muted = !session.muted - session.room.localParticipant.setMicrophoneEnabled(!muted) - currentVoiceSession.set({...session, muted}) + if (muted) { + // Disable and re-enable microphone to trigger permission prompt + session.room.localParticipant.setMicrophoneEnabled(false) + currentVoiceSession.set({...session, muted}) + return + } + + try { + await session.room.localParticipant.setMicrophoneEnabled(true) + currentVoiceSession.set({...session, muted}) + } catch (e) { + pushToast({theme: "error", message: "Could not access microphone"}) + } } -- 2.52.0 From 50199268f7c49f6ff95d439db70688562bb61965 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 11 Mar 2026 16:08:40 -0400 Subject: [PATCH 47/68] Remove no-text rooms, highlight active room, fix custom voice room icons --- src/app/components/RoomForm.svelte | 28 ++++++----------- src/app/components/RoomImage.svelte | 21 ++++++++++++- src/app/components/SpaceMenu.svelte | 26 ++++----------- src/app/components/SpaceMenuRoomItem.svelte | 12 ++++++- src/app/components/VoiceRoomItem.svelte | 4 +-- src/app/core/state.ts | 35 ++++++++++++--------- 6 files changed, 68 insertions(+), 58 deletions(-) diff --git a/src/app/components/RoomForm.svelte b/src/app/components/RoomForm.svelte index eb803004..9779aae3 100644 --- a/src/app/components/RoomForm.svelte +++ b/src/app/components/RoomForm.svelte @@ -6,6 +6,7 @@ import {waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app" import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl" import Hashtag from "@assets/icons/hashtag.svg?dataurl" + import Volume from "@assets/icons/volume.svg?dataurl" import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl" import {preventDefault} from "@lib/html" import FieldInline from "@lib/components/FieldInline.svelte" @@ -16,15 +17,7 @@ import ModalBody from "@lib/components/ModalBody.svelte" import {pushToast} from "@app/util/toast" import {uploadFile} from "@app/core/commands" - import {deriveHasLivekit} from "@app/core/state" - - type RoomMode = "text" | "voice" | "both" - - const getRoomMode = (room: RoomMeta): RoomMode => { - if (room.livekit && room.noText) return "voice" - if (room.livekit) return "both" - return "text" - } + import {deriveHasLivekit, getRoomType, RoomType} from "@app/core/state" type Props = { url: string @@ -37,13 +30,13 @@ const {url, header, footer, onsubmit, initialValues = makeRoomMeta()}: Props = $props() const values = $state(initialValues) - let roomMode = $state(getRoomMode(initialValues)) + let roomType = $state(getRoomType(initialValues)) const relayHasLivekit = deriveHasLivekit(url) const submit = async () => { const room = $state.snapshot(values) - if ((roomMode === "voice" || roomMode === "both") && !get(relayHasLivekit)) { + if (roomType === RoomType.Voice && !get(relayHasLivekit)) { return pushToast({ theme: "error", message: "This relay does not support voice rooms.", @@ -71,8 +64,8 @@ } if (get(relayHasLivekit)) { - room.livekit = roomMode === "both" || roomMode === "voice" - room.noText = roomMode === "voice" + room.livekit = roomType === RoomType.Voice + room.noText = false } const editMessage = await waitForThunkError(editRoom(url, room)) @@ -171,7 +164,7 @@ {#if imagePreview} {:else} - + {/if} @@ -195,11 +188,10 @@ {#snippet input()} {/snippet} diff --git a/src/app/components/RoomImage.svelte b/src/app/components/RoomImage.svelte index d84930fb..f350010a 100644 --- a/src/app/components/RoomImage.svelte +++ b/src/app/components/RoomImage.svelte @@ -1,8 +1,11 @@ -{#if $room.picture} +{#if isVoiceRoom} +
+ + {#if $room.picture} + / + + {/if} +
+{:else if $room.picture} {:else} diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 81f15ec6..6d11f577 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -38,7 +38,6 @@ 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 { @@ -47,8 +46,6 @@ deriveSpaceMembers, deriveUserRooms, deriveOtherRooms, - deriveRoomsWithLivekit, - deriveRoomsNoText, deriveOtherVoiceRooms, userSpaceUrls, hasNip29, @@ -73,8 +70,6 @@ const calendarPath = makeSpacePath(url, "calendar") const userRooms = deriveUserRooms(url) const otherRooms = deriveOtherRooms(url) - const roomsWithLivekit = deriveRoomsWithLivekit(url) - const roomsNoText = deriveRoomsNoText(url) const otherVoiceRooms = deriveOtherVoiceRooms(url) const members = deriveSpaceMembers(url) const userIsAdmin = deriveUserIsSpaceAdmin(url) @@ -264,14 +259,7 @@ Your Rooms {/if} {#each $userRooms as h (h)} - {#if !$roomsNoText.has(h)} - - {/if} - {#if $roomsWithLivekit.has(h)} - - {/if} + {/each} {#if $otherRooms.length > 0}
@@ -293,13 +281,11 @@ {/each} {#if $otherVoiceRooms.length > 0} - +
+ Voice Rooms + {#each $otherVoiceRooms as h (h)} + + {/each} {/if} {#if $canCreateRoom} diff --git a/src/app/components/SpaceMenuRoomItem.svelte b/src/app/components/SpaceMenuRoomItem.svelte index c8709422..19d4d4e1 100644 --- a/src/app/components/SpaceMenuRoomItem.svelte +++ b/src/app/components/SpaceMenuRoomItem.svelte @@ -4,9 +4,10 @@ import Icon from "@lib/components/Icon.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte" - import {deriveShouldNotify} from "@app/core/state" + 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 @@ -17,15 +18,24 @@ const {url, h, notify = false, replaceState = false}: Props = $props() + const room = deriveRoom(url, h) + const roomType = $derived(getRoomType($room)) const path = makeRoomPath(url, h) 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} diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 33c25a03..d77da0d1 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -1,8 +1,6 @@ {#if $currentVoiceSession} -
+
Voice Connected -- 2.52.0 From 87d509df064f2be67ef2455f68b1478d6da9c870 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 12 Mar 2026 10:42:35 -0400 Subject: [PATCH 50/68] Expect HTTP 204 for livekit support --- src/lib/livekit.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lib/livekit.ts b/src/lib/livekit.ts index 1fbb15ec..431e282a 100644 --- a/src/lib/livekit.ts +++ b/src/lib/livekit.ts @@ -1,18 +1,20 @@ -const livekitEndpoint = (url: string, groupId: string) => { - const httpUrl = url +const toHttpUrl = (url: string) => + url .replace(/^wss:\/\//, "https://") .replace(/^ws:\/\//, "http://") .replace(/\/$/, "") - return `${httpUrl}/.well-known/nip29/livekit/${groupId}` + +const livekitEndpoint = (url: string, groupId?: string) => { + const base = `${toHttpUrl(url)}/.well-known/nip29/livekit` + return groupId ? `${base}/${groupId}` : base } export const checkRelayHasLivekit = async (url: string): Promise => { - const endpoint = livekitEndpoint(url, "nop") + const endpoint = livekitEndpoint(url) try { - // Zooid returns 401 when livekit is configured and 404 if it is not. const response = await fetch(endpoint) - return response.status === 401 + return response.status === 204 } catch { return false } -- 2.52.0 From 1e42fd9b2dae4e43080bcb0654e0b117ebba72a6 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 12 Mar 2026 18:27:01 -0400 Subject: [PATCH 51/68] Switch to 39004 for room presence --- src/app/components/ProfileCircle.svelte | 12 +- src/app/components/SpaceMenuRoomItem.svelte | 31 ++-- src/app/components/VoiceRoomItem.svelte | 16 ++- src/app/core/sync.ts | 4 +- src/app/voice.ts | 150 ++++++++++++-------- 5 files changed, 124 insertions(+), 89 deletions(-) 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) } -- 2.52.0 From 2acf971a10a4b8760e1b27ef11b656b9aae12318 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 13 Mar 2026 09:13:57 -0400 Subject: [PATCH 52/68] Integrate new PageBar behavior --- src/app/components/SpaceMenuRoomItem.svelte | 2 +- src/app/components/VoiceRoomItem.svelte | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/components/SpaceMenuRoomItem.svelte b/src/app/components/SpaceMenuRoomItem.svelte index 1ac59b01..d173498e 100644 --- a/src/app/components/SpaceMenuRoomItem.svelte +++ b/src/app/components/SpaceMenuRoomItem.svelte @@ -27,7 +27,7 @@ {#if roomType === RoomType.Voice} - + {:else}
-- 2.52.0 From 103a87e880498dc97623fe1b5f43ccb49a5e9bf1 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 13 Mar 2026 09:19:26 -0400 Subject: [PATCH 53/68] Fix voice room icon getting truncated in PageBar --- src/app/components/RoomImage.svelte | 2 +- src/app/components/RoomName.svelte | 2 +- src/app/components/SpaceBar.svelte | 18 +++++++++++++----- src/routes/spaces/[relay]/[h]/+page.svelte | 4 +++- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/app/components/RoomImage.svelte b/src/app/components/RoomImage.svelte index f350010a..aad4b99a 100644 --- a/src/app/components/RoomImage.svelte +++ b/src/app/components/RoomImage.svelte @@ -25,7 +25,7 @@ {#if isVoiceRoom} -
+
- + {$room?.name || h} diff --git a/src/app/components/SpaceBar.svelte b/src/app/components/SpaceBar.svelte index e32e5051..a0273a31 100644 --- a/src/app/components/SpaceBar.svelte +++ b/src/app/components/SpaceBar.svelte @@ -12,12 +12,13 @@ interface Props { back?: () => unknown + icon?: Snippet title?: Snippet action?: Snippet [key: string]: any } - const {back = () => goto(makeSpacePath(url)), title, action, ...props}: Props = $props() + const {back = () => goto(makeSpacePath(url)), icon, title, action, ...props}: Props = $props() const url = decodeRelay($page.params.relay!) @@ -27,10 +28,17 @@ -
-
-
- {@render title?.()} +
+
+
+ {#if icon} +
{@render icon?.()}
+
{@render title?.()}
+ {:else} +
+ {@render title?.()} +
+ {/if}
{displayRelayUrl(url)} diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index 73bdcc65..1865283b 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -358,8 +358,10 @@ - {#snippet title()} + {#snippet icon()} + {/snippet} + {#snippet title()} {/snippet} {#snippet action()} -- 2.52.0 From 36eb8ace32160c022572ad9ffc6e321aa0109c88 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 13 Mar 2026 09:24:40 -0400 Subject: [PATCH 54/68] Fix voice widget layout on mobile --- src/app/components/SpaceMenu.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 6d11f577..77bbf0e3 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -296,7 +296,7 @@ {/if}
-
+
- {:else} - +
+
+ {#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted} + + {:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted} +
+

Only members are allowed to post to this room.

+ {#if !$room.isClosed} + {#if $membershipStatus === MembershipStatus.Pending} + + {:else} + + {/if} {/if} - {/if} +
+ {:else} +
+ {#if parent} + + {/if} + {#if share} + + {/if} + {#if eventToEdit} + + {/if} +
+ {#key eventToEdit} + + {/key} + {/if} +
+ {#if $currentVoiceSession} +
+
- {:else} -
- {#if parent} - - {/if} - {#if share} - - {/if} - {#if eventToEdit} - - {/if} -
- {#key eventToEdit} - - {/key} {/if}
-- 2.52.0 From 5135da826e6fb48b57af24aac6f09105cc312bc1 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 13 Mar 2026 09:55:51 -0400 Subject: [PATCH 56/68] Don't leave voice room on second click --- src/app/components/VoiceRoomItem.svelte | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte index 37f60073..d24fef95 100644 --- a/src/app/components/VoiceRoomItem.svelte +++ b/src/app/components/VoiceRoomItem.svelte @@ -10,7 +10,6 @@ import { deriveVoiceParticipants, joinVoiceRoom, - leaveVoiceRoom, currentVoiceSession, isParticipantSpeaking, participantKey, @@ -31,7 +30,6 @@ const handleClick = async () => { if (isActive) { - await leaveVoiceRoom() return } if (joinAbortController) { -- 2.52.0 From edc22bc88d8facd97bc2061ed93b3333378d4bd1 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 13 Mar 2026 10:29:16 -0400 Subject: [PATCH 57/68] Request microphone permission on ios --- ios/App/App/Info.plist | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index 79214e9f..6d05083f 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -20,8 +20,16 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS + NSMicrophoneUsageDescription + Flotilla uses the microphone for voice chat in rooms. + UIBackgroundModes + + remote-notification + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -47,11 +55,5 @@ UIViewControllerBasedStatusBarAppearance - ITSAppUsesNonExemptEncryption - - UIBackgroundModes - - remote-notification - -- 2.52.0 From f89f1da947c38a2fd9b6af173e5f72e03b190805 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 13 Mar 2026 10:29:40 -0400 Subject: [PATCH 58/68] Fix build on ios --- capacitor.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/capacitor.config.ts b/capacitor.config.ts index 460d5749..79d2e75b 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -4,6 +4,9 @@ const config: CapacitorConfig = { appId: "social.flotilla", appName: "Flotilla", webDir: "build", + ios: { + scheme: "Flotilla Chat", + }, android: { adjustMarginsForEdgeToEdge: true, }, -- 2.52.0 From 61a3514cd2a718b5f9cd442dd3f3824b243e073c Mon Sep 17 00:00:00 2001 From: mplorentz Date: Fri, 13 Mar 2026 10:51:22 -0400 Subject: [PATCH 59/68] Fix scrolling of space menu on mobile --- src/app/components/SpaceMenu.svelte | 11 +++++------ src/routes/spaces/[relay]/+page.svelte | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 77bbf0e3..2ab91455 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -136,9 +136,9 @@ }) -
- -
+
+ +
-
+
{#if hasNip29($relay)} Recent Activity @@ -296,7 +295,7 @@ {/if}
-
+
+
+ Voice Connected + + {roomName} / {spaceName} +
-
-
-
- {#if icon} -
{@render icon?.()}
-
{@render title?.()}
- {:else} -
- {@render title?.()} -
- {/if} +
+
+
+ {@render title?.()}
{displayRelayUrl(url)} diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index bb5223c2..7b457907 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -296,7 +296,8 @@
-
+
- + {#if $voiceState === "joining"} + + + {:else if $voiceState === "connected" && $currentVoiceSession} + + + {:else} + + {/if}
{/if} diff --git a/src/app/voice.ts b/src/app/voice.ts index 3b2893df..40aa962c 100644 --- a/src/app/voice.ts +++ b/src/app/voice.ts @@ -28,8 +28,14 @@ export type Pubkey = string export type VoiceParticipant = {pubkey?: Pubkey; identity: string} +export type VoiceState = "joining" | "connected" | "disconnected" + export const currentVoiceSession = writable(undefined) +export const voiceState = writable("disconnected") + +export const currentVoiceRoom = writable<{url: string; h: string} | undefined>(undefined) + export const participantPubkeyMap = writable>(new Map()) const addParticipant = (identity: string) => { @@ -48,8 +54,6 @@ const deleteParticipant = (identity: string) => { }) } -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 @@ -133,6 +137,7 @@ const onRoomDisconnected = (reason?: DisconnectReason) => { participantPubkeyMap.set(new Map()) currentVoiceSession.set(undefined) if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { + voiceState.set("disconnected") const message = reason === DisconnectReason.JOIN_FAILURE ? "Could not connect to voice room. Please try again." @@ -172,59 +177,77 @@ const onParticipantDisconnected = (participant: {identity: string}) => { deleteParticipant(participant.identity) } -export const joinVoiceRoom = async ( - url: string, - h: string, - signal?: AbortSignal, -): Promise => { - const session = get(currentVoiceSession) +let joinAbortController: AbortController | undefined +export const cancelJoinVoiceRoom = () => { + joinAbortController?.abort() +} + +export const joinVoiceRoom = async (url: string, h: string): Promise => { + cancelJoinVoiceRoom() + + const session = get(currentVoiceSession) if (session) await leaveVoiceRoom() - const {server_url, participant_token} = await fetchLivekitToken(url, h, signal) + currentVoiceRoom.set({url, h}) + voiceState.set("joining") - if (signal?.aborted) return - - const room = new Room({adaptiveStream: true, dynacast: true}) - - 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) - - const connect = room.connect(server_url, participant_token, {maxRetries: 0}) - const timeout = whenTimeout(5_000, { - message: "Connection timed out. Please check your network and try again.", - }) - const abort = whenAborted(signal) + const controller = new AbortController() + joinAbortController = controller + const signal = controller.signal + const isActive = () => joinAbortController === controller try { - await Promise.race([connect, timeout, abort]) + const {server_url, participant_token} = await fetchLivekitToken(url, h, signal) + + if (signal.aborted) throw new AbortError() + + const room = new Room({adaptiveStream: true, dynacast: true}) + + 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) + + try { + await Promise.race([ + room.connect(server_url, participant_token, {maxRetries: 0}), + whenTimeout(5_000, { + message: "Connection timed out. Please check your network and try again.", + }), + whenAborted(signal), + ]) + } catch (e) { + room.disconnect() + 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) + } catch (e) { + muted = true + pushToast({theme: "error", message: "Could not access microphone"}) + } + + currentVoiceSession.set({url, h, room, muted}) + voiceState.set("connected") + playJoinSound() } catch (e) { - room.disconnect() + if (isActive()) voiceState.set("disconnected") if (e instanceof AbortError) return throw e + } finally { + if (isActive()) joinAbortController = undefined } - - 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) - } catch (e) { - muted = true - pushToast({theme: "error", message: "Could not access microphone"}) - } - - currentVoiceSession.set({url, h, room, muted}) - - playJoinSound() } export const leaveVoiceRoom = async () => { @@ -236,10 +259,16 @@ export const leaveVoiceRoom = async () => { speakingParticipants.set([]) participantPubkeyMap.set(new Map()) + voiceState.set("disconnected") session.room.disconnect() currentVoiceSession.set(undefined) } +export const rejoinVoiceRoom = () => { + const target = get(currentVoiceRoom) + if (target) joinVoiceRoom(target.url, target.h) +} + export const toggleMute = async () => { const session = get(currentVoiceSession) if (!session) return diff --git a/src/routes/spaces/[relay]/[h]/+page.svelte b/src/routes/spaces/[relay]/[h]/+page.svelte index fde2bcfe..6fc79453 100644 --- a/src/routes/spaces/[relay]/[h]/+page.svelte +++ b/src/routes/spaces/[relay]/[h]/+page.svelte @@ -42,13 +42,15 @@ decodeRelay, deriveRoom, deriveUserRoomMembershipStatus, + getRoomType, MESSAGE_KINDS, MembershipStatus, PROTECTED, + RoomType, userSettingsValues, } from "@app/core/state" import VoiceWidget from "@app/components/VoiceWidget.svelte" - import {currentVoiceSession} from "@app/voice" + import {voiceState} from "@app/voice" import {makeFeed} from "@app/core/requests" import {popKey} from "@lib/implicit" import {checked} from "@app/util/notifications" @@ -60,6 +62,7 @@ const lastChecked = $checked[$page.url.pathname] const url = decodeRelay(relay) const room = deriveRoom(url, h) + const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice) const shouldProtect = canEnforceNip70(url) const membershipStatus = deriveUserRoomMembershipStatus(url, h) const at = $derived(parseInt($page.url.searchParams.get("at")!)) @@ -497,7 +500,7 @@ {/key} {/if}
- {#if $currentVoiceSession} + {#if isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"}
-- 2.52.0 From 9364b46e5d8937845e68cd79a4c880fdf56d5378 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 16 Mar 2026 09:52:40 -0400 Subject: [PATCH 68/68] Clean up deriveProfile call in ProfileCircle --- src/app/components/ProfileCircle.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/ProfileCircle.svelte b/src/app/components/ProfileCircle.svelte index abf970c6..49f769b2 100644 --- a/src/app/components/ProfileCircle.svelte +++ b/src/app/components/ProfileCircle.svelte @@ -14,11 +14,11 @@ const {pubkey, url, size = 7, ...props}: Props = $props() - const profile = pubkey ? deriveProfile(pubkey, removeUndefined([url])) : undefined + const profile = deriveProfile(pubkey, removeUndefined([url])) + src={$profile?.picture || UserRounded} /> -- 2.52.0