From ce3082010849dc5e28946118e064b333159c239e Mon Sep 17 00:00:00 2001 From: Matt Lorentz Date: Mon, 16 Mar 2026 20:38:05 +0000 Subject: [PATCH] feature/23-voice-room/poc (#93) Add voice rooms Co-authored-by: Matt Lorentz Co-committed-by: Matt Lorentz --- .gitignore | 1 + Dockerfile | 2 + android/app/src/main/AndroidManifest.xml | 2 + capacitor.config.ts | 3 + ios/App/App/Info.plist | 14 +- package.json | 1 + pnpm-lock.yaml | 88 ++++++ src/app.css | 8 + src/app/components/ProfileCircle.svelte | 2 +- src/app/components/RoomDetail.svelte | 4 +- src/app/components/RoomForm.svelte | 36 ++- src/app/components/RoomImage.svelte | 25 +- src/app/components/RoomName.svelte | 2 +- src/app/components/SpaceMenu.svelte | 42 ++- src/app/components/SpaceMenuRoomItem.svelte | 31 ++- src/app/components/VoiceRoomItem.svelte | 91 ++++++ src/app/components/VoiceWidget.svelte | 79 ++++++ src/app/core/state.ts | 59 +++- src/app/core/sync.ts | 7 + src/app/voice.ts | 290 ++++++++++++++++++++ src/assets/icons/microphone-off.svg | 8 + src/lib/components/SecondaryNavItem.svelte | 4 +- src/lib/livekit.ts | 23 ++ src/lib/util.ts | 30 ++ src/routes/spaces/[relay]/+page.svelte | 2 +- src/routes/spaces/[relay]/[h]/+page.svelte | 106 ++++--- static/join-voice-room.mp3 | Bin 0 -> 12525 bytes static/leave-voice-room.mp3 | Bin 0 -> 7149 bytes 28 files changed, 862 insertions(+), 98 deletions(-) create mode 100644 src/app/components/VoiceRoomItem.svelte create mode 100644 src/app/components/VoiceWidget.svelte create mode 100644 src/app/voice.ts create mode 100644 src/assets/icons/microphone-off.svg create mode 100644 src/lib/livekit.ts create mode 100644 static/join-voice-room.mp3 create mode 100644 static/leave-voice-room.mp3 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/ diff --git a/Dockerfile b/Dockerfile index 18f909db..780ce934 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,8 @@ FROM node:20-bookworm AS builder +RUN apt-get update && apt-get install -y --no-install-recommends curl + RUN npm install -g pnpm@latest WORKDIR /app 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 @@ + + 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, }, 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 - diff --git a/package.json b/package.json index 6e91a8fc..d2369b2d 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "fuse.js": "^7.1.0", "husky": "^9.1.7", "idb": "^8.0.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 c48e9fa4..4bd063e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: idb: specifier: ^8.0.3 version: 8.0.3 + livekit-client: + specifier: ^2.17.2 + 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) @@ -737,6 +740,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 +1289,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 +1835,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 +3349,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@6.2.1: + resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} + js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} @@ -3438,6 +3456,11 @@ packages: linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + livekit-client@2.17.3: + resolution: {integrity: sha512-htwsAL/BMylY/zwdcT/z00U789csbi9DldSW7DO+5tz7Q15pwu++E1X+ZdtZDfkmlysfQLLibdcqlyg9FY7veQ==} + 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 +3495,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==} @@ -4273,6 +4300,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'} @@ -4302,6 +4332,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 @@ -4703,6 +4740,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} @@ -4847,6 +4887,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==} @@ -5733,6 +5777,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)': @@ -6298,6 +6344,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': {} @@ -6859,6 +6911,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 @@ -8530,6 +8584,8 @@ snapshots: jiti@1.21.7: {} + jose@6.2.1: {} + js-base64@3.7.8: {} js-tokens@4.0.0: {} @@ -8605,6 +8661,19 @@ snapshots: linkifyjs@4.3.2: {} + 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.2.1 + loglevel: 1.9.2 + sdp-transform: 2.15.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 @@ -8637,6 +8706,8 @@ snapshots: lodash@4.17.23: {} + loglevel@1.9.2: {} + lru-cache@10.4.3: {} lru-cache@11.2.4: {} @@ -9427,6 +9498,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 @@ -9458,6 +9534,10 @@ snapshots: sax@1.4.4: {} + sdp-transform@2.15.0: {} + + sdp@3.2.1: {} + semver@5.7.2: {} semver@6.3.1: {} @@ -9982,6 +10062,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) @@ -10090,6 +10174,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.css b/src/app.css index a51f10f5..ae198f34 100644 --- a/src/app.css +++ b/src/app.css @@ -422,6 +422,14 @@ body.keyboard-open .hide-on-keyboard { @apply cb cw fixed z-compose; } +.chat__compose-zone { + @apply cb cw fixed z-compose; +} + +.chat__compose-zone .chat__compose-inner { + @apply min-w-0; +} + .chat__scroll-down { @apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16; } diff --git a/src/app/components/ProfileCircle.svelte b/src/app/components/ProfileCircle.svelte index 255cbffe..49f769b2 100644 --- a/src/app/components/ProfileCircle.svelte +++ b/src/app/components/ProfileCircle.svelte @@ -6,7 +6,7 @@ import ImageIcon from "@lib/components/ImageIcon.svelte" type Props = { - pubkey: string + pubkey?: string class?: string size?: number url?: string diff --git a/src/app/components/RoomDetail.svelte b/src/app/components/RoomDetail.svelte index 03cf406c..69746d90 100644 --- a/src/app/components/RoomDetail.svelte +++ b/src/app/components/RoomDetail.svelte @@ -16,7 +16,7 @@ import Lock from "@assets/icons/lock.svg?dataurl" import Microphone from "@assets/icons/microphone.svg?dataurl" import Bookmark from "@assets/icons/bookmark.svg?dataurl" - import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl" + import Bell from "@assets/icons/bell.svg?dataurl" import {fly} from "@lib/transition" import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" @@ -255,7 +255,7 @@ Room Settings
- + Notifications
{ const room = $state.snapshot(values) + if (roomType === RoomType.Voice && !$relayHasLivekit) { + return pushToast({ + theme: "error", + message: "This relay does not support voice rooms.", + }) + } + + room.livekit = roomType === RoomType.Voice + 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}) @@ -76,6 +91,7 @@ let loading = $state(false) let imageFile = $state() let imagePreview = $state(initialValues.picture) + let roomType = $state(getRoomType(initialValues)) const handleImageUpload = async (event: Event) => { const file = (event.target as HTMLInputElement).files?.[0] @@ -145,7 +161,7 @@ {#if imagePreview} {:else} - + {/if} @@ -161,6 +177,22 @@ {/snippet} + {#if $relayHasLivekit} + + {#snippet label()} +

Room type

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ {/if} Permissions
diff --git a/src/app/components/RoomImage.svelte b/src/app/components/RoomImage.svelte index 656cf664..53843ff0 100644 --- a/src/app/components/RoomImage.svelte +++ b/src/app/components/RoomImage.svelte @@ -1,22 +1,41 @@ -{#if $room.picture} +{#if isVoiceRoom} +
+ + {#if $room.picture} + / + + {/if} +
+{:else if $room.picture} {:else} - + {/if} diff --git a/src/app/components/RoomName.svelte b/src/app/components/RoomName.svelte index f657a6b7..a65fb17f 100644 --- a/src/app/components/RoomName.svelte +++ b/src/app/components/RoomName.svelte @@ -12,6 +12,6 @@ const room = deriveRoom(url, h) - + {$room?.name || h} diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 540fa88c..7b457907 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -20,8 +20,8 @@ import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl" import ChatRound from "@assets/icons/chat-round.svg?dataurl" - import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl" - import VolumeCross from "@assets/icons/volume-cross.svg?dataurl" + import Bell from "@assets/icons/bell.svg?dataurl" + import BellOff from "@assets/icons/bell-off.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Link from "@lib/components/Link.svelte" import Button from "@lib/components/Button.svelte" @@ -38,6 +38,7 @@ import SpaceReports from "@app/components/SpaceReports.svelte" import RoomCreate from "@app/components/RoomCreate.svelte" import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte" + import VoiceWidget from "@app/components/VoiceWidget.svelte" import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte" import { ENABLE_ZAPS, @@ -45,6 +46,7 @@ deriveSpaceMembers, deriveUserRooms, deriveOtherRooms, + deriveOtherVoiceRooms, userSpaceUrls, hasNip29, deriveUserCanCreateRoom, @@ -68,6 +70,7 @@ const calendarPath = makeSpacePath(url, "calendar") const userRooms = deriveUserRooms(url) const otherRooms = deriveOtherRooms(url) + const otherVoiceRooms = deriveOtherVoiceRooms(url) const members = deriveSpaceMembers(url) const userIsAdmin = deriveUserIsSpaceAdmin(url) const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}]) @@ -133,9 +136,9 @@ }) -
- -
+
+ +
{:else} - + Enable notifications {/if} @@ -219,8 +222,7 @@ {/if}
-
+
{#if hasNip29($relay)} Recent Activity @@ -252,14 +254,14 @@ {/if} {#if hasNip29($relay)} {#if $userRooms.length > 0} -
+
Your Rooms {/if} - {#each $userRooms as h, i (h)} + {#each $userRooms as h (h)} {/each} {#if $otherRooms.length > 0} -
+
{#if $userRooms.length > 0} Other Rooms @@ -274,9 +276,16 @@ {/if} - {#each $roomSearch.searchValues(term) as h, i (h)} + {#each $roomSearch.searchValues(term) as h (h)} {/each} + {#if $otherVoiceRooms.length > 0} +
+ Voice Rooms + {#each $otherVoiceRooms as h (h)} + + {/each} + {/if} {#if $canCreateRoom} @@ -284,9 +293,12 @@ {/if} {/if} +
-
+
+ diff --git a/src/app/components/SpaceMenuRoomItem.svelte b/src/app/components/SpaceMenuRoomItem.svelte index 992f5142..d173498e 100644 --- a/src/app/components/SpaceMenuRoomItem.svelte +++ b/src/app/components/SpaceMenuRoomItem.svelte @@ -1,12 +1,13 @@ - - - {#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 new file mode 100644 index 00000000..5676c314 --- /dev/null +++ b/src/app/components/VoiceRoomItem.svelte @@ -0,0 +1,91 @@ + + + +
+
+ {#if isJoining} + + {:else} + + {/if} + +
+ {#if $participants.length > 0} + {#each $participants as p (participantKey(p as VoiceParticipant))} +
+
+ +
+ + {p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"} + +
+ {/each} + {/if} +
+
diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte new file mode 100644 index 00000000..ae3f3eb5 --- /dev/null +++ b/src/app/components/VoiceWidget.svelte @@ -0,0 +1,79 @@ + + +{#if $currentVoiceRoom} +
+
+ {#if $voiceState === "joining"} + Joining... + {:else if $voiceState === "connected"} + Voice Connected + {:else} + Disconnected + {/if} + + {roomName} / {spaceName} + +
+
+ {#if $voiceState === "joining"} + + + {:else if $voiceState === "connected" && $currentVoiceSession} + + + {:else} + + {/if} +
+
+{/if} diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 7e64a12f..04a7751b 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -125,6 +125,7 @@ import type { RelayProfile, PublishedList, PublishedRoomMeta, + RoomMeta, List, Filter, } from "@welshman/util" @@ -146,6 +147,7 @@ import { displayProfileByPubkey, getProfile, } from "@welshman/app" +import {checkRelayHasLivekit} from "$lib/livekit" import {readFeed} from "@lib/feeds" export const fromCsv = (s: string) => (s || "").split(",").filter(identity) @@ -567,11 +569,19 @@ export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => { // Rooms +export enum RoomType { + Text = "text", + Voice = "voice", +} + export type Room = PublishedRoomMeta & { id: string url: string } +export const getRoomType = (room: RoomMeta): RoomType => + room.livekit ? RoomType.Voice : RoomType.Text + export const makeRoomId = (url: string, h: string) => `${url}'${h}` export const splitRoomId = (id: string) => id.split("'") @@ -663,6 +673,30 @@ export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase() +export const deriveVoiceRooms = (url: string) => + derived(roomsById, $roomsById => { + const set = new Set() + for (const room of $roomsById.values()) { + if (room.url === url && room.livekit) { + set.add(room.h) + } + } + return set + }) + +export const deriveOtherVoiceRooms = (url: string) => + derived([deriveVoiceRooms(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({ @@ -752,17 +786,20 @@ export const deriveUserRooms = (url: string) => }) export const deriveOtherRooms = (url: string) => - derived([deriveUserRooms(url), roomsByUrl], ([$userRooms, $roomsByUrl]) => { - const rooms: string[] = [] + derived( + [deriveUserRooms(url), deriveVoiceRooms(url), roomsByUrl], + ([$userRooms, voiceRooms, $roomsByUrl]) => { + const rooms: string[] = [] - for (const {h} of $roomsByUrl.get(url) || []) { - if (!$userRooms.includes(h)) { - rooms.push(h) + for (const {h} of $roomsByUrl.get(url) || []) { + if (!$userRooms.includes(h) && !voiceRooms.has(h)) { + rooms.push(h) + } } - } - return sortBy(roomComparator(url), uniq(rooms)) - }) + return sortBy(roomComparator(url), uniq(rooms)) + }, + ) // Space/room memberships @@ -1165,6 +1202,12 @@ export const deriveSupportedMethods = simpleCache(([url]: [string]) => { }) }) +export const deriveHasLivekit = simpleCache(([url]: [string]) => + readable(undefined, set => { + checkRelayHasLivekit(url).then(has => set(has)) + }), +) + export const deriveTimeout = (timeout: number) => { const store = writable(false) diff --git a/src/app/core/sync.ts b/src/app/core/sync.ts index 96eab24f..ebe46474 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 {LIVEKIT_PARTICIPANTS} from "@app/voice" // Utils @@ -316,6 +317,12 @@ const syncSpace = (url: string, rooms: string[]) => { }) } + pullAndListen({ + url, + signal: controller.signal, + filters: [{kinds: [LIVEKIT_PARTICIPANTS]}], + }) + return () => controller.abort() } diff --git a/src/app/voice.ts b/src/app/voice.ts new file mode 100644 index 00000000..40aa962c --- /dev/null +++ b/src/app/voice.ts @@ -0,0 +1,290 @@ +/** + * 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 {derived, get, writable} from "svelte/store" +import {map, removeUndefined, 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 {deriveLatestEventForUrl} from "@app/core/state" +import {pushToast} from "@app/util/toast" + +export const LIVEKIT_PARTICIPANTS = 39004 + +export {checkRelayHasLivekit} from "$lib/livekit" + +export type VoiceSession = { + url: string + h: string + room: Room + muted: boolean +} + +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) => { + 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 + }) +} + +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, + groupId: string, + signal?: AbortSignal, +): Promise<{server_url: string; participant_token: string}> => { + const endpoint = getLivekitEndpoint(url, groupId) + + const $signer = signer.get() + if (!$signer) throw new Error("No signer available") + + if (signal?.aborted) throw new DOMException("Aborted", "AbortError") + + const template = await makeHttpAuth(endpoint, "GET") + const signedEvent = await $signer.sign(template) + const authHeader = makeHttpAuthHeader(signedEvent) + + const response = await fetch(endpoint, { + headers: {Authorization: authHeader}, + signal, + }) + + 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) => + // 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 + + 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 = removeUndefined( + map( + (tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined), + getTags("participant", latestEvent.tags), + ), + ) + return uniqBy((p: VoiceParticipant) => participantKey(p), participants) + } + }, + ) + +const onRoomDisconnected = (reason?: DisconnectReason) => { + speakingParticipants.set([]) + 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." + : "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}[]) => { + speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity))) +} + +const playJoinSound = () => { + const audio = new Audio("/join-voice-room.mp3") + audio.play().catch(() => {}) +} + +const onParticipantConnected = (participant: {identity: string}) => { + addParticipant(participant.identity) + playJoinSound() +} + +const onParticipantDisconnected = (participant: {identity: string}) => { + deleteParticipant(participant.identity) +} + +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() + + currentVoiceRoom.set({url, h}) + voiceState.set("joining") + + const controller = new AbortController() + joinAbortController = controller + const signal = controller.signal + const isActive = () => joinAbortController === controller + + try { + 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) { + if (isActive()) voiceState.set("disconnected") + if (e instanceof AbortError) return + throw e + } finally { + if (isActive()) joinAbortController = undefined + } +} + +export const leaveVoiceRoom = async () => { + const session = get(currentVoiceSession) + if (!session) return + + const audio = new Audio("/leave-voice-room.mp3") + audio.play().catch(() => {}) + + 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 + + const muted = !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"}) + } +} diff --git a/src/assets/icons/microphone-off.svg b/src/assets/icons/microphone-off.svg new file mode 100644 index 00000000..2a90f378 --- /dev/null +++ b/src/assets/icons/microphone-off.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/lib/components/SecondaryNavItem.svelte b/src/lib/components/SecondaryNavItem.svelte index a61c7f0d..3dd7a401 100644 --- a/src/lib/components/SecondaryNavItem.svelte +++ b/src/lib/components/SecondaryNavItem.svelte @@ -34,7 +34,7 @@ {href} {...restProps} data-sveltekit-replacestate={replaceState} - class="{restProps.class} relative flex items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content" + class="{restProps.class} relative flex flex-shrink-0 items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content" class:text-base-content={active} class:bg-base-100={active}> {@render children?.()} @@ -45,7 +45,7 @@ {:else} - {: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 isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"} +
+
- {:else} -
- {#if parent} - - {/if} - {#if share} - - {/if} - {#if eventToEdit} - - {/if} -
- {#key eventToEdit} - - {/key} {/if}
diff --git a/static/join-voice-room.mp3 b/static/join-voice-room.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ed4f91e6269b27c9a6516ff604a410cb71de2897 GIT binary patch literal 12525 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 D253>E literal 0 HcmV?d00001 diff --git a/static/leave-voice-room.mp3 b/static/leave-voice-room.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..44cab1895e11bfa39808705d67a309c80dda5220 GIT binary patch literal 7149 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 literal 0 HcmV?d00001