Files
fluxer/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx
T

1460 lines
52 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {showDmActionErrorModal} from '@app/features/app/components/alerts/DmActionErrorModal';
import {showVoiceMemberModerationFailedModal} from '@app/features/app/components/alerts/VoiceMemberModerationFailedModal';
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import Authentication from '@app/features/auth/state/Authentication';
import * as PrivateChannelCommands from '@app/features/channel/commands/PrivateChannelCommands';
import Channels from '@app/features/channel/state/Channels';
import * as VoiceStateCommands from '@app/features/devtools/commands/VoiceStateCommands';
import {UserDebugModal} from '@app/features/devtools/components/debug/UserDebugModal';
import type {VoiceState} from '@app/features/gateway/types/GatewayVoiceTypes';
import Guilds from '@app/features/guild/state/Guilds';
import {resolveGuildModerationCapabilities} from '@app/features/guild/utils/GuildModerationCapabilityUtils';
import {
ADD_NOTE_DESCRIPTOR,
CHANGE_FRIEND_NICKNAME_DESCRIPTOR,
CHANGE_NICKNAME_DESCRIPTOR,
COPY_USER_ID_DESCRIPTOR,
DEBUG_USER_DESCRIPTOR,
KICK_MEMBER_DESCRIPTOR,
OPEN_DM_DESCRIPTOR,
START_VOICE_CALL_DESCRIPTOR,
TURN_OFF_CAMERA_DESCRIPTOR,
USER_DEBUG_DESCRIPTOR,
VIEW_PROFILE_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import * as GuildMemberCommands from '@app/features/member/commands/GuildMemberCommands';
import type {GuildMember} from '@app/features/member/models/GuildMember';
import GuildMembers from '@app/features/member/state/GuildMembers';
import {BanMemberModal} from '@app/features/moderation/components/modals/BanMemberModal';
import {KickMemberModal} from '@app/features/moderation/components/modals/KickMemberModal';
import {RemoveTimeoutModal} from '@app/features/moderation/components/modals/RemoveTimeoutModal';
import {TimeoutMemberModal} from '@app/features/moderation/components/modals/TimeoutMemberModal';
import {
BLOCK_DESCRIPTOR,
REMOVE_TIMEOUT_DESCRIPTOR,
TIMEOUT_DESCRIPTOR,
} from '@app/features/moderation/utils/ModerationMessageDescriptors';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import {SoundType} from '@app/features/notification/utils/SoundUtils';
import {useRoleHierarchy} from '@app/features/permissions/hooks/useRoleHierarchy';
import Permission from '@app/features/permissions/state/Permission';
import * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import {ChangeFriendNicknameModal} from '@app/features/relationship/components/modals/ChangeFriendNicknameModal';
import Relationships from '@app/features/relationship/state/Relationships';
import * as RelationshipActionUtils from '@app/features/relationship/utils/RelationshipActionUtils';
import {
ACCEPT_FRIEND_REQUEST_DESCRIPTOR,
ADD_FRIEND_DESCRIPTOR,
IGNORE_FRIEND_REQUEST_DESCRIPTOR,
REMOVE_FRIEND_DESCRIPTOR,
UNBLOCK_USER_ACTION_DESCRIPTOR,
} from '@app/features/relationship/utils/RelationshipMessageDescriptors';
import {
AcceptFriendRequestIcon,
AddNoteIcon,
BanMemberIcon,
BlockUserIcon,
BulkTurnOffCameraIcon,
ChangeNicknameIcon,
CollapseIcon,
CopyIdIcon,
DebugIcon,
DisconnectIcon,
ExpandIcon,
FocusIcon,
GuildDeafenIcon,
GuildMuteIcon,
IgnoreFriendRequestIcon,
KickMemberIcon,
LocalDisableVideoIcon,
LocalMuteIcon,
MentionUserIcon,
MessageUserIcon,
PopOutIcon,
RemoveFriendIcon,
SelfDeafenIcon,
SelfMuteIcon,
SendFriendRequestIcon,
SettingsIcon,
TimeoutIcon,
TurnOffCameraIcon,
TurnOffStreamIcon,
UnfocusIcon,
ViewDetailsIcon,
ViewProfileIcon,
VoiceCallIcon,
} from '@app/features/ui/action_menu/ContextMenuIcons';
import {
normalizeVoiceParticipantMenuRequest,
type VoiceParticipantMenuRequest,
} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes';
import {buildVoiceParticipantStreamMenu} from '@app/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder';
import {
BAN_MEMBER_DESCRIPTOR,
BLOCKED_USER_DM_WARNING_DESCRIPTOR,
COLLAPSE_DEVICES_DESCRIPTOR,
CONNECTION_VOLUME_DESCRIPTOR,
COPY_DEVICE_ID_DESCRIPTOR,
DISABLE_VIDEO_LOCALLY_DESCRIPTOR,
FOCUS_THIS_DEVICE_DESCRIPTOR,
FOCUS_THIS_PERSON_DESCRIPTOR,
MENTION_DESCRIPTOR,
MESSAGE_DESCRIPTOR,
MUTE_DESCRIPTOR,
MUTE_DEVICE_DESCRIPTOR,
POP_OUT_CAMERA_DESCRIPTOR,
POP_OUT_USER_DESCRIPTOR,
PREVIEW_CAMERA_DESCRIPTOR,
SHOW_NON_VIDEO_PARTICIPANTS_DESCRIPTOR,
TURN_OFF_ALL_DEVICE_CAMERAS_DESCRIPTOR,
TURN_OFF_DEVICE_CAMERA_DESCRIPTOR,
TURN_OFF_DEVICE_STREAM_DESCRIPTOR,
TURN_OFF_STREAM_DESCRIPTOR,
UNFOCUS_DESCRIPTOR,
} from '@app/features/ui/action_menu/items/voice_participant_menu_data/shared';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import * as SoundCommands from '@app/features/ui/commands/SoundCommands';
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
import type {
MenuCheckboxType,
MenuGroupType,
MenuItemType,
MenuSliderType,
MenuSubmenuItemType,
} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
import * as UserProfileCommands from '@app/features/user/commands/UserProfileCommands';
import {ChangeNicknameModal} from '@app/features/user/components/modals/ChangeNicknameModal';
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
import type {User} from '@app/features/user/models/User';
import UserSettings from '@app/features/user/state/UserSettings';
import Users from '@app/features/user/state/Users';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import * as VoiceCallLayoutCommands from '@app/features/voice/commands/VoiceCallLayoutCommands';
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
import {CameraPreviewModalInRoom} from '@app/features/voice/components/modals/CameraPreviewModal';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import CallMediaPrefs from '@app/features/voice/state/CallMediaPrefs';
import EntranceSoundListenerPrefs from '@app/features/voice/state/EntranceSoundListenerPrefs';
import ParticipantVolume from '@app/features/voice/state/ParticipantVolume';
import PopoutWindowManager, {
isVoicePopoutSupported,
type VoiceTilePopoutSource,
} from '@app/features/voice/state/PopoutWindowManager';
import StreamAudioPrefs from '@app/features/voice/state/StreamAudioPrefs';
import VoiceCallLayout from '@app/features/voice/state/VoiceCallLayout';
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
import * as CallUtils from '@app/features/voice/utils/CallUtils';
import {hasActiveDirectCallWithUser} from '@app/features/voice/utils/PrivateCallMenuUtils';
import {
getVoiceVideoSettingsLabel,
VOICE_COMMUNITY_DEAFEN_DESCRIPTOR,
VOICE_COMMUNITY_MUTE_DESCRIPTOR,
VOICE_DEAFEN_ALL_DEVICES_DESCRIPTOR,
VOICE_DEAFEN_DESCRIPTOR,
VOICE_DEAFEN_DEVICE_DESCRIPTOR,
VOICE_DISCONNECT_ALL_DEVICES_DESCRIPTOR,
VOICE_DISCONNECT_DESCRIPTOR,
VOICE_DISCONNECT_DEVICE_DESCRIPTOR,
VOICE_MUTE_ALL_DEVICES_DESCRIPTOR,
VOICE_UNDEAFEN_ALL_DEVICES_DESCRIPTOR,
VOICE_UNMUTE_ALL_DEVICES_DESCRIPTOR,
VOICE_USER_VOLUME_DESCRIPTOR,
} from '@app/features/voice/utils/VoiceMessageDescriptors';
import {buildVoiceParticipantIdentity} from '@app/features/voice/utils/VoiceParticipantIdentity';
import {ChannelTypes, Permissions, TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import {msg, plural} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {Track} from 'livekit-client';
import {useCallback, useMemo} from 'react';
const logger = new Logger('VoiceParticipantMenuData');
const ENTRANCE_SOUND_SUBMENU_DESCRIPTOR = msg({
message: 'Entrance sound',
comment: 'Voice menu submenu label for per-user entrance sound controls.',
});
const MUTE_ENTRANCE_SOUND_DESCRIPTOR = msg({
message: 'Mute entrance sound',
comment: "Voice menu checkbox label that locally silences a user's entrance sound.",
});
const ENTRANCE_SOUND_VOLUME_DESCRIPTOR = msg({
message: 'Entrance sound volume',
comment: "Voice menu slider label for a user's entrance sound playback volume.",
});
type VoiceParticipantMenuLeafItem = MenuItemType | MenuCheckboxType | MenuSliderType | MenuSubmenuItemType;
export interface VoiceParticipantMenuDataOptions extends VoiceParticipantMenuRequest {
user: User;
guildId?: string;
connectionId?: string;
isGroupedItem?: boolean;
isParentGroupedItem?: boolean;
groupContainsLocalConnection?: boolean;
onClose: () => void;
hiddenConnectionCount?: number;
deviceConnectionCount?: number;
isDeviceGroupExpanded?: boolean;
onToggleDeviceGroup?: () => void;
}
export interface VoiceParticipantMenuData {
groups: Array<MenuGroupType>;
guildManagementGroupIndex: number;
member: GuildMember | null;
isCurrentUser: boolean;
developerMode: boolean;
relationshipType: number | undefined;
canMoveMembers: boolean;
userVoiceStates: Array<{connectionId: string; voiceState: VoiceState}>;
hasMultipleConnections: boolean;
voiceChannels: Array<{id: string; name: string}>;
hasVoiceChannels: boolean;
}
export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOptions): VoiceParticipantMenuData {
const {
user,
guildId,
connectionId,
isGroupedItem = false,
isParentGroupedItem = false,
groupContainsLocalConnection = false,
surface,
source: requestedSource,
onClose,
hiddenConnectionCount = 0,
deviceConnectionCount = 0,
isDeviceGroupExpanded = false,
onToggleDeviceGroup,
} = options;
const source = normalizeVoiceParticipantMenuRequest({surface, source: requestedSource});
const streamKey = source.kind === 'screen-share' ? source.streamKey : undefined;
const {i18n} = useLingui();
const member = GuildMembers.getMember(guildId ?? '', user.id);
const isCurrentUser = user.id === Authentication.currentUserId;
const developerMode = UserSettings.developerMode;
const relationship = Relationships.getRelationship(user.id);
const relationshipType = relationship?.type;
const isBlocked = relationshipType === RelationshipTypes.BLOCKED;
const hasActiveDirectCall = hasActiveDirectCallWithUser(user.id);
const canMuteMembers = guildId ? Permission.can(Permissions.MUTE_MEMBERS, {guildId}) : false;
const canDeafenMembers = guildId ? Permission.can(Permissions.DEAFEN_MEMBERS, {guildId}) : false;
const canMoveMembers = guildId ? Permission.can(Permissions.MOVE_MEMBERS, {guildId}) : false;
const canKickMembers = guildId ? Permission.can(Permissions.KICK_MEMBERS, {guildId}) : false;
const canBanMembers = guildId ? Permission.can(Permissions.BAN_MEMBERS, {guildId}) : false;
const canModerateMembers = guildId ? Permission.can(Permissions.MODERATE_MEMBERS, {guildId}) : false;
const guild = guildId ? Guilds.getGuild(guildId) : null;
const {canManageTarget} = useRoleHierarchy(guild);
const guildSnapshot = guild?.toJSON();
const targetHasAdministratorPermission =
guildSnapshot !== undefined && PermissionUtils.can(Permissions.ADMINISTRATOR, user.id, guildSnapshot);
const {
canKick: canKickTarget,
canBan: canBanTarget,
canTimeout: canTimeoutTarget,
} = resolveGuildModerationCapabilities({
isCurrentUser,
canManageTarget: canManageTarget(user.id),
canKickMembers,
canBanMembers,
canModerateMembers,
targetHasAdministratorPermission,
});
const focusedChannelId = SelectedChannel.currentChannelId;
const focusedChannel = focusedChannelId ? Channels.getChannel(focusedChannelId) : null;
const isFocusedChannelTextBased = focusedChannel ? TEXT_BASED_CHANNEL_TYPES.has(focusedChannel.type) : false;
const canMention =
isFocusedChannelTextBased &&
focusedChannelId &&
Permission.can(Permissions.SEND_MESSAGES, {guildId: focusedChannel?.guildId, channelId: focusedChannelId});
const openDmChannel = useCallback(async () => {
try {
await PrivateChannelCommands.openDMChannel(user.id);
} catch (error) {
logger.error('Failed to open DM channel:', error);
showDmActionErrorModal(error);
}
}, [user.id]);
const handleMessage = useCallback(async () => {
onClose();
await openDmChannel();
}, [onClose, openDmChannel]);
const displayName = NicknameUtils.getNickname(user, guildId);
const handleOpenBlockedDm = useCallback(() => {
ModalCommands.pushAfterBottomSheetClose(
onClose,
modal(() => (
<ConfirmModal
title={i18n._(OPEN_DM_DESCRIPTOR)}
description={i18n._(BLOCKED_USER_DM_WARNING_DESCRIPTOR, {userName: displayName})}
primaryText={i18n._(OPEN_DM_DESCRIPTOR)}
primaryVariant="primary"
onPrimary={openDmChannel}
data-flx="ui.action-menu.items.voice-participant-menu-data.handle-open-blocked-dm.confirm-modal"
/>
)),
);
}, [onClose, openDmChannel, displayName, i18n]);
const currentUserVoiceStateInGuild = guildId
? MediaEngine.getCurrentUserVoiceState(guildId)
: MediaEngine.getCurrentUserVoiceState();
const isCurrentUserConnectedToVoice = currentUserVoiceStateInGuild !== null || MediaEngine.connectionId !== null;
const connectionVoiceState = connectionId ? MediaEngine.getVoiceStateByConnectionId(connectionId) : null;
const allVoiceStates = MediaEngine.getAllVoiceStates();
const targetHasVoiceStateInLocalRoom =
MediaEngine.channelId !== null &&
Object.values(allVoiceStates).some((guildData) =>
Object.values(guildData[MediaEngine.channelId ?? ''] ?? {}).some((voiceState) => voiceState.user_id === user.id),
);
const isTargetInLocalVoiceRoom =
MediaEngine.connected &&
MediaEngine.connectionId !== null &&
MediaEngine.channelId !== null &&
(connectionVoiceState?.channel_id === MediaEngine.channelId || (!connectionId && targetHasVoiceStateInLocalRoom));
const currentUserVoiceState = MediaEngine.getCurrentUserVoiceState();
const currentConnectionId = MediaEngine.connectionId;
const currentConnectionVoiceState = currentConnectionId
? MediaEngine.getVoiceStateByConnectionId(currentConnectionId)
: null;
const showMyOwnScreenShare = VoiceSettings.showMyOwnScreenShare;
const showNonVideoParticipants = VoiceSettings.showNonVideoParticipants;
const pauseOwnScreenSharePreviewOnUnfocus = VoiceSettings.pauseOwnScreenSharePreviewOnUnfocus;
const showConnectionVolumeControls = VoiceSettings.showConnectionVolumeControls;
const participantVolume = ParticipantVolume.getVolume(user.id);
const isParticipantLocallyMuted = ParticipantVolume.isLocalMuted(user.id);
const entranceSoundMuted = EntranceSoundListenerPrefs.isMuted(user.id);
const entranceSoundVolume = EntranceSoundListenerPrefs.getVolume(user.id);
const connectionVolume = connectionId ? ParticipantVolume.getConnectionVolume(connectionId) : 100;
const streamVolume = streamKey ? StreamAudioPrefs.getVolume(streamKey) : 100;
const isStreamMuted = streamKey ? StreamAudioPrefs.isMuted(streamKey) : false;
const callId = MediaEngine.connectionId ?? '';
const participantIdentity = connectionId ? buildVoiceParticipantIdentity(user.id, connectionId) : '';
const isVideoDisabled =
callId && participantIdentity ? CallMediaPrefs.isVideoDisabled(callId, participantIdentity) : false;
const pinnedParticipantIdentity = VoiceCallLayout.pinnedParticipantIdentity;
const pinnedParticipantSource = VoiceCallLayout.pinnedParticipantSource;
const memberMute = member?.mute ?? false;
const memberDeaf = member?.deaf ?? false;
const memberTimedOut = member?.isTimedOut() ?? false;
const userVoiceStates = useMemo(() => {
if (!guildId) return [] as Array<{connectionId: string; voiceState: VoiceState}>;
const acc: Array<{connectionId: string; voiceState: VoiceState}> = [];
Object.entries(allVoiceStates).forEach(([g, guildData]) => {
if (g === guildId) {
Object.entries(guildData).forEach(([, channelData]) => {
Object.entries(channelData).forEach(([cid, vs]) => {
if (vs.user_id === user.id) acc.push({connectionId: cid, voiceState: vs});
});
});
}
});
return acc;
}, [guildId, user.id, allVoiceStates]);
const hasMultipleConnections = userVoiceStates.length > 1;
const userVoiceState = guildId ? MediaEngine.getVoiceState(guildId, user.id) : null;
const voiceChannels = useMemo(() => {
if (!guildId) return [];
const channels = Channels.getGuildChannels(guildId);
return channels
.filter((channel) => {
if (channel.type !== ChannelTypes.GUILD_VOICE) return false;
if (userVoiceState?.channel_id === channel.id) return false;
return Permission.can(Permissions.CONNECT, {guildId, channelId: channel.id});
})
.map((channel) => ({id: channel.id, name: channel.name ?? ''}));
}, [guildId, userVoiceState?.channel_id]);
const menuAssembly = useMemo(() => {
const menuGroups: Array<MenuGroupType> = [];
const hasDeviceGroup = deviceConnectionCount > 1 || hasMultipleConnections;
const isDeviceScopedMenu = isGroupedItem || isParentGroupedItem;
const focusActions: Array<MenuItemType> = [];
const mediaActions: Array<MenuItemType | MenuCheckboxType> = [];
const deviceActions: Array<MenuItemType> = [];
const popoutActions: Array<MenuItemType> = [];
const displayActions: Array<MenuCheckboxType> = [];
const volumeActions: Array<MenuSliderType> = [];
const voiceActions: Array<VoiceParticipantMenuLeafItem> = [];
const relationshipActions: Array<MenuItemType> = [];
const moderationActions: Array<MenuItemType | MenuCheckboxType> = [];
const advancedActions: Array<VoiceParticipantMenuLeafItem> = [];
const createCopyDeviceIdAction = (targetConnectionId: string): MenuItemType => ({
icon: (
<CopyIdIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.create-copy-device-id-action.copy-id-icon"
/>
),
label: i18n._(COPY_DEVICE_ID_DESCRIPTOR),
onClick: () => {
TextCopyCommands.copy(i18n, targetConnectionId, true).catch((error) => {
logger.error('Failed to copy voice device id', {targetConnectionId, error});
});
onClose();
},
});
const buildUserVolumeSlider = (): MenuSliderType => ({
label: i18n._(VOICE_USER_VOLUME_DESCRIPTOR),
value: participantVolume,
minValue: 0,
maxValue: 200,
onChange: (value: number) => {
ParticipantVolume.setVolume(user.id, value);
if (isTargetInLocalVoiceRoom) {
MediaEngine.applyLocalAudioPreferencesForUser(user.id);
}
},
onFormat: (value: number) => `${Math.round(value)}%`,
factoryDefaultValue: 100,
});
const buildConnectionVolumeSlider = (targetConnectionId: string): MenuSliderType => ({
label: i18n._(CONNECTION_VOLUME_DESCRIPTOR),
value: connectionVolume,
minValue: 0,
maxValue: 200,
onChange: (value: number) => {
ParticipantVolume.setConnectionVolume(targetConnectionId, value);
if (isTargetInLocalVoiceRoom) {
MediaEngine.applyLocalAudioPreferencesForUser(user.id);
}
},
onFormat: (value: number) => `${Math.round(value)}%`,
factoryDefaultValue: 100,
});
const buildVolumeControls = (targetConnectionId?: string): Array<MenuSliderType> => {
const volumeControls: Array<MenuSliderType> = [buildUserVolumeSlider()];
if (showConnectionVolumeControls && targetConnectionId) {
volumeControls.push(buildConnectionVolumeSlider(targetConnectionId));
}
return volumeControls;
};
const buildEntranceSoundControls = (): MenuSubmenuItemType => ({
label: i18n._(ENTRANCE_SOUND_SUBMENU_DESCRIPTOR),
items: [
{
label: i18n._(MUTE_ENTRANCE_SOUND_DESCRIPTOR),
checked: entranceSoundMuted,
onChange: (checked: boolean) => EntranceSoundListenerPrefs.setMuted(user.id, checked),
},
{
label: i18n._(ENTRANCE_SOUND_VOLUME_DESCRIPTOR),
value: entranceSoundVolume,
minValue: 0,
maxValue: 200,
onChange: (value: number) => EntranceSoundListenerPrefs.setVolume(user.id, value),
onFormat: (value: number) => `${Math.round(value)}%`,
factoryDefaultValue: 100,
},
],
});
if (source.kind === 'screen-share') {
const streamGroups = buildVoiceParticipantStreamMenu({
i18n,
userId: user.id,
guildId,
connectionId,
channelId: connectionVoiceState?.channel_id ?? null,
participantIdentity,
displayName,
isCurrentUserConnectedToVoice: isTargetInLocalVoiceRoom,
source,
streamVolume,
isStreamMuted,
showMyOwnScreenShare,
pauseOwnScreenSharePreviewOnUnfocus,
onClose,
});
return {
groups: streamGroups,
guildManagementGroupIndex: streamGroups.length,
};
}
const primaryActions: Array<MenuItemType> = [];
primaryActions.push({
icon: (
<ViewProfileIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.view-profile-icon"
/>
),
label: i18n._(VIEW_PROFILE_DESCRIPTOR),
onClick: () => {
ModalCommands.runAfterBottomSheetClose(onClose, () => UserProfileCommands.openUserProfile(user.id, guildId));
},
});
const connectionParticipant = connectionId
? MediaEngine.getParticipantByUserIdAndConnectionId(user.id, connectionId)
: null;
const isCameraLive =
source.kind === 'camera' && Boolean(connectionParticipant?.isCameraEnabled || connectionVoiceState?.self_video);
const focusSource = source.kind === 'camera' ? (isCameraLive ? 'camera' : undefined) : source.focusSource;
const shouldShowFocus = focusSource !== undefined && (surface === 'call-tile' || surface === 'participant-list');
if (shouldShowFocus && connectionId && isTargetInLocalVoiceRoom) {
const identity = buildVoiceParticipantIdentity(user.id, connectionId);
const liveKitFocusSource = focusSource === 'screen-share' ? Track.Source.ScreenShare : Track.Source.Camera;
const isFocused =
pinnedParticipantIdentity === identity &&
(pinnedParticipantSource == null || pinnedParticipantSource === liveKitFocusSource);
const allStates = MediaEngine.getAllVoiceStates();
let connectionCount = 0;
Object.values(allStates).forEach((guildData) => {
Object.values(guildData).forEach((channelData) => {
Object.values(channelData).forEach((vs: VoiceState) => {
if (vs.user_id === user.id) connectionCount++;
});
});
});
const hasMultipleConnectionsForFocus = connectionCount > 1;
const focusLabel = (() => {
if (isFocused) return i18n._(UNFOCUS_DESCRIPTOR);
if (hasMultipleConnectionsForFocus) return i18n._(FOCUS_THIS_DEVICE_DESCRIPTOR);
return i18n._(FOCUS_THIS_PERSON_DESCRIPTOR);
})();
focusActions.push({
icon: isFocused ? (
<UnfocusIcon size={16} data-flx="ui.action-menu.items.voice-participant-menu-data.groups.unfocus-icon" />
) : (
<FocusIcon size={16} data-flx="ui.action-menu.items.voice-participant-menu-data.groups.focus-icon" />
),
label: focusLabel,
onClick: () => {
if (isFocused) {
VoiceCallLayoutCommands.setPinnedParticipant(null);
VoiceCallLayoutCommands.setLayoutMode('grid');
VoiceCallLayoutCommands.markUserOverride();
} else {
VoiceCallLayoutCommands.setLayoutMode('focus');
VoiceCallLayoutCommands.setPinnedParticipant(identity, liveKitFocusSource);
VoiceCallLayoutCommands.markUserOverride();
}
onClose();
},
});
}
const popoutChannelId = connectionVoiceState?.channel_id ?? null;
const isParticipantPopoutSurface = surface === 'call-tile' || surface === 'call-avatar';
const participantPopoutSource: VoiceTilePopoutSource | null =
isCameraLive && isTargetInLocalVoiceRoom ? 'camera' : isParticipantPopoutSurface ? 'user' : null;
if (participantPopoutSource && connectionId && popoutChannelId && isVoicePopoutSupported()) {
const openParticipantPopout = (): void => {
const didOpen = PopoutWindowManager.openTilePopout({
participantIdentity,
source: participantPopoutSource,
userId: user.id,
connectionId,
channelId: popoutChannelId,
guildId: guildId ?? null,
title: displayName,
});
if (didOpen) onClose();
};
popoutActions.push({
icon: (
<PopOutIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.pop-out-camera-icon"
/>
),
label: i18n._(participantPopoutSource === 'camera' ? POP_OUT_CAMERA_DESCRIPTOR : POP_OUT_USER_DESCRIPTOR),
onClick: openParticipantPopout,
});
}
if (isCurrentUser && !isCameraLive && (surface === 'call-tile' || surface === 'call-avatar')) {
mediaActions.push({
icon: (
<ViewDetailsIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.menu-assembly.view-details-icon"
/>
),
label: i18n._(PREVIEW_CAMERA_DESCRIPTOR),
onClick: () => {
ModalCommands.pushAfterBottomSheetClose(
onClose,
modal(() => (
<CameraPreviewModalInRoom data-flx="ui.action-menu.items.voice-participant-menu-data.on-click.camera-preview-modal-in-room" />
)),
);
},
});
}
if (surface === 'participant-list' && canMention) {
primaryActions.push({
icon: (
<MentionUserIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.mention-user-icon"
/>
),
label: i18n._(MENTION_DESCRIPTOR),
onClick: () => {
onClose();
ComponentBus.dispatch('INSERT_MENTION', {userId: user.id});
},
});
}
if (!isCurrentUser) {
primaryActions.push({
icon: (
<MessageUserIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.message-user-icon"
/>
),
label: isBlocked ? i18n._(OPEN_DM_DESCRIPTOR) : i18n._(MESSAGE_DESCRIPTOR),
onClick: isBlocked ? handleOpenBlockedDm : handleMessage,
});
}
if (!isCurrentUser && relationshipType === RelationshipTypes.FRIEND) {
relationshipActions.push({
icon: (
<ChangeNicknameIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.change-nickname-icon"
/>
),
label: i18n._(CHANGE_FRIEND_NICKNAME_DESCRIPTOR),
onClick: () => {
ModalCommands.pushAfterBottomSheetClose(
onClose,
modal(() => (
<ChangeFriendNicknameModal
user={user}
data-flx="ui.action-menu.items.voice-participant-menu-data.on-click.change-friend-nickname-modal"
/>
)),
);
},
});
}
if (!isCurrentUser && !user.bot && !hasActiveDirectCall) {
primaryActions.push({
icon: (
<VoiceCallIcon size={16} data-flx="ui.action-menu.items.voice-participant-menu-data.groups.voice-call-icon" />
),
label: i18n._(START_VOICE_CALL_DESCRIPTOR),
onClick: async () => {
ModalCommands.runAfterBottomSheetClose(onClose, () => {
void (async () => {
try {
const channelId = await PrivateChannelCommands.ensureDMChannel(user.id);
await CallUtils.requestStartCall(i18n, channelId, {kind: 'voice'});
} catch (error) {
logger.error('Failed to start voice call:', error);
showDmActionErrorModal(error);
}
})();
});
},
});
}
if (!isCurrentUser) {
primaryActions.push({
icon: (
<AddNoteIcon size={16} data-flx="ui.action-menu.items.voice-participant-menu-data.groups.add-note-icon" />
),
label: i18n._(ADD_NOTE_DESCRIPTOR),
onClick: () => {
ModalCommands.runAfterBottomSheetClose(onClose, () =>
UserProfileCommands.openUserProfile(user.id, guildId, true),
);
},
});
}
if (hasDeviceGroup && onToggleDeviceGroup && hiddenConnectionCount > 0) {
primaryActions.push({
icon: <ExpandIcon size={16} data-flx="ui.action-menu.items.voice-participant-menu-data.groups.expand-icon" />,
label: plural(hiddenConnectionCount, {
one: 'Expand # other device',
other: 'Expand # other devices',
}),
onClick: () => {
onToggleDeviceGroup();
onClose();
},
});
} else if (hasDeviceGroup && onToggleDeviceGroup && isDeviceGroupExpanded) {
primaryActions.push({
icon: (
<CollapseIcon size={16} data-flx="ui.action-menu.items.voice-participant-menu-data.groups.collapse-icon" />
),
label: i18n._(COLLAPSE_DEVICES_DESCRIPTOR),
onClick: () => {
onToggleDeviceGroup();
onClose();
},
});
}
if (primaryActions.length > 0) {
menuGroups.push({items: primaryActions});
}
if (isCurrentUser) {
const selfRootActions: Array<MenuItemType | MenuCheckboxType> = [];
const canUseCurrentConnectionAsRoot = !isParentGroupedItem || groupContainsLocalConnection;
const useConnectionSpecificControls =
connectionId !== undefined && (isGroupedItem || connectionId !== MediaEngine.connectionId);
if (useConnectionSpecificControls) {
const isSelfMuted = connectionVoiceState?.self_mute ?? false;
const isSelfDeafened = connectionVoiceState?.self_deaf ?? false;
const isCameraOn = connectionVoiceState?.self_video ?? false;
const isStreaming = connectionVoiceState?.self_stream ?? false;
selfRootActions.push({
icon: (
<SelfMuteIcon size={16} data-flx="ui.action-menu.items.voice-participant-menu-data.groups.self-mute-icon" />
),
label: i18n._(MUTE_DEVICE_DESCRIPTOR),
checked: isSelfMuted,
onChange: () => {
VoiceStateCommands.toggleSelfMuteForConnection(connectionId);
},
});
selfRootActions.push({
icon: (
<SelfDeafenIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.self-deafen-icon"
/>
),
label: i18n._(VOICE_DEAFEN_DEVICE_DESCRIPTOR),
checked: isSelfDeafened,
onChange: () => {
VoiceStateCommands.toggleSelfDeafenForConnection(connectionId);
},
});
if (isCameraOn && isDeviceScopedMenu) {
selfRootActions.push({
icon: (
<TurnOffCameraIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.turn-off-camera-icon"
/>
),
label: i18n._(TURN_OFF_DEVICE_CAMERA_DESCRIPTOR),
onClick: () => {
VoiceStateCommands.turnOffCameraForConnection(connectionId);
onClose();
},
});
}
if (isStreaming && isDeviceScopedMenu) {
selfRootActions.push({
icon: (
<TurnOffStreamIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.turn-off-stream-icon--2"
/>
),
label: i18n._(TURN_OFF_DEVICE_STREAM_DESCRIPTOR),
onClick: () => {
VoiceStateCommands.turnOffStreamForConnection(connectionId);
onClose();
},
});
}
if (isDeviceScopedMenu) advancedActions.push(createCopyDeviceIdAction(connectionId));
} else if (currentConnectionId && canUseCurrentConnectionAsRoot) {
const isSelfMuted = currentUserVoiceState?.self_mute ?? false;
const isSelfDeafened = currentUserVoiceState?.self_deaf ?? false;
const isCameraOn = currentConnectionVoiceState?.self_video ?? false;
const isStreaming = currentConnectionVoiceState?.self_stream ?? false;
selfRootActions.push({
icon: (
<SelfMuteIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.self-mute-icon--2"
/>
),
label: i18n._(MUTE_DESCRIPTOR),
checked: isSelfMuted,
onChange: () => {
VoiceStateCommands.toggleSelfMute(null);
},
});
selfRootActions.push({
icon: (
<SelfDeafenIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.self-deafen-icon--2"
/>
),
label: i18n._(VOICE_DEAFEN_DESCRIPTOR),
checked: isSelfDeafened,
onChange: () => {
VoiceStateCommands.toggleSelfDeaf(null);
},
});
if (isCameraOn && isDeviceScopedMenu) {
selfRootActions.push({
icon: (
<TurnOffCameraIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.turn-off-camera-icon--2"
/>
),
label: i18n._(TURN_OFF_CAMERA_DESCRIPTOR),
onClick: () => {
if (currentConnectionId) {
VoiceStateCommands.turnOffCameraForConnection(currentConnectionId);
}
onClose();
},
});
}
if (isStreaming && isDeviceScopedMenu) {
selfRootActions.push({
icon: (
<TurnOffStreamIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.turn-off-stream-icon--3"
/>
),
label: i18n._(TURN_OFF_STREAM_DESCRIPTOR),
onClick: () => {
if (currentConnectionId) {
VoiceStateCommands.turnOffStreamForConnection(currentConnectionId);
}
onClose();
},
});
}
if (isDeviceScopedMenu) {
mediaActions.push({
icon: (
<SettingsIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.settings-icon"
/>
),
label: getVoiceVideoSettingsLabel(i18n),
onClick: () => {
ModalCommands.pushAfterBottomSheetClose(
onClose,
modal(() => (
<UserSettingsModal
initialTab="voice_video"
data-flx="ui.action-menu.items.voice-participant-menu-data.on-click.user-settings-modal"
/>
)),
);
},
});
}
if (currentConnectionId && isDeviceScopedMenu) {
advancedActions.push(createCopyDeviceIdAction(currentConnectionId));
}
}
const targetConnectionId = connectionId ?? (canUseCurrentConnectionAsRoot ? currentConnectionId : null);
const targetGuildId = guildId ?? connectionVoiceState?.guild_id ?? null;
const isCurrentDevice = targetConnectionId !== null && targetConnectionId === currentConnectionId;
if (isDeviceScopedMenu && targetConnectionId && (isCurrentDevice || targetGuildId)) {
selfRootActions.push({
icon: (
<DisconnectIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.disconnect-icon"
/>
),
label: i18n._(isGroupedItem ? VOICE_DISCONNECT_DEVICE_DESCRIPTOR : VOICE_DISCONNECT_DESCRIPTOR),
onClick: async () => {
if (isCurrentDevice) {
await MediaEngine.disconnectFromVoiceChannel('user');
} else if (targetGuildId) {
MediaEngine.disconnectRemoteDevice(targetGuildId, targetConnectionId);
}
onClose();
},
danger: true,
});
}
if (selfRootActions.length > 0) {
voiceActions.push(...selfRootActions);
}
if (connectionId && connectionId !== currentConnectionId && isCurrentUserConnectedToVoice) {
volumeActions.push(...buildVolumeControls(connectionId));
}
} else {
volumeActions.push(...buildVolumeControls(connectionId));
voiceActions.push(
{
icon: (
<LocalMuteIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.menu-assembly.local-mute-icon"
/>
),
label: i18n._(MUTE_DESCRIPTOR),
checked: isParticipantLocallyMuted,
onChange: (checked: boolean) => {
ParticipantVolume.setLocalMute(user.id, checked);
if (isTargetInLocalVoiceRoom) {
MediaEngine.applyLocalAudioPreferencesForUser(user.id);
}
},
},
buildEntranceSoundControls(),
);
if (isTargetInLocalVoiceRoom && connectionId && source.kind === 'camera') {
mediaActions.push({
icon: (
<LocalDisableVideoIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.local-disable-video-icon"
/>
),
label: i18n._(DISABLE_VIDEO_LOCALLY_DESCRIPTOR),
checked: isVideoDisabled,
onChange: (checked: boolean) => {
const activeCallId = MediaEngine.connectionId;
if (!activeCallId) {
const error = new Error('Cannot toggle local video without an active voice connection');
logger.error('Voice participant menu action invoked without connection id', {
participantIdentity,
connectionId,
callId,
});
throw error;
}
MediaEngine.setLocalVideoDisabled(participantIdentity, checked);
},
});
}
if (connectionId && isDeviceScopedMenu) {
advancedActions.push(createCopyDeviceIdAction(connectionId));
}
}
if (surface === 'call-tile' || surface === 'call-avatar') {
displayActions.push({
label: i18n._(SHOW_NON_VIDEO_PARTICIPANTS_DESCRIPTOR),
checked: showNonVideoParticipants,
onChange: (checked: boolean) => VoiceSettingsCommands.update({showNonVideoParticipants: checked}),
});
}
if (isParentGroupedItem && hasMultipleConnections && guildId) {
const bulkActions: Array<MenuItemType> = [];
const connectionIds = userVoiceStates.map((u) => u.connectionId);
if (isCurrentUser && isCurrentUserConnectedToVoice) {
const allMuted = userVoiceStates.every(({voiceState}) => voiceState.self_mute);
bulkActions.push({
icon: (
<SelfMuteIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.self-mute-icon--3"
/>
),
label: i18n._(allMuted ? VOICE_UNMUTE_ALL_DEVICES_DESCRIPTOR : VOICE_MUTE_ALL_DEVICES_DESCRIPTOR),
onClick: () => {
const targetMute = !allMuted;
VoiceStateCommands.bulkMuteConnections(connectionIds, targetMute);
if (targetMute) SoundCommands.playSound(SoundType.Mute);
else SoundCommands.playSound(SoundType.Unmute);
onClose();
},
});
const allDeafened = userVoiceStates.every(({voiceState}) => voiceState.self_deaf);
bulkActions.push({
icon: (
<SelfDeafenIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.self-deafen-icon--3"
/>
),
label: i18n._(allDeafened ? VOICE_UNDEAFEN_ALL_DEVICES_DESCRIPTOR : VOICE_DEAFEN_ALL_DEVICES_DESCRIPTOR),
onClick: () => {
const targetDeafen = !allDeafened;
VoiceStateCommands.bulkDeafenConnections(connectionIds, targetDeafen);
if (targetDeafen) SoundCommands.playSound(SoundType.Deaf);
else SoundCommands.playSound(SoundType.Undeaf);
onClose();
},
});
bulkActions.push({
icon: (
<BulkTurnOffCameraIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.bulk-turn-off-camera-icon"
/>
),
label: i18n._(TURN_OFF_ALL_DEVICE_CAMERAS_DESCRIPTOR),
onClick: () => {
VoiceStateCommands.bulkTurnOffCameras(connectionIds);
onClose();
},
});
bulkActions.push({
icon: (
<DisconnectIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.disconnect-icon--2"
/>
),
label: i18n._(VOICE_DISCONNECT_ALL_DEVICES_DESCRIPTOR),
onClick: async () => {
await VoiceStateCommands.bulkDisconnect(connectionIds);
onClose();
},
danger: true,
});
} else if (!isCurrentUser && canMoveMembers && canManageTarget(user.id)) {
bulkActions.push({
icon: (
<DisconnectIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.disconnect-icon--3"
/>
),
label: i18n._(VOICE_DISCONNECT_ALL_DEVICES_DESCRIPTOR),
onClick: async () => {
await VoiceStateCommands.bulkDisconnect(connectionIds);
onClose();
},
danger: true,
});
}
if (bulkActions.length > 0) {
if (isCurrentUser) {
deviceActions.push(...bulkActions);
} else {
moderationActions.push(...bulkActions);
}
}
}
if (guildId && member) {
const guildActions: Array<MenuItemType> = [];
const hasChangeNicknamePermission = Permission.can(Permissions.CHANGE_NICKNAME, {guildId});
const hasManageNicknamesPermission = Permission.can(Permissions.MANAGE_NICKNAMES, {guildId});
const canManageNicknames =
(isCurrentUser && hasChangeNicknamePermission) || (!isCurrentUser && hasManageNicknamesPermission);
if (canManageNicknames && !isCurrentUser) {
guildActions.push({
icon: (
<ChangeNicknameIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.change-nickname-icon--2"
/>
),
label: i18n._(CHANGE_NICKNAME_DESCRIPTOR),
onClick: () => {
ModalCommands.pushAfterBottomSheetClose(
onClose,
modal(() => (
<ChangeNicknameModal
guildId={guildId}
user={user}
member={member}
data-flx="ui.action-menu.items.voice-participant-menu-data.on-click.change-nickname-modal"
/>
)),
);
},
});
}
if (guildActions.length > 0) {
relationshipActions.push(...guildActions);
}
}
if (!isCurrentUser) {
const nextRelationshipActions: Array<MenuItemType> = [];
if (!RuntimeConfig.directMessagesDisabled) {
switch (relationshipType) {
case RelationshipTypes.FRIEND:
nextRelationshipActions.push({
icon: (
<RemoveFriendIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.remove-friend-icon"
/>
),
label: i18n._(REMOVE_FRIEND_DESCRIPTOR),
onClick: (event?: {shiftKey?: boolean}) => {
ModalCommands.runAfterBottomSheetClose(onClose, () =>
RelationshipActionUtils.showRemoveFriendConfirmation(i18n, user, {
bypassConfirm: RelationshipActionUtils.shouldBypassRelationshipConfirmation(event),
showShiftBypassConfirmationTip: true,
}),
);
},
});
break;
case RelationshipTypes.INCOMING_REQUEST:
nextRelationshipActions.push({
icon: (
<AcceptFriendRequestIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.accept-friend-request-icon"
/>
),
label: i18n._(ACCEPT_FRIEND_REQUEST_DESCRIPTOR),
onClick: (event?: {shiftKey?: boolean}) => {
ModalCommands.runAfterBottomSheetClose(onClose, () =>
RelationshipActionUtils.showAcceptFriendRequestConfirmation(i18n, user, {
bypassConfirm: RelationshipActionUtils.shouldBypassRelationshipConfirmation(event),
showShiftBypassConfirmationTip: true,
}),
);
},
});
nextRelationshipActions.push({
icon: (
<IgnoreFriendRequestIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.ignore-friend-request-icon"
/>
),
label: i18n._(IGNORE_FRIEND_REQUEST_DESCRIPTOR),
onClick: () => {
onClose();
RelationshipActionUtils.ignoreFriendRequest(i18n, user.id);
},
});
break;
case RelationshipTypes.OUTGOING_REQUEST:
break;
case RelationshipTypes.BLOCKED:
break;
default:
if (!user.bot && Users.currentUser?.verified !== false) {
nextRelationshipActions.push({
icon: (
<SendFriendRequestIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.send-friend-request-icon"
/>
),
label: i18n._(ADD_FRIEND_DESCRIPTOR),
onClick: async () => {
await RelationshipActionUtils.sendFriendRequest(i18n, user.id);
},
});
}
break;
}
}
if (!user.system) {
if (relationshipType === RelationshipTypes.BLOCKED) {
nextRelationshipActions.push({
icon: (
<BlockUserIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.block-user-icon"
/>
),
label: i18n._(UNBLOCK_USER_ACTION_DESCRIPTOR),
onClick: (event?: {shiftKey?: boolean}) => {
ModalCommands.runAfterBottomSheetClose(onClose, () =>
RelationshipActionUtils.showUnblockUserConfirmation(i18n, user, {
bypassConfirm: RelationshipActionUtils.shouldBypassRelationshipConfirmation(event),
showShiftBypassConfirmationTip: true,
}),
);
},
});
} else {
nextRelationshipActions.push({
icon: (
<BlockUserIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.block-user-icon--2"
/>
),
label: i18n._(BLOCK_DESCRIPTOR),
onClick: (event?: {shiftKey?: boolean}) => {
ModalCommands.runAfterBottomSheetClose(onClose, () =>
RelationshipActionUtils.showBlockUserConfirmation(i18n, user, {
bypassConfirm: RelationshipActionUtils.shouldBypassRelationshipConfirmation(event),
showShiftBypassConfirmationTip: true,
}),
);
},
danger: true,
});
}
}
if (nextRelationshipActions.length > 0) {
relationshipActions.push(...nextRelationshipActions);
}
}
if (guildId && member) {
const nextModerationActions: Array<MenuItemType | MenuCheckboxType> = [];
if ((canMuteMembers || canDeafenMembers) && (isCurrentUser || canManageTarget(user.id))) {
const isGuildMuted = memberMute;
const isGuildDeafened = memberDeaf;
if (canMuteMembers) {
nextModerationActions.push({
icon: (
<GuildMuteIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.guild-mute-icon"
/>
),
label: i18n._(VOICE_COMMUNITY_MUTE_DESCRIPTOR),
checked: isGuildMuted,
onChange: async (checked: boolean) => {
try {
await GuildMemberCommands.update(guildId, user.id, {mute: checked});
if (checked) SoundCommands.playSound(SoundType.Mute);
else SoundCommands.playSound(SoundType.Unmute);
} catch (error) {
logger.error('Failed to update community mute:', error);
showVoiceMemberModerationFailedModal(error, VOICE_COMMUNITY_MUTE_DESCRIPTOR);
}
},
});
}
if (canDeafenMembers) {
nextModerationActions.push({
icon: (
<GuildDeafenIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.guild-deafen-icon"
/>
),
label: i18n._(VOICE_COMMUNITY_DEAFEN_DESCRIPTOR),
checked: isGuildDeafened,
onChange: async (checked: boolean) => {
try {
await GuildMemberCommands.update(guildId, user.id, {deaf: checked});
if (checked) SoundCommands.playSound(SoundType.Deaf);
else SoundCommands.playSound(SoundType.Undeaf);
} catch (error) {
logger.error('Failed to update community deafen:', error);
showVoiceMemberModerationFailedModal(error, VOICE_COMMUNITY_DEAFEN_DESCRIPTOR);
}
},
});
}
}
if (!isCurrentUser && canMoveMembers && canManageTarget(user.id) && !isParentGroupedItem) {
nextModerationActions.push({
icon: (
<DisconnectIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.disconnect-icon--4"
/>
),
label: i18n._(connectionId ? VOICE_DISCONNECT_DEVICE_DESCRIPTOR : VOICE_DISCONNECT_DESCRIPTOR),
onClick: async () => {
try {
await GuildMemberCommands.update(guildId, user.id, {
channel_id: null,
connection_id: connectionId,
});
} catch (error) {
logger.error('Failed to disconnect participant:', error);
showVoiceMemberModerationFailedModal(error, VOICE_DISCONNECT_DESCRIPTOR);
}
onClose();
},
danger: true,
});
}
if (canTimeoutTarget) {
const isTimedOut = memberTimedOut;
nextModerationActions.push({
icon: (
<TimeoutIcon size={16} data-flx="ui.action-menu.items.voice-participant-menu-data.groups.timeout-icon" />
),
label: isTimedOut ? i18n._(REMOVE_TIMEOUT_DESCRIPTOR) : i18n._(TIMEOUT_DESCRIPTOR),
onClick: () => {
ModalCommands.runAfterBottomSheetClose(onClose, () => {
if (isTimedOut) {
ModalCommands.push(
modal(() => (
<RemoveTimeoutModal
guildId={guildId}
targetUser={user}
data-flx="ui.action-menu.items.voice-participant-menu-data.on-click.remove-timeout-modal"
/>
)),
);
} else {
ModalCommands.push(
modal(() => (
<TimeoutMemberModal
guildId={guildId}
targetUser={user}
data-flx="ui.action-menu.items.voice-participant-menu-data.on-click.timeout-member-modal"
/>
)),
);
}
});
},
danger: !isTimedOut,
});
}
if (canKickTarget) {
nextModerationActions.push({
icon: (
<KickMemberIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.kick-member-icon"
/>
),
label: i18n._(KICK_MEMBER_DESCRIPTOR),
onClick: () => {
ModalCommands.pushAfterBottomSheetClose(
onClose,
modal(() => (
<KickMemberModal
guildId={guildId}
targetUser={user}
data-flx="ui.action-menu.items.voice-participant-menu-data.on-click.kick-member-modal"
/>
)),
);
},
danger: true,
});
}
if (canBanTarget) {
nextModerationActions.push({
icon: (
<BanMemberIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-menu-data.groups.ban-member-icon"
/>
),
label: i18n._(BAN_MEMBER_DESCRIPTOR),
onClick: () => {
ModalCommands.pushAfterBottomSheetClose(
onClose,
modal(() => (
<BanMemberModal
guildId={guildId}
targetUser={user}
data-flx="ui.action-menu.items.voice-participant-menu-data.on-click.ban-member-modal"
/>
)),
);
},
danger: true,
});
}
if (nextModerationActions.length > 0) {
moderationActions.push(...nextModerationActions);
}
}
if (developerMode) {
advancedActions.push({
icon: <DebugIcon size={16} data-flx="ui.action-menu.items.voice-participant-menu-data.groups.debug-icon" />,
label: i18n._(DEBUG_USER_DESCRIPTOR),
onClick: () => {
ModalCommands.pushAfterBottomSheetClose(
onClose,
modal(() => (
<UserDebugModal
title={i18n._(USER_DEBUG_DESCRIPTOR)}
user={user}
data-flx="ui.action-menu.items.voice-participant-menu-data.on-click.user-debug-modal"
/>
)),
);
},
});
}
advancedActions.push({
icon: <CopyIdIcon size={16} data-flx="ui.action-menu.items.voice-participant-menu-data.groups.copy-id-icon" />,
label: i18n._(COPY_USER_ID_DESCRIPTOR),
onClick: () => {
onClose();
TextCopyCommands.copy(i18n, user.id, true);
},
});
if (!isCurrentUser && volumeActions.length > 0) menuGroups.push({items: volumeActions});
const participantActions: Array<VoiceParticipantMenuLeafItem> = [
...voiceActions,
...focusActions,
...mediaActions,
...relationshipActions,
];
if (participantActions.length > 0) menuGroups.push({items: participantActions});
if (isCurrentUser && volumeActions.length > 0) menuGroups.push({items: volumeActions});
if (deviceActions.length > 0) menuGroups.push({items: deviceActions});
const guildManagementGroupIndex = menuGroups.length;
const visualActions: Array<VoiceParticipantMenuLeafItem> = [...popoutActions, ...displayActions];
if (visualActions.length > 0) menuGroups.push({items: visualActions});
if (moderationActions.length > 0) menuGroups.push({items: moderationActions});
if (advancedActions.length > 0) menuGroups.push({items: advancedActions});
return {groups: menuGroups, guildManagementGroupIndex};
}, [
i18n.locale,
user,
guildId,
connectionId,
surface,
source,
isCurrentUser,
isGroupedItem,
isParentGroupedItem,
groupContainsLocalConnection,
member,
developerMode,
relationshipType,
hasActiveDirectCall,
canMention,
focusedChannelId,
canMuteMembers,
canDeafenMembers,
canMoveMembers,
canManageTarget,
canKickTarget,
canBanTarget,
canTimeoutTarget,
userVoiceStates,
hasMultipleConnections,
onClose,
connectionVoiceState,
pinnedParticipantIdentity,
pinnedParticipantSource,
memberMute,
memberDeaf,
memberTimedOut,
displayName,
currentUserVoiceState,
currentUserVoiceStateInGuild,
isCurrentUserConnectedToVoice,
isTargetInLocalVoiceRoom,
currentConnectionId,
currentConnectionVoiceState,
showMyOwnScreenShare,
showNonVideoParticipants,
pauseOwnScreenSharePreviewOnUnfocus,
showConnectionVolumeControls,
participantVolume,
isParticipantLocallyMuted,
entranceSoundMuted,
entranceSoundVolume,
connectionVolume,
isVideoDisabled,
callId,
participantIdentity,
streamKey,
streamVolume,
isStreamMuted,
hiddenConnectionCount,
deviceConnectionCount,
isDeviceGroupExpanded,
onToggleDeviceGroup,
]);
return {
groups: menuAssembly.groups,
guildManagementGroupIndex: menuAssembly.guildManagementGroupIndex,
member,
isCurrentUser,
developerMode,
relationshipType,
canMoveMembers,
userVoiceStates,
hasMultipleConnections,
voiceChannels,
hasVoiceChannels: voiceChannels.length > 0,
};
}