diff --git a/.gitignore b/.gitignore index e65071e4..e49b0404 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ android/app/src/main/assets/public/ # Web/JavaScript node_modules/ +.pnpm-store/ build/ .svelte-kit/ diff --git a/Dockerfile b/Dockerfile index 18f909db..780ce934 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,8 @@ FROM node:20-bookworm AS builder +RUN apt-get update && apt-get install -y --no-install-recommends curl + RUN npm install -g pnpm@latest WORKDIR /app diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 64ff6fe5..243166be 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -42,4 +42,6 @@ + + diff --git a/capacitor.config.ts b/capacitor.config.ts index 460d5749..79d2e75b 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -4,6 +4,9 @@ const config: CapacitorConfig = { appId: "social.flotilla", appName: "Flotilla", webDir: "build", + ios: { + scheme: "Flotilla Chat", + }, android: { adjustMarginsForEdgeToEdge: true, }, diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index 79214e9f..6d05083f 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -20,8 +20,16 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS + NSMicrophoneUsageDescription + Flotilla uses the microphone for voice chat in rooms. + UIBackgroundModes + + remote-notification + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -47,11 +55,5 @@ UIViewControllerBasedStatusBarAppearance - ITSAppUsesNonExemptEncryption - - UIBackgroundModes - - remote-notification - diff --git a/package.json b/package.json index 6e91a8fc..d2369b2d 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "fuse.js": "^7.1.0", "husky": "^9.1.7", "idb": "^8.0.3", + "livekit-client": "^2.17.2", "nostr-signer-capacitor-plugin": "github:coracle-social/nostr-signer-capacitor-plugin#main", "nostr-tools": "^2.19.4", "prettier-plugin-tailwindcss": "^0.6.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c48e9fa4..4bd063e0 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/src/app.css b/src/app.css index a51f10f5..ae198f34 100644 --- a/src/app.css +++ b/src/app.css @@ -422,6 +422,14 @@ body.keyboard-open .hide-on-keyboard { @apply cb cw fixed z-compose; } +.chat__compose-zone { + @apply cb cw fixed z-compose; +} + +.chat__compose-zone .chat__compose-inner { + @apply min-w-0; +} + .chat__scroll-down { @apply pb-sai fixed bottom-28 right-4 z-feature md:bottom-16; } diff --git a/src/app/components/ProfileCircle.svelte b/src/app/components/ProfileCircle.svelte index 255cbffe..49f769b2 100644 --- a/src/app/components/ProfileCircle.svelte +++ b/src/app/components/ProfileCircle.svelte @@ -6,7 +6,7 @@ import ImageIcon from "@lib/components/ImageIcon.svelte" type Props = { - pubkey: string + pubkey?: string class?: string size?: number url?: string diff --git a/src/app/components/RoomDetail.svelte b/src/app/components/RoomDetail.svelte index 03cf406c..69746d90 100644 --- a/src/app/components/RoomDetail.svelte +++ b/src/app/components/RoomDetail.svelte @@ -16,7 +16,7 @@ import Lock from "@assets/icons/lock.svg?dataurl" import Microphone from "@assets/icons/microphone.svg?dataurl" import Bookmark from "@assets/icons/bookmark.svg?dataurl" - import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl" + import Bell from "@assets/icons/bell.svg?dataurl" import {fly} from "@lib/transition" import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" @@ -255,7 +255,7 @@ Room Settings
- + Notifications
{ const room = $state.snapshot(values) + if (roomType === RoomType.Voice && !$relayHasLivekit) { + return pushToast({ + theme: "error", + message: "This relay does not support voice rooms.", + }) + } + + room.livekit = roomType === RoomType.Voice + if (imageFile) { - const {error, result} = await uploadFile(imageFile, {maxWidth: 256, maxHeight: 256}) + const {error, result} = await uploadFile(imageFile, { + maxWidth: 256, + maxHeight: 256, + }) if (error) { return pushToast({theme: "error", message: error}) @@ -76,6 +91,7 @@ let loading = $state(false) let imageFile = $state() let imagePreview = $state(initialValues.picture) + let roomType = $state(getRoomType(initialValues)) const handleImageUpload = async (event: Event) => { const file = (event.target as HTMLInputElement).files?.[0] @@ -145,7 +161,7 @@ {#if imagePreview} {:else} - + {/if} @@ -161,6 +177,22 @@ {/snippet} + {#if $relayHasLivekit} + + {#snippet label()} +

Room type

+ {/snippet} + {#snippet input()} + + {/snippet} +
+ {/if} Permissions
diff --git a/src/app/components/RoomImage.svelte b/src/app/components/RoomImage.svelte index 656cf664..53843ff0 100644 --- a/src/app/components/RoomImage.svelte +++ b/src/app/components/RoomImage.svelte @@ -1,22 +1,41 @@ -{#if $room.picture} +{#if isVoiceRoom} +
+ + {#if $room.picture} + / + + {/if} +
+{:else if $room.picture} {:else} - + {/if} diff --git a/src/app/components/RoomName.svelte b/src/app/components/RoomName.svelte index f657a6b7..a65fb17f 100644 --- a/src/app/components/RoomName.svelte +++ b/src/app/components/RoomName.svelte @@ -12,6 +12,6 @@ const room = deriveRoom(url, h) - + {$room?.name || h} diff --git a/src/app/components/SpaceMenu.svelte b/src/app/components/SpaceMenu.svelte index 540fa88c..7b457907 100644 --- a/src/app/components/SpaceMenu.svelte +++ b/src/app/components/SpaceMenu.svelte @@ -20,8 +20,8 @@ import CaseMinimalistic from "@assets/icons/case-minimalistic.svg?dataurl" import AddCircle from "@assets/icons/add-circle.svg?dataurl" import ChatRound from "@assets/icons/chat-round.svg?dataurl" - import VolumeLoud from "@assets/icons/volume-loud.svg?dataurl" - import VolumeCross from "@assets/icons/volume-cross.svg?dataurl" + import Bell from "@assets/icons/bell.svg?dataurl" + import BellOff from "@assets/icons/bell-off.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Link from "@lib/components/Link.svelte" import Button from "@lib/components/Button.svelte" @@ -38,6 +38,7 @@ import SpaceReports from "@app/components/SpaceReports.svelte" import RoomCreate from "@app/components/RoomCreate.svelte" import SpaceMenuRoomItem from "@app/components/SpaceMenuRoomItem.svelte" + import VoiceWidget from "@app/components/VoiceWidget.svelte" import SocketStatusIndicator from "@app/components/SocketStatusIndicator.svelte" import { ENABLE_ZAPS, @@ -45,6 +46,7 @@ deriveSpaceMembers, deriveUserRooms, deriveOtherRooms, + deriveOtherVoiceRooms, userSpaceUrls, hasNip29, deriveUserCanCreateRoom, @@ -68,6 +70,7 @@ const calendarPath = makeSpacePath(url, "calendar") const userRooms = deriveUserRooms(url) const otherRooms = deriveOtherRooms(url) + const otherVoiceRooms = deriveOtherVoiceRooms(url) const members = deriveSpaceMembers(url) const userIsAdmin = deriveUserIsSpaceAdmin(url) const reports = deriveEventsForUrl(url, [{kinds: [REPORT]}]) @@ -133,9 +136,9 @@ }) -
- -
+
+ +
{:else} - + Enable notifications {/if} @@ -219,8 +222,7 @@ {/if}
-
+
{#if hasNip29($relay)} Recent Activity @@ -252,14 +254,14 @@ {/if} {#if hasNip29($relay)} {#if $userRooms.length > 0} -
+
Your Rooms {/if} - {#each $userRooms as h, i (h)} + {#each $userRooms as h (h)} {/each} {#if $otherRooms.length > 0} -
+
{#if $userRooms.length > 0} Other Rooms @@ -274,9 +276,16 @@ {/if} - {#each $roomSearch.searchValues(term) as h, i (h)} + {#each $roomSearch.searchValues(term) as h (h)} {/each} + {#if $otherVoiceRooms.length > 0} +
+ Voice Rooms + {#each $otherVoiceRooms as h (h)} + + {/each} + {/if} {#if $canCreateRoom} @@ -284,9 +293,12 @@ {/if} {/if} +
-
+
+ diff --git a/src/app/components/SpaceMenuRoomItem.svelte b/src/app/components/SpaceMenuRoomItem.svelte index 992f5142..d173498e 100644 --- a/src/app/components/SpaceMenuRoomItem.svelte +++ b/src/app/components/SpaceMenuRoomItem.svelte @@ -1,12 +1,13 @@ - - - {#if showDifferenceIcon} - - {/if} - +{#if roomType === RoomType.Voice} + +{:else} + + + {#if showDifferenceIcon} + + {/if} + +{/if} diff --git a/src/app/components/VoiceRoomItem.svelte b/src/app/components/VoiceRoomItem.svelte new file mode 100644 index 00000000..5676c314 --- /dev/null +++ b/src/app/components/VoiceRoomItem.svelte @@ -0,0 +1,91 @@ + + + +
+
+ {#if isJoining} + + {:else} + + {/if} + +
+ {#if $participants.length > 0} + {#each $participants as p (participantKey(p as VoiceParticipant))} +
+
+ +
+ + {p.pubkey ? displayProfileByPubkey(p.pubkey) : "Unknown"} + +
+ {/each} + {/if} +
+
diff --git a/src/app/components/VoiceWidget.svelte b/src/app/components/VoiceWidget.svelte new file mode 100644 index 00000000..ae3f3eb5 --- /dev/null +++ b/src/app/components/VoiceWidget.svelte @@ -0,0 +1,79 @@ + + +{#if $currentVoiceRoom} +
+
+ {#if $voiceState === "joining"} + Joining... + {:else if $voiceState === "connected"} + Voice Connected + {:else} + Disconnected + {/if} + + {roomName} / {spaceName} + +
+
+ {#if $voiceState === "joining"} + + + {:else if $voiceState === "connected" && $currentVoiceSession} + + + {:else} + + {/if} +
+
+{/if} diff --git a/src/app/core/state.ts b/src/app/core/state.ts index 7e64a12f..04a7751b 100644 --- a/src/app/core/state.ts +++ b/src/app/core/state.ts @@ -125,6 +125,7 @@ import type { RelayProfile, PublishedList, PublishedRoomMeta, + RoomMeta, List, Filter, } from "@welshman/util" @@ -146,6 +147,7 @@ import { displayProfileByPubkey, getProfile, } from "@welshman/app" +import {checkRelayHasLivekit} from "$lib/livekit" import {readFeed} from "@lib/feeds" export const fromCsv = (s: string) => (s || "").split(",").filter(identity) @@ -567,11 +569,19 @@ export const chatSearch = derived(throttled(800, chatsById), $chatsByPubkey => { // Rooms +export enum RoomType { + Text = "text", + Voice = "voice", +} + export type Room = PublishedRoomMeta & { id: string url: string } +export const getRoomType = (room: RoomMeta): RoomType => + room.livekit ? RoomType.Voice : RoomType.Text + export const makeRoomId = (url: string, h: string) => `${url}'${h}` export const splitRoomId = (id: string) => id.split("'") @@ -663,6 +673,30 @@ export const displayRoom = (url: string, h: string) => getRoom(makeRoomId(url, h export const roomComparator = (url: string) => (h: string) => displayRoom(url, h).toLowerCase() +export const deriveVoiceRooms = (url: string) => + derived(roomsById, $roomsById => { + const set = new Set() + for (const room of $roomsById.values()) { + if (room.url === url && room.livekit) { + set.add(room.h) + } + } + return set + }) + +export const deriveOtherVoiceRooms = (url: string) => + derived([deriveVoiceRooms(url), deriveUserRooms(url)], ([$roomsWithLivekit, $userRooms]) => { + const rooms: string[] = [] + + for (const h of $roomsWithLivekit) { + if (!$userRooms.includes(h)) { + rooms.push(h) + } + } + + return sortBy(roomComparator(url), uniq(rooms)) + }) + // User space/room lists export const groupListsByPubkey = deriveItemsByKey({ @@ -752,17 +786,20 @@ export const deriveUserRooms = (url: string) => }) export const deriveOtherRooms = (url: string) => - derived([deriveUserRooms(url), roomsByUrl], ([$userRooms, $roomsByUrl]) => { - const rooms: string[] = [] + derived( + [deriveUserRooms(url), deriveVoiceRooms(url), roomsByUrl], + ([$userRooms, voiceRooms, $roomsByUrl]) => { + const rooms: string[] = [] - for (const {h} of $roomsByUrl.get(url) || []) { - if (!$userRooms.includes(h)) { - rooms.push(h) + for (const {h} of $roomsByUrl.get(url) || []) { + if (!$userRooms.includes(h) && !voiceRooms.has(h)) { + rooms.push(h) + } } - } - return sortBy(roomComparator(url), uniq(rooms)) - }) + return sortBy(roomComparator(url), uniq(rooms)) + }, + ) // Space/room memberships @@ -1165,6 +1202,12 @@ export const deriveSupportedMethods = simpleCache(([url]: [string]) => { }) }) +export const deriveHasLivekit = simpleCache(([url]: [string]) => + readable(undefined, set => { + checkRelayHasLivekit(url).then(has => set(has)) + }), +) + export const deriveTimeout = (timeout: number) => { const store = writable(false) diff --git a/src/app/core/sync.ts b/src/app/core/sync.ts index 96eab24f..ebe46474 100644 --- a/src/app/core/sync.ts +++ b/src/app/core/sync.ts @@ -55,6 +55,7 @@ import { loadFeedsForPubkey, } from "@app/core/state" import {hasBlossomSupport} from "@app/core/commands" +import {LIVEKIT_PARTICIPANTS} from "@app/voice" // Utils @@ -316,6 +317,12 @@ const syncSpace = (url: string, rooms: string[]) => { }) } + pullAndListen({ + url, + signal: controller.signal, + filters: [{kinds: [LIVEKIT_PARTICIPANTS]}], + }) + return () => controller.abort() } diff --git a/src/app/voice.ts b/src/app/voice.ts new file mode 100644 index 00000000..40aa962c --- /dev/null +++ b/src/app/voice.ts @@ -0,0 +1,290 @@ +/** + * Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox + * (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS. + */ +import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client" +import {derived, get, writable} from "svelte/store" +import {map, removeUndefined, uniqBy} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" +import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util" +import {signer} from "@welshman/app" +import {getLivekitEndpoint} from "$lib/livekit" +import {AbortError, whenAborted, whenTimeout} from "$lib/util" +import {deriveLatestEventForUrl} from "@app/core/state" +import {pushToast} from "@app/util/toast" + +export const LIVEKIT_PARTICIPANTS = 39004 + +export {checkRelayHasLivekit} from "$lib/livekit" + +export type VoiceSession = { + url: string + h: string + room: Room + muted: boolean +} + +export type Pubkey = string + +export type VoiceParticipant = {pubkey?: Pubkey; identity: string} + +export type VoiceState = "joining" | "connected" | "disconnected" + +export const currentVoiceSession = writable(undefined) + +export const voiceState = writable("disconnected") + +export const currentVoiceRoom = writable<{url: string; h: string} | undefined>(undefined) + +export const participantPubkeyMap = writable>(new Map()) + +const addParticipant = (identity: string) => { + participantPubkeyMap.update(m => { + const next = new Map(m) + next.set(identity, pubkeyFromLiveKitIdentity(identity) ?? "") + return next + }) +} + +const deleteParticipant = (identity: string) => { + participantPubkeyMap.update(m => { + const next = new Map(m) + next.delete(identity) + return next + }) +} + +export const pubkeyFromLiveKitIdentity = (identity: string): string | undefined => + /^[a-f0-9]{64}$/.test(identity.slice(0, 64)) ? identity.slice(0, 64) : undefined + +export const participantFromLiveKitIdentity = (identity: string): VoiceParticipant => { + const pk = pubkeyFromLiveKitIdentity(identity) + return pk ? {pubkey: pk, identity} : {identity} +} + +export const participantKey = (p: VoiceParticipant) => p.pubkey ?? p.identity + +export const speakingParticipants = writable([]) + +export const isParticipantSpeaking = derived( + speakingParticipants, + $participants => (p: VoiceParticipant) => + $participants.some(sp => participantKey(sp) === participantKey(p)), +) + +const fetchLivekitToken = async ( + url: string, + groupId: string, + signal?: AbortSignal, +): Promise<{server_url: string; participant_token: string}> => { + const endpoint = getLivekitEndpoint(url, groupId) + + const $signer = signer.get() + if (!$signer) throw new Error("No signer available") + + if (signal?.aborted) throw new DOMException("Aborted", "AbortError") + + const template = await makeHttpAuth(endpoint, "GET") + const signedEvent = await $signer.sign(template) + const authHeader = makeHttpAuthHeader(signedEvent) + + const response = await fetch(endpoint, { + headers: {Authorization: authHeader}, + signal, + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Token request failed (${response.status}): ${text}`) + } + + return response.json() +} + +export const deriveVoiceParticipants = (url: string, h: string) => + // We use the livekit identity list while in a call, and fall back to the list in kind 39004. + derived( + [ + participantPubkeyMap, + currentVoiceRoom, + deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]), + ], + ([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => { + const inCall = + $participantPubkeyMap.size > 0 && + $currentVoiceRoom?.url === url && + $currentVoiceRoom?.h === h + + if (inCall) { + const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity) + return uniqBy((p: VoiceParticipant) => participantKey(p), participants) + } else { + const latestEvent = $publishedParticipantList as TrustedEvent | undefined + if (!latestEvent) return [] + const participants = removeUndefined( + map( + (tag: string[]) => (tag[1] ? participantFromLiveKitIdentity(tag[1]) : undefined), + getTags("participant", latestEvent.tags), + ), + ) + return uniqBy((p: VoiceParticipant) => participantKey(p), participants) + } + }, + ) + +const onRoomDisconnected = (reason?: DisconnectReason) => { + speakingParticipants.set([]) + participantPubkeyMap.set(new Map()) + currentVoiceSession.set(undefined) + if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) { + voiceState.set("disconnected") + const message = + reason === DisconnectReason.JOIN_FAILURE + ? "Could not connect to voice room. Please try again." + : "Voice connection lost." + pushToast({theme: "error", message}) + } +} + +const onTrackSubscribed = (track: Track) => { + if (track.kind === Track.Kind.Audio) { + const element = track.attach() + element.style.display = "none" + document.body.appendChild(element) + element.play().catch(() => {}) + } +} + +const onTrackUnsubscribed = (track: Track) => { + track.detach().forEach(el => el.remove()) +} + +const onActiveSpeakersChanged = (participants: {identity: string}[]) => { + speakingParticipants.set(participants.map(p => participantFromLiveKitIdentity(p.identity))) +} + +const playJoinSound = () => { + const audio = new Audio("/join-voice-room.mp3") + audio.play().catch(() => {}) +} + +const onParticipantConnected = (participant: {identity: string}) => { + addParticipant(participant.identity) + playJoinSound() +} + +const onParticipantDisconnected = (participant: {identity: string}) => { + deleteParticipant(participant.identity) +} + +let joinAbortController: AbortController | undefined + +export const cancelJoinVoiceRoom = () => { + joinAbortController?.abort() +} + +export const joinVoiceRoom = async (url: string, h: string): Promise => { + cancelJoinVoiceRoom() + + const session = get(currentVoiceSession) + if (session) await leaveVoiceRoom() + + currentVoiceRoom.set({url, h}) + voiceState.set("joining") + + const controller = new AbortController() + joinAbortController = controller + const signal = controller.signal + const isActive = () => joinAbortController === controller + + try { + const {server_url, participant_token} = await fetchLivekitToken(url, h, signal) + + if (signal.aborted) throw new AbortError() + + const room = new Room({adaptiveStream: true, dynacast: true}) + + room.on(RoomEvent.Disconnected, onRoomDisconnected) + room.on(RoomEvent.ParticipantConnected, onParticipantConnected) + room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) + room.on(RoomEvent.TrackSubscribed, onTrackSubscribed) + room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) + room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) + + try { + await Promise.race([ + room.connect(server_url, participant_token, {maxRetries: 0}), + whenTimeout(5_000, { + message: "Connection timed out. Please check your network and try again.", + }), + whenAborted(signal), + ]) + } catch (e) { + room.disconnect() + throw e + } + + participantPubkeyMap.set(new Map()) + addParticipant(room.localParticipant.identity) + for (const p of room.remoteParticipants.values()) { + addParticipant(p.identity) + } + + let muted = false + try { + await room.localParticipant.setMicrophoneEnabled(true) + } catch (e) { + muted = true + pushToast({theme: "error", message: "Could not access microphone"}) + } + + currentVoiceSession.set({url, h, room, muted}) + voiceState.set("connected") + playJoinSound() + } catch (e) { + if (isActive()) voiceState.set("disconnected") + if (e instanceof AbortError) return + throw e + } finally { + if (isActive()) joinAbortController = undefined + } +} + +export const leaveVoiceRoom = async () => { + const session = get(currentVoiceSession) + if (!session) return + + const audio = new Audio("/leave-voice-room.mp3") + audio.play().catch(() => {}) + + speakingParticipants.set([]) + participantPubkeyMap.set(new Map()) + voiceState.set("disconnected") + session.room.disconnect() + currentVoiceSession.set(undefined) +} + +export const rejoinVoiceRoom = () => { + const target = get(currentVoiceRoom) + if (target) joinVoiceRoom(target.url, target.h) +} + +export const toggleMute = async () => { + const session = get(currentVoiceSession) + if (!session) return + + const muted = !session.muted + if (muted) { + // Disable and re-enable microphone to trigger permission prompt + session.room.localParticipant.setMicrophoneEnabled(false) + currentVoiceSession.set({...session, muted}) + return + } + + try { + await session.room.localParticipant.setMicrophoneEnabled(true) + currentVoiceSession.set({...session, muted}) + } catch (e) { + pushToast({theme: "error", message: "Could not access microphone"}) + } +} diff --git a/src/assets/icons/microphone-off.svg b/src/assets/icons/microphone-off.svg new file mode 100644 index 00000000..2a90f378 --- /dev/null +++ b/src/assets/icons/microphone-off.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/lib/components/SecondaryNavItem.svelte b/src/lib/components/SecondaryNavItem.svelte index a61c7f0d..3dd7a401 100644 --- a/src/lib/components/SecondaryNavItem.svelte +++ b/src/lib/components/SecondaryNavItem.svelte @@ -34,7 +34,7 @@ {href} {...restProps} data-sveltekit-replacestate={replaceState} - class="{restProps.class} relative flex items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content" + class="{restProps.class} relative flex flex-shrink-0 items-center gap-3 text-left transition-all hover:bg-base-100 hover:text-base-content" class:text-base-content={active} class:bg-base-100={active}> {@render children?.()} @@ -45,7 +45,7 @@ {:else} - {:else} - +
+
+ {#if $room.isPrivate && $membershipStatus !== MembershipStatus.Granted} + + {:else if $room.isRestricted && $membershipStatus !== MembershipStatus.Granted} +
+

Only members are allowed to post to this room.

+ {#if !$room.isClosed} + {#if $membershipStatus === MembershipStatus.Pending} + + {:else} + + {/if} {/if} - {/if} +
+ {:else} +
+ {#if parent} + + {/if} + {#if share} + + {/if} + {#if eventToEdit} + + {/if} +
+ {#key eventToEdit} + + {/key} + {/if} +
+ {#if isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"} +
+
- {:else} -
- {#if parent} - - {/if} - {#if share} - - {/if} - {#if eventToEdit} - - {/if} -
- {#key eventToEdit} - - {/key} {/if}
diff --git a/static/join-voice-room.mp3 b/static/join-voice-room.mp3 new file mode 100644 index 00000000..ed4f91e6 Binary files /dev/null and b/static/join-voice-room.mp3 differ diff --git a/static/leave-voice-room.mp3 b/static/leave-voice-room.mp3 new file mode 100644 index 00000000..44cab189 Binary files /dev/null and b/static/leave-voice-room.mp3 differ