feat: show inline voice users in mobile sidebar
This commit is contained in:
@@ -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 { useUIStore } from '../../stores/uiStore';
|
||||||
import { useSpaceStore, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
||||||
import type { TaggedSpace } from '../../stores/spaceStore';
|
import type { TaggedSpace } from '../../stores/spaceStore';
|
||||||
@@ -15,6 +15,8 @@ import { ConfirmDialog } from '../ui/ConfirmDialog';
|
|||||||
import { TransferOwnershipModal } from '../modals/TransferOwnershipModal';
|
import { TransferOwnershipModal } from '../modals/TransferOwnershipModal';
|
||||||
import { MobileFolderSheet } from './MobileFolderSheet';
|
import { MobileFolderSheet } from './MobileFolderSheet';
|
||||||
import { useInstanceStore } from '../../stores/instanceStore';
|
import { useInstanceStore } from '../../stores/instanceStore';
|
||||||
|
import { VoiceUserRow } from '../voice/VoiceUserRow';
|
||||||
|
import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems';
|
||||||
|
|
||||||
type ResolvedItem =
|
type ResolvedItem =
|
||||||
| { type: 'space'; space: TaggedSpace }
|
| { type: 'space'; space: TaggedSpace }
|
||||||
@@ -40,7 +42,14 @@ export function MobileSpacesScreen() {
|
|||||||
|
|
||||||
const voiceUsers = useVoiceStore((s) => s.voiceUsers);
|
const voiceUsers = useVoiceStore((s) => s.voiceUsers);
|
||||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
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 pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
|
||||||
const setMobileTab = useUIStore((s) => s.setMobileTab);
|
const setMobileTab = useUIStore((s) => s.setMobileTab);
|
||||||
@@ -62,6 +71,7 @@ export function MobileSpacesScreen() {
|
|||||||
const [deleteCategoryId, setDeleteCategoryId] = useState<string | null>(null);
|
const [deleteCategoryId, setDeleteCategoryId] = useState<string | null>(null);
|
||||||
const [transferModalSpaceId, setTransferModalSpaceId] = useState<string | null>(null);
|
const [transferModalSpaceId, setTransferModalSpaceId] = useState<string | null>(null);
|
||||||
const [openFolderId, setOpenFolderId] = 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
|
// Sync selected space with store's current space
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -169,7 +179,7 @@ export function MobileSpacesScreen() {
|
|||||||
|
|
||||||
const handleChannelTap = (channel: Channel) => {
|
const handleChannelTap = (channel: Channel) => {
|
||||||
if (channel.type === 'voice') {
|
if (channel.type === 'voice') {
|
||||||
setCurrentVoiceChannel(channel.id);
|
setVoiceJoinChannelId(channel.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedSpaceId) {
|
if (selectedSpaceId) {
|
||||||
@@ -457,6 +467,41 @@ export function MobileSpacesScreen() {
|
|||||||
const canInvite = hasPermissionBit(myPerms, PermissionBits.CREATE_INVITE);
|
const canInvite = hasPermissionBit(myPerms, PermissionBits.CREATE_INVITE);
|
||||||
const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE);
|
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 renderChannelItem = (channel: Channel) => {
|
||||||
const isVoice = channel.type === 'voice';
|
const isVoice = channel.type === 'voice';
|
||||||
const isActive = !isVoice && currentChannelId === channel.id;
|
const isActive = !isVoice && currentChannelId === channel.id;
|
||||||
@@ -466,11 +511,14 @@ export function MobileSpacesScreen() {
|
|||||||
const canView = hasPermissionBit(channelPermissions.get(channel.id), PermissionBits.VIEW_CHANNEL);
|
const canView = hasPermissionBit(channelPermissions.get(channel.id), PermissionBits.VIEW_CHANNEL);
|
||||||
if (canView === false) return null;
|
if (canView === false) return null;
|
||||||
|
|
||||||
|
const spaceId = channelToSpaceMap.get(channel.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div key={channel.id}>
|
||||||
<button
|
<button
|
||||||
key={channel.id}
|
|
||||||
onClick={() => handleChannelTap(channel)}
|
onClick={() => handleChannelTap(channel)}
|
||||||
onContextMenu={(e) => handleChannelContextMenu(e, channel.id)}
|
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 ${
|
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' :
|
isActive ? 'bg-interactive-selected text-txt-primary' :
|
||||||
isInVoice ? 'bg-accent-mint/10 text-accent-mint' :
|
isInVoice ? 'bg-accent-mint/10 text-accent-mint' :
|
||||||
@@ -488,10 +536,55 @@ export function MobileSpacesScreen() {
|
|||||||
{channel.name}
|
{channel.name}
|
||||||
</span>
|
</span>
|
||||||
{isUnread && <span className="w-2 h-2 rounded-full bg-txt-primary shrink-0" />}
|
{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>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user