feat: show inline voice users in mobile sidebar

This commit is contained in:
Jannis Braun
2026-03-23 20:52:12 +01:00
parent 7c1ec5e846
commit cd30774596
@@ -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<string | null>(null);
const [transferModalSpaceId, setTransferModalSpaceId] = useState<string | null>(null);
const [openFolderId, setOpenFolderId] = useState<string | null>(null);
const [voiceJoinChannelId, setVoiceJoinChannelId] = useState<string | null>(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,11 +511,14 @@ 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 (
<div key={channel.id}>
<button
key={channel.id}
onClick={() => handleChannelTap(channel)}
onContextMenu={(e) => handleChannelContextMenu(e, channel.id)}
data-context-menu={`channel-${channel.id}`}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
isActive ? 'bg-interactive-selected text-txt-primary' :
isInVoice ? 'bg-accent-mint/10 text-accent-mint' :
@@ -488,10 +536,55 @@ export function MobileSpacesScreen() {
{channel.name}
</span>
{isUnread && <span className="w-2 h-2 rounded-full bg-txt-primary shrink-0" />}
{isVoice && vUsers.length > 0 && (
<span className="text-xs text-txt-tertiary">{vUsers.length}</span>
)}
</button>
{/* Inline voice users */}
{isVoice && vUsers.length > 0 && (
<div className="ml-7 mt-0.5 space-y-0.5">
{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 (
<div
key={userId}
data-context-menu={`voice-user-${userId}`}
className="px-3 py-1 rounded-lg hover:bg-interactive-hover transition-colors"
onClick={() => openModal('userProfile', { userId: member?.user.homeUserId ?? userId })}
onContextMenu={(e) => handleVoiceUserContextMenu(e, userId, channel.id)}
>
<VoiceUserRow
userId={member?.user.homeUserId ?? userId}
displayName={displayName}
avatar={avatar}
avatarColor={avatarColor ?? undefined}
isMuted={isMuted}
isDeafened={isDeafened}
isCameraOn={hasCamera}
isScreenSharing={isScreenSharing}
isServerMuted={isSpaceMuted}
isServerDeafened={isSpaceDeafened}
isPermissionMuted={isPermissionMuted}
isLocallyMuted={userId !== user?.id && (participantMutes.get(userId) ?? false)}
isSpeaking={speakingUserIds.has(userId)}
size="compact"
/>
</div>
);
})}
</div>
)}
</div>
);
};