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 @@
})
-
-
-
+
+
+
-
+
{#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