From 8aa66816d8cedbd6a99fbc2e1b6636928c54af82 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:59:00 +0100 Subject: [PATCH] feat: add MobileVoiceJoinSheet bottom sheet for voice preview --- .../components/layout/MobileSpacesScreen.tsx | 18 ++ .../components/voice/MobileVoiceJoinSheet.tsx | 199 ++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 packages/web/src/components/voice/MobileVoiceJoinSheet.tsx diff --git a/packages/web/src/components/layout/MobileSpacesScreen.tsx b/packages/web/src/components/layout/MobileSpacesScreen.tsx index 3831018e..6928073b 100644 --- a/packages/web/src/components/layout/MobileSpacesScreen.tsx +++ b/packages/web/src/components/layout/MobileSpacesScreen.tsx @@ -16,7 +16,9 @@ import { TransferOwnershipModal } from '../modals/TransferOwnershipModal'; import { MobileFolderSheet } from './MobileFolderSheet'; import { useInstanceStore } from '../../stores/instanceStore'; import { VoiceUserRow } from '../voice/VoiceUserRow'; +import { MobileVoiceJoinSheet } from '../voice/MobileVoiceJoinSheet'; import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems'; +import { joinVoiceChannel } from '../../utils/voice'; type ResolvedItem = | { type: 'space'; space: TaggedSpace } @@ -192,6 +194,12 @@ export function MobileSpacesScreen() { }); }; + const handleVoiceJoin = useCallback((chId: string, _preMuted: boolean) => { + joinVoiceChannel(chId); + setVoiceJoinChannelId(null); + pushMobileScreen('voice'); + }, [pushMobileScreen]); + const toggleCategory = (categoryId: string) => { setCollapsedCategories(prev => { const next = new Set(prev); @@ -908,6 +916,16 @@ export function MobileSpacesScreen() { /> ); })()} + + {voiceJoinChannelId && selectedSpaceId && ( + c.id === voiceJoinChannelId)?.name || ''} + spaceId={selectedSpaceId} + onClose={() => setVoiceJoinChannelId(null)} + onJoin={handleVoiceJoin} + /> + )} ); } diff --git a/packages/web/src/components/voice/MobileVoiceJoinSheet.tsx b/packages/web/src/components/voice/MobileVoiceJoinSheet.tsx new file mode 100644 index 00000000..8ec18009 --- /dev/null +++ b/packages/web/src/components/voice/MobileVoiceJoinSheet.tsx @@ -0,0 +1,199 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { useSpaceStore } from '../../stores/spaceStore'; +import { VoiceUserRow } from './VoiceUserRow'; + +interface MobileVoiceJoinSheetProps { + channelId: string; + channelName: string; + spaceId: string; + onClose: () => void; + onJoin: (channelId: string, preMuted: boolean) => void; +} + +export function MobileVoiceJoinSheet({ + channelId, + channelName, + spaceId, + onClose, + onJoin, +}: MobileVoiceJoinSheetProps) { + const [preMuted, setPreMuted] = useState(false); + const [visible, setVisible] = useState(false); + + const voiceUsers = useVoiceStore((s) => s.voiceUsers); + const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); + const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); + const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds); + const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds); + const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); + const speakingUserIds = useVoiceStore((s) => s.speakingUserIds); + + const members = useSpaceStore((s) => s.members); + const channels = useSpaceStore((s) => s.channels); + + // Trigger slide-up animation on mount + useEffect(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setVisible(true); + }); + }); + }, []); + + const userIds = useMemo(() => voiceUsers.get(channelId) || [], [voiceUsers, channelId]); + + const userCount = userIds.length; + const userCountLabel = userCount === 1 ? '1 Person in Voice' : `${userCount} People in Voice`; + + // Determine if switching channels + const isSwitching = currentVoiceChannelId !== null && currentVoiceChannelId !== channelId; + const currentChannelName = useMemo(() => { + if (!currentVoiceChannelId) return ''; + const ch = channels.find((c) => c.id === currentVoiceChannelId); + return ch?.name || ''; + }, [currentVoiceChannelId, channels]); + + const handleJoin = useCallback(() => { + onJoin(channelId, preMuted); + }, [channelId, preMuted, onJoin]); + + const handleBackdropClick = useCallback(() => { + onClose(); + }, [onClose]); + + return createPortal( + <> + {/* Backdrop */} +
+ + {/* Sheet container */} +
+ {/* Drag handle */} +
+ + {/* Header */} +
+

{channelName}

+
+ + {/* User count */} + {userCount > 0 && ( +

{userCountLabel}

+ )} + + {/* Channel switch warning */} + {isSwitching && ( +
+ You'll leave {currentChannelName} and join{' '} + {channelName} +
+ )} + + {/* User list */} + {userCount > 0 && ( +
+
+ {userIds.map((userId) => { + const member = members.find((m) => m.userId === userId); + const displayName = + member?.user.displayName ?? member?.user.username ?? userId; + const avatar = member?.user.avatar ?? null; + const avatarColor = member?.user.avatarColor; + const wsStatus = voiceUserStates.get(userId); + const isMuted = wsStatus?.isMuted ?? false; + const isDeafened = wsStatus?.isDeafened ?? false; + const isCameraOn = wsStatus?.isCameraOn ?? false; + const isScreenSharing = wsStatus?.isScreenSharing ?? false; + const isSpaceMuted = spaceMutedUserIds.has(`${spaceId}:${userId}`); + const isSpaceDeafened = spaceDeafenedUserIds.has(`${spaceId}:${userId}`); + const isPermMuted = permissionMutedUserIds.has(`${spaceId}:${userId}`); + + return ( +
+ +
+ ); + })} +
+
+ )} + + {/* Empty state */} + {userCount === 0 && ( +
+

No one is in this channel yet.

+

Be the first to join!

+
+ )} + + {/* Bottom action bar */} +
+ {/* Mic toggle */} + + + {/* Join Voice button */} + + + {/* Chat button — navigates to associated text channel (placeholder for future) */} + +
+
+ , + document.body, + ); +}