Compare commits

...

68 Commits

Author SHA1 Message Date
mplorentz b5dd7dd590 Clean up deriveProfile call in ProfileCircle 2026-03-16 09:57:02 -04:00
mplorentz b34f6b2754 Show VoiceWidget when disconnected but still viewing room page. 2026-03-16 09:57:02 -04:00
Jon Staab 00573580e4 A little bit of cleanup 2026-03-16 09:57:02 -04:00
Jon Staab 6fd2acc332 Revert "Fix a docker rebuild issue (#88)"
This reverts commit bc145f4caf.
2026-03-16 09:57:02 -04:00
Jon Staab 2e1148e514 Prevent shrinkage 2026-03-16 09:57:02 -04:00
Jon Staab cdce8d917d Slight style tweaks to room nav 2026-03-16 09:57:02 -04:00
Jon Staab bbb95ecaa3 Slight style tweaks to room nav 2026-03-16 09:57:02 -04:00
mplorentz 10eb3e71ad Remove info button from voice widgetn 2026-03-16 09:57:02 -04:00
mplorentz b54ec90b33 Expect livekit identity not pubkey in 39004 2026-03-16 09:57:02 -04:00
mplorentz 451a5d5130 Fix scrolling of space menu on mobile 2026-03-16 09:57:02 -04:00
mplorentz c6f11e63a2 Fix build on ios 2026-03-16 09:57:02 -04:00
mplorentz a3f76b8b41 Request microphone permission on ios 2026-03-16 09:57:01 -04:00
mplorentz 8c44eaba72 Don't leave voice room on second click 2026-03-16 09:57:01 -04:00
mplorentz fad369b689 Display voice widget in chat rooms on mobile 2026-03-16 09:57:01 -04:00
mplorentz 3ac3dab628 Fix voice widget layout on mobile 2026-03-16 09:57:01 -04:00
mplorentz bb15011464 Fix voice room icon getting truncated in PageBar 2026-03-16 09:57:01 -04:00
mplorentz 7f88202e18 Integrate new PageBar behavior 2026-03-16 09:54:12 -04:00
mplorentz ca7fe9442a Switch to 39004 for room presence 2026-03-16 09:54:12 -04:00
mplorentz 5b4fcc6c9e Expect HTTP 204 for livekit support 2026-03-16 09:54:12 -04:00
mplorentz 039ebc4ca7 Animate voice widget in and out 2026-03-16 09:54:12 -04:00
mplorentz 93a1fed958 Add join and leave sounds 2026-03-16 09:54:12 -04:00
mplorentz c91a52a31d Remove no-text rooms, highlight active room, fix custom voice room icons 2026-03-16 09:54:12 -04:00
mplorentz fc4e1281d9 Request microphone permissions when unmuting 2026-03-16 09:54:12 -04:00
mplorentz 64617f585b Show custom icon for voice rooms 2026-03-16 09:54:12 -04:00
mplorentz 97b81b2ddd Remove voice-only rooms from "Other rooms" 2026-03-16 09:54:12 -04:00
mplorentz a7421eb789 Add lables on hover for voice room controls 2026-03-16 09:54:12 -04:00
mplorentz 19ef84c9b4 Use joinAbortController as isJoining 2026-03-16 09:54:12 -04:00
mplorentz 630abbbf4e Hide voice rooms on mobile 2026-03-16 09:54:12 -04:00
mplorentz 1c754a10d7 Move unfavorited voice rooms into a new section in the SpaceMenu 2026-03-16 09:54:12 -04:00
mplorentz bc69c0f2e6 Use if let 2026-03-16 09:54:12 -04:00
mplorentz 8303c9c6e2 use new livekit welshman properties 2026-03-16 09:54:12 -04:00
mplorentz 47d6e9f963 Allow joining without a microphone 2026-03-16 09:54:12 -04:00
mplorentz ac543ac5bf Log join errors 2026-03-16 09:54:12 -04:00
mplorentz 84e2e16e49 include room participants inside VoiceRoomItem border 2026-03-16 09:54:12 -04:00
Jon Staab ef020aa8c1 Bump welshman 2026-03-16 09:54:12 -04:00
Jon Staab 616c6beed4 Tweak voice room display 2026-03-16 09:54:12 -04:00
mplorentz 0853ef45e7 Don't show technical error message to the user 2026-03-16 09:54:12 -04:00
mplorentz 5e0531ec92 Revert changes to dockerfile 2026-03-16 09:54:12 -04:00
mplorentz 378aeec7e5 Address remaining PR comments 2026-03-16 09:54:12 -04:00
mplorentz d128eb6c7a Use TrustedEvent 2026-03-16 09:54:12 -04:00
mplorentz 68844226ca Use bell icon for notifications 2026-03-16 09:54:12 -04:00
mplorentz 9c2d2093ec Address PR comments on RoomForm 2026-03-16 09:54:12 -04:00
mplorentz 02b2ccdee3 Reorder imports 2026-03-16 09:54:12 -04:00
mplorentz 2350123136 Add room info button to VoiceWidget 2026-03-16 09:54:11 -04:00
mplorentz 69a01db926 Fix muted icon 2026-03-16 09:54:11 -04:00
mplorentz 46483c7097 Add a right around user avatar when speaking 2026-03-16 09:54:11 -04:00
mplorentz 4ffe26ca56 Move room type field up in the RoomForm 2026-03-16 09:54:11 -04:00
mplorentz 760aecc376 Disable rooms on mobile temporarily 2026-03-16 09:54:11 -04:00
mplorentz be65325122 Check if livekit is configured on the relay during room creation/edit 2026-03-16 09:54:11 -04:00
mplorentz 866dfb1d8f Add ability to joining a voice room while it's in progress 2026-03-16 09:54:11 -04:00
mplorentz 5350dab324 Add loading indicator while joining 2026-03-16 09:54:11 -04:00
mplorentz 9aaeddf066 Add error toast on connection failure. 2026-03-16 09:54:11 -04:00
mplorentz 22e8e3ed32 Make voice rooms in sidebar reactive 2026-03-16 09:54:11 -04:00
mplorentz a16ffa5c31 Get rid of volume icon next to text rooms 2026-03-16 09:54:11 -04:00
mplorentz 68745530f7 Allow user to configure room for voice, text, or both. 2026-03-16 09:54:11 -04:00
mplorentz 8072a64a41 Remove plan 2026-03-16 09:54:11 -04:00
mplorentz f3c5a75445 Move livekit auth to relay 2026-03-16 09:54:11 -04:00
mplorentz c24428e944 Fix logo download during docker build 2026-03-16 09:54:11 -04:00
mplorentz 7949ac8b0b Source .env explicitly during build 2026-03-16 09:54:11 -04:00
mplorentz 70a94717ca Fix issue where docker build would rebuild when app did not change 2026-03-16 09:54:11 -04:00
mplorentz 5aa4221078 Try just serving 404.html 2026-03-16 09:54:11 -04:00
mplorentz 117bf487dc Try rewrites to get SPA mode working 2026-03-16 09:54:11 -04:00
mplorentz 4c753676b0 Serve in SPA mode 2026-03-16 09:54:11 -04:00
mplorentz fc2d4adc21 Auto-play voice track when joining a voice room 2026-03-16 09:54:11 -04:00
mplorentz 1a16a9ec0b Add android microphoen permissions 2026-03-16 09:54:11 -04:00
mplorentz 221e80ca82 ignore pnpm store 2026-03-16 09:54:11 -04:00
mplorentz d8fb794d16 WIP voice channels 2026-03-16 09:54:11 -04:00
mplorentz e614840667 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 <mplorentz@noreply.gitea.coracle.social>
Reviewed-on: #88
Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social>
Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
2026-03-16 09:54:11 -04:00
28 changed files with 862 additions and 98 deletions
+1
View File
@@ -25,6 +25,7 @@ android/app/src/main/assets/public/
# Web/JavaScript # Web/JavaScript
node_modules/ node_modules/
.pnpm-store/
build/ build/
.svelte-kit/ .svelte-kit/
+2
View File
@@ -4,6 +4,8 @@
FROM node:20-bookworm AS builder FROM node:20-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends curl
RUN npm install -g pnpm@latest RUN npm install -g pnpm@latest
WORKDIR /app WORKDIR /app
+2
View File
@@ -42,4 +42,6 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <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> </manifest>
+3
View File
@@ -4,6 +4,9 @@ const config: CapacitorConfig = {
appId: "social.flotilla", appId: "social.flotilla",
appName: "Flotilla", appName: "Flotilla",
webDir: "build", webDir: "build",
ios: {
scheme: "Flotilla Chat",
},
android: { android: {
adjustMarginsForEdgeToEdge: true, adjustMarginsForEdgeToEdge: true,
}, },
+8 -6
View File
@@ -20,8 +20,16 @@
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSMicrophoneUsageDescription</key>
<string>Flotilla uses the microphone for voice chat in rooms.</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@@ -47,11 +55,5 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict> </dict>
</plist> </plist>
+1
View File
@@ -83,6 +83,7 @@
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"idb": "^8.0.3", "idb": "^8.0.3",
"livekit-client": "^2.17.2",
"nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main", "nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main",
"nostr-tools": "^2.19.4", "nostr-tools": "^2.19.4",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.6.14",
+88
View File
@@ -134,6 +134,9 @@ importers:
idb: idb:
specifier: ^8.0.3 specifier: ^8.0.3
version: 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: nostr-signer-capacitor-plugin:
specifier: github:coracle-social/nostr-signer-capacitor-plugin#main 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) 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': '@braintree/sanitize-url@7.1.1':
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
'@bufbuild/protobuf@1.10.1':
resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==}
'@canvas/image-data@1.1.0': '@canvas/image-data@1.1.0':
resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==} resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==}
@@ -1283,6 +1289,12 @@ packages:
'@jridgewell/trace-mapping@0.3.9': '@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} 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': '@noble/ciphers@0.5.3':
resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==}
@@ -1823,6 +1835,9 @@ packages:
'@types/cookie@0.6.0': '@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} 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': '@types/eslint@9.6.1':
resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==}
@@ -3334,6 +3349,9 @@ packages:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true hasBin: true
jose@6.2.1:
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
js-base64@3.7.8: js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
@@ -3438,6 +3456,11 @@ packages:
linkifyjs@4.3.2: linkifyjs@4.3.2:
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} 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: load-json-file@4.0.0:
resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3472,6 +3495,10 @@ packages:
lodash@4.17.23: lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} 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: lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -4273,6 +4300,9 @@ packages:
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
sade@1.8.1: sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -4302,6 +4332,13 @@ packages:
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
engines: {node: '>=11.0.0'} 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: semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true hasBin: true
@@ -4703,6 +4740,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
typed-emitter@2.1.0:
resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
typescript-eslint@8.53.1: typescript-eslint@8.53.1:
resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==} resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4847,6 +4887,10 @@ packages:
webidl-conversions@4.0.2: webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} 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: whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -5733,6 +5777,8 @@ snapshots:
'@braintree/sanitize-url@7.1.1': {} '@braintree/sanitize-url@7.1.1': {}
'@bufbuild/protobuf@1.10.1': {}
'@canvas/image-data@1.1.0': {} '@canvas/image-data@1.1.0': {}
'@capacitor-community/safe-area@8.0.1(@capacitor/core@8.0.1)': '@capacitor-community/safe-area@8.0.1(@capacitor/core@8.0.1)':
@@ -6298,6 +6344,12 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@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@0.5.3': {}
'@noble/ciphers@1.3.0': {} '@noble/ciphers@1.3.0': {}
@@ -6859,6 +6911,8 @@ snapshots:
'@types/cookie@0.6.0': {} '@types/cookie@0.6.0': {}
'@types/dom-mediacapture-record@1.0.22': {}
'@types/eslint@9.6.1': '@types/eslint@9.6.1':
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -8530,6 +8584,8 @@ snapshots:
jiti@1.21.7: {} jiti@1.21.7: {}
jose@6.2.1: {}
js-base64@3.7.8: {} js-base64@3.7.8: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -8605,6 +8661,19 @@ snapshots:
linkifyjs@4.3.2: {} 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: load-json-file@4.0.0:
dependencies: dependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
@@ -8637,6 +8706,8 @@ snapshots:
lodash@4.17.23: {} lodash@4.17.23: {}
loglevel@1.9.2: {}
lru-cache@10.4.3: {} lru-cache@10.4.3: {}
lru-cache@11.2.4: {} lru-cache@11.2.4: {}
@@ -9427,6 +9498,11 @@ snapshots:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
optional: true
sade@1.8.1: sade@1.8.1:
dependencies: dependencies:
mri: 1.2.0 mri: 1.2.0
@@ -9458,6 +9534,10 @@ snapshots:
sax@1.4.4: {} sax@1.4.4: {}
sdp-transform@2.15.0: {}
sdp@3.2.1: {}
semver@5.7.2: {} semver@5.7.2: {}
semver@6.3.1: {} semver@6.3.1: {}
@@ -9982,6 +10062,10 @@ snapshots:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10 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): typescript-eslint@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies: 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) '@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: {} webidl-conversions@4.0.2: {}
webrtc-adapter@9.0.4:
dependencies:
sdp: 3.2.1
whatwg-url@5.0.0: whatwg-url@5.0.0:
dependencies: dependencies:
tr46: 0.0.3 tr46: 0.0.3
+8
View File
@@ -422,6 +422,14 @@ body.keyboard-open .hide-on-keyboard {
@apply cb cw fixed z-compose; @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 { .chat__scroll-down {
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16; @apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
} }
+1 -1
View File
@@ -6,7 +6,7 @@
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
type Props = { type Props = {
pubkey: string pubkey?: string
class?: string class?: string
size?: number size?: number
url?: string url?: string
+2 -2
View File
@@ -16,7 +16,7 @@
import Lock from "@assets/icons/lock.svg?dataurl" import Lock from "@assets/icons/lock.svg?dataurl"
import Microphone from "@assets/icons/microphone.svg?dataurl" import Microphone from "@assets/icons/microphone.svg?dataurl"
import Bookmark from "@assets/icons/bookmark.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 {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -255,7 +255,7 @@
<strong class="text-lg">Room Settings</strong> <strong class="text-lg">Room Settings</strong>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Icon icon={VolumeLoud} /> <Icon icon={Bell} />
<span>Notifications</span> <span>Notifications</span>
</div> </div>
<input <input
+34 -2
View File
@@ -5,6 +5,7 @@
import {waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app" import {waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl" import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
import Hashtag from "@assets/icons/hashtag.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 UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
import {preventDefault} from "@lib/html" import {preventDefault} from "@lib/html"
import FieldInline from "@lib/components/FieldInline.svelte" import FieldInline from "@lib/components/FieldInline.svelte"
@@ -15,6 +16,7 @@
import ModalBody from "@lib/components/ModalBody.svelte" import ModalBody from "@lib/components/ModalBody.svelte"
import {pushToast} from "@app/util/toast" import {pushToast} from "@app/util/toast"
import {uploadFile} from "@app/core/commands" import {uploadFile} from "@app/core/commands"
import {deriveHasLivekit, getRoomType, RoomType} from "@app/core/state"
type Props = { type Props = {
url: string url: string
@@ -27,12 +29,25 @@
const {url, header, footer, onsubmit, initialValues = makeRoomMeta()}: Props = $props() const {url, header, footer, onsubmit, initialValues = makeRoomMeta()}: Props = $props()
const values = $state(initialValues) const values = $state(initialValues)
const relayHasLivekit = deriveHasLivekit(url)
const submit = async () => { const submit = async () => {
const room = $state.snapshot(values) 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) { if (imageFile) {
const {error, result} = await uploadFile(imageFile, {maxWidth: 256, maxHeight: 256}) const {error, result} = await uploadFile(imageFile, {
maxWidth: 256,
maxHeight: 256,
})
if (error) { if (error) {
return pushToast({theme: "error", message: error}) return pushToast({theme: "error", message: error})
@@ -76,6 +91,7 @@
let loading = $state(false) let loading = $state(false)
let imageFile = $state<File | undefined>() let imageFile = $state<File | undefined>()
let imagePreview = $state(initialValues.picture) let imagePreview = $state(initialValues.picture)
let roomType = $state(getRoomType(initialValues))
const handleImageUpload = async (event: Event) => { const handleImageUpload = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0] const file = (event.target as HTMLInputElement).files?.[0]
@@ -145,7 +161,7 @@
{#if imagePreview} {#if imagePreview}
<ImageIcon src={imagePreview} alt="" class="rounded-lg" /> <ImageIcon src={imagePreview} alt="" class="rounded-lg" />
{:else} {:else}
<Icon icon={Hashtag} /> <Icon icon={roomType === RoomType.Voice ? Volume : Hashtag} />
{/if} {/if}
<input bind:value={values.name} class="grow" type="text" /> <input bind:value={values.name} class="grow" type="text" />
</label> </label>
@@ -161,6 +177,22 @@
</label> </label>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
{#if $relayHasLivekit}
<FieldInline>
{#snippet label()}
<p>Room type</p>
{/snippet}
{#snippet input()}
<select
class="select select-bordered w-full"
bind:value={roomType}
aria-label="Room type">
<option value={RoomType.Text}>Text</option>
<option value={RoomType.Voice}>Voice</option>
</select>
{/snippet}
</FieldInline>
{/if}
<strong class="md:hidden">Permissions</strong> <strong class="md:hidden">Permissions</strong>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="checkbox" class="checkbox" bind:checked={values.isRestricted} /> <input type="checkbox" class="checkbox" bind:checked={values.isRestricted} />
+22 -3
View File
@@ -1,22 +1,41 @@
<script lang="ts"> <script lang="ts">
import Hashtag from "@assets/icons/hashtag.svg?dataurl" import Hashtag from "@assets/icons/hashtag.svg?dataurl"
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 Icon from "@lib/components/Icon.svelte"
import ImageIcon from "@lib/components/ImageIcon.svelte" import ImageIcon from "@lib/components/ImageIcon.svelte"
import {deriveRoom} from "@app/core/state" import {deriveRoom} from "@app/core/state"
import {currentVoiceSession} from "@app/voice"
interface Props { interface Props {
h: string h: string
url: string url: string
size?: number size?: number
fallbackIcon?: string
} }
const {url, h, size = 5}: Props = $props() const {url, h, size = 5, fallbackIcon = Hashtag}: Props = $props()
const room = deriveRoom(url, h) const room = deriveRoom(url, h)
const isVoiceRoom = $derived($room.livekit)
const isVoiceRoomActive = $derived(
$currentVoiceSession?.url === url && $currentVoiceSession?.h === h,
)
</script> </script>
{#if $room.picture} {#if isVoiceRoom}
<div class="flex shrink-0 items-center gap-1.5">
<Icon
size={size + 1}
icon={isVoiceRoomActive ? VolumeLoud : Volume}
class={isVoiceRoomActive ? "text-primary -translate-x-0.5" : ""} />
{#if $room.picture}
<span class="text-base">/</span>
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" />
{/if}
</div>
{:else if $room.picture}
<ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" /> <ImageIcon src={$room.picture} {size} alt="" class="rounded-lg" />
{:else} {:else}
<Icon icon={Hashtag} {size} /> <Icon icon={fallbackIcon} {size} />
{/if} {/if}
+1 -1
View File
@@ -12,6 +12,6 @@
const room = deriveRoom(url, h) const room = deriveRoom(url, h)
</script> </script>
<span class="ellipsize {props.class}"> <span class="ellipsize min-w-0 {props.class}">
{$room?.name || h} {$room?.name || h}
</span> </span>
+27 -15
View File
@@ -20,8 +20,8 @@
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl" import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
import AddCircle from "@assets/icons/add-circle.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl"
import ChatRound from "@assets/icons/chat-round.svg?dataurl" import ChatRound from "@assets/icons/chat-round.svg?dataurl"
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl" import Bell from "@assets/icons/bell.svg?dataurl"
import VolumeCross from "@assets/icons/volume-cross.svg?dataurl" import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@@ -38,6 +38,7 @@
import SpaceReports from "@app/components/SpaceReports.svelte" import SpaceReports from "@app/components/SpaceReports.svelte"
import RoomCreate from "@app/components/RoomCreate.svelte" import RoomCreate from "@app/components/RoomCreate.svelte"
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte" import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte" import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
import { import {
ENABLE_ZAPS, ENABLE_ZAPS,
@@ -45,6 +46,7 @@
deriveSpaceMembers, deriveSpaceMembers,
deriveUserRooms, deriveUserRooms,
deriveOtherRooms, deriveOtherRooms,
deriveOtherVoiceRooms,
userSpaceUrls, userSpaceUrls,
hasNip29, hasNip29,
deriveUserCanCreateRoom, deriveUserCanCreateRoom,
@@ -68,6 +70,7 @@
const calendarPath = makeSpacePath(url, "calendar") const calendarPath = makeSpacePath(url, "calendar")
const userRooms = deriveUserRooms(url) const userRooms = deriveUserRooms(url)
const otherRooms = deriveOtherRooms(url) const otherRooms = deriveOtherRooms(url)
const otherVoiceRooms = deriveOtherVoiceRooms(url)
const members = deriveSpaceMembers(url) const members = deriveSpaceMembers(url)
const userIsAdmin = deriveUserIsSpaceAdmin(url) const userIsAdmin = deriveUserIsSpaceAdmin(url)
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}]) const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
@@ -133,9 +136,9 @@
}) })
</script> </script>
<div bind:this={element} class="flex h-full flex-col justify-between"> <div bind:this={element} class="flex min-h-0 flex-1 flex-col">
<SecondaryNavSection class="pb-0"> <SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0">
<div> <div class="flex-shrink-0">
<Button <Button
class="flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100" class="flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
onclick={openMenu}> onclick={openMenu}>
@@ -143,7 +146,7 @@
<strong class="ellipsize flex items-center gap-1"> <strong class="ellipsize flex items-center gap-1">
<RelayName {url} /> <RelayName {url} />
{#if $notificationSettings.push && !$shouldNotify} {#if $notificationSettings.push && !$shouldNotify}
<Icon icon={VolumeCross} size={3} class="opacity-50" /> <Icon icon={BellOff} size={3} class="opacity-50" />
{/if} {/if}
</strong> </strong>
<Icon icon={AltArrowDown} /> <Icon icon={AltArrowDown} />
@@ -192,12 +195,12 @@
<li> <li>
{#if $notificationSettings.push} {#if $notificationSettings.push}
<Button onclick={toggleSpaceNotifications}> <Button onclick={toggleSpaceNotifications}>
<Icon icon={$shouldNotify ? VolumeLoud : VolumeCross} /> <Icon icon={$shouldNotify ? Bell : BellOff} />
{$shouldNotify ? "Turn off" : "Turn on"} notifications {$shouldNotify ? "Turn off" : "Turn on"} notifications
</Button> </Button>
{:else} {:else}
<Link href="/settings/alerts"> <Link href="/settings/alerts">
<Icon icon={VolumeLoud} /> <Icon icon={Bell} />
Enable notifications Enable notifications
</Link> </Link>
{/if} {/if}
@@ -219,8 +222,7 @@
</Popover> </Popover>
{/if} {/if}
</div> </div>
<div <div class="flex min-h-0 flex-1 flex-col gap-1 overflow-auto overflow-x-hidden">
class="flex max-h-[calc(100vh-150px)] min-h-0 flex-col gap-1 overflow-auto overflow-x-hidden">
{#if hasNip29($relay)} {#if hasNip29($relay)}
<SecondaryNavItem {replaceState} href={makeSpacePath(url, "recent")}> <SecondaryNavItem {replaceState} href={makeSpacePath(url, "recent")}>
<Icon icon={History} /> Recent Activity <Icon icon={History} /> Recent Activity
@@ -252,14 +254,14 @@
{/if} {/if}
{#if hasNip29($relay)} {#if hasNip29($relay)}
{#if $userRooms.length > 0} {#if $userRooms.length > 0}
<div class="h-2"></div> <div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader> <SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
{/if} {/if}
{#each $userRooms as h, i (h)} {#each $userRooms as h (h)}
<SpaceMenuRoomItem notify {replaceState} {url} {h} /> <SpaceMenuRoomItem notify {replaceState} {url} {h} />
{/each} {/each}
{#if $otherRooms.length > 0} {#if $otherRooms.length > 0}
<div class="h-2"></div> <div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader> <SecondaryNavHeader>
{#if $userRooms.length > 0} {#if $userRooms.length > 0}
Other Rooms Other Rooms
@@ -274,9 +276,16 @@
<input bind:value={term} onblur={clearTerm} class="grow" /> <input bind:value={term} onblur={clearTerm} class="grow" />
</label> </label>
{/if} {/if}
{#each $roomSearch.searchValues(term) as h, i (h)} {#each $roomSearch.searchValues(term) as h (h)}
<SpaceMenuRoomItem {replaceState} {url} {h} /> <SpaceMenuRoomItem {replaceState} {url} {h} />
{/each} {/each}
{#if $otherVoiceRooms.length > 0}
<div class="h-2 flex-shrink-0"></div>
<SecondaryNavHeader>Voice Rooms</SecondaryNavHeader>
{#each $otherVoiceRooms as h (h)}
<SpaceMenuRoomItem {replaceState} {url} {h} />
{/each}
{/if}
{#if $canCreateRoom} {#if $canCreateRoom}
<SecondaryNavItem {replaceState} onclick={addRoom}> <SecondaryNavItem {replaceState} onclick={addRoom}>
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
@@ -284,9 +293,12 @@
</SecondaryNavItem> </SecondaryNavItem>
{/if} {/if}
{/if} {/if}
<div class="h-5 flex-shrink-0"></div>
</div> </div>
</SecondaryNavSection> </SecondaryNavSection>
<div class="flex flex-col gap-2 pb-2 p-4 pt-0"> <div
class="flex flex-shrink-0 flex-col gap-2 p-2 pt-0 -mt-4 pb-[calc(var(--saib)+3rem)] sm:pb-2 z-nav">
<VoiceWidget />
<Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}> <Button class="btn btn-neutral btn-sm h-10" onclick={showDetail}>
<SocketStatusIndicator {url} /> <SocketStatusIndicator {url} />
</Button> </Button>
+19 -12
View File
@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import VolumeCross from "@assets/icons/volume-cross.svg?dataurl" import Bell from "@assets/icons/bell.svg?dataurl"
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl" import BellOff from "@assets/icons/bell-off.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte" import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.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 {notifications} from "@app/util/notifications"
import {makeRoomPath} from "@app/util/routes" import {makeRoomPath} from "@app/util/routes"
import {deriveShouldNotify} from "@app/core/state"
interface Props { interface Props {
url: any url: any
@@ -17,18 +18,24 @@
const {url, h, notify = false, replaceState = false}: Props = $props() 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 path = makeRoomPath(url, h)
const shouldNotifyForSpace = deriveShouldNotify(url) const shouldNotifyForSpace = deriveShouldNotify(url)
const shouldNotifyForRoom = deriveShouldNotify(url, h) const shouldNotifyForRoom = deriveShouldNotify(url, h)
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace) const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
</script> </script>
<SecondaryNavItem {#if roomType === RoomType.Voice}
href={path} <VoiceRoomItem {url} {h} {replaceState} />
{replaceState} {:else}
notification={notify ? $notifications.has(path) : false}> <SecondaryNavItem
<RoomNameWithImage {url} {h} /> href={path}
{#if showDifferenceIcon} {replaceState}
<Icon icon={$shouldNotifyForRoom ? VolumeLoud : VolumeCross} size={4} class="opacity-50" /> notification={notify ? $notifications.has(path) : false}>
{/if} <RoomNameWithImage {url} {h} />
</SecondaryNavItem> {#if showDifferenceIcon}
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
{/if}
</SecondaryNavItem>
{/if}
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import cx from "classnames"
import {loadProfile, displayProfileByPubkey} from "@welshman/app"
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import RoomImage from "@app/components/RoomImage.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {pushToast} from "@app/util/toast"
import {makeRoomPath} from "@app/util/routes"
import {
deriveVoiceParticipants,
joinVoiceRoom,
cancelJoinVoiceRoom,
currentVoiceRoom,
voiceState,
isParticipantSpeaking,
participantKey,
type VoiceParticipant,
} from "@app/voice"
interface Props {
url: string
h: string
replaceState?: boolean
}
const {url, h, replaceState = false}: Props = $props()
const participants = deriveVoiceParticipants(url, h)
const isActive = $derived(
$voiceState === "connected" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
)
const isJoining = $derived(
$voiceState === "joining" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
)
const handleClick = async () => {
if (isActive) return
if (isJoining) {
cancelJoinVoiceRoom()
return
}
try {
await joinVoiceRoom(url, h)
} catch (e) {
console.error("Failed to join voice room", e)
pushToast({theme: "error", message: "Failed to join voice room"})
}
}
$effect(() => {
for (const p of $participants) {
if (p.pubkey) loadProfile(p.pubkey)
}
})
</script>
<SecondaryNavItem
href={makeRoomPath(url, h)}
{replaceState}
onclick={handleClick}
class={cx("!items-start", isActive && "!bg-base-100 !text-base-content")}>
<div class="flex w-full min-w-0 flex-col gap-2">
<div class="flex gap-2 items-center">
{#if isJoining}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<RoomImage {url} {h} size={4} />
{/if}
<RoomName {url} {h} />
</div>
{#if $participants.length > 0}
{#each $participants as p (participantKey(p as VoiceParticipant))}
<div class="flex items-center gap-2 ml-6">
<div
class={cx(
"inline-flex shrink-0 items-center justify-center rounded-full transition-shadow",
isActive && $isParticipantSpeaking(p) && "ring-2 ring-success",
)}>
<ProfileCircle pubkey={p.pubkey} size={5} class="h-5 w-5" />
</div>
<span class="ellipsize text-xs opacity-70">
{p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"}
</span>
</div>
{/each}
{/if}
</div>
</SecondaryNavItem>
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import {fly} from "svelte/transition"
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 PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {displayRoom} from "@app/core/state"
import {
currentVoiceSession,
currentVoiceRoom,
voiceState,
leaveVoiceRoom,
toggleMute,
rejoinVoiceRoom,
cancelJoinVoiceRoom,
} from "@app/voice"
const roomName = $derived(
$currentVoiceRoom ? displayRoom($currentVoiceRoom.url, $currentVoiceRoom.h) : "",
)
const spaceName = $derived($currentVoiceRoom ? displayRelayUrl($currentVoiceRoom.url) : "")
</script>
{#if $currentVoiceRoom}
<div
in:fly={{y: 60, duration: 350}}
out:fly={{y: 60, duration: 250}}
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
<div class="flex flex-col gap-0.5">
{#if $voiceState === "joining"}
<span class="text-sm font-semibold text-warning">Joining...</span>
{:else if $voiceState === "connected"}
<span class="text-sm font-semibold text-success">Voice Connected</span>
{:else}
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
{/if}
<span class="ellipsize text-xs opacity-70">
{roomName} / {spaceName}
</span>
</div>
<div class="flex items-center gap-1">
{#if $voiceState === "joining"}
<span class="loading loading-spinner loading-sm"></span>
<Button
data-tip="Cancel"
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={4} />
</Button>
{:else if $voiceState === "connected" && $currentVoiceSession}
<Button
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
? 'btn-error'
: 'btn-ghost'}"
onclick={toggleMute}>
<Icon icon={$currentVoiceSession.muted ? MicrophoneOff : Microphone} size={4} />
</Button>
<Button
data-tip="Leave room"
class="center tooltip tooltip-top btn btn-sm btn-square btn-error"
onclick={leaveVoiceRoom}>
<Icon icon={PhoneRounded} size={4} />
</Button>
{:else}
<Button
data-tip="Join Voice"
class="center tooltip tooltip-top btn btn-sm btn-square btn-success"
onclick={rejoinVoiceRoom}>
<Icon icon={PhoneCallingRounded} size={4} />
</Button>
{/if}
</div>
</div>
{/if}
+51 -8
View File
@@ -125,6 +125,7 @@ import type {
RelayProfile, RelayProfile,
PublishedList, PublishedList,
PublishedRoomMeta, PublishedRoomMeta,
RoomMeta,
List, List,
Filter, Filter,
} from "@welshman/util" } from "@welshman/util"
@@ -146,6 +147,7 @@ import {
displayProfileByPubkey, displayProfileByPubkey,
getProfile, getProfile,
} from "@welshman/app" } from "@welshman/app"
import {checkRelayHasLivekit} from "$lib/livekit"
import {readFeed} from "@lib/feeds" import {readFeed} from "@lib/feeds"
export const fromCsv = (s: string) => (s || "").split(",").filter(identity) export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
@@ -567,11 +569,19 @@ export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => {
// Rooms // Rooms
export enum RoomType {
Text = "text",
Voice = "voice",
}
export type Room = PublishedRoomMeta & { export type Room = PublishedRoomMeta & {
id: string id: string
url: 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 makeRoomId = (url: string, h: string) => `${url}'${h}`
export const splitRoomId = (id: string) => id.split("'") 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 roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase()
export const deriveVoiceRooms = (url: string) =>
derived(roomsById, $roomsById => {
const set = new Set<string>()
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 // User space/room lists
export const groupListsByPubkey = deriveItemsByKey({ export const groupListsByPubkey = deriveItemsByKey({
@@ -752,17 +786,20 @@ export const deriveUserRooms = (url: string) =>
}) })
export const deriveOtherRooms = (url: string) => export const deriveOtherRooms = (url: string) =>
derived([deriveUserRooms(url), roomsByUrl], ([$userRooms, $roomsByUrl]) => { derived(
const rooms: string[] = [] [deriveUserRooms(url), deriveVoiceRooms(url), roomsByUrl],
([$userRooms, voiceRooms, $roomsByUrl]) => {
const rooms: string[] = []
for (const {h} of $roomsByUrl.get(url) || []) { for (const {h} of $roomsByUrl.get(url) || []) {
if (!$userRooms.includes(h)) { if (!$userRooms.includes(h) && !voiceRooms.has(h)) {
rooms.push(h) rooms.push(h)
}
} }
}
return sortBy(roomComparator(url), uniq(rooms)) return sortBy(roomComparator(url), uniq(rooms))
}) },
)
// Space/room memberships // Space/room memberships
@@ -1165,6 +1202,12 @@ export const deriveSupportedMethods = simpleCache(([url]: [string]) => {
}) })
}) })
export const deriveHasLivekit = simpleCache(([url]: [string]) =>
readable<boolean | undefined>(undefined, set => {
checkRelayHasLivekit(url).then(has => set(has))
}),
)
export const deriveTimeout = (timeout: number) => { export const deriveTimeout = (timeout: number) => {
const store = writable<boolean>(false) const store = writable<boolean>(false)
+7
View File
@@ -55,6 +55,7 @@ import {
loadFeedsForPubkey, loadFeedsForPubkey,
} from "@app/core/state" } from "@app/core/state"
import {hasBlossomSupport} from "@app/core/commands" import {hasBlossomSupport} from "@app/core/commands"
import {LIVEKIT_PARTICIPANTS} from "@app/voice"
// Utils // Utils
@@ -316,6 +317,12 @@ const syncSpace = (url: string, rooms: string[]) => {
}) })
} }
pullAndListen({
url,
signal: controller.signal,
filters: [{kinds: [LIVEKIT_PARTICIPANTS]}],
})
return () => controller.abort() return () => controller.abort()
} }
+290
View File
@@ -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<VoiceSession | undefined>(undefined)
export const voiceState = writable<VoiceState>("disconnected")
export const currentVoiceRoom = writable<{url: string; h: string} | undefined>(undefined)
export const participantPubkeyMap = writable<Map<string, Pubkey>>(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<VoiceParticipant[]>([])
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<void> => {
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"})
}
}
+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

+2 -2
View File
@@ -34,7 +34,7 @@
{href} {href}
{...restProps} {...restProps}
data-sveltekit-replacestate={replaceState} 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:text-base-content={active}
class:bg-base-100={active}> class:bg-base-100={active}>
{@render children?.()} {@render children?.()}
@@ -45,7 +45,7 @@
{:else} {:else}
<button <button
{...restProps} {...restProps}
class="{restProps.class} relative flex w-full items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content" class="{restProps.class} relative flex flex-shrink-0 w-full items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content"
class:text-base-content={active} class:text-base-content={active}
class:bg-base-100={active}> class:bg-base-100={active}>
{#if notification} {#if notification}
+23
View File
@@ -0,0 +1,23 @@
const toHttpUrl = (url: string) =>
url
.replace(/^wss:\/\//, "https://")
.replace(/^ws:\/\//, "http://")
.replace(/\/$/, "")
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<boolean> => {
const endpoint = livekitEndpoint(url)
try {
const response = await fetch(endpoint)
return response.status === 204
} catch {
return false
}
}
export const getLivekitEndpoint = (url: string, groupId: string) => livekitEndpoint(url, groupId)
+30
View File
@@ -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 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<never>(() => {})
return new Promise<never>((_, 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<never>((_, reject) => setTimeout(() => reject(new TimeoutError()), ms))
}
export const buildUrl = (base: string | URL, ...pathname: string[]) => { export const buildUrl = (base: string | URL, ...pathname: string[]) => {
const url = new URL(base) const url = new URL(base)
+1 -1
View File
@@ -25,7 +25,7 @@
<div class="ml-sai mt-sai mb-sai relative z-nav w-14 flex-shrink-0 bg-base-200 pt-4"> <div class="ml-sai mt-sai mb-sai relative z-nav w-14 flex-shrink-0 bg-base-200 pt-4">
<PrimaryNavSpaces /> <PrimaryNavSpaces />
</div> </div>
<SecondaryNav class="!w-auto !flex flex-grow"> <SecondaryNav class="!flex !min-h-0 !w-auto flex-grow pb-4">
<SpaceMenu {url} /> <SpaceMenu {url} />
</SecondaryNav> </SecondaryNav>
{/if} {/if}
+61 -45
View File
@@ -42,11 +42,15 @@
decodeRelay, decodeRelay,
deriveRoom, deriveRoom,
deriveUserRoomMembershipStatus, deriveUserRoomMembershipStatus,
getRoomType,
MESSAGE_KINDS, MESSAGE_KINDS,
MembershipStatus, MembershipStatus,
PROTECTED, PROTECTED,
RoomType,
userSettingsValues, userSettingsValues,
} from "@app/core/state" } from "@app/core/state"
import VoiceWidget from "@app/components/VoiceWidget.svelte"
import {voiceState} from "@app/voice"
import {makeFeed} from "@app/core/requests" import {makeFeed} from "@app/core/requests"
import {popKey} from "@lib/implicit" import {popKey} from "@lib/implicit"
import {checked} from "@app/util/notifications" import {checked} from "@app/util/notifications"
@@ -58,6 +62,7 @@
const lastChecked = $checked[$page.url.pathname] const lastChecked = $checked[$page.url.pathname]
const url = decodeRelay(relay) const url = decodeRelay(relay)
const room = deriveRoom(url, h) const room = deriveRoom(url, h)
const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice)
const shouldProtect = canEnforceNip70(url) const shouldProtect = canEnforceNip70(url)
const membershipStatus = deriveUserRoomMembershipStatus(url, h) const membershipStatus = deriveUserRoomMembershipStatus(url, h)
const at = $derived(parseInt($page.url.searchParams.get("at")!)) const at = $derived(parseInt($page.url.searchParams.get("at")!))
@@ -358,8 +363,10 @@
</script> </script>
<SpaceBar> <SpaceBar>
{#snippet title()} {#snippet icon()}
<RoomImage {url} {h} /> <RoomImage {url} {h} />
{/snippet}
{#snippet title()}
<RoomName {url} {h} /> <RoomName {url} {h} />
{/snippet} {/snippet}
{#snippet action()} {#snippet action()}
@@ -442,52 +449,61 @@
{/if} {/if}
</PageContent> </PageContent>
<div class="chat__compose bg-base-200" bind:this={chatCompose}> <div
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted} class="chat__compose-zone flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0"
<!-- pass --> bind:this={chatCompose}>
{:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted} <div class="chat__compose-inner min-w-0 flex-1">
<div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3"> {#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
<p class="opacity-75">Only members are allowed to post to this room.</p> <!-- pass -->
{#if !$room.isClosed} {:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted}
{#if $membershipStatus === MembershipStatus.Pending} <div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3">
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}> <p class="opacity-75">Only members are allowed to post to this room.</p>
<Icon icon={ClockCircle} /> {#if !$room.isClosed}
Access Pending {#if $membershipStatus === MembershipStatus.Pending}
</Button> <Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
{:else} <Icon icon={ClockCircle} />
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}> Access Pending
{#if joining} </Button>
<span class="loading loading-spinner loading-sm"></span> {:else}
{:else} <Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
<Icon icon={Login2} /> {#if joining}
{/if} <span class="loading loading-spinner loading-sm"></span>
Ask to Join {:else}
</Button> <Icon icon={Login2} />
{/if}
Ask to Join
</Button>
{/if}
{/if} {/if}
{/if} </div>
{:else}
<div>
{#if parent}
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<RoomComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key eventToEdit}
<RoomCompose
{url}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content}
bind:this={compose} />
{/key}
{/if}
</div>
{#if isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"}
<div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
<VoiceWidget />
</div> </div>
{:else}
<div>
{#if parent}
<RoomComposeParent event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if share}
<RoomComposeParent event={share} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key eventToEdit}
<RoomCompose
{url}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
content={eventToEdit?.content}
bind:this={compose} />
{/key}
{/if} {/if}
</div> </div>
Binary file not shown.
Binary file not shown.