Compare commits

..

25 Commits

Author SHA1 Message Date
mplorentz 8d38432598 Reorder imports 2026-03-04 10:28:34 -05:00
mplorentz c7bb9c291b Add room info button to VoiceWidget 2026-03-04 10:28:34 -05:00
mplorentz ed76364dda Fix muted icon 2026-03-04 10:28:34 -05:00
mplorentz de08a4775c Add a right around user avatar when speaking 2026-03-04 10:28:34 -05:00
mplorentz a07febf99c Move room type field up in the RoomForm 2026-03-04 10:28:34 -05:00
mplorentz 57ff3523eb Disable rooms on mobile temporarily 2026-03-04 10:28:34 -05:00
mplorentz 67f06f9d17 Check if livekit is configured on the relay during room creation/edit 2026-03-04 10:28:34 -05:00
mplorentz 4ad5637433 Add ability to joining a voice room while it's in progress 2026-03-04 10:28:34 -05:00
mplorentz d3f800ce74 Add loading indicator while joining 2026-03-04 10:28:34 -05:00
mplorentz b0ff13b2c3 Add error toast on connection failure. 2026-03-04 10:28:34 -05:00
mplorentz f7094d34a5 Make voice rooms in sidebar reactive 2026-03-04 10:28:33 -05:00
mplorentz e97b95057f Get rid of volume icon next to text rooms 2026-03-04 10:28:28 -05:00
mplorentz bce2ae5593 Allow user to configure room for voice, text, or both. 2026-03-04 10:28:28 -05:00
mplorentz 5f496cf25b Remove plan 2026-03-04 10:28:28 -05:00
mplorentz fc303a3c1b Move livekit auth to relay 2026-03-04 10:28:28 -05:00
mplorentz 47a857faba Fix logo download during docker build 2026-03-04 10:28:21 -05:00
mplorentz 10c19858bb Source .env explicitly during build 2026-03-04 10:28:21 -05:00
mplorentz 4c5bd03150 Fix issue where docker build would rebuild when app did not change 2026-03-04 10:28:18 -05:00
mplorentz d74cbe2db1 Try just serving 404.html 2026-03-04 10:28:10 -05:00
mplorentz 534649c9bd Try rewrites to get SPA mode working 2026-03-04 10:28:07 -05:00
mplorentz d9b551d0ff Serve in SPA mode 2026-03-04 10:28:04 -05:00
mplorentz e0a04c6b34 Auto-play voice track when joining a voice room 2026-03-04 10:28:00 -05:00
mplorentz f06ea62fd2 Add android microphoen permissions 2026-03-04 10:28:00 -05:00
mplorentz 59a2debb1a ignore pnpm store 2026-03-04 10:28:00 -05:00
mplorentz fb9eae9e09 WIP voice channels 2026-03-04 10:28:00 -05:00
15 changed files with 632 additions and 17 deletions
+1
View File
@@ -24,6 +24,7 @@ android/app/src/main/assets/public/
# Web/JavaScript
node_modules/
.pnpm-store/
build/
.svelte-kit/
+5 -1
View File
@@ -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
@@ -28,4 +31,5 @@ WORKDIR /app
# Copy only the built output - no source, no .env, no dev deps
COPY --from=builder /app/build ./build
CMD ["npx", "serve", "build"]
# Serve in SPA mode
CMD ["npx", "serve", "-s", "build"]
+2
View File
@@ -42,4 +42,6 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
</manifest>
+1
View File
@@ -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",
+94
View File
@@ -134,6 +134,9 @@ importers:
idb:
specifier: ^8.0.3
version: 8.0.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 +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==}
@@ -1253,6 +1259,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==}
@@ -1759,6 +1771,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==}
@@ -3258,6 +3273,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==}
@@ -3362,6 +3380,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'}
@@ -3396,6 +3419,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==}
@@ -4187,6 +4214,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'}
@@ -4216,6 +4246,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
@@ -4551,6 +4588,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==}
@@ -4610,6 +4650,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}
@@ -4754,6 +4797,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==}
@@ -5640,6 +5687,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)':
@@ -6187,6 +6236,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': {}
@@ -6656,6 +6711,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
@@ -8314,6 +8371,8 @@ snapshots:
jiti@1.21.7: {}
jose@6.1.3: {}
js-base64@3.7.8: {}
js-tokens@4.0.0: {}
@@ -8389,6 +8448,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
@@ -8421,6 +8494,8 @@ snapshots:
lodash@4.17.23: {}
loglevel@1.9.2: {}
lru-cache@10.4.3: {}
lru-cache@11.2.4: {}
@@ -9207,6 +9282,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
@@ -9238,6 +9318,10 @@ snapshots:
sax@1.4.4: {}
sdp-transform@2.15.0: {}
sdp@3.2.1: {}
semver@5.7.2: {}
semver@6.3.1: {}
@@ -9687,6 +9771,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):
@@ -9756,6 +9842,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)
@@ -9864,6 +9954,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
+63 -1
View File
@@ -15,6 +15,25 @@
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"
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
@@ -27,12 +46,35 @@
const {url, header, footer, onsubmit, initialValues = makeRoomMeta()}: Props = $props()
const values = $state(initialValues)
let roomMode = $state<RoomMode>(getRoomModeFromEvent(initialValues.event))
let relayHasLivekit = $state<boolean | undefined>(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, maxHeight: 256})
const {error, result} = await uploadFile(imageFile, {
maxWidth: 256,
maxHeight: 256,
})
if (error) {
return pushToast({theme: "error", message: error})
@@ -42,6 +84,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")) {
@@ -161,6 +207,22 @@
</label>
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<p>Room type</p>
{/snippet}
{#snippet input()}
<select class="select select-bordered w-full" bind:value={roomMode} aria-label="Room type">
<option value="text">Text only</option>
<option value="both" disabled={relayHasLivekit === false}>
Text and voice{relayHasLivekit === false ? " (not setup)" : ""}
</option>
<option value="voice" disabled={relayHasLivekit === false}>
Voice only{relayHasLivekit === false ? " (not setup)" : ""}
</option>
</select>
{/snippet}
</FieldInline>
<strong class="md:hidden">Permissions</strong>
<div class="flex items-center gap-2">
<input type="checkbox" class="checkbox" bind:checked={values.isRestricted} />
+22 -5
View File
@@ -38,6 +38,8 @@
import SpaceReports from "@app/components/SpaceReports.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import VoiceRoomItem from "@app/components/VoiceRoomItem.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import {
ENABLE_ZAPS,
@@ -45,6 +47,8 @@
deriveSpaceMembers,
deriveUserRooms,
deriveOtherRooms,
deriveRoomsWithLivekit,
deriveRoomsNoText,
userSpaceUrls,
hasNip29,
deriveUserCanCreateRoom,
@@ -68,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]}])
@@ -255,8 +261,13 @@
<div class="h-2"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if}
{#each $userRooms as h, i (h)}
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
{#each $userRooms as h (h)}
{#if !$roomsNoText.has(h)}
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
{/if}
{#if $roomsWithLivekit.has(h)}
<VoiceRoomItem {url} {h} />
{/if}
{/each}
{#if $otherRooms.length > 0}
<div class="h-2"></div>
@@ -274,8 +285,13 @@
<input bind:value={term} onblur={clearTerm} class="grow" />
</label>
{/if}
{#each $roomSearch.searchValues(term) as h, i (h)}
<SpaceMenuRoomItem {replaceState} {url} {h} />
{#each $roomSearch.searchValues(term) as h (h)}
{#if !$roomsNoText.has(h)}
<SpaceMenuRoomItem {replaceState} {url} {h} />
{/if}
{#if $roomsWithLivekit.has(h)}
<VoiceRoomItem {url} {h} />
{/if}
{/each}
{#if $canCreateRoom}
<SecondaryNavItem {replaceState} onclick={addRoom}>
@@ -286,7 +302,8 @@
{/if}
</div>
</SecondaryNavSection>
<div class="flex flex-col gap-2 pb-2 p-4 pt-0">
<div class="flex flex-col gap-2 p-4 pb-2 pt-0">
<VoiceWidget />
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
<SocketStatusIndicator {url} />
</Button>
@@ -1,12 +1,8 @@
<script lang="ts">
import VolumeCross from "@assets/icons/volume-cross.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 RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
import {notifications} from "@app/util/notifications"
import {makeRoomPath} from "@app/util/routes"
import {deriveShouldNotify} from "@app/core/state"
interface Props {
url: any
@@ -18,9 +14,6 @@
const {url, h, notify = false, replaceState = false}: Props = $props()
const path = makeRoomPath(url, h)
const shouldNotifyForSpace = deriveShouldNotify(url)
const shouldNotifyForRoom = deriveShouldNotify(url, h)
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
</script>
<SecondaryNavItem
@@ -28,7 +21,4 @@
{replaceState}
notification={notify ? $notifications.has(path) : false}>
<RoomNameWithImage {url} {h} />
{#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? VolumeLoud : VolumeCross} size={4} class="opacity-50" />
{/if}
</SecondaryNavItem>
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import cx from "classnames"
import {loadProfile, displayProfileByPubkey} from "@welshman/app"
import Volume from "@assets/icons/volume.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import {isMobileViewport} from "@lib/html"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {pushToast} from "@app/util/toast"
import {
deriveVoiceParticipants,
joinVoiceRoom,
leaveVoiceRoom,
currentVoiceSession,
speakingPubkeys,
} from "@app/voice"
interface Props {
url: string
h: string
}
const {url, h}: Props = $props()
const participants = deriveVoiceParticipants(url, h)
const isActive = $derived($currentVoiceSession?.url === url && $currentVoiceSession?.h === h)
let isJoining = $state(false)
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
}
if (isJoining) {
joinAbortController?.abort()
return
}
joinAbortController = new AbortController()
isJoining = true
try {
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
}
}
$effect(() => {
for (const pk of $participants) {
loadProfile(pk)
}
})
</script>
<div>
<SecondaryNavItem onclick={handleClick} class={isActive ? "!bg-base-100 !text-base-content" : ""}>
{#if isJoining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Icon icon={Volume} size={4} class="opacity-70" />
{/if}
<RoomName {url} {h} />
</SecondaryNavItem>
{#if $participants.length > 0}
<div class="mt-2 flex flex-col gap-1 pb-1 pl-10">
{#each $participants as pk (pk)}
<div class="flex items-center gap-2">
<div
class={cx(
"inline-flex shrink-0 items-center justify-center rounded-full transition-shadow",
isActive && $speakingPubkeys.has(pk) && "ring-2 ring-success",
)}>
<ProfileCircle pubkey={pk} size={5} class="h-5 w-5" />
</div>
<span class="ellipsize text-xs opacity-70">
{displayProfileByPubkey(pk)}
</span>
</div>
{/each}
</div>
{/if}
</div>
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import {get} from "svelte/store"
import {displayRelayUrl} from "@welshman/util"
import Microphone from "@assets/icons/microphone.svg?dataurl"
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
import InfoCircle from "@assets/icons/info-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {displayRoom} from "@app/core/state"
import RoomDetail from "@app/components/RoomDetail.svelte"
import {currentVoiceSession, leaveVoiceRoom, toggleMute} from "@app/voice"
import {pushModal} from "@app/util/modal"
const roomName = $derived(
$currentVoiceSession ? displayRoom($currentVoiceSession.url, $currentVoiceSession.h) : "",
)
const spaceName = $derived($currentVoiceSession ? displayRelayUrl($currentVoiceSession.url) : "")
const handleDisconnect = () => leaveVoiceRoom()
const handleToggleMute = () => toggleMute()
const showRoomDetail = () => {
const session = get(currentVoiceSession)
if (session) pushModal(RoomDetail, {url: session.url, h: session.h})
}
</script>
{#if $currentVoiceSession}
<div class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex flex-col gap-0.5">
<span class="text-sm font-semibold text-success">Voice Connected</span>
<span class="ellipsize text-xs opacity-70">
{roomName} / {spaceName}
</span>
</div>
<Button class="btn btn-sm btn-square btn-ghost shrink-0" onclick={showRoomDetail}>
<Icon icon={InfoCircle} size={4} />
</Button>
</div>
<div class="flex items-center gap-1">
<Button
class="btn btn-sm btn-square {$currentVoiceSession.muted ? 'btn-error' : 'btn-ghost'}"
onclick={handleToggleMute}>
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
</Button>
<Button class="btn btn-sm btn-square btn-error" onclick={handleDisconnect}>
<Icon icon={PhoneRounded} size={4} />
</Button>
</div>
</div>
{/if}
+32
View File
@@ -632,6 +632,38 @@ 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<string>()
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<string>()
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
}
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({
+7
View File
@@ -55,6 +55,7 @@ import {
loadFeedsForPubkey,
} from "@app/core/state"
import {hasBlossomSupport} from "@app/core/commands"
import {ROOM_PRESENCE} from "@app/voice"
// Utils
@@ -316,6 +317,12 @@ const syncSpace = (url: string, rooms: string[]) => {
})
}
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: [ROOM_PRESENCE]}],
})
return () => controller.abort()
}
+249
View File
@@ -0,0 +1,249 @@
/**
* 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"
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
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<boolean> => {
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
export type VoiceSession = {
url: string
h: string
room: Room
muted: boolean
}
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
export const speakingPubkeys = writable<Set<string>>(new Set())
const fetchLivekitToken = async (
url: string,
groupId: string,
signal?: AbortSignal,
): Promise<{server_url: string; participant_token: string}> => {
const endpoint = livekitEndpoint(url, groupId)
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",
template =>
$signer.sign(
makeEvent(template.kind, {
tags: template.tags,
content: template.content ?? "",
}),
),
true,
)
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()
throw new Error(`Token request failed (${response.status}): ${text}`)
}
return response.json()
}
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
if (getTagValue("h", event.tags) === h) {
pubkeys.push(event.pubkey)
}
}
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<typeof setInterval> | undefined
const startPresenceHeartbeat = (url: string, h: string) => {
stopPresenceHeartbeat()
publishPresence(url, h)
presenceInterval = setInterval(() => publishPresence(url, h), PRESENCE_INTERVAL_MS)
}
const stopPresenceHeartbeat = () => {
if (presenceInterval) {
clearInterval(presenceInterval)
presenceInterval = undefined
}
}
export const joinVoiceRoom = async (
url: string,
h: string,
signal?: AbortSignal,
): Promise<void> => {
const session = get(currentVoiceSession)
if (session) {
if (session.url === url && session.h === h) return
await leaveVoiceRoom()
}
const {server_url, participant_token} = await fetchLivekitToken(url, h, signal)
if (signal?.aborted) throw new Error("Join cancelled")
const room = new Room({
adaptiveStream: true,
dynacast: true,
})
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
try {
await Promise.race([
room.connect(server_url, participant_token, {maxRetries: 0}),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("Connection timed out. Please check your network and try again.")),
CONNECT_TIMEOUT_MS,
),
),
])
} 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})
startPresenceHeartbeat(url, h)
}
export const leaveVoiceRoom = async () => {
const session = get(currentVoiceSession)
if (!session) return
speakingPubkeys.set(new Set())
stopPresenceHeartbeat()
session.room.disconnect()
deletePresence(session.url)
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})
}
+8
View File
@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 8C7 5.23858 9.23858 3 12 3C14.7614 3 17 5.23858 17 8V11C17 13.7614 14.7614 16 12 16C9.23858 16 7 13.7614 7 11V8Z" stroke="#000000" stroke-width="1.5"/>
<path d="M13 8L17 8" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
<path d="M13 11L17 11" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
<path d="M20 10V11C20 15.4183 16.4183 19 12 19C7.58172 19 4 15.4183 4 11V10" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
<path d="M12 19V22" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
<path d="M22 2L2 22" stroke="#000000" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 734 B

+3
View File
@@ -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)