/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {useLingui} from '@lingui/react/macro'; import { CopyIcon, EyeIcon, EyeSlashIcon, GearIcon, MicrophoneSlashIcon, PhoneXIcon, ProjectorScreenIcon, SpeakerSlashIcon, VideoCameraSlashIcon, VideoIcon, } from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; import React from 'react'; import * as GuildMemberActionCreators from '~/actions/GuildMemberActionCreators'; import * as ModalActionCreators from '~/actions/ModalActionCreators'; import {modal} from '~/actions/ModalActionCreators'; import * as SoundActionCreators from '~/actions/SoundActionCreators'; import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators'; import * as VoiceCallLayoutActionCreators from '~/actions/VoiceCallLayoutActionCreators'; import * as VoiceStateActionCreators from '~/actions/VoiceStateActionCreators'; import {UserSettingsModal} from '~/components/modals/UserSettingsModal'; import CallMediaPrefsStore from '~/stores/CallMediaPrefsStore'; import ConnectionStore from '~/stores/ConnectionStore'; import GuildMemberStore from '~/stores/GuildMemberStore'; import ParticipantVolumeStore from '~/stores/ParticipantVolumeStore'; import UserStore from '~/stores/UserStore'; import VoiceCallLayoutStore from '~/stores/VoiceCallLayoutStore'; import MediaEngineStore from '~/stores/voice/MediaEngineFacade'; import type {VoiceState} from '~/stores/voice/VoiceStateManager'; import {SoundType} from '~/utils/SoundUtils'; import {MenuItem} from '../MenuItem'; import {MenuItemCheckbox} from '../MenuItemCheckbox'; import {MenuItemSlider} from '../MenuItemSlider'; import styles from './MenuItems.module.css'; interface SelfMuteMenuItemProps { onClose: () => void; connectionId?: string; isDeviceSpecific?: boolean; label?: string; } export const SelfMuteMenuItem: React.FC = observer( ({connectionId, isDeviceSpecific = false, label}) => { const {t} = useLingui(); const voiceState = connectionId ? MediaEngineStore.getVoiceStateByConnectionId(connectionId) : MediaEngineStore.getCurrentUserVoiceState(); const isSelfMuted = voiceState?.self_mute ?? false; const handleToggle = React.useCallback(() => { if (isDeviceSpecific && connectionId) { VoiceStateActionCreators.toggleSelfMuteForConnection(connectionId); } else { VoiceStateActionCreators.toggleSelfMute(null); } }, [connectionId, isDeviceSpecific]); return ( } checked={isSelfMuted} onChange={handleToggle} > {label ?? t`Mute`} ); }, ); interface SelfDeafenMenuItemProps { onClose: () => void; connectionId?: string; isDeviceSpecific?: boolean; label?: string; } export const SelfDeafenMenuItem: React.FC = observer( ({connectionId, isDeviceSpecific = false, label}) => { const {t} = useLingui(); const voiceState = connectionId ? MediaEngineStore.getVoiceStateByConnectionId(connectionId) : MediaEngineStore.getCurrentUserVoiceState(); const isSelfDeafened = voiceState?.self_deaf ?? false; const handleToggle = React.useCallback(() => { if (isDeviceSpecific && connectionId) { VoiceStateActionCreators.toggleSelfDeafenForConnection(connectionId); } else { VoiceStateActionCreators.toggleSelfDeaf(null); } }, [connectionId, isDeviceSpecific]); return ( } checked={isSelfDeafened} onChange={handleToggle} > {label ?? t`Deafen`} ); }, ); interface VoiceVideoSettingsMenuItemProps { onClose: () => void; } export const VoiceVideoSettingsMenuItem: React.FC = observer(({onClose}) => { const {t} = useLingui(); const handleClick = React.useCallback(() => { onClose(); ModalActionCreators.push(modal(() => )); }, [onClose]); return ( } onClick={handleClick}> {t`Voice & Video Settings`} ); }); interface SelfTurnOffCameraMenuItemProps { onClose: () => void; } export const SelfTurnOffCameraMenuItem: React.FC = observer(({onClose}) => { const {t} = useLingui(); const connectionId = MediaEngineStore.connectionId; const voiceState = connectionId ? MediaEngineStore.getVoiceStateByConnectionId(connectionId) : null; const isCameraOn = voiceState?.self_video ?? false; const handleClick = React.useCallback(() => { if (connectionId) VoiceStateActionCreators.turnOffCameraForConnection(connectionId); onClose(); }, [connectionId, onClose]); if (!isCameraOn) return null; return ( } onClick={handleClick}> {t`Turn Off Camera`} ); }); interface SelfTurnOffStreamMenuItemProps { onClose: () => void; } export const SelfTurnOffStreamMenuItem: React.FC = observer(({onClose}) => { const {t} = useLingui(); const connectionId = MediaEngineStore.connectionId; const voiceState = connectionId ? MediaEngineStore.getVoiceStateByConnectionId(connectionId) : null; const isStreaming = voiceState?.self_stream ?? false; const handleClick = React.useCallback(() => { if (connectionId) VoiceStateActionCreators.turnOffStreamForConnection(connectionId); onClose(); }, [connectionId, onClose]); if (!isStreaming) return null; return ( } onClick={handleClick}> {t`Turn Off Stream`} ); }); interface ParticipantVolumeSliderProps { userId: string; } export const ParticipantVolumeSlider: React.FC = observer(({userId}) => { const {t} = useLingui(); const participantVolume = ParticipantVolumeStore.getVolume(userId); const handleChange = React.useCallback( (value: number) => { ParticipantVolumeStore.setVolume(userId, value); MediaEngineStore.applyLocalAudioPreferencesForUser(userId); }, [userId], ); return ( `${Math.round(value)}%`} /> ); }); interface LocalMuteParticipantMenuItemProps { userId: string; onClose: () => void; } export const LocalMuteParticipantMenuItem: React.FC = observer(({userId}) => { const {t} = useLingui(); const isLocalMuted = ParticipantVolumeStore.isLocalMuted(userId); const handleToggle = React.useCallback( (checked: boolean) => { ParticipantVolumeStore.setLocalMute(userId, checked); MediaEngineStore.applyLocalAudioPreferencesForUser(userId); }, [userId], ); return ( } checked={isLocalMuted} onChange={handleToggle} > {t`Mute`} ); }); interface LocalDisableVideoMenuItemProps { userId: string; connectionId: string; onClose: () => void; } export const LocalDisableVideoMenuItem: React.FC = observer( ({userId, connectionId, onClose}) => { const {t} = useLingui(); const callId = MediaEngineStore.connectionId ?? ''; const identity = `user_${userId}_${connectionId}`; const disabled = callId ? CallMediaPrefsStore.isVideoDisabled(callId, identity) : false; const handleToggle = React.useCallback( (checked: boolean) => { const id = MediaEngineStore.connectionId ?? ''; if (!id) return; MediaEngineStore.setLocalVideoDisabled(identity, checked); onClose(); }, [identity, onClose], ); return ( } checked={disabled} onChange={handleToggle} > {t`Disable Video (Local)`} ); }, ); interface GuildMuteMenuItemProps { userId: string; guildId: string; onClose: () => void; } export const GuildMuteMenuItem: React.FC = observer(function GuildMuteMenuItem({ userId, guildId, }) { const {t} = useLingui(); const member = GuildMemberStore.getMember(guildId, userId); const isGuildMuted = member?.mute ?? false; const isTimedOut = member?.isTimedOut() ?? false; const handleToggle = React.useCallback( async (checked: boolean) => { try { await GuildMemberActionCreators.update(guildId, userId, {mute: checked}); if (checked) SoundActionCreators.playSound(SoundType.Mute); else SoundActionCreators.playSound(SoundType.Unmute); } catch {} }, [guildId, userId], ); return ( } checked={!!isGuildMuted} onChange={handleToggle} danger disabled={isTimedOut} description={isTimedOut ? t`Disabled while the member is timed out.` : undefined} > {t`Community Mute`} ); }); interface GuildDeafenMenuItemProps { userId: string; guildId: string; onClose: () => void; } export const GuildDeafenMenuItem: React.FC = observer(function GuildDeafenMenuItem({ userId, guildId, }) { const {t} = useLingui(); const member = GuildMemberStore.getMember(guildId, userId); const isGuildDeafened = member?.deaf ?? false; const handleToggle = React.useCallback( async (checked: boolean) => { try { await GuildMemberActionCreators.update(guildId, userId, {deaf: checked}); if (checked) SoundActionCreators.playSound(SoundType.Deaf); else SoundActionCreators.playSound(SoundType.Undeaf); } catch {} }, [guildId, userId], ); return ( } checked={!!isGuildDeafened} onChange={handleToggle} danger > {t`Community Deafen`} ); }); interface DisconnectParticipantMenuItemProps { userId: string; guildId: string; participantName: string; connectionId?: string; onClose: () => void; label?: string; } export const DisconnectParticipantMenuItem: React.FC = observer( function DisconnectParticipantMenuItem({userId, guildId, connectionId, onClose, label}) { const {t} = useLingui(); const currentUser = UserStore.currentUser; const isSelf = currentUser?.id === userId; const handleClick = React.useCallback(async () => { onClose(); if (isSelf) { const socket = ConnectionStore.socket; const cid = connectionId ?? MediaEngineStore.connectionId ?? null; if (socket && cid) { socket.updateVoiceState({ guild_id: guildId, channel_id: null, self_mute: true, self_deaf: true, self_video: false, self_stream: false, connection_id: cid, }); } } else { try { await GuildMemberActionCreators.update(guildId, userId, {channel_id: null, connection_id: connectionId}); } catch {} } }, [guildId, userId, connectionId, onClose, isSelf]); const defaultLabel = connectionId ? t`Disconnect Device` : t`Disconnect`; return ( } onClick={handleClick} danger> {label ?? defaultLabel} ); }, ); interface TurnOffDeviceCameraMenuItemProps { onClose: () => void; connectionId: string; } export const TurnOffDeviceCameraMenuItem: React.FC = observer( ({connectionId, onClose}) => { const {t} = useLingui(); const voiceState = MediaEngineStore.getVoiceStateByConnectionId(connectionId); const isCameraOn = voiceState?.self_video ?? false; const handleClick = React.useCallback(() => { VoiceStateActionCreators.turnOffCameraForConnection(connectionId); onClose(); }, [connectionId, onClose]); if (!isCameraOn) return null; return ( } onClick={handleClick}> {t`Turn Off Device Camera`} ); }, ); interface TurnOffDeviceStreamMenuItemProps { onClose: () => void; connectionId: string; } export const TurnOffDeviceStreamMenuItem: React.FC = observer( ({connectionId, onClose}) => { const {t} = useLingui(); const voiceState = MediaEngineStore.getVoiceStateByConnectionId(connectionId); const isStreaming = voiceState?.self_stream ?? false; const handleClick = React.useCallback(() => { VoiceStateActionCreators.turnOffStreamForConnection(connectionId); onClose(); }, [connectionId, onClose]); if (!isStreaming) return null; return ( } onClick={handleClick}> {t`Turn Off Device Stream`} ); }, ); interface CopyDeviceIdMenuItemProps { onClose: () => void; connectionId: string; } export const CopyDeviceIdMenuItem: React.FC = observer(({connectionId, onClose}) => { const {t, i18n} = useLingui(); const handleClick = React.useCallback(() => { TextCopyActionCreators.copy(i18n, connectionId, true).catch(() => {}); onClose(); }, [connectionId, onClose, i18n]); return ( } onClick={handleClick}> {t`Copy Device ID`} ); }); interface BulkMuteDevicesMenuItemProps { userVoiceStates: Array<{connectionId: string; voiceState: VoiceState}>; onClose: () => void; } export const BulkMuteDevicesMenuItem: React.FC = observer( ({userVoiceStates, onClose}) => { const {t} = useLingui(); const allMuted = React.useMemo( () => userVoiceStates.every(({voiceState}) => voiceState.self_mute), [userVoiceStates], ); const handleClick = React.useCallback(() => { const connectionIds = userVoiceStates.map(({connectionId}) => connectionId); const targetMute = !allMuted; VoiceStateActionCreators.bulkMuteConnections(connectionIds, targetMute); if (targetMute) SoundActionCreators.playSound(SoundType.Mute); else SoundActionCreators.playSound(SoundType.Unmute); onClose(); }, [userVoiceStates, allMuted, onClose]); return ( } onClick={handleClick}> {allMuted ? t`Unmute All Devices` : t`Mute All Devices`} ); }, ); interface BulkDeafenDevicesMenuItemProps { userVoiceStates: Array<{connectionId: string; voiceState: VoiceState}>; onClose: () => void; } export const BulkDeafenDevicesMenuItem: React.FC = observer( ({userVoiceStates, onClose}) => { const {t} = useLingui(); const allDeafened = React.useMemo( () => userVoiceStates.every(({voiceState}) => voiceState.self_deaf), [userVoiceStates], ); const handleClick = React.useCallback(() => { const connectionIds = userVoiceStates.map(({connectionId}) => connectionId); const targetDeafen = !allDeafened; VoiceStateActionCreators.bulkDeafenConnections(connectionIds, targetDeafen); if (targetDeafen) SoundActionCreators.playSound(SoundType.Deaf); else SoundActionCreators.playSound(SoundType.Undeaf); onClose(); }, [userVoiceStates, allDeafened, onClose]); return ( } onClick={handleClick}> {allDeafened ? t`Undeafen All Devices` : t`Deafen All Devices`} ); }, ); interface BulkCameraDevicesMenuItemProps { userVoiceStates: Array<{connectionId: string; voiceState: VoiceState}>; onClose: () => void; } export const BulkCameraDevicesMenuItem: React.FC = observer( ({userVoiceStates, onClose}) => { const {t} = useLingui(); const handleClick = React.useCallback(() => { const connectionIds = userVoiceStates.map(({connectionId}) => connectionId); VoiceStateActionCreators.bulkTurnOffCameras(connectionIds); onClose(); }, [userVoiceStates, onClose]); return ( } onClick={handleClick}> {t`Turn Off All Device Cameras`} ); }, ); interface BulkDisconnectDevicesMenuItemProps { userVoiceStates: Array<{connectionId: string; voiceState: VoiceState}>; onClose: () => void; } export const BulkDisconnectDevicesMenuItem: React.FC = observer( ({userVoiceStates, onClose}) => { const {t} = useLingui(); const handleClick = React.useCallback(async () => { await VoiceStateActionCreators.bulkDisconnect(userVoiceStates.map(({connectionId}) => connectionId)); onClose(); }, [userVoiceStates, onClose]); return ( } onClick={handleClick} danger> {t`Disconnect All Devices`} ); }, ); interface FocusParticipantMenuItemProps { userId: string; connectionId: string; onClose: () => void; } export const FocusParticipantMenuItem: React.FC = observer( ({userId, connectionId, onClose}) => { const {t} = useLingui(); const identity = `user_${userId}_${connectionId}`; const isFocused = VoiceCallLayoutStore.pinnedParticipantIdentity === identity; const hasMultipleConnections = React.useMemo(() => { const allStates = MediaEngineStore.getAllVoiceStates(); let count = 0; Object.values(allStates).forEach((guildData: any) => { Object.values(guildData).forEach((channelData: any) => { Object.values(channelData).forEach((vs: any) => { if ((vs as any)?.user_id === userId) count++; }); }); }); return count > 1; }, [userId]); const handleClick = React.useCallback(() => { if (isFocused) { VoiceCallLayoutActionCreators.setPinnedParticipant(null); VoiceCallLayoutActionCreators.setLayoutMode('grid'); VoiceCallLayoutActionCreators.markUserOverride(); } else { VoiceCallLayoutActionCreators.setLayoutMode('focus'); VoiceCallLayoutActionCreators.setPinnedParticipant(identity); VoiceCallLayoutActionCreators.markUserOverride(); } onClose(); }, [identity, onClose, isFocused]); return ( ) : ( ) } onClick={handleClick} > {isFocused ? t`Unfocus` : hasMultipleConnections ? t`Focus This Device` : t`Focus This Person`} ); }, );