From cd30774596613a1f9f0bc9c09d9f049b906cd101 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 23 Mar 2026 20:52:12 +0100 Subject: [PATCH] feat: show inline voice users in mobile sidebar --- .../components/layout/MobileSpacesScreen.tsx | 145 ++++++++++++++---- 1 file changed, 119 insertions(+), 26 deletions(-) diff --git a/packages/web/src/components/layout/MobileSpacesScreen.tsx b/packages/web/src/components/layout/MobileSpacesScreen.tsx index 48b17f7d..3831018e 100644 --- a/packages/web/src/components/layout/MobileSpacesScreen.tsx +++ b/packages/web/src/components/layout/MobileSpacesScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useCallback } from 'react'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore, getMyUserIdForOrigin } from '../../stores/spaceStore'; import type { TaggedSpace } from '../../stores/spaceStore'; @@ -15,6 +15,8 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'; import { TransferOwnershipModal } from '../modals/TransferOwnershipModal'; import { MobileFolderSheet } from './MobileFolderSheet'; import { useInstanceStore } from '../../stores/instanceStore'; +import { VoiceUserRow } from '../voice/VoiceUserRow'; +import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems'; type ResolvedItem = | { type: 'space'; space: TaggedSpace } @@ -40,7 +42,14 @@ export function MobileSpacesScreen() { const voiceUsers = useVoiceStore((s) => s.voiceUsers); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); - const setCurrentVoiceChannel = useVoiceStore((s) => s.setCurrentVoiceChannel); + const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); + const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds); + const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds); + const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds); + const participantMutes = useVoiceStore((s) => s.participantMutes); + const speakingUserIds = useVoiceStore((s) => s.speakingUserIds); + + const members = useSpaceStore((s) => s.members); const pushMobileScreen = useUIStore((s) => s.pushMobileScreen); const setMobileTab = useUIStore((s) => s.setMobileTab); @@ -62,6 +71,7 @@ export function MobileSpacesScreen() { const [deleteCategoryId, setDeleteCategoryId] = useState(null); const [transferModalSpaceId, setTransferModalSpaceId] = useState(null); const [openFolderId, setOpenFolderId] = useState(null); + const [voiceJoinChannelId, setVoiceJoinChannelId] = useState(null); // Sync selected space with store's current space useEffect(() => { @@ -169,7 +179,7 @@ export function MobileSpacesScreen() { const handleChannelTap = (channel: Channel) => { if (channel.type === 'voice') { - setCurrentVoiceChannel(channel.id); + setVoiceJoinChannelId(channel.id); return; } if (selectedSpaceId) { @@ -457,6 +467,41 @@ export function MobileSpacesScreen() { const canInvite = hasPermissionBit(myPerms, PermissionBits.CREATE_INVITE); const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE); + const handleVoiceUserContextMenu = useCallback( + (e: React.MouseEvent, userId: string, channelId: string) => { + if (userId === user?.id) return; + e.preventDefault(); + e.stopPropagation(); + + const modItems = buildVoiceModMenuItems(userId, channelId); + const items: ContextMenuItem[] = [...modItems]; + + if (modItems.length > 0) { + items.push({ key: 'mod-end-sep', type: 'separator' }); + } + + const isUserMuted = useVoiceStore.getState().participantMutes.get(userId) ?? false; + items.push({ + key: 'mute-user', + type: 'checkbox', + label: 'Mute User', + checked: isUserMuted, + onChange: (checked) => useVoiceStore.getState().setParticipantMute(userId, checked), + }); + + items.push({ key: 'vol-sep', type: 'separator' }); + + items.push({ + key: 'volume', + type: 'custom', + render: () => React.createElement(VolumeSliderItem, { userId }), + }); + + openContextMenu({ x: e.clientX, y: e.clientY }, items); + }, + [user?.id, openContextMenu], + ); + const renderChannelItem = (channel: Channel) => { const isVoice = channel.type === 'voice'; const isActive = !isVoice && currentChannelId === channel.id; @@ -466,32 +511,80 @@ export function MobileSpacesScreen() { const canView = hasPermissionBit(channelPermissions.get(channel.id), PermissionBits.VIEW_CHANNEL); if (canView === false) return null; + const spaceId = channelToSpaceMap.get(channel.id); + return ( - + + {/* Inline voice users */} {isVoice && vUsers.length > 0 && ( - {vUsers.length} +
+ {vUsers.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 hasCamera = wsStatus?.isCameraOn ?? false; + const isScreenSharing = wsStatus?.isScreenSharing ?? false; + const isSpaceMuted = spaceMutedUserIds.has(`${spaceId}:${userId}`); + const isSpaceDeafened = spaceDeafenedUserIds.has(`${spaceId}:${userId}`); + const isPermissionMuted = permissionMutedUserIds.has(`${spaceId}:${userId}`); + + return ( +
openModal('userProfile', { userId: member?.user.homeUserId ?? userId })} + onContextMenu={(e) => handleVoiceUserContextMenu(e, userId, channel.id)} + > + +
+ ); + })} +
)} - + ); };