feature/23-voice-room/poc #93
@@ -25,6 +25,7 @@ android/app/src/main/assets/public/
|
||||
|
||||
# Web/JavaScript
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
build/
|
||||
.svelte-kit/
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
FROM node:20-bookworm AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl
|
||||
|
||||
RUN npm install -g pnpm@latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,9 @@ const config: CapacitorConfig = {
|
||||
appId: "social.flotilla",
|
||||
appName: "Flotilla",
|
||||
webDir: "build",
|
||||
ios: {
|
||||
scheme: "Flotilla Chat",
|
||||
},
|
||||
android: {
|
||||
adjustMarginsForEdgeToEdge: true,
|
||||
},
|
||||
|
||||
@@ -20,8 +20,16 @@
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<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>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -47,11 +55,5 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -134,6 +134,9 @@ importers:
|
||||
idb:
|
||||
specifier: ^8.0.3
|
||||
version: 8.0.3
|
||||
livekit-client:
|
||||
specifier: ^2.17.2
|
||||
version: 2.17.3(@types/dom-mediacapture-record@1.0.22)
|
||||
nostr-signer-capacitor-plugin:
|
||||
specifier: github:coracle-social/nostr-signer-capacitor-plugin#main
|
||||
version: https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/be4bb90a1a15c8eec0934f2f66ce9e82ecc72d51(@capacitor/core@8.0.1)
|
||||
@@ -737,6 +740,9 @@ packages:
|
||||
'@braintree/sanitize-url@7.1.1':
|
||||
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
|
||||
|
||||
'@bufbuild/protobuf@1.10.1':
|
||||
resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==}
|
||||
|
||||
'@canvas/image-data@1.1.0':
|
||||
resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==}
|
||||
|
||||
@@ -1283,6 +1289,12 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@livekit/mutex@1.1.1':
|
||||
resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==}
|
||||
|
||||
'@livekit/protocol@1.44.0':
|
||||
resolution: {integrity: sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==}
|
||||
|
||||
'@noble/ciphers@0.5.3':
|
||||
resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==}
|
||||
|
||||
@@ -1823,6 +1835,9 @@ packages:
|
||||
'@types/cookie@0.6.0':
|
||||
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
||||
|
||||
'@types/dom-mediacapture-record@1.0.22':
|
||||
resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==}
|
||||
|
||||
'@types/eslint@9.6.1':
|
||||
resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==}
|
||||
|
||||
@@ -3334,6 +3349,9 @@ packages:
|
||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.1:
|
||||
resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
|
||||
|
||||
js-base64@3.7.8:
|
||||
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
|
||||
|
||||
@@ -3438,6 +3456,11 @@ packages:
|
||||
linkifyjs@4.3.2:
|
||||
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
|
||||
|
||||
livekit-client@2.17.3:
|
||||
resolution: {integrity: sha512-htwsAL/BMylY/zwdcT/z00U789csbi9DldSW7DO+5tz7Q15pwu++E1X+ZdtZDfkmlysfQLLibdcqlyg9FY7veQ==}
|
||||
peerDependencies:
|
||||
'@types/dom-mediacapture-record': ^1
|
||||
|
||||
load-json-file@4.0.0:
|
||||
resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3472,6 +3495,10 @@ packages:
|
||||
lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
|
||||
loglevel@1.9.2:
|
||||
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
@@ -4273,6 +4300,9 @@ packages:
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
rxjs@7.8.2:
|
||||
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
||||
|
||||
sade@1.8.1:
|
||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4302,6 +4332,13 @@ packages:
|
||||
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
|
||||
engines: {node: '>=11.0.0'}
|
||||
|
||||
sdp-transform@2.15.0:
|
||||
resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==}
|
||||
hasBin: true
|
||||
|
||||
sdp@3.2.1:
|
||||
resolution: {integrity: sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==}
|
||||
|
||||
semver@5.7.2:
|
||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||
hasBin: true
|
||||
@@ -4703,6 +4740,9 @@ packages:
|
||||
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
typed-emitter@2.1.0:
|
||||
resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
|
||||
|
||||
typescript-eslint@8.53.1:
|
||||
resolution: {integrity: sha512-gB+EVQfP5RDElh9ittfXlhZJdjSU4jUSTyE2+ia8CYyNvet4ElfaLlAIqDvQV9JPknKx0jQH1racTYe/4LaLSg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -4847,6 +4887,10 @@ packages:
|
||||
webidl-conversions@4.0.2:
|
||||
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
|
||||
|
||||
webrtc-adapter@9.0.4:
|
||||
resolution: {integrity: sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==}
|
||||
engines: {node: '>=6.0.0', npm: '>=3.10.0'}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
@@ -5733,6 +5777,8 @@ snapshots:
|
||||
|
||||
'@braintree/sanitize-url@7.1.1': {}
|
||||
|
||||
'@bufbuild/protobuf@1.10.1': {}
|
||||
|
||||
'@canvas/image-data@1.1.0': {}
|
||||
|
||||
'@capacitor-community/safe-area@8.0.1(@capacitor/core@8.0.1)':
|
||||
@@ -6298,6 +6344,12 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@livekit/mutex@1.1.1': {}
|
||||
|
||||
'@livekit/protocol@1.44.0':
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 1.10.1
|
||||
|
||||
'@noble/ciphers@0.5.3': {}
|
||||
|
||||
'@noble/ciphers@1.3.0': {}
|
||||
@@ -6859,6 +6911,8 @@ snapshots:
|
||||
|
||||
'@types/cookie@0.6.0': {}
|
||||
|
||||
'@types/dom-mediacapture-record@1.0.22': {}
|
||||
|
||||
'@types/eslint@9.6.1':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -8530,6 +8584,8 @@ snapshots:
|
||||
|
||||
jiti@1.21.7: {}
|
||||
|
||||
jose@6.2.1: {}
|
||||
|
||||
js-base64@3.7.8: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
@@ -8605,6 +8661,19 @@ snapshots:
|
||||
|
||||
linkifyjs@4.3.2: {}
|
||||
|
||||
livekit-client@2.17.3(@types/dom-mediacapture-record@1.0.22):
|
||||
dependencies:
|
||||
'@livekit/mutex': 1.1.1
|
||||
'@livekit/protocol': 1.44.0
|
||||
'@types/dom-mediacapture-record': 1.0.22
|
||||
events: 3.3.0
|
||||
jose: 6.2.1
|
||||
loglevel: 1.9.2
|
||||
sdp-transform: 2.15.0
|
||||
tslib: 2.8.1
|
||||
typed-emitter: 2.1.0
|
||||
webrtc-adapter: 9.0.4
|
||||
|
||||
load-json-file@4.0.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -8637,6 +8706,8 @@ snapshots:
|
||||
|
||||
lodash@4.17.23: {}
|
||||
|
||||
loglevel@1.9.2: {}
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
lru-cache@11.2.4: {}
|
||||
@@ -9427,6 +9498,11 @@ snapshots:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
rxjs@7.8.2:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
sade@1.8.1:
|
||||
dependencies:
|
||||
mri: 1.2.0
|
||||
@@ -9458,6 +9534,10 @@ snapshots:
|
||||
|
||||
sax@1.4.4: {}
|
||||
|
||||
sdp-transform@2.15.0: {}
|
||||
|
||||
sdp@3.2.1: {}
|
||||
|
||||
semver@5.7.2: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
@@ -9982,6 +10062,10 @@ snapshots:
|
||||
possible-typed-array-names: 1.1.0
|
||||
reflect.getprototypeof: 1.0.10
|
||||
|
||||
typed-emitter@2.1.0:
|
||||
optionalDependencies:
|
||||
rxjs: 7.8.2
|
||||
|
||||
typescript-eslint@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
@@ -10090,6 +10174,10 @@ snapshots:
|
||||
|
||||
webidl-conversions@4.0.2: {}
|
||||
|
||||
webrtc-adapter@9.0.4:
|
||||
dependencies:
|
||||
sdp: 3.2.1
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
|
||||
@@ -422,6 +422,14 @@ body.keyboard-open .hide-on-keyboard {
|
||||
@apply cb cw fixed z-compose;
|
||||
}
|
||||
|
||||
.chat__compose-zone {
|
||||
@apply cb cw fixed z-compose;
|
||||
}
|
||||
|
||||
.chat__compose-zone .chat__compose-inner {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.chat__scroll-down {
|
||||
@apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
|
||||
type Props = {
|
||||
pubkey: string
|
||||
pubkey?: string
|
||||
class?: string
|
||||
size?: number
|
||||
url?: string
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import Lock from "@assets/icons/lock.svg?dataurl"
|
||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||
import Bookmark from "@assets/icons/bookmark.svg?dataurl"
|
||||
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl"
|
||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||
import {fly} from "@lib/transition"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
@@ -255,7 +255,7 @@
|
||||
<strong class="text-lg">Room Settings</strong>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={VolumeLoud} />
|
||||
<Icon icon={Bell} />
|
||||
<span>Notifications</span>
|
||||
</div>
|
||||
<input
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import {waitForThunkError, createRoom, editRoom, joinRoom} from "@welshman/app"
|
||||
import StickerSmileSquare from "@assets/icons/sticker-smile-square.svg?dataurl"
|
||||
import Hashtag from "@assets/icons/hashtag.svg?dataurl"
|
||||
import Volume from "@assets/icons/volume.svg?dataurl"
|
||||
import UploadMinimalistic from "@assets/icons/upload-minimalistic.svg?dataurl"
|
||||
import {preventDefault} from "@lib/html"
|
||||
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||
@@ -15,6 +16,7 @@
|
||||
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||
import {pushToast} from "@app/util/toast"
|
||||
import {uploadFile} from "@app/core/commands"
|
||||
import {deriveHasLivekit, getRoomType, RoomType} from "@app/core/state"
|
||||
|
||||
type Props = {
|
||||
url: string
|
||||
@@ -27,12 +29,25 @@
|
||||
const {url, header, footer, onsubmit, initialValues = makeRoomMeta()}: Props = $props()
|
||||
|
||||
const values = $state(initialValues)
|
||||
const relayHasLivekit = deriveHasLivekit(url)
|
||||
|
||||
const submit = async () => {
|
||||
const room = $state.snapshot(values)
|
||||
|
hodlbod marked this conversation as resolved
Outdated
|
||||
|
||||
if (roomType === RoomType.Voice && !$relayHasLivekit) {
|
||||
return pushToast({
|
||||
theme: "error",
|
||||
message: "This relay does not support voice rooms.",
|
||||
})
|
||||
}
|
||||
|
||||
room.livekit = roomType === RoomType.Voice
|
||||
|
||||
if (imageFile) {
|
||||
const {error, result} = await uploadFile(imageFile, {maxWidth: 256, maxHeight: 256})
|
||||
const {error, result} = await uploadFile(imageFile, {
|
||||
maxWidth: 256,
|
||||
maxHeight: 256,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return pushToast({theme: "error", message: error})
|
||||
@@ -76,6 +91,7 @@
|
||||
let loading = $state(false)
|
||||
let imageFile = $state<File | undefined>()
|
||||
let imagePreview = $state(initialValues.picture)
|
||||
let roomType = $state(getRoomType(initialValues))
|
||||
|
||||
const handleImageUpload = async (event: Event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
@@ -145,7 +161,7 @@
|
||||
{#if imagePreview}
|
||||
<ImageIcon src={imagePreview} alt="" class="rounded-lg" />
|
||||
{:else}
|
||||
<Icon icon={Hashtag} />
|
||||
<Icon icon={roomType === RoomType.Voice ? Volume : Hashtag} />
|
||||
{/if}
|
||||
<input bind:value={values.name} class="grow" type="text" />
|
||||
</label>
|
||||
@@ -161,6 +177,22 @@
|
||||
</label>
|
||||
{/snippet}
|
||||
</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>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" class="checkbox" bind:checked={values.isRestricted} />
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
<script lang="ts">
|
||||
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 ImageIcon from "@lib/components/ImageIcon.svelte"
|
||||
import {deriveRoom} from "@app/core/state"
|
||||
import {currentVoiceSession} from "@app/voice"
|
||||
|
||||
interface Props {
|
||||
h: string
|
||||
url: string
|
||||
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 isVoiceRoom = $derived($room.livekit)
|
||||
const isVoiceRoomActive = $derived(
|
||||
$currentVoiceSession?.url === url && $currentVoiceSession?.h === h,
|
||||
)
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
Now that we're showing the widget in the room, I think we can probably get rid of this change, what do you think? Now that we're showing the widget in the room, I think we can probably get rid of this change, what do you think?
mplorentz
commented
I'm confused - did you want to show the VoiceWidget only on the room page? Right now I have it showing in the SpaceMenu on desktop and both in SpaceMenu and on the room page on mobile. It seems like a better use of space on desktop to have it down in the corner than to stretch it across the whole width of the chat room. I'm confused - did you want to show the VoiceWidget *only* on the room page? Right now I have it showing in the SpaceMenu on desktop and both in SpaceMenu and on the room page on mobile. It seems like a better use of space on desktop to have it down in the corner than to stretch it across the whole width of the chat room.
mplorentz
commented
I guess that's orthogonal to the question of whether we want a different icon for voice rooms once you have joined them. I think it's a nice affordance if you are looking a the sidebar to be able to tell which room you are in without having to read the room name out of the voice widget. I guess that's orthogonal to the question of whether we want a different icon for voice rooms once you have joined them. I think it's a nice affordance if you are looking a the sidebar to be able to tell which room you are in without having to read the room name out of the voice widget.
hodlbod
commented
Yeah, I just mean we should just show the room's actual icon at the top of the page regardless of whether the room is active (the widget at the bottom will be indication enough) Yeah, I just mean we should just show the room's actual icon at the top of the page regardless of whether the room is active (the widget at the bottom will be indication enough)
|
||||
</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" />
|
||||
{:else}
|
||||
<Icon icon={Hashtag} {size} />
|
||||
<Icon icon={fallbackIcon} {size} />
|
||||
{/if}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
const room = deriveRoom(url, h)
|
||||
</script>
|
||||
|
||||
<span class="ellipsize {props.class}">
|
||||
<span class="ellipsize min-w-0 {props.class}">
|
||||
{$room?.name || h}
|
||||
</span>
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl"
|
||||
import AddCircle from "@assets/icons/add-circle.svg?dataurl"
|
||||
import ChatRound from "@assets/icons/chat-round.svg?dataurl"
|
||||
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl"
|
||||
import VolumeCross from "@assets/icons/volume-cross.svg?dataurl"
|
||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
@@ -38,6 +38,7 @@
|
||||
import SpaceReports from "@app/components/SpaceReports.svelte"
|
||||
import RoomCreate from "@app/components/RoomCreate.svelte"
|
||||
import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte"
|
||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||
import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte"
|
||||
import {
|
||||
ENABLE_ZAPS,
|
||||
@@ -45,6 +46,7 @@
|
||||
deriveSpaceMembers,
|
||||
deriveUserRooms,
|
||||
deriveOtherRooms,
|
||||
deriveOtherVoiceRooms,
|
||||
userSpaceUrls,
|
||||
hasNip29,
|
||||
deriveUserCanCreateRoom,
|
||||
@@ -68,6 +70,7 @@
|
||||
const calendarPath = makeSpacePath(url, "calendar")
|
||||
const userRooms = deriveUserRooms(url)
|
||||
const otherRooms = deriveOtherRooms(url)
|
||||
const otherVoiceRooms = deriveOtherVoiceRooms(url)
|
||||
const members = deriveSpaceMembers(url)
|
||||
const userIsAdmin = deriveUserIsSpaceAdmin(url)
|
||||
const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}])
|
||||
@@ -133,9 +136,9 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<div bind:this={element} class="flex h-full flex-col justify-between">
|
||||
<SecondaryNavSection class="pb-0">
|
||||
<div>
|
||||
<div bind:this={element} class="flex min-h-0 flex-1 flex-col">
|
||||
<SecondaryNavSection class="min-h-0 flex-1 flex flex-col overflow-hidden pb-0">
|
||||
<div class="flex-shrink-0">
|
||||
<Button
|
||||
class="flex w-full flex-col rounded-xl p-3 transition-all hover:bg-base-100"
|
||||
onclick={openMenu}>
|
||||
@@ -143,7 +146,7 @@
|
||||
<strong class="ellipsize flex items-center gap-1">
|
||||
<RelayName {url} />
|
||||
{#if $notificationSettings.push && !$shouldNotify}
|
||||
<Icon icon={VolumeCross} size={3} class="opacity-50" />
|
||||
<Icon icon={BellOff} size={3} class="opacity-50" />
|
||||
{/if}
|
||||
</strong>
|
||||
<Icon icon={AltArrowDown} />
|
||||
@@ -192,12 +195,12 @@
|
||||
<li>
|
||||
{#if $notificationSettings.push}
|
||||
<Button onclick={toggleSpaceNotifications}>
|
||||
<Icon icon={$shouldNotify ? VolumeLoud : VolumeCross} />
|
||||
<Icon icon={$shouldNotify ? Bell : BellOff} />
|
||||
{$shouldNotify ? "Turn off" : "Turn on"} notifications
|
||||
</Button>
|
||||
{:else}
|
||||
<Link href="/settings/alerts">
|
||||
<Icon icon={VolumeLoud} />
|
||||
<Icon icon={Bell} />
|
||||
Enable notifications
|
||||
</Link>
|
||||
{/if}
|
||||
@@ -219,8 +222,7 @@
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex max-h-[calc(100vh-150px)] min-h-0 flex-col gap-1 overflow-auto overflow-x-hidden">
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-1 overflow-auto overflow-x-hidden">
|
||||
{#if hasNip29($relay)}
|
||||
<SecondaryNavItem {replaceState} href={makeSpacePath(url, "recent")}>
|
||||
<Icon icon={History} /> Recent Activity
|
||||
@@ -252,14 +254,14 @@
|
||||
{/if}
|
||||
{#if hasNip29($relay)}
|
||||
{#if $userRooms.length > 0}
|
||||
<div class="h-2"></div>
|
||||
<div class="h-2 flex-shrink-0"></div>
|
||||
<SecondaryNavHeader>Your Rooms</SecondaryNavHeader>
|
||||
{/if}
|
||||
{#each $userRooms as h, i (h)}
|
||||
{#each $userRooms as h (h)}
|
||||
<SpaceMenuRoomItem notify {replaceState} {url} {h} />
|
||||
{/each}
|
||||
{#if $otherRooms.length > 0}
|
||||
<div class="h-2"></div>
|
||||
<div class="h-2 flex-shrink-0"></div>
|
||||
<SecondaryNavHeader>
|
||||
{#if $userRooms.length > 0}
|
||||
Other Rooms
|
||||
@@ -274,9 +276,16 @@
|
||||
<input bind:value={term} onblur={clearTerm} class="grow" />
|
||||
</label>
|
||||
{/if}
|
||||
{#each $roomSearch.searchValues(term) as h, i (h)}
|
||||
{#each $roomSearch.searchValues(term) as h (h)}
|
||||
<SpaceMenuRoomItem {replaceState} {url} {h} />
|
||||
{/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}
|
||||
<SecondaryNavItem {replaceState} onclick={addRoom}>
|
||||
<Icon icon={AddCircle} />
|
||||
@@ -284,9 +293,12 @@
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
I think we talked about putting voice rooms below, separate from text rooms, is that right? I can't remember, but I think it would make sense for any room with livekit support to go there instead of in the text room area regardless of whether the room has text support. I think we talked about putting voice rooms below, separate from text rooms, is that right? I can't remember, but I think it would make sense for any room with livekit support to go there instead of in the text room area regardless of whether the room has text support.
mplorentz
commented
I don't love the labels I landed on but see what you think of the current take: Or when the user has no favorited rooms it looks like: Idk if you want to say "Text Rooms" or "Chat Rooms" or something. Those feel quite wordy for menu headings to me though. I don't love the labels I landed on but see what you think of the current take:
"Your Rooms"
{ favorited voice and text}
"Other Rooms"
{ text rooms}
Voice Rooms
{ voice rooms}
Or when the user has no favorited rooms it looks like:
"Rooms"
{ text rooms}
Voice Rooms
{ voice rooms}
Idk if you want to say "Text Rooms" or "Chat Rooms" or something. Those feel quite wordy for menu headings to me though.
mplorentz
commented
This surfaces another weird consequence of rooms that are "livekit" but not "no-text": currently favoriting either the text or vioce channel pulls both into the "Your Rooms" section. This surfaces another weird consequence of rooms that are "livekit" but not "no-text": currently favoriting either the text or vioce channel pulls both into the "Your Rooms" section.
hodlbod
commented
That's probably ok, I like how the labels look. That's probably ok, I like how the labels look.
|
||||
{/if}
|
||||
<div class="h-5 flex-shrink-0"></div>
|
||||
</div>
|
||||
</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}>
|
||||
<SocketStatusIndicator {url} />
|
||||
</Button>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts">
|
||||
import VolumeCross from "@assets/icons/volume-cross.svg?dataurl"
|
||||
import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl"
|
||||
import Bell from "@assets/icons/bell.svg?dataurl"
|
||||
import BellOff from "@assets/icons/bell-off.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
|
||||
import VoiceRoomItem from "@app/components/VoiceRoomItem.svelte"
|
||||
import {deriveRoom, deriveShouldNotify, getRoomType, RoomType} from "@app/core/state"
|
||||
import {notifications} from "@app/util/notifications"
|
||||
import {makeRoomPath} from "@app/util/routes"
|
||||
import {deriveShouldNotify} from "@app/core/state"
|
||||
|
||||
interface Props {
|
||||
url: any
|
||||
@@ -17,18 +18,24 @@
|
||||
|
||||
const {url, h, notify = false, replaceState = false}: Props = $props()
|
||||
|
||||
const room = deriveRoom(url, h)
|
||||
const roomType = $derived(getRoomType($room))
|
||||
const path = makeRoomPath(url, h)
|
||||
const shouldNotifyForSpace = deriveShouldNotify(url)
|
||||
const shouldNotifyForRoom = deriveShouldNotify(url, h)
|
||||
const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace)
|
||||
</script>
|
||||
|
||||
<SecondaryNavItem
|
||||
href={path}
|
||||
{replaceState}
|
||||
notification={notify ? $notifications.has(path) : false}>
|
||||
<RoomNameWithImage {url} {h} />
|
||||
{#if showDifferenceIcon}
|
||||
<Icon icon={$shouldNotifyForRoom ? VolumeLoud : VolumeCross} size={4} class="opacity-50" />
|
||||
{/if}
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
I can see why this would be confusing, but we should figure out what to do about muted rooms still I can see why this would be confusing, but we should figure out what to do about muted rooms still
mplorentz
commented
Oh yes that's my mistake. How about we use a bell icon for notifications and speaker icon for voice rooms? Oh yes that's my mistake. How about we use a bell icon for notifications and speaker icon for voice rooms?
hodlbod
commented
sure, that makes sense sure, that makes sense
|
||||
</SecondaryNavItem>
|
||||
{#if roomType === RoomType.Voice}
|
||||
<VoiceRoomItem {url} {h} {replaceState} />
|
||||
{:else}
|
||||
<SecondaryNavItem
|
||||
href={path}
|
||||
{replaceState}
|
||||
notification={notify ? $notifications.has(path) : false}>
|
||||
<RoomNameWithImage {url} {h} />
|
||||
{#if showDifferenceIcon}
|
||||
<Icon icon={$shouldNotifyForRoom ? Bell : BellOff} size={4} class="opacity-50" />
|
||||
{/if}
|
||||
</SecondaryNavItem>
|
||||
{/if}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
Instead of this, we should just not render voice rooms on mobile Instead of this, we should just not render voice rooms on mobile
mplorentz
commented
I tried that, but realized that you could then join a call on desktop web, resize your browser to be small and then lose the UI to leave the call. As I type it out I realize that's too edgy of a case 😂 I will just hide it. I tried that, but realized that you could then join a call on desktop web, resize your browser to be small and then lose the UI to leave the call. As I type it out I realize that's too edgy of a case 😂 I will just hide it.
|
||||
|
||||
const handleClick = async () => {
|
||||
if (isActive) return
|
||||
|
||||
if (isJoining) {
|
||||
cancelJoinVoiceRoom()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
[nit] in situations like this, I like to overload the controller as the isJoining boolean and just check whether joinAbortController is undefined. One fewer variable to keep track of [nit] in situations like this, I like to overload the controller as the isJoining boolean and just check whether joinAbortController is undefined. One fewer variable to keep track of
|
||||
await joinVoiceRoom(url, h)
|
||||
} catch (e) {
|
||||
console.error("Failed to join voice room", e)
|
||||
pushToast({theme: "error", message: "Failed to join voice room"})
|
||||
}
|
||||
}
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
Why do the bots do this? I have seen this cause bugs more than once. Just do Why do the bots do this? I have seen this cause bugs more than once. Just do `e.message || String(e)`. Or, better yet, actually design the error types and don't put anything that gets thrown in a message to the user. Just tell them "Failed to join voice room" and log it if it's not an error we know what to do with.
mplorentz
commented
I am cleaning this up but I want to fully understand you. Can you explain how this causes bugs? As a non-javascript guy this doesn't look pretty but makes sense to me because I understand the current code risks showing raw error messages to the user (which doesn't bother me actually as long as it has a nice prefix like "Failed to join voice room"). Is there something worse that I am missing? I am cleaning this up but I want to fully understand you. Can you explain how this causes bugs? As a non-javascript guy this doesn't look pretty but makes sense to me because `e` could literally be any type and there is no way to write a catch block for a specific type, so it seems reasonably defensive to check the type before just grabbing the `message` property off of whatever might have been thrown. Or in the case where some library throws a String it seems reasonable to surface that.
I understand the current code risks showing raw error messages to the user (which doesn't bother me actually as long as it has a nice prefix like "Failed to join voice room"). Is there something worse that I am missing?
mplorentz
commented
Idk maybe it is stupid to think that some library would throw some random object with a Idk maybe it is stupid to think that some library would throw some random object with a `message` property, but anything seems possible when `node_modules` are involved 😓
mplorentz
commented
Oh interesting, Oh interesting, `e.message || String(e)` actually gives a typescript error: `'e' is of type 'unknown'.`. But AI found the `errorMessage()` function from `@lib/util` so I'll use that.
hodlbod
commented
The problem is when a pojo with a message gets thrown or rejected, which means the object gets cast to a string, which fails to show the message. This is the case when you probably actually want to show the message to the user, whereas showing an error's message is almost always confusing. Errors being completely untyped are a huge flaw in typescript, so I like to save throws for truly exceptional cases (https://effect.website/ does this more formally, and I think it's a good idea), in which case you want to log and recover if you can, and resolve/return everything else (even errors that are deferred to the caller). But I'm certainly not consistent about this.
The problem is when a pojo with a message gets thrown or rejected, which means the object gets cast to a string, which fails to show the message. This is the case when you probably actually want to show the message to the user, whereas showing an error's message is almost always confusing. Errors being completely untyped are a huge flaw in typescript, so I like to save throws for truly exceptional cases (https://effect.website/ does this more formally, and I think it's a good idea), in which case you want to log and recover if you can, and resolve/return everything else (even errors that are deferred to the caller). But I'm certainly not consistent about this.
`errorMessage` was introduced by an LLM too (via Tyson), I don't really believe in it either. I think we should just avoid propagating thrown errors to the user (I don't like the "Failed to do x: [technical jargon]" pattern. It helps to debug some, but not really in that many cases.
|
||||
|
||||
$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",
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
There's a typescript error here because pubkey may be undefined. Is there a reason for this or can we strengthen the type to guarantee it? There's a typescript error here because pubkey may be undefined. Is there a reason for this or can we strengthen the type to guarantee it?
mplorentz
commented
Yes, in the case where there is a voice participant in the room but we can't derive a Yes, in the case where there is a voice participant in the room but we can't derive a `pubkey` from their LiveKit identity string it seems important to still show that there is _someone_ in the room. I just wanted to reuse the default profile icon for that and this was my way of doing it.
|
||||
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>
|
||||
@@ -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"
|
||||
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
Don't do indirection like this, just call the actual function Don't do indirection like this, just call the actual function
hodlbod
commented
A stupid way to do this that I like would be A stupid way to do this that I like would be `ifLet($currentVoiceSession, ({url, h}) => pushModal(RoomDetail, {url, h}))` but you don't have to do that
mplorentz
commented
It is harder to read at first glance but It is harder to read at first glance but `ifLet` brings warm feelings from better programming languages so it's done.
|
||||
const roomName = $derived(
|
||||
$currentVoiceRoom ? displayRoom($currentVoiceRoom.url, $currentVoiceRoom.h) : "",
|
||||
)
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
$currentVoiceSession is better than $currentVoiceSession is better than `get`
|
||||
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"}
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
If we have room here, it might be good to label the buttons with mute/leave If we have room here, it might be good to label the buttons with mute/leave
mplorentz
commented
There is not a ton of room. How about a hover state? There is not a ton of room. How about a hover state?
hodlbod
commented
Yeah, that's a fine compromise. Yeah, that's a fine compromise.
|
||||
<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}
|
||||
@@ -125,6 +125,7 @@ import type {
|
||||
RelayProfile,
|
||||
PublishedList,
|
||||
PublishedRoomMeta,
|
||||
RoomMeta,
|
||||
List,
|
||||
Filter,
|
||||
} from "@welshman/util"
|
||||
@@ -146,6 +147,7 @@ import {
|
||||
displayProfileByPubkey,
|
||||
getProfile,
|
||||
} from "@welshman/app"
|
||||
import {checkRelayHasLivekit} from "$lib/livekit"
|
||||
import {readFeed} from "@lib/feeds"
|
||||
|
||||
export const fromCsv = (s: string) => (s || "").split(",").filter(identity)
|
||||
@@ -567,11 +569,19 @@ export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => {
|
||||
|
||||
// Rooms
|
||||
|
||||
export enum RoomType {
|
||||
Text = "text",
|
||||
Voice = "voice",
|
||||
}
|
||||
|
||||
export type Room = PublishedRoomMeta & {
|
||||
id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export const getRoomType = (room: RoomMeta): RoomType =>
|
||||
room.livekit ? RoomType.Voice : RoomType.Text
|
||||
|
||||
export const makeRoomId = (url: string, h: string) => `${url}'${h}`
|
||||
|
||||
export const splitRoomId = (id: string) => id.split("'")
|
||||
@@ -663,6 +673,30 @@ export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h
|
||||
|
||||
export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase()
|
||||
|
||||
export const deriveVoiceRooms = (url: string) =>
|
||||
derived(roomsById, $roomsById => {
|
||||
const set = new Set<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
|
||||
|
||||
export const groupListsByPubkey = deriveItemsByKey({
|
||||
@@ -752,17 +786,20 @@ export const deriveUserRooms = (url: string) =>
|
||||
})
|
||||
|
||||
export const deriveOtherRooms = (url: string) =>
|
||||
derived([deriveUserRooms(url), roomsByUrl], ([$userRooms, $roomsByUrl]) => {
|
||||
const rooms: string[] = []
|
||||
derived(
|
||||
[deriveUserRooms(url), deriveVoiceRooms(url), roomsByUrl],
|
||||
([$userRooms, voiceRooms, $roomsByUrl]) => {
|
||||
const rooms: string[] = []
|
||||
|
||||
for (const {h} of $roomsByUrl.get(url) || []) {
|
||||
if (!$userRooms.includes(h)) {
|
||||
rooms.push(h)
|
||||
for (const {h} of $roomsByUrl.get(url) || []) {
|
||||
if (!$userRooms.includes(h) && !voiceRooms.has(h)) {
|
||||
rooms.push(h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(roomComparator(url), uniq(rooms))
|
||||
})
|
||||
return sortBy(roomComparator(url), uniq(rooms))
|
||||
},
|
||||
)
|
||||
|
||||
// Space/room memberships
|
||||
|
||||
@@ -1165,6 +1202,12 @@ export const deriveSupportedMethods = simpleCache(([url]: [string]) => {
|
||||
})
|
||||
})
|
||||
|
||||
export const deriveHasLivekit = simpleCache(([url]: [string]) =>
|
||||
readable<boolean | undefined>(undefined, set => {
|
||||
checkRelayHasLivekit(url).then(has => set(has))
|
||||
}),
|
||||
)
|
||||
|
||||
export const deriveTimeout = (timeout: number) => {
|
||||
const store = writable<boolean>(false)
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
loadFeedsForPubkey,
|
||||
} from "@app/core/state"
|
||||
import {hasBlossomSupport} from "@app/core/commands"
|
||||
import {LIVEKIT_PARTICIPANTS} from "@app/voice"
|
||||
|
||||
// Utils
|
||||
|
||||
@@ -316,6 +317,12 @@ const syncSpace = (url: string, rooms: string[]) => {
|
||||
})
|
||||
}
|
||||
|
||||
pullAndListen({
|
||||
url,
|
||||
signal: controller.signal,
|
||||
filters: [{kinds: [LIVEKIT_PARTICIPANTS]}],
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
[nit] simpler would be [nit] simpler would be `writable(new Set<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}
|
||||
}
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
Use welshman's version instead (makeHttpAuth/makeHttpAuthHeader) Use welshman's version instead (makeHttpAuth/makeHttpAuthHeader)
|
||||
|
||||
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")
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
If we really need to propagate these errors (probably not, they're either triggered by a user action and shouldn't notify the user, or technical in nature), we should return an error instead of throwing one. As it is, we should just assert signer and let errors fly If we really need to propagate these errors (probably not, they're either triggered by a user action and shouldn't notify the user, or technical in nature), we should return an error instead of throwing one. As it is, we should just assert signer and let errors fly
mplorentz
commented
I tried this several different ways. I think it turned out ok but I added some util functions. Let me know what you think. I tried this several different ways. I think it turned out ok but I added some util functions. Let me know what you think.
|
||||
|
||||
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
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
This will never happen because there are no awaits between this and the previous check. The if (signal) is redundant too, we should just require the caller to provide a signal. This will never happen because there are no awaits between this and the previous check.
The if (signal) is redundant too, we should just require the caller to provide a signal.
|
||||
|
||||
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.",
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
too much of this noise too much of this noise
|
||||
}),
|
||||
whenAborted(signal),
|
||||
])
|
||||
} catch (e) {
|
||||
room.disconnect()
|
||||
|
hodlbod marked this conversation as resolved
Outdated
hodlbod
commented
aaaaa aaaaa
mplorentz
commented
haha, yes. Sorry I did do a self-review of this code but clearly I missed this function or my eyes glazed over while reading it. Cleaning it up. haha, yes. Sorry I did do a self-review of this code but clearly I missed this function or my eyes glazed over while reading it. Cleaning it up.
|
||||
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"})
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -34,7 +34,7 @@
|
||||
{href}
|
||||
{...restProps}
|
||||
data-sveltekit-replacestate={replaceState}
|
||||
class="{restProps.class} relative flex items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content"
|
||||
class="{restProps.class} relative flex flex-shrink-0 items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content"
|
||||
class:text-base-content={active}
|
||||
class:bg-base-100={active}>
|
||||
{@render children?.()}
|
||||
@@ -45,7 +45,7 @@
|
||||
{:else}
|
||||
<button
|
||||
{...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:bg-base-100={active}>
|
||||
{#if notification}
|
||||
|
||||
@@ -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)
|
||||
@@ -19,6 +19,36 @@ export const ucFirst = (s: string) => s.slice(0, 1).toUpperCase() + s.slice(1)
|
||||
|
||||
export const errorMessage = (err: unknown) => String(err).replace(/^.*Error: /, "")
|
||||
|
||||
export class AbortError extends Error {
|
||||
constructor() {
|
||||
super("Aborted")
|
||||
this.name = "AbortError"
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends Error {
|
||||
constructor(message = "Timed out") {
|
||||
super(message)
|
||||
this.name = "TimeoutError"
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a promise that rejects with AbortError when signal aborts. Use with Promise.race. */
|
||||
export const whenAborted = (signal?: AbortSignal) => {
|
||||
if (!signal) return new Promise<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[]) => {
|
||||
const url = new URL(base)
|
||||
|
||||
|
||||
@@ -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">
|
||||
<PrimaryNavSpaces />
|
||||
</div>
|
||||
<SecondaryNav class="!w-auto !flex flex-grow">
|
||||
<SecondaryNav class="!flex !min-h-0 !w-auto flex-grow pb-4">
|
||||
<SpaceMenu {url} />
|
||||
</SecondaryNav>
|
||||
{/if}
|
||||
|
||||
@@ -42,11 +42,15 @@
|
||||
decodeRelay,
|
||||
deriveRoom,
|
||||
deriveUserRoomMembershipStatus,
|
||||
getRoomType,
|
||||
MESSAGE_KINDS,
|
||||
MembershipStatus,
|
||||
PROTECTED,
|
||||
RoomType,
|
||||
userSettingsValues,
|
||||
} from "@app/core/state"
|
||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||
import {voiceState} from "@app/voice"
|
||||
import {makeFeed} from "@app/core/requests"
|
||||
import {popKey} from "@lib/implicit"
|
||||
import {checked} from "@app/util/notifications"
|
||||
@@ -58,6 +62,7 @@
|
||||
const lastChecked = $checked[$page.url.pathname]
|
||||
const url = decodeRelay(relay)
|
||||
const room = deriveRoom(url, h)
|
||||
const isVoiceRoom = $derived(getRoomType($room) === RoomType.Voice)
|
||||
const shouldProtect = canEnforceNip70(url)
|
||||
const membershipStatus = deriveUserRoomMembershipStatus(url, h)
|
||||
const at = $derived(parseInt($page.url.searchParams.get("at")!))
|
||||
@@ -358,8 +363,10 @@
|
||||
</script>
|
||||
|
||||
<SpaceBar>
|
||||
{#snippet title()}
|
||||
{#snippet icon()}
|
||||
<RoomImage {url} {h} />
|
||||
{/snippet}
|
||||
{#snippet title()}
|
||||
<RoomName {url} {h} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
@@ -442,52 +449,61 @@
|
||||
{/if}
|
||||
</PageContent>
|
||||
|
||||
<div class="chat__compose bg-base-200" bind:this={chatCompose}>
|
||||
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
|
||||
<!-- pass -->
|
||||
{:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted}
|
||||
<div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3">
|
||||
<p class="opacity-75">Only members are allowed to post to this room.</p>
|
||||
{#if !$room.isClosed}
|
||||
{#if $membershipStatus === MembershipStatus.Pending}
|
||||
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
|
||||
<Icon icon={ClockCircle} />
|
||||
Access Pending
|
||||
</Button>
|
||||
{:else}
|
||||
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
|
||||
{#if joining}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Icon icon={Login2} />
|
||||
{/if}
|
||||
Ask to Join
|
||||
</Button>
|
||||
<div
|
||||
class="chat__compose-zone flex flex-col gap-1 bg-base-200 md:flex-row md:gap-0"
|
||||
bind:this={chatCompose}>
|
||||
<div class="chat__compose-inner min-w-0 flex-1">
|
||||
{#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted}
|
||||
<!-- pass -->
|
||||
{:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted}
|
||||
<div class="bg-alt card m-4 flex flex-row items-center justify-between px-4 py-3">
|
||||
<p class="opacity-75">Only members are allowed to post to this room.</p>
|
||||
{#if !$room.isClosed}
|
||||
{#if $membershipStatus === MembershipStatus.Pending}
|
||||
<Button class="btn btn-neutral btn-sm" disabled={leaving} onclick={leave}>
|
||||
<Icon icon={ClockCircle} />
|
||||
Access Pending
|
||||
</Button>
|
||||
{:else}
|
||||
<Button class="btn btn-neutral btn-sm" disabled={joining} onclick={join}>
|
||||
{#if joining}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Icon icon={Login2} />
|
||||
{/if}
|
||||
Ask to Join
|
||||
</Button>
|
||||
{/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>
|
||||
{: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>
|
||||
|
||||
|
||||
[nit]
Stylistically I normally do something like: