From 03641f622fc25c97793500ead2d3a17e094206d2 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 30 Aug 2026 03:02:30 +0200 Subject: [PATCH] refactor(voice): flatten participant context menu and extract stream menus (#2112) --- .../layout/GroupedVoiceParticipant.tsx | 40 +- .../layout/VoiceParticipantItem.tsx | 12 +- .../dm_channel_view/CallParticipantsRow.tsx | 2 + .../ui/action_menu/ContextMenuIcons.tsx | 8 + .../ui/action_menu/MenuItemRadio.module.css | 21 + .../features/ui/action_menu/MenuItemRadio.tsx | 13 +- .../VoiceParticipantContextMenu.tsx | 249 ++++--- .../items/VoiceParticipantMenuData.test.tsx | 167 +++++ .../items/VoiceParticipantMenuData.tsx | 610 +++++++++--------- .../items/VoiceParticipantMenuTypes.test.ts | 65 ++ .../items/VoiceParticipantMenuTypes.ts | 74 +++ ...VoiceParticipantStreamMenuBuilder.test.tsx | 116 ++++ .../VoiceParticipantStreamMenuBuilder.tsx | 341 ++++++++++ .../__fixtures__/VoiceMenuTestBootstrap.ts | 71 ++ .../voice_participant_menu_data/shared.ts | 32 +- .../components/ActiveScreenShareMenu.tsx | 123 ++++ .../components/StreamSettingsMenuContent.tsx | 133 ++++ .../components/StreamSpectatorsPopout.tsx | 2 + .../voice/components/VoiceControlBar.tsx | 89 +-- .../components/VoiceParticipantAvatarList.tsx | 6 + .../voice/components/VoiceParticipantTile.tsx | 61 +- .../VoiceParticipantBottomSheet.tsx | 28 +- .../modals/ScreenSharePickerModal.tsx | 16 +- .../LocalParticipantControls.tsx | 71 +- .../voice/state/PopoutWindowManager.ts | 2 +- 25 files changed, 1787 insertions(+), 565 deletions(-) create mode 100644 fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.test.tsx create mode 100644 fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuTypes.test.ts create mode 100644 fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuTypes.ts create mode 100644 fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.test.tsx create mode 100644 fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.tsx create mode 100644 fluxer_app/src/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap.ts create mode 100644 fluxer_app/src/features/voice/components/ActiveScreenShareMenu.tsx diff --git a/fluxer_app/src/features/app/components/layout/GroupedVoiceParticipant.tsx b/fluxer_app/src/features/app/components/layout/GroupedVoiceParticipant.tsx index c9c1b8903..53c110a48 100644 --- a/fluxer_app/src/features/app/components/layout/GroupedVoiceParticipant.tsx +++ b/fluxer_app/src/features/app/components/layout/GroupedVoiceParticipant.tsx @@ -8,6 +8,7 @@ import {PreloadableUserPopout} from '@app/features/channel/components/Preloadabl import type {VoiceState} from '@app/features/gateway/types/GatewayVoiceTypes'; import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands'; +import type {VoiceParticipantMenuSource} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes'; import {VoiceParticipantContextMenu} from '@app/features/ui/action_menu/VoiceParticipantContextMenu'; import {AvatarWithPresence} from '@app/features/ui/avatars/AvatarWithPresence'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; @@ -70,6 +71,19 @@ export const GroupedVoiceParticipant = observer(function GroupedVoiceParticipant const isContextMenuOpen = useContextMenuHoverState(rowRef); const toggleExpanded = useCallback(() => setIsExpanded((prev) => !prev), []); const connectionCount = voiceStates.length; + const containsLocalConnection = + currentConnectionId !== null && voiceStates.some((state) => state.connection_id === currentConnectionId); + const participantMenuSource = useMemo(() => { + const hasCamera = + voiceStates.some((state) => state.self_video === true) || + (isCurrentUser && containsLocalConnection && localSelfVideo); + if (hasCamera) return {kind: 'participant', focusSource: 'camera'}; + const hasScreenShare = + voiceStates.some((state) => state.self_stream === true) || + (isCurrentUser && containsLocalConnection && localSelfStream); + if (hasScreenShare) return {kind: 'participant', focusSource: 'screen-share'}; + return {kind: 'participant'}; + }, [containsLocalConnection, isCurrentUser, localSelfStream, localSelfVideo, voiceStates]); const handleContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -80,13 +94,16 @@ export const GroupedVoiceParticipant = observer(function GroupedVoiceParticipant participantName={NicknameUtils.getNickname(user, guildId)} onClose={onClose} guildId={guildId} + surface="participant-list" + source={participantMenuSource} isGroupedItem={true} isParentGroupedItem={true} + groupContainsLocalConnection={containsLocalConnection} data-flx="app.grouped-voice-participant.handle-context-menu.voice-participant-context-menu" /> )); }, - [user, guildId], + [user, guildId, participantMenuSource, containsLocalConnection], ); const stateAgg = useMemo(() => { let anySpeaking = propAnySpeaking ?? false; @@ -128,7 +145,7 @@ export const GroupedVoiceParticipant = observer(function GroupedVoiceParticipant allSelfMuted = allSelfMuted && displayState.selfMute; allSelfDeaf = allSelfDeaf && displayState.selfDeaf; } - if (isCurrentUser) { + if (isCurrentUser && containsLocalConnection) { anyCameraOn = anyCameraOn || localSelfVideo; anyLive = anyLive || localSelfStream; } @@ -138,6 +155,7 @@ export const GroupedVoiceParticipant = observer(function GroupedVoiceParticipant user.id, guildId, isCurrentUser, + containsLocalConnection, currentConnectionId, localSelfMute, localSelfDeaf, @@ -154,15 +172,19 @@ export const GroupedVoiceParticipant = observer(function GroupedVoiceParticipant const live = state.self_stream === true || (participant ? participant.isScreenShareEnabled : false); if (live) return state; } - if (isCurrentUser && localSelfStream) { - return ( - voiceStates.find((state) => state.connection_id === currentConnectionId) ?? - voiceStates.find((state) => Boolean(state.connection_id)) ?? - null - ); + if (isCurrentUser && containsLocalConnection && localSelfStream) { + return voiceStates.find((state) => state.connection_id === currentConnectionId) ?? null; } return null; - }, [voiceStates, user.id, isCurrentUser, localSelfStream, currentConnectionId, mediaEngineVersion]); + }, [ + voiceStates, + user.id, + isCurrentUser, + containsLocalConnection, + localSelfStream, + currentConnectionId, + mediaEngineVersion, + ]); const streamKey = activeStreamState?.connection_id ? getStreamKey(guildId, activeStreamState.channel_id ?? null, activeStreamState.connection_id) : ''; diff --git a/fluxer_app/src/features/app/components/layout/VoiceParticipantItem.tsx b/fluxer_app/src/features/app/components/layout/VoiceParticipantItem.tsx index 012e4d596..edb7a6056 100644 --- a/fluxer_app/src/features/app/components/layout/VoiceParticipantItem.tsx +++ b/fluxer_app/src/features/app/components/layout/VoiceParticipantItem.tsx @@ -10,6 +10,7 @@ import {PreloadableUserPopout} from '@app/features/channel/components/Preloadabl import type {VoiceState} from '@app/features/gateway/types/GatewayVoiceTypes'; import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands'; import Permission from '@app/features/permissions/state/Permission'; +import type {VoiceParticipantMenuSource} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes'; import {VoiceParticipantContextMenu} from '@app/features/ui/action_menu/VoiceParticipantContextMenu'; import {AvatarWithPresence} from '@app/features/ui/avatars/AvatarWithPresence'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; @@ -126,6 +127,11 @@ export const VoiceParticipantItem = observer(function VoiceParticipantItem({ const isActuallySpeaking = displayState.speaking; const displayCameraOn = displayState.cameraOn; const displayLive = displayState.streaming; + const participantMenuSource = useMemo(() => { + if (displayCameraOn) return {kind: 'participant', focusSource: 'camera'}; + if (displayLive) return {kind: 'participant', focusSource: 'screen-share'}; + return {kind: 'participant'}; + }, [displayCameraOn, displayLive]); const streamKey = useMemo( () => getStreamKey(guildId, currentChannelId, connectionId), [guildId, currentChannelId, connectionId], @@ -166,12 +172,14 @@ export const VoiceParticipantItem = observer(function VoiceParticipantItem({ onClose={onClose} guildId={guildId} connectionId={connectionId} + surface="participant-list" + source={participantMenuSource} isGroupedItem={isGroupedItem} data-flx="app.voice-participant-item.handle-context-menu.voice-participant-context-menu" /> )); }, - [user, guildId, connectionId, currentChannelId], + [user, guildId, connectionId, currentChannelId, isGroupedItem, participantMenuSource], ); const handleProfilePopoutOpen = useCallback(() => { setIsProfilePopoutOpen(true); @@ -318,6 +326,8 @@ export const VoiceParticipantItem = observer(function VoiceParticipantItem({ participant={participant} guildId={guildId} connectionId={connectionId} + surface="participant-list" + source={participantMenuSource} isConnectionItem={isGroupedItem} data-flx="app.voice-participant-item.voice-participant-bottom-sheet" /> diff --git a/fluxer_app/src/features/channel/components/channel_view/dm_channel_view/CallParticipantsRow.tsx b/fluxer_app/src/features/channel/components/channel_view/dm_channel_view/CallParticipantsRow.tsx index 5a5955bae..752323683 100644 --- a/fluxer_app/src/features/channel/components/channel_view/dm_channel_view/CallParticipantsRow.tsx +++ b/fluxer_app/src/features/channel/components/channel_view/dm_channel_view/CallParticipantsRow.tsx @@ -81,6 +81,8 @@ export const CallParticipantsRow = observer( onClose={onClose} guildId={channel.guildId ?? undefined} connectionId={participantEntry.connectionId} + surface="call-avatar" + source={{kind: 'participant'}} data-flx="channel.channel-view.dm-channel-view.handle-context-menu.voice-participant-context-menu" /> ) : ( diff --git a/fluxer_app/src/features/ui/action_menu/ContextMenuIcons.tsx b/fluxer_app/src/features/ui/action_menu/ContextMenuIcons.tsx index 67c19c8e5..b12bd58b6 100644 --- a/fluxer_app/src/features/ui/action_menu/ContextMenuIcons.tsx +++ b/fluxer_app/src/features/ui/action_menu/ContextMenuIcons.tsx @@ -50,6 +50,7 @@ import { MagnifyingGlassIcon, MicrophoneIcon, MicrophoneSlashIcon, + MonitorArrowUpIcon, MonitorPlayIcon, NotePencilIcon, PaperPlaneIcon, @@ -883,6 +884,13 @@ export const TurnOffStreamIcon: React.FC = observer(({size = 16}) => data-flx="ui.action-menu.context-menu-icons.turn-off-stream-icon.monitor-play-icon" /> )); +export const ChangeStreamIcon: React.FC = observer(({size = 16}) => ( + +)); export const DisconnectIcon: React.FC = observer(({size = 16}) => ( { return ''; }; export const MenuItemRadio = React.forwardRef( - ({label, children, icon: _icon, selected, disabled = false, onSelect, closeOnSelect = false}, forwardedRef) => { + ({label, children, icon, selected, disabled = false, onSelect, closeOnSelect = false}, forwardedRef) => { const closeMenu = useContextMenuClose(); const handleAction = useCallback(() => { if (disabled) return; @@ -48,7 +48,16 @@ export const MenuItemRadio = React.forwardRef
- {visibleLabel} +
+ {icon && ( + + {icon} + + )} + + {visibleLabel} + +
void; guildId?: string; connectionId?: string; + surface: VoiceParticipantMenuSurface; + source: VoiceParticipantMenuSource; isGroupedItem?: boolean; isParentGroupedItem?: boolean; - streamKey?: string; - isScreenShare?: boolean; - isWatching?: boolean; - hasScreenShareAudio?: boolean; - isOwnScreenShare?: boolean; - onStopWatching?: () => void; + groupContainsLocalConnection?: boolean; hiddenConnectionCount?: number; deviceConnectionCount?: number; isDeviceGroupExpanded?: boolean; @@ -51,44 +63,48 @@ interface VoiceParticipantContextMenuProps { export const VoiceParticipantContextMenu: React.FC = observer( ({ user, + participantName, onClose, guildId, connectionId, + surface, + source, isGroupedItem = false, isParentGroupedItem = false, - streamKey, - isScreenShare = false, - isWatching = false, - hasScreenShareAudio = false, - isOwnScreenShare = false, - onStopWatching, + groupContainsLocalConnection = false, hiddenConnectionCount = 0, deviceConnectionCount = 0, isDeviceGroupExpanded = false, onToggleDeviceGroup, }) => { const {i18n} = useLingui(); - const {groups, member, canMoveMembers, userVoiceStates, hasMultipleConnections, hasVoiceChannels} = - useVoiceParticipantMenuData({ - user, - guildId, - connectionId, - isGroupedItem, - isParentGroupedItem, - streamKey, - isScreenShare, - isWatching, - hasScreenShareAudio, - isOwnScreenShare, - onStopWatching, - onClose, - hiddenConnectionCount, - deviceConnectionCount, - isDeviceGroupExpanded, - onToggleDeviceGroup, - }); + const { + groups, + guildManagementGroupIndex, + member, + isCurrentUser, + canMoveMembers, + userVoiceStates, + hasMultipleConnections, + hasVoiceChannels, + } = useVoiceParticipantMenuData({ + user, + guildId, + connectionId, + surface, + source, + isGroupedItem, + isParentGroupedItem, + groupContainsLocalConnection, + onClose, + hiddenConnectionCount, + deviceConnectionCount, + isDeviceGroupExpanded, + onToggleDeviceGroup, + }); const connectionIds = useMemo(() => userVoiceStates.map((u) => u.connectionId), [userVoiceStates]); const guild = guildId ? Guilds.getGuild(guildId) : null; + const privateCallChannel = !guildId && MediaEngine.channelId ? Channels.getChannel(MediaEngine.channelId) : null; const hasRoles = guild && Object.values(guild.roles).some((r) => !r.isEveryone); const canManageRoles = guildId ? Permission.can(Permissions.MANAGE_ROLES, {guildId}) : false; const memberHasVisibleRoles = useMemo(() => { @@ -98,54 +114,133 @@ export const VoiceParticipantContextMenu: React.FC !role.isEveryone && member.roles.has(role.id)); }, [guild, member]); const shouldShowManageRoles = hasRoles && (canManageRoles || memberHasVisibleRoles); + const shouldShowMoveDevice = Boolean(isGroupedItem && connectionId && guildId && hasVoiceChannels); + const shouldShowMoveAllDevices = Boolean( + isParentGroupedItem && hasMultipleConnections && guildId && hasVoiceChannels, + ); + const shouldShowMoveParticipant = Boolean( + guildId && canMoveMembers && !isParentGroupedItem && !isGroupedItem && hasVoiceChannels, + ); + const shouldShowGuildManagement = Boolean( + guildId && + ((member && shouldShowManageRoles) || + shouldShowMoveDevice || + shouldShowMoveAllDevices || + shouldShowMoveParticipant), + ); + const leadingGroups = groups.slice(0, guildManagementGroupIndex); + const trailingGroups = groups.slice(guildManagementGroupIndex); + const guildManagementMenu = shouldShowGuildManagement && guildId && ( + + {member && shouldShowManageRoles && ( + + )} + {shouldShowMoveDevice && connectionId && ( + + )} + {shouldShowMoveAllDevices && ( + + )} + {shouldShowMoveParticipant && ( + + )} + + ); + const auxiliaryMenu = (guild || privateCallChannel?.isDM() || (!isCurrentUser && !user.bot)) && ( + + {guild && isCurrentUser && ( + + )} + {!isCurrentUser && !user.bot && ( + + )} + {privateCallChannel?.isDM() && ( + + )} + + ); + const isOwnScreenShareMenu = source.kind === 'screen-share' && source.state.kind === 'own'; + if (isOwnScreenShareMenu) { + const displayShareEnvironment = resolveDisplayShareEnvironment( + isDesktop(), + NativePermission.isLinuxWaylandDesktop, + ); + const shareContext = ActiveScreenShareSource.getSourceId()?.startsWith('window:') ? 'app' : 'display'; + const shareContextResolved = ActiveScreenShareSource.getSourceId() != null; + return ( + + } + data-flx="ui.action-menu.voice-participant-context-menu.active-screen-share-menu" + /> + ); + } + if (source.kind === 'screen-share') { + return ( + + ); + } return ( <> - - {isGroupedItem && connectionId && guildId && hasVoiceChannels && ( - - - - )} - {isParentGroupedItem && hasMultipleConnections && guildId && hasVoiceChannels && ( - - - - )} - {guildId && canMoveMembers && !isParentGroupedItem && !isGroupedItem && hasVoiceChannels && ( - - - - )} - {guildId && member && shouldShowManageRoles && ( - - - - )} + + {auxiliaryMenu} + {guildManagementMenu} + ); }, diff --git a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.test.tsx b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.test.tsx new file mode 100644 index 000000000..d1bc6c8fe --- /dev/null +++ b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.test.tsx @@ -0,0 +1,167 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap'; +import type {VoiceParticipantMenuSource} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes'; +import type {User} from '@app/features/user/models/User'; +import {act, createElement} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, expect, test, vi} from 'vitest'; + +vi.mock('@lingui/core/macro', () => { + const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value); + return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''}; +}); +vi.mock('@lingui/react/macro', () => ({ + Trans: () => null, + useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? '', locale: 'en'}}), +})); + +installVoiceMenuTestBootstrap(); + +const {useVoiceParticipantMenuData} = await import('@app/features/ui/action_menu/items/VoiceParticipantMenuData'); +const {default: EntranceSoundListenerPrefs} = await import('@app/features/voice/state/EntranceSoundListenerPrefs'); +const {default: MediaEngine} = await import('@app/features/voice/engine/MediaEngineFacade'); +const {observer} = await import('mobx-react-lite'); + +(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true; + +const TARGET_USER = {id: '111', username: 'target'} as unknown as User; +const PARTICIPANT_SOURCE: VoiceParticipantMenuSource = {kind: 'participant'}; +const CAMERA_SOURCE: VoiceParticipantMenuSource = {kind: 'camera'}; +const noop = (): void => undefined; + +interface MenuLeaf { + label?: string; + checked?: boolean; + value?: number; + items?: Array; +} + +interface MenuGroup { + items: Array; +} + +const shadowedMediaEngineKeys: Array = []; +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +function shadowMediaEngine(key: string, value: unknown): void { + Object.defineProperty(MediaEngine, key, {value, configurable: true, writable: true}); + shadowedMediaEngineKeys.push(key); +} + +function findLeaf(groups: Array, label: string): MenuLeaf | null { + for (const group of groups) { + for (const item of group.items) { + if (item.label === label) return item; + for (const child of item.items ?? []) { + if (child.label === label) return child; + } + } + } + return null; +} + +async function renderMenu( + options: Parameters[0], +): Promise<{groups: () => Array}> { + const latest: {groups: Array} = {groups: []}; + const Probe = observer(function Probe() { + latest.groups = useVoiceParticipantMenuData(options).groups as unknown as Array; + return null; + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render(createElement(Probe)); + }); + return {groups: () => latest.groups}; +} + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + root = null; + container?.remove(); + container = null; + for (const key of shadowedMediaEngineKeys.splice(0)) { + delete (MediaEngine as unknown as Record)[key]; + } + EntranceSoundListenerPrefs.reset(TARGET_USER.id); +}); + +test('entrance-sound checkbox tracks the pref while the menu stays open', async () => { + const menu = await renderMenu({ + user: TARGET_USER, + surface: 'call-tile', + source: PARTICIPANT_SOURCE, + onClose: noop, + }); + expect(findLeaf(menu.groups(), 'Mute entrance sound')?.checked).toBe(false); + await act(async () => { + EntranceSoundListenerPrefs.setMuted(TARGET_USER.id, true); + }); + expect(findLeaf(menu.groups(), 'Mute entrance sound')?.checked).toBe(true); + await act(async () => { + EntranceSoundListenerPrefs.setVolume(TARGET_USER.id, 42); + }); + expect(findLeaf(menu.groups(), 'Entrance sound volume')?.value).toBe(42); +}); + +test('disable video locally is offered for another user camera tile in the local room', async () => { + shadowMediaEngine('connected', true); + shadowMediaEngine('connectionId', 'self-connection'); + shadowMediaEngine('channelId', 'channel-1'); + shadowMediaEngine('getVoiceStateByConnectionId', (connectionId: string) => + connectionId === 'target-connection' ? {channel_id: 'channel-1', user_id: TARGET_USER.id} : null, + ); + const menu = await renderMenu({ + user: TARGET_USER, + connectionId: 'target-connection', + surface: 'call-tile', + source: CAMERA_SOURCE, + onClose: noop, + }); + expect(findLeaf(menu.groups(), 'Disable video locally')).not.toBeNull(); +}); + +test('offers a single inline user-volume slider and no nested secondary submenu wrapper', async () => { + const REMOVED_SUBMENU_LABELS = [ + 'Media controls', + 'Device controls', + 'Display options', + 'User actions', + 'Relationship', + 'Relationship actions', + 'Moderation', + 'Moderation actions', + 'Advanced', + 'Advanced actions', + ]; + shadowMediaEngine('connected', true); + shadowMediaEngine('connectionId', 'self-connection'); + shadowMediaEngine('channelId', 'channel-1'); + shadowMediaEngine('getVoiceStateByConnectionId', (connectionId: string) => + connectionId === 'target-connection' ? {channel_id: 'channel-1', user_id: TARGET_USER.id} : null, + ); + const menu = await renderMenu({ + user: TARGET_USER, + connectionId: 'target-connection', + surface: 'participant-list', + source: PARTICIPANT_SOURCE, + onClose: noop, + }); + const groups = menu.groups(); + const topLevelItems = groups.flatMap((group) => group.items); + const topLevelSliders = topLevelItems.filter((item) => typeof item.value === 'number'); + expect(topLevelSliders).toHaveLength(1); + expect(topLevelSliders[0]?.label).toBe('User volume'); + const submenuLabels = topLevelItems.filter((item) => Array.isArray(item.items)).map((item) => item.label); + expect(submenuLabels.every((label) => label === 'Entrance sound')).toBe(true); + for (const label of REMOVED_SUBMENU_LABELS) { + expect(findLeaf(groups, label)).toBeNull(); + } +}); diff --git a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx index f2d480c55..d2ba26a5a 100644 --- a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx @@ -85,9 +85,15 @@ import { 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, @@ -102,12 +108,9 @@ import { MUTE_DESCRIPTOR, MUTE_DEVICE_DESCRIPTOR, POP_OUT_CAMERA_DESCRIPTOR, - POP_OUT_STREAM_DESCRIPTOR, - PRIORITIZE_SPEAKERS_DESCRIPTOR, - SHOW_MY_OWN_CAMERA_DESCRIPTOR, - SHOW_MY_SCREEN_SHARE_DESCRIPTOR, + POP_OUT_USER_DESCRIPTOR, + PREVIEW_CAMERA_DESCRIPTOR, SHOW_NON_VIDEO_PARTICIPANTS_DESCRIPTOR, - STREAM_VOLUME_DESCRIPTOR, TURN_OFF_ALL_DEVICE_CAMERAS_DESCRIPTOR, TURN_OFF_DEVICE_CAMERA_DESCRIPTOR, TURN_OFF_DEVICE_STREAM_DESCRIPTOR, @@ -134,10 +137,10 @@ 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 {HideOwnCameraConfirmModal} from '@app/features/voice/components/modals/HideOwnCameraConfirmModal'; -import {HideOwnScreenShareConfirmModal} from '@app/features/voice/components/modals/HideOwnScreenShareConfirmModal'; +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, @@ -145,7 +148,6 @@ import PopoutWindowManager, { } from '@app/features/voice/state/PopoutWindowManager'; import StreamAudioPrefs from '@app/features/voice/state/StreamAudioPrefs'; import VoiceCallLayout from '@app/features/voice/state/VoiceCallLayout'; -import VoicePrompts from '@app/features/voice/state/VoicePrompts'; 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'; @@ -160,7 +162,6 @@ import { VOICE_DISCONNECT_DESCRIPTOR, VOICE_DISCONNECT_DEVICE_DESCRIPTOR, VOICE_MUTE_ALL_DEVICES_DESCRIPTOR, - VOICE_STOP_WATCHING_DESCRIPTOR, VOICE_UNDEAFEN_ALL_DEVICES_DESCRIPTOR, VOICE_UNMUTE_ALL_DEVICES_DESCRIPTOR, VOICE_USER_VOLUME_DESCRIPTOR, @@ -175,49 +176,28 @@ import {useCallback, useMemo} from 'react'; const logger = new Logger('VoiceParticipantMenuData'); -const ADVANCED_ACTIONS_DESCRIPTOR = msg({ - message: 'Advanced', - comment: 'Voice participant context menu submenu label for IDs and diagnostics.', +const ENTRANCE_SOUND_SUBMENU_DESCRIPTOR = msg({ + message: 'Entrance sound', + comment: 'Voice menu submenu label for per-user entrance sound controls.', }); -const DEVICE_CONTROLS_DESCRIPTOR = msg({ - message: 'Device controls', - comment: 'Voice participant context menu submenu label for actions that affect one or more voice devices.', +const MUTE_ENTRANCE_SOUND_DESCRIPTOR = msg({ + message: 'Mute entrance sound', + comment: "Voice menu checkbox label that locally silences a user's entrance sound.", }); -const DISPLAY_OPTIONS_DESCRIPTOR = msg({ - message: 'Display options', - comment: 'Voice participant context menu submenu label for call display and diagnostics preferences.', -}); -const MEDIA_CONTROLS_DESCRIPTOR = msg({ - message: 'Media controls', - comment: 'Voice participant context menu submenu label for camera, stream, and video actions.', -}); -const MODERATION_ACTIONS_DESCRIPTOR = msg({ - message: 'Moderation', - comment: 'Voice participant context menu submenu label for member moderation actions.', -}); -const RELATIONSHIP_ACTIONS_DESCRIPTOR = msg({ - message: 'Relationship', - comment: 'Voice participant context menu submenu label for friend and block actions.', -}); -const USER_ACTIONS_DESCRIPTOR = msg({ - message: 'User actions', - comment: 'Voice participant context menu submenu label for lower-frequency profile and communication actions.', +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; +type VoiceParticipantMenuLeafItem = MenuItemType | MenuCheckboxType | MenuSliderType | MenuSubmenuItemType; -export interface VoiceParticipantMenuDataOptions { +export interface VoiceParticipantMenuDataOptions extends VoiceParticipantMenuRequest { user: User; guildId?: string; connectionId?: string; isGroupedItem?: boolean; isParentGroupedItem?: boolean; - streamKey?: string; - isScreenShare?: boolean; - isWatching?: boolean; - hasScreenShareAudio?: boolean; - isOwnScreenShare?: boolean; - onStopWatching?: () => void; + groupContainsLocalConnection?: boolean; onClose: () => void; hiddenConnectionCount?: number; deviceConnectionCount?: number; @@ -227,6 +207,7 @@ export interface VoiceParticipantMenuDataOptions { export interface VoiceParticipantMenuData { groups: Array; + guildManagementGroupIndex: number; member: GuildMember | null; isCurrentUser: boolean; developerMode: boolean; @@ -245,18 +226,17 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt connectionId, isGroupedItem = false, isParentGroupedItem = false, - streamKey, - isScreenShare = false, - isWatching = false, - hasScreenShareAudio = false, - isOwnScreenShare = false, - onStopWatching, + 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; @@ -327,17 +307,30 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt : 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 showMyOwnCamera = VoiceSettings.showMyOwnCamera; const showMyOwnScreenShare = VoiceSettings.showMyOwnScreenShare; const showNonVideoParticipants = VoiceSettings.showNonVideoParticipants; - const prioritizeSpeakingParticipants = VoiceSettings.prioritizeSpeakingParticipants; + 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; @@ -345,7 +338,6 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt const participantIdentity = connectionId ? buildVoiceParticipantIdentity(user.id, connectionId) : ''; const isVideoDisabled = callId && participantIdentity ? CallMediaPrefs.isVideoDisabled(callId, participantIdentity) : false; - const allVoiceStates = MediaEngine.getAllVoiceStates(); const pinnedParticipantIdentity = VoiceCallLayout.pinnedParticipantIdentity; const pinnedParticipantSource = VoiceCallLayout.pinnedParticipantSource; const memberMute = member?.mute ?? false; @@ -378,23 +370,20 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt }) .map((channel) => ({id: channel.id, name: channel.name ?? ''})); }, [guildId, userVoiceState?.channel_id]); - const groups = useMemo(() => { + const menuAssembly = useMemo(() => { const menuGroups: Array = []; const hasDeviceGroup = deviceConnectionCount > 1 || hasMultipleConnections; - const secondarySubmenus: Array = []; - const streamControlActions: Array = []; + const isDeviceScopedMenu = isGroupedItem || isParentGroupedItem; + const focusActions: Array = []; const mediaActions: Array = []; const deviceActions: Array = []; + const popoutActions: Array = []; const displayActions: Array = []; - const userActions: Array = []; - const relationshipSubmenuActions: Array = []; - const moderationSubmenuActions: Array = []; + const volumeActions: Array = []; + const voiceActions: Array = []; + const relationshipActions: Array = []; + const moderationActions: Array = []; const advancedActions: Array = []; - const addSecondarySubmenu = (label: string, items: Array) => { - if (items.length > 0) { - secondarySubmenus.push({label, items}); - } - }; const createCopyDeviceIdAction = (targetConnectionId: string): MenuItemType => ({ icon: ( { - TextCopyCommands.copy(i18n, targetConnectionId, true).catch(() => {}); + TextCopyCommands.copy(i18n, targetConnectionId, true).catch((error) => { + logger.error('Failed to copy voice device id', {targetConnectionId, error}); + }); onClose(); }, }); @@ -415,7 +406,9 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt maxValue: 200, onChange: (value: number) => { ParticipantVolume.setVolume(user.id, value); - MediaEngine.applyLocalAudioPreferencesForUser(user.id); + if (isTargetInLocalVoiceRoom) { + MediaEngine.applyLocalAudioPreferencesForUser(user.id); + } }, onFormat: (value: number) => `${Math.round(value)}%`, factoryDefaultValue: 100, @@ -427,7 +420,9 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt maxValue: 200, onChange: (value: number) => { ParticipantVolume.setConnectionVolume(targetConnectionId, value); - MediaEngine.applyLocalAudioPreferencesForUser(user.id); + if (isTargetInLocalVoiceRoom) { + MediaEngine.applyLocalAudioPreferencesForUser(user.id); + } }, onFormat: (value: number) => `${Math.round(value)}%`, factoryDefaultValue: 100, @@ -439,57 +434,46 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt } return volumeControls; }; - const streamRootActions: Array = []; - if (isScreenShare && !isOwnScreenShare && streamKey) { - if (isWatching && onStopWatching) { - streamRootActions.push({ - icon: ( - - ), - label: i18n._(VOICE_STOP_WATCHING_DESCRIPTOR), - onClick: () => { - onStopWatching(); - onClose(); - }, - }); - } - if (hasScreenShareAudio) { - streamControlActions.push({ - label: i18n._(STREAM_VOLUME_DESCRIPTOR), - value: streamVolume, + 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) => { - StreamAudioPrefs.setVolume(streamKey, value); - MediaEngine.applyLocalAudioPreferencesForUser(user.id); - }, + onChange: (value: number) => EntranceSoundListenerPrefs.setVolume(user.id, value), onFormat: (value: number) => `${Math.round(value)}%`, factoryDefaultValue: 100, - }); - streamControlActions.push({ - icon: ( - - ), - label: i18n._(MUTE_DESCRIPTOR), - checked: isStreamMuted, - onChange: (checked: boolean) => { - StreamAudioPrefs.setMuted(streamKey, checked); - MediaEngine.applyLocalAudioPreferencesForUser(user.id); - }, - }); - } - } - if (streamRootActions.length > 0) { - menuGroups.push({items: streamRootActions}); - } - if (streamControlActions.length > 0) { - menuGroups.push({items: streamControlActions}); + }, + ], + }); + 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 = []; primaryActions.push({ @@ -504,12 +488,19 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt ModalCommands.runAfterBottomSheetClose(onClose, () => UserProfileCommands.openUserProfile(user.id, guildId)); }, }); - if (connectionId && guildId && isCurrentUserConnectedToVoice) { + 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 === (isScreenShare ? Track.Source.ScreenShare : Track.Source.Camera)); + (pinnedParticipantSource == null || pinnedParticipantSource === liveKitFocusSource); const allStates = MediaEngine.getAllVoiceStates(); let connectionCount = 0; Object.values(allStates).forEach((guildData) => { @@ -525,7 +516,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt if (hasMultipleConnectionsForFocus) return i18n._(FOCUS_THIS_DEVICE_DESCRIPTOR); return i18n._(FOCUS_THIS_PERSON_DESCRIPTOR); })(); - primaryActions.push({ + focusActions.push({ icon: isFocused ? ( ) : ( @@ -539,10 +530,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt VoiceCallLayoutCommands.markUserOverride(); } else { VoiceCallLayoutCommands.setLayoutMode('focus'); - VoiceCallLayoutCommands.setPinnedParticipant( - identity, - isScreenShare ? Track.Source.ScreenShare : Track.Source.Camera, - ); + VoiceCallLayoutCommands.setPinnedParticipant(identity, liveKitFocusSource); VoiceCallLayoutCommands.markUserOverride(); } onClose(); @@ -550,47 +538,54 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt }); } const popoutChannelId = connectionVoiceState?.channel_id ?? null; - if (connectionId && popoutChannelId && isCurrentUserConnectedToVoice && isVoicePopoutSupported()) { - const connectionParticipant = MediaEngine.getParticipantByUserIdAndConnectionId(user.id, connectionId); - const isCameraLive = Boolean(connectionParticipant?.isCameraEnabled || connectionVoiceState?.self_video); - const isStreamLive = Boolean(connectionParticipant?.isScreenShareEnabled || connectionVoiceState?.self_stream); - const openTilePopout = (source: VoiceTilePopoutSource): void => { - PopoutWindowManager.openTilePopout({ + 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, + source: participantPopoutSource, userId: user.id, connectionId, channelId: popoutChannelId, guildId: guildId ?? null, title: displayName, }); - onClose(); + if (didOpen) onClose(); }; - primaryActions.push({ + popoutActions.push({ icon: ( ), - label: i18n._(POP_OUT_CAMERA_DESCRIPTOR), - disabled: !isCameraLive, - onClick: () => openTilePopout('camera'), - }); - primaryActions.push({ - icon: ( - - ), - label: i18n._(POP_OUT_STREAM_DESCRIPTOR), - disabled: !isStreamLive, - onClick: () => openTilePopout('screen_share'), + label: i18n._(participantPopoutSource === 'camera' ? POP_OUT_CAMERA_DESCRIPTOR : POP_OUT_USER_DESCRIPTOR), + onClick: openParticipantPopout, }); } - if (canMention) { - userActions.push({ + if (isCurrentUser && !isCameraLive && (surface === 'call-tile' || surface === 'call-avatar')) { + mediaActions.push({ + icon: ( + + ), + label: i18n._(PREVIEW_CAMERA_DESCRIPTOR), + onClick: () => { + ModalCommands.pushAfterBottomSheetClose( + onClose, + modal(() => ( + + )), + ); + }, + }); + } + if (surface === 'participant-list' && canMention) { + primaryActions.push({ icon: ( , - label: i18n._(ADD_NOTE_DESCRIPTOR), - onClick: () => { - ModalCommands.runAfterBottomSheetClose(onClose, () => - UserProfileCommands.openUserProfile(user.id, guildId, true), - ); - }, - }); if (!isCurrentUser && relationshipType === RelationshipTypes.FRIEND) { - userActions.push({ + relationshipActions.push({ icon: ( ), @@ -668,6 +654,19 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt }, }); } + if (!isCurrentUser) { + primaryActions.push({ + icon: ( + + ), + label: i18n._(ADD_NOTE_DESCRIPTOR), + onClick: () => { + ModalCommands.runAfterBottomSheetClose(onClose, () => + UserProfileCommands.openUserProfile(user.id, guildId, true), + ); + }, + }); + } if (hasDeviceGroup && onToggleDeviceGroup && hiddenConnectionCount > 0) { primaryActions.push({ icon: , @@ -697,7 +696,10 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt } if (isCurrentUser) { const selfRootActions: Array = []; - if (isGroupedItem && connectionId) { + 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; @@ -725,7 +727,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt VoiceStateCommands.toggleSelfDeafenForConnection(connectionId); }, }); - if (isCameraOn) { + if (isCameraOn && isDeviceScopedMenu) { selfRootActions.push({ icon: ( - ), - label: getVoiceVideoSettingsLabel(i18n), - onClick: () => { - ModalCommands.pushAfterBottomSheetClose( - onClose, - modal(() => ( - - )), - ); - }, - }); - if (currentConnectionId) { + if (isDeviceScopedMenu) { + mediaActions.push({ + icon: ( + + ), + label: getVoiceVideoSettingsLabel(i18n), + onClick: () => { + ModalCommands.pushAfterBottomSheetClose( + onClose, + modal(() => ( + + )), + ); + }, + }); + } + if (currentConnectionId && isDeviceScopedMenu) { advancedActions.push(createCopyDeviceIdAction(currentConnectionId)); } } - if (guildId) { - const cid = connectionId ?? MediaEngine.connectionId ?? null; - const isCurrentDevice = !connectionId || connectionId === MediaEngine.connectionId; + 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: ( { if (isCurrentDevice) { await MediaEngine.disconnectFromVoiceChannel('user'); - } else if (cid) { - MediaEngine.disconnectRemoteDevice(guildId, cid); + } else if (targetGuildId) { + MediaEngine.disconnectRemoteDevice(targetGuildId, targetConnectionId); } onClose(); }, @@ -865,97 +873,68 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt }); } if (selfRootActions.length > 0) { - menuGroups.push({items: selfRootActions}); + voiceActions.push(...selfRootActions); } if (connectionId && connectionId !== currentConnectionId && isCurrentUserConnectedToVoice) { - menuGroups.push({items: buildVolumeControls(connectionId)}); + volumeActions.push(...buildVolumeControls(connectionId)); } - const displayPrefs: Array = [ - { - label: i18n._(SHOW_MY_OWN_CAMERA_DESCRIPTOR), - checked: showMyOwnCamera, - onChange: (checked) => { - if (!checked) { - if (VoicePrompts.getSkipHideOwnCameraConfirm()) { - VoiceSettingsCommands.update({showMyOwnCamera: false}); - } else { - ModalCommands.pushAfterBottomSheetClose( - onClose, - modal(() => ( - - )), - ); - } - } else { - VoiceSettingsCommands.update({showMyOwnCamera: true}); - } - }, - }, - { - label: i18n._(SHOW_MY_SCREEN_SHARE_DESCRIPTOR), - checked: showMyOwnScreenShare, - onChange: (checked) => { - if (!checked) { - if (VoicePrompts.getSkipHideOwnScreenShareConfirm()) { - VoiceSettingsCommands.update({showMyOwnScreenShare: false}); - } else { - ModalCommands.pushAfterBottomSheetClose( - onClose, - modal(() => ( - - )), - ); - } - } else { - VoiceSettingsCommands.update({showMyOwnScreenShare: true}); - } - }, - }, - { - label: i18n._(SHOW_NON_VIDEO_PARTICIPANTS_DESCRIPTOR), - checked: showNonVideoParticipants, - onChange: (checked) => VoiceSettingsCommands.update({showNonVideoParticipants: checked}), - }, - { - label: i18n._(PRIORITIZE_SPEAKERS_DESCRIPTOR), - checked: prioritizeSpeakingParticipants, - onChange: (checked) => VoiceSettings.setPrioritizeSpeakingParticipants(checked), - }, - ]; - displayActions.push(...displayPrefs); } else { - if (isCurrentUserConnectedToVoice) { - menuGroups.push({items: buildVolumeControls(connectionId)}); - if (connectionId) { - mediaActions.push({ - 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); - }, - }); - } + volumeActions.push(...buildVolumeControls(connectionId)); + voiceActions.push( + { + 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: ( + + ), + 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) { + 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 = []; const connectionIds = userVoiceStates.map((u) => u.connectionId); @@ -1041,7 +1020,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt if (isCurrentUser) { deviceActions.push(...bulkActions); } else { - moderationSubmenuActions.push(...bulkActions); + moderationActions.push(...bulkActions); } } } @@ -1051,7 +1030,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt const hasManageNicknamesPermission = Permission.can(Permissions.MANAGE_NICKNAMES, {guildId}); const canManageNicknames = (isCurrentUser && hasChangeNicknamePermission) || (!isCurrentUser && hasManageNicknamesPermission); - if (canManageNicknames) { + if (canManageNicknames && !isCurrentUser) { guildActions.push({ icon: ( 0) { - userActions.push(...guildActions); + relationshipActions.push(...guildActions); } } if (!isCurrentUser) { - const relationshipActions: Array = []; + const nextRelationshipActions: Array = []; if (!RuntimeConfig.directMessagesDisabled) { switch (relationshipType) { case RelationshipTypes.FRIEND: - relationshipActions.push({ + nextRelationshipActions.push({ icon: ( 0) { - relationshipSubmenuActions.push(...relationshipActions); + if (nextRelationshipActions.length > 0) { + relationshipActions.push(...nextRelationshipActions); } } - if (!isCurrentUser && guildId && member) { - const moderationActions: Array = []; - if (canMuteMembers && canManageTarget(user.id)) { + if (guildId && member) { + const nextModerationActions: Array = []; + if (canMuteMembers && (isCurrentUser || canManageTarget(user.id))) { const isGuildMuted = memberMute; const isGuildDeafened = memberDeaf; - moderationActions.push({ + nextModerationActions.push({ icon: ( ), @@ -1306,7 +1285,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt }); } if (canKickTarget) { - moderationActions.push({ + nextModerationActions.push({ icon: ( 0) { - moderationSubmenuActions.push(...moderationActions); + if (nextModerationActions.length > 0) { + moderationActions.push(...nextModerationActions); } } if (developerMode) { @@ -1383,25 +1362,33 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt TextCopyCommands.copy(i18n, user.id, true); }, }); - addSecondarySubmenu(i18n._(MEDIA_CONTROLS_DESCRIPTOR), mediaActions); - addSecondarySubmenu(i18n._(DEVICE_CONTROLS_DESCRIPTOR), deviceActions); - addSecondarySubmenu(i18n._(DISPLAY_OPTIONS_DESCRIPTOR), displayActions); - addSecondarySubmenu(i18n._(USER_ACTIONS_DESCRIPTOR), userActions); - addSecondarySubmenu(i18n._(RELATIONSHIP_ACTIONS_DESCRIPTOR), relationshipSubmenuActions); - addSecondarySubmenu(i18n._(MODERATION_ACTIONS_DESCRIPTOR), moderationSubmenuActions); - addSecondarySubmenu(i18n._(ADVANCED_ACTIONS_DESCRIPTOR), advancedActions); - if (secondarySubmenus.length > 0) { - menuGroups.push({items: secondarySubmenus}); - } - return menuGroups; + if (!isCurrentUser && volumeActions.length > 0) menuGroups.push({items: volumeActions}); + const participantActions: Array = [ + ...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 = [...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, @@ -1427,24 +1414,22 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt currentUserVoiceState, currentUserVoiceStateInGuild, isCurrentUserConnectedToVoice, + isTargetInLocalVoiceRoom, currentConnectionId, currentConnectionVoiceState, - showMyOwnCamera, showMyOwnScreenShare, showNonVideoParticipants, - prioritizeSpeakingParticipants, + pauseOwnScreenSharePreviewOnUnfocus, showConnectionVolumeControls, participantVolume, + isParticipantLocallyMuted, + entranceSoundMuted, + entranceSoundVolume, connectionVolume, isVideoDisabled, callId, participantIdentity, streamKey, - isScreenShare, - isWatching, - hasScreenShareAudio, - isOwnScreenShare, - onStopWatching, streamVolume, isStreamMuted, hiddenConnectionCount, @@ -1453,7 +1438,8 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt onToggleDeviceGroup, ]); return { - groups, + groups: menuAssembly.groups, + guildManagementGroupIndex: menuAssembly.guildManagementGroupIndex, member, isCurrentUser, developerMode, diff --git a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuTypes.test.ts b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuTypes.test.ts new file mode 100644 index 000000000..768d593b6 --- /dev/null +++ b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuTypes.test.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {describe, expect, it, vi} from 'vitest'; + +const errors: Array = []; + +vi.mock('@app/features/platform/utils/AppLogger', () => ({ + Logger: class { + error(...args: Array) { + errors.push(args); + } + }, +})); + +const {normalizeVoiceParticipantMenuRequest} = await import( + '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes' +); + +describe('normalizeVoiceParticipantMenuRequest', () => { + it('passes participant sources through on every surface', () => { + expect(normalizeVoiceParticipantMenuRequest({surface: 'participant-list', source: {kind: 'participant'}})).toEqual({ + kind: 'participant', + }); + expect( + normalizeVoiceParticipantMenuRequest({ + surface: 'stream-spectator-list', + source: {kind: 'participant', focusSource: 'camera'}, + }), + ).toEqual({kind: 'participant', focusSource: 'camera'}); + }); + + it('keeps camera and screen-share sources on the call-tile surface', () => { + expect(normalizeVoiceParticipantMenuRequest({surface: 'call-tile', source: {kind: 'camera'}})).toEqual({ + kind: 'camera', + }); + const screenShare = {kind: 'screen-share', streamKey: 'guild:channel:conn', state: {kind: 'own'}} as const; + expect(normalizeVoiceParticipantMenuRequest({surface: 'call-tile', source: screenShare})).toBe(screenShare); + }); + + it('degrades to a participant menu instead of throwing on a mismatched surface', () => { + errors.length = 0; + expect(normalizeVoiceParticipantMenuRequest({surface: 'call-avatar', source: {kind: 'camera'}})).toEqual({ + kind: 'participant', + focusSource: 'camera', + }); + expect( + normalizeVoiceParticipantMenuRequest({ + surface: 'participant-list', + source: {kind: 'screen-share', streamKey: 'guild:channel:conn', state: {kind: 'own'}}, + }), + ).toEqual({kind: 'participant', focusSource: 'screen-share'}); + expect(errors).toHaveLength(2); + }); + + it('degrades to a participant menu when a screen-share source has an empty stream key', () => { + errors.length = 0; + expect( + normalizeVoiceParticipantMenuRequest({ + surface: 'call-tile', + source: {kind: 'screen-share', streamKey: '', state: {kind: 'own'}}, + }), + ).toEqual({kind: 'participant', focusSource: 'screen-share'}); + expect(errors).toHaveLength(1); + }); +}); diff --git a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuTypes.ts b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuTypes.ts new file mode 100644 index 000000000..56cacbd00 --- /dev/null +++ b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuTypes.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Logger} from '@app/features/platform/utils/AppLogger'; + +const logger = new Logger('VoiceParticipantMenuTypes'); + +export type VoiceParticipantMenuSurface = + | 'participant-list' + | 'call-tile' + | 'call-avatar' + | 'participant-avatar-list' + | 'stream-spectator-list'; + +export type VoiceParticipantVisualSource = 'camera' | 'screen-share'; + +export interface VoiceParticipantMenuParticipantSource { + kind: 'participant'; + focusSource?: VoiceParticipantVisualSource; +} + +export interface VoiceParticipantMenuCameraSource { + kind: 'camera'; +} + +export type VoiceParticipantStreamState = + | { + kind: 'own'; + } + | { + kind: 'remote-unwatched'; + onWatch: () => void; + } + | { + kind: 'remote-watched'; + hasAudio: boolean; + onStopWatching: () => void; + }; + +export interface VoiceParticipantMenuScreenShareSource { + kind: 'screen-share'; + streamKey: string; + state: VoiceParticipantStreamState; +} + +export type VoiceParticipantMenuSource = + | VoiceParticipantMenuParticipantSource + | VoiceParticipantMenuCameraSource + | VoiceParticipantMenuScreenShareSource; + +export interface VoiceParticipantMenuRequest { + surface: VoiceParticipantMenuSurface; + source: VoiceParticipantMenuSource; +} + +export function normalizeVoiceParticipantMenuRequest(request: VoiceParticipantMenuRequest): VoiceParticipantMenuSource { + const {surface, source} = request; + if (source.kind === 'participant') return source; + if (source.kind === 'camera') { + if (surface !== 'call-tile') { + logger.error('Camera participant menus require the call-tile surface', {surface}); + return {kind: 'participant', focusSource: 'camera'}; + } + return source; + } + if (surface !== 'call-tile') { + logger.error('Screen-share participant menus require the call-tile surface', {surface}); + return {kind: 'participant', focusSource: 'screen-share'}; + } + if (source.streamKey.length === 0) { + logger.error('Screen-share participant menus require a non-empty stream key', {surface}); + return {kind: 'participant', focusSource: 'screen-share'}; + } + return source; +} diff --git a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.test.tsx b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.test.tsx new file mode 100644 index 000000000..c059bae98 --- /dev/null +++ b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap'; +import type {VoiceParticipantMenuScreenShareSource} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes'; +import type {I18n} from '@lingui/core'; +import {expect, test, vi} from 'vitest'; + +vi.mock('@lingui/core/macro', () => { + const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value); + return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''}; +}); +vi.mock('@lingui/react/macro', () => ({ + Trans: () => null, + useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? '', locale: 'en'}}), +})); +vi.mock('@app/features/voice/components/ActiveScreenShareMenu', () => ({ + changeActiveScreenShare: vi.fn(async () => undefined), + stopActiveScreenShare: vi.fn(async () => undefined), +})); +vi.mock('@app/features/voice/components/modals/ScreenSharePickerModal', () => ({ + openScreenSharePreviewPrivacyModal: vi.fn(), +})); +vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({ + default: { + applyLocalAudioPreferencesForUser: vi.fn(), + getVoiceStateByConnectionId: () => null, + connectionId: null, + }, +})); +vi.mock('@app/features/voice/state/PopoutWindowManager', () => ({ + default: {openTilePopout: vi.fn()}, + isVoicePopoutSupported: () => false, +})); +vi.mock('@app/features/voice/state/StreamAudioPrefs', () => ({ + default: {setMuted: vi.fn(), setVolume: vi.fn()}, +})); +vi.mock('@app/features/voice/state/VoiceSettings', () => ({ + default: {showMyOwnScreenShare: false, pauseOwnScreenSharePreviewOnUnfocus: false}, +})); +vi.mock('@app/features/voice/commands/VoiceSettingsCommands', () => ({ + update: vi.fn(), +})); + +installVoiceMenuTestBootstrap(); + +const {buildVoiceParticipantStreamMenu} = await import( + '@app/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder' +); + +const i18n = { + locale: 'en', + _: (descriptor: {message?: string}) => descriptor.message ?? '', +} as unknown as I18n; + +interface MenuLeaf { + label?: string; + items?: Array; +} + +function streamMenu(source: VoiceParticipantMenuScreenShareSource): Array<{items: Array}> { + return buildVoiceParticipantStreamMenu({ + i18n, + userId: '111', + channelId: null, + participantIdentity: 'user_111_conn', + displayName: 'Alice', + isCurrentUserConnectedToVoice: false, + source, + streamVolume: 100, + isStreamMuted: false, + showMyOwnScreenShare: false, + pauseOwnScreenSharePreviewOnUnfocus: false, + onClose: () => undefined, + }) as unknown as Array<{items: Array}>; +} + +function findLeaf(groups: Array<{items: Array}>, label: string): MenuLeaf | null { + for (const group of groups) { + for (const item of group.items) { + if (item.label === label) return item; + for (const child of item.items ?? []) { + if (child.label === label) return child; + } + } + } + return null; +} + +const OWN_STREAM_SOURCE: VoiceParticipantMenuScreenShareSource = { + kind: 'screen-share', + streamKey: 'stream-key', + state: {kind: 'own'}, +}; + +const WATCHED_REMOTE_STREAM_SOURCE: VoiceParticipantMenuScreenShareSource = { + kind: 'screen-share', + streamKey: 'stream-key', + state: {kind: 'remote-watched', hasAudio: true, onStopWatching: () => undefined}, +}; + +test('own stream keeps a More options submenu with the screen-share preferences', () => { + const groups = streamMenu(OWN_STREAM_SOURCE); + const moreOptions = findLeaf(groups, 'More options'); + expect(moreOptions).not.toBeNull(); + expect(findLeaf(groups, 'Show My Screen Share')).not.toBeNull(); + expect(findLeaf(groups, 'Report Problem')).toBeNull(); +}); + +test('remote watched stream omits the now-empty More options submenu and keeps audio controls', () => { + const groups = streamMenu(WATCHED_REMOTE_STREAM_SOURCE); + expect(findLeaf(groups, 'More options')).toBeNull(); + expect(findLeaf(groups, 'Mute')).not.toBeNull(); + expect(findLeaf(groups, 'Stream volume')).not.toBeNull(); + expect(findLeaf(groups, 'Report Problem')).toBeNull(); +}); diff --git a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.tsx b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.tsx new file mode 100644 index 000000000..5bbe4c794 --- /dev/null +++ b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.tsx @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {MORE_OPTIONS_DESCRIPTOR, WATCH_STREAM_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; +import {Logger} from '@app/features/platform/utils/AppLogger'; +import {CheckboxItem} from '@app/features/ui/action_menu/ContextMenu'; +import { + ChangeStreamIcon, + LocalMuteIcon, + PopOutIcon, + TurnOffStreamIcon, +} from '@app/features/ui/action_menu/ContextMenuIcons'; +import type {VoiceParticipantMenuScreenShareSource} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes'; +import { + CHANGE_STREAM_DESCRIPTOR, + MUTE_DESCRIPTOR, + PAUSE_OWN_STREAM_PREVIEW_DESCRIPTOR, + POP_OUT_STREAM_DESCRIPTOR, + SCREEN_SHARE_PRIVACY_DESCRIPTOR, + SHOW_MY_SCREEN_SHARE_DESCRIPTOR, + STOP_STREAMING_DESCRIPTOR, + STREAM_VOLUME_DESCRIPTOR, +} from '@app/features/ui/action_menu/items/voice_participant_menu_data/shared'; +import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup'; +import {MenuItem} from '@app/features/ui/action_menu/MenuItem'; +import {MenuItemSubmenu} from '@app/features/ui/action_menu/MenuItemSubmenu'; +import type { + MenuCheckboxType, + MenuGroupType, + MenuItemType, + MenuSliderType, + MenuSubmenuItemType, +} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet'; +import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands'; +import {changeActiveScreenShare, stopActiveScreenShare} from '@app/features/voice/components/ActiveScreenShareMenu'; +import {openScreenSharePreviewPrivacyModal} from '@app/features/voice/components/modals/ScreenSharePickerModal'; +import MediaEngine from '@app/features/voice/engine/MediaEngineFacade'; +import PopoutWindowManager, {isVoicePopoutSupported} from '@app/features/voice/state/PopoutWindowManager'; +import StreamAudioPrefs from '@app/features/voice/state/StreamAudioPrefs'; +import VoiceSettings from '@app/features/voice/state/VoiceSettings'; +import {VOICE_STOP_WATCHING_DESCRIPTOR} from '@app/features/voice/utils/VoiceMessageDescriptors'; +import {buildVoiceParticipantIdentity} from '@app/features/voice/utils/VoiceParticipantIdentity'; +import type {I18n} from '@lingui/core'; +import {useLingui} from '@lingui/react/macro'; +import {observer} from 'mobx-react-lite'; +import type React from 'react'; + +const logger = new Logger('VoiceParticipantStreamMenuBuilder'); + +interface VoiceParticipantStreamMenuBuilderOptions { + i18n: I18n; + userId: string; + guildId?: string; + connectionId?: string; + channelId: string | null; + participantIdentity: string; + displayName: string; + isCurrentUserConnectedToVoice: boolean; + source: VoiceParticipantMenuScreenShareSource; + streamVolume: number; + isStreamMuted: boolean; + showMyOwnScreenShare: boolean; + pauseOwnScreenSharePreviewOnUnfocus: boolean; + onClose: () => void; +} + +function buildStreamStateAction(options: VoiceParticipantStreamMenuBuilderOptions): MenuItemType { + const {i18n, source, onClose} = options; + const streamState = source.state; + if (streamState.kind === 'own') { + return { + icon: ( + + ), + label: i18n._(STOP_STREAMING_DESCRIPTOR), + onClick: () => { + onClose(); + void stopActiveScreenShare().catch((error) => { + logger.error('Failed to stop active screen share from participant menu', error); + }); + }, + danger: true, + }; + } + if (streamState.kind === 'remote-unwatched') { + return { + label: i18n._(WATCH_STREAM_DESCRIPTOR), + onClick: () => { + streamState.onWatch(); + onClose(); + }, + }; + } + return { + icon: ( + + ), + label: i18n._(VOICE_STOP_WATCHING_DESCRIPTOR), + onClick: () => { + streamState.onStopWatching(); + onClose(); + }, + }; +} + +function buildOwnStreamChangeAction(options: VoiceParticipantStreamMenuBuilderOptions): MenuItemType | null { + const {i18n, source, onClose} = options; + const streamState = source.state; + if (streamState.kind !== 'own') return null; + return { + icon: ( + + ), + label: i18n._(CHANGE_STREAM_DESCRIPTOR), + onClick: () => { + onClose(); + void changeActiveScreenShare('display').catch((error) => { + logger.error('Failed to change active screen share from participant menu', error); + }); + }, + }; +} + +function buildStreamAudioActions( + options: VoiceParticipantStreamMenuBuilderOptions, +): Array { + const {i18n, source, streamVolume, isStreamMuted, userId} = options; + const streamState = source.state; + if (streamState.kind !== 'remote-watched') return []; + if (!streamState.hasAudio) return []; + return [ + { + icon: ( + + ), + label: i18n._(MUTE_DESCRIPTOR), + checked: isStreamMuted, + onChange: (checked: boolean) => { + StreamAudioPrefs.setMuted(source.streamKey, checked); + MediaEngine.applyLocalAudioPreferencesForUser(userId); + }, + }, + { + label: i18n._(STREAM_VOLUME_DESCRIPTOR), + value: streamVolume, + minValue: 0, + maxValue: 200, + onChange: (value: number) => { + StreamAudioPrefs.setVolume(source.streamKey, value); + MediaEngine.applyLocalAudioPreferencesForUser(userId); + }, + onFormat: (value: number) => `${Math.round(value)}%`, + factoryDefaultValue: 100, + }, + ]; +} + +function buildStreamPopoutAction(options: VoiceParticipantStreamMenuBuilderOptions): MenuItemType | null { + const { + i18n, + userId, + guildId, + connectionId, + channelId, + participantIdentity, + displayName, + isCurrentUserConnectedToVoice, + onClose, + } = options; + if (!connectionId) return null; + if (!channelId) return null; + if (!isCurrentUserConnectedToVoice) return null; + if (!isVoicePopoutSupported()) return null; + return { + icon: ( + + ), + label: i18n._(POP_OUT_STREAM_DESCRIPTOR), + onClick: () => { + const didOpen = PopoutWindowManager.openTilePopout({ + participantIdentity, + source: 'screen_share', + userId, + connectionId, + channelId, + guildId: guildId ?? null, + title: displayName, + }); + if (didOpen) onClose(); + }, + }; +} + +function buildStreamMoreOptions(options: VoiceParticipantStreamMenuBuilderOptions): MenuSubmenuItemType | null { + const {i18n, guildId, source, showMyOwnScreenShare, pauseOwnScreenSharePreviewOnUnfocus, onClose} = options; + if (source.state.kind !== 'own') return null; + const items: Array = [ + { + label: i18n._(SHOW_MY_SCREEN_SHARE_DESCRIPTOR), + checked: showMyOwnScreenShare, + onChange: (checked: boolean) => VoiceSettingsCommands.update({showMyOwnScreenShare: checked}), + }, + { + label: i18n._(PAUSE_OWN_STREAM_PREVIEW_DESCRIPTOR), + checked: pauseOwnScreenSharePreviewOnUnfocus, + onChange: (checked: boolean) => VoiceSettingsCommands.update({pauseOwnScreenSharePreviewOnUnfocus: checked}), + }, + ]; + if (guildId === undefined) { + items.push({ + label: i18n._(SCREEN_SHARE_PRIVACY_DESCRIPTOR), + onClick: () => { + onClose(); + openScreenSharePreviewPrivacyModal(); + }, + }); + } + if (items.length === 0) return null; + return { + label: i18n._(MORE_OPTIONS_DESCRIPTOR), + items, + }; +} + +export function buildVoiceParticipantStreamMenu( + options: VoiceParticipantStreamMenuBuilderOptions, +): Array { + const stateActions: Array = [buildStreamStateAction(options)]; + const changeAction = buildOwnStreamChangeAction(options); + if (changeAction) stateActions.push(changeAction); + const groups: Array = [{items: stateActions}]; + const audioActions = buildStreamAudioActions(options); + if (audioActions.length > 0) groups.push({items: audioActions}); + const popoutAction = buildStreamPopoutAction(options); + if (popoutAction) groups.push({items: [popoutAction]}); + const moreOptions = buildStreamMoreOptions(options); + if (moreOptions) groups.push({items: [moreOptions]}); + return groups; +} + +interface VoiceParticipantOwnStreamMenuTailProps { + userId: string; + guildId?: string; + connectionId?: string; + displayName: string; + onClose: () => void; +} + +export const VoiceParticipantOwnStreamMenuTail: React.FC = observer( + ({userId, guildId, connectionId, displayName, onClose}) => { + const {i18n} = useLingui(); + const connectionVoiceState = connectionId ? MediaEngine.getVoiceStateByConnectionId(connectionId) : null; + const channelId = connectionVoiceState?.channel_id ?? null; + const participantIdentity = connectionId ? buildVoiceParticipantIdentity(userId, connectionId) : ''; + const canPopOut = Boolean( + connectionId && channelId && participantIdentity && MediaEngine.connectionId && isVoicePopoutSupported(), + ); + return ( + <> + {canPopOut && connectionId && channelId && ( + + + } + onClick={() => { + const didOpen = PopoutWindowManager.openTilePopout({ + participantIdentity, + source: 'screen_share', + userId, + connectionId, + channelId, + guildId: guildId ?? null, + title: displayName, + }); + if (didOpen) onClose(); + }} + data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.popout-stream" + > + {i18n._(POP_OUT_STREAM_DESCRIPTOR)} + + + )} + + ( + + VoiceSettingsCommands.update({showMyOwnScreenShare: checked})} + data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.voice-participant-own-stream-menu-tail.checkbox-item" + > + {i18n._(SHOW_MY_SCREEN_SHARE_DESCRIPTOR)} + + + VoiceSettingsCommands.update({pauseOwnScreenSharePreviewOnUnfocus: checked}) + } + data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.voice-participant-own-stream-menu-tail.checkbox-item--2" + > + {i18n._(PAUSE_OWN_STREAM_PREVIEW_DESCRIPTOR)} + + {guildId === undefined && ( + { + onClose(); + openScreenSharePreviewPrivacyModal(); + }} + data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.voice-participant-own-stream-menu-tail.menu-item.close" + > + {i18n._(SCREEN_SHARE_PRIVACY_DESCRIPTOR)} + + )} + + )} + data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.more-options-submenu" + /> + + + ); + }, +); diff --git a/fluxer_app/src/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap.ts b/fluxer_app/src/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap.ts new file mode 100644 index 000000000..d48bc07f7 --- /dev/null +++ b/fluxer_app/src/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +const HARNESS_ENDPOINT = 'https://primary.test/api'; + +export function installVoiceMenuTestBootstrap(): void { + const host = globalThis as unknown as {window?: Record}; + if (typeof host.window === 'undefined') { + host.window = host as unknown as Record; + } + host.window.__FLUXER_BOOTSTRAP__ = { + config: { + releaseChannel: 'stable', + bootstrapApiEndpoint: HARNESS_ENDPOINT, + bootstrapApiPublicEndpoint: HARNESS_ENDPOINT, + }, + instance: { + api_code_version: Number.MAX_SAFE_INTEGER, + endpoints: { + api: HARNESS_ENDPOINT, + api_client: HARNESS_ENDPOINT, + api_public: HARNESS_ENDPOINT, + gateway: 'wss://gateway.primary.test', + media: 'https://media.primary.test', + static_cdn: 'https://cdn.primary.test', + marketing: 'https://primary.test', + admin: 'https://admin.primary.test', + invite: 'https://primary.test/invite', + gift: 'https://primary.test/gift', + webapp: 'https://app.primary.test', + upload_relay: 'https://upload.primary.test', + }, + captcha: {provider: 'none', hcaptcha_site_key: null, turnstile_site_key: null}, + features: { + voice_enabled: false, + stripe_enabled: false, + self_hosted: false, + presigned_attachment_uploads: false, + emails_enabled: false, + }, + gif: {provider: 'klipy', display_name: 'Klipy', attribution_required: false}, + sso: {enabled: false, enforced: false, display_name: null, redirect_uri: ''}, + registration: {mode: 'open', admin_registration_urls_enabled: true}, + community: {single_community: false, single_community_guild_id: null, direct_messages_disabled: false}, + services: {gif_enabled: true, youtube_enabled: false, bluesky_enabled: false}, + limits: undefined, + push: {public_vapid_key: null}, + app_public: { + branding: { + product_name: 'Fluxer', + icon_url: null, + symbol_url: null, + logo_url: null, + wordmark_url: null, + favicon_url: null, + theme_color: null, + }, + setup: {configured: true, admin_url: null}, + legal: {terms_url: null, privacy_url: null}, + registration: {collect_date_of_birth: true}, + }, + }, + geoip: { + countryCode: null, + regionCode: null, + latitude: null, + longitude: null, + ageRestrictedGeos: [], + ageBlockedGeos: [], + }, + }; +} diff --git a/fluxer_app/src/features/ui/action_menu/items/voice_participant_menu_data/shared.ts b/fluxer_app/src/features/ui/action_menu/items/voice_participant_menu_data/shared.ts index 154b44f78..c77b4f0e8 100644 --- a/fluxer_app/src/features/ui/action_menu/items/voice_participant_menu_data/shared.ts +++ b/fluxer_app/src/features/ui/action_menu/items/voice_participant_menu_data/shared.ts @@ -17,6 +17,22 @@ export const STREAM_VOLUME_DESCRIPTOR = msg({ message: 'Stream volume', comment: 'Voice menu slider label that controls volume of an incoming screen share with audio.', }); +export const STOP_STREAMING_DESCRIPTOR = msg({ + message: 'Stop Streaming', + comment: 'Voice screen share menu action that stops the current user stream.', +}); +export const CHANGE_STREAM_DESCRIPTOR = msg({ + message: 'Change Stream', + comment: 'Voice screen share menu action that changes the shared source.', +}); +export const PAUSE_OWN_STREAM_PREVIEW_DESCRIPTOR = msg({ + message: 'Pause Preview When Fluxer Isn’t Focused', + comment: 'Voice screen share menu preference that pauses the local stream preview while the app is unfocused.', +}); +export const SCREEN_SHARE_PRIVACY_DESCRIPTOR = msg({ + message: 'Screen Share Privacy', + comment: 'Voice screen share menu action that opens the screen share preview privacy controls.', +}); export const MUTE_DESCRIPTOR = msg({ message: 'Mute', comment: @@ -27,11 +43,19 @@ export const UNFOCUS_DESCRIPTOR = msg({ comment: 'Voice call layout action that removes focus from the currently pinned participant.', }); export const POP_OUT_CAMERA_DESCRIPTOR = msg({ - message: 'Pop out camera', - comment: 'Voice menu action on desktop that opens the participant camera feed in a separate window.', + message: 'Pop Out Video', + comment: 'Voice menu action on desktop that opens the active participant camera feed in a separate window.', +}); +export const POP_OUT_USER_DESCRIPTOR = msg({ + message: 'Pop Out User', + comment: 'Voice menu action on desktop that opens the participant placeholder tile in a separate window.', +}); +export const PREVIEW_CAMERA_DESCRIPTOR = msg({ + message: 'Preview Camera', + comment: 'Voice menu action that opens a preview of the current user camera.', }); export const POP_OUT_STREAM_DESCRIPTOR = msg({ - message: 'Pop out stream', + message: 'Pop Out Stream', comment: 'Voice menu action on desktop that opens the participant screen share in a separate window.', }); export const FOCUS_THIS_DEVICE_DESCRIPTOR = msg({ @@ -71,7 +95,7 @@ export const SHOW_MY_OWN_CAMERA_DESCRIPTOR = msg({ comment: 'Voice display preference checkbox that controls whether the current user sees their own camera tile.', }); export const SHOW_MY_SCREEN_SHARE_DESCRIPTOR = msg({ - message: 'Show my screen share', + message: 'Show My Screen Share', comment: 'Voice display preference checkbox that controls whether the current user sees their own screen share tile.', }); export const SHOW_NON_VIDEO_PARTICIPANTS_DESCRIPTOR = msg({ diff --git a/fluxer_app/src/features/voice/components/ActiveScreenShareMenu.tsx b/fluxer_app/src/features/voice/components/ActiveScreenShareMenu.tsx new file mode 100644 index 000000000..271c91218 --- /dev/null +++ b/fluxer_app/src/features/voice/components/ActiveScreenShareMenu.tsx @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Logger} from '@app/features/platform/utils/AppLogger'; +import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup'; +import {MenuItem} from '@app/features/ui/action_menu/MenuItem'; +import { + openScreenShareSourceSwitcherModal, + type ScreenSharePickerTab, +} from '@app/features/voice/components/modals/ScreenSharePickerModal'; +import {StreamSettingsMenuContent} from '@app/features/voice/components/StreamSettingsMenuContent'; +import MediaEngine from '@app/features/voice/engine/MediaEngineFacade'; +import type {DisplayShareEnvironment} from '@app/features/voice/utils/ScreenShareEnvironment'; +import type {StreamSettingsShareContext} from '@app/features/voice/utils/StreamSettingsUpdatePolicy'; +import {msg} from '@lingui/core/macro'; +import {useLingui} from '@lingui/react/macro'; +import {MonitorPlayIcon, StopCircleIcon} from '@phosphor-icons/react'; +import type React from 'react'; + +const STOP_STREAMING_DESCRIPTOR = msg({ + message: 'Stop Streaming', + comment: 'Danger action that stops the active screen share.', +}); +const CHANGE_STREAM_DESCRIPTOR = msg({ + message: 'Change Stream', + comment: 'Action that opens the source picker for the active screen share.', +}); +const logger = new Logger('ActiveScreenShareMenu'); + +export interface ActiveScreenShareMenuProps { + onClose: () => void; + displayShareEnvironment: DisplayShareEnvironment; + shareContext: StreamSettingsShareContext; + shareContextResolved: boolean; + iconClassName?: string; + additionalActions?: React.ReactNode; + showLiveSettings?: boolean; + tail?: React.ReactNode; +} + +export async function stopActiveScreenShare(): Promise { + await MediaEngine.setScreenShareEnabled(false); +} + +function getSourceSwitcherTab(shareContext: StreamSettingsShareContext): ScreenSharePickerTab { + if (shareContext === 'device') return 'devices'; + if (shareContext === 'app') return 'apps'; + return 'displays'; +} + +export async function changeActiveScreenShare(shareContext: StreamSettingsShareContext = 'display'): Promise { + await openScreenShareSourceSwitcherModal({initialTab: getSourceSwitcherTab(shareContext)}); +} + +export const ActiveScreenShareMenu: React.FC = ({ + onClose, + displayShareEnvironment, + shareContext, + shareContextResolved, + iconClassName, + additionalActions, + showLiveSettings = true, + tail, +}) => { + const {i18n} = useLingui(); + const isWeb = displayShareEnvironment === 'web'; + const openChangeStream = (nextShareContext: StreamSettingsShareContext) => { + onClose(); + void changeActiveScreenShare(nextShareContext).catch((error) => { + logger.error('Failed to change active screen share source', error); + }); + }; + const handleChangeStream = () => openChangeStream(shareContext); + return ( + <> + + + } + danger + onClick={() => { + onClose(); + void stopActiveScreenShare().catch((error) => { + logger.error('Failed to stop active screen share', error); + }); + }} + data-flx="voice.active-screen-share-menu.stop" + > + {i18n._(STOP_STREAMING_DESCRIPTOR)} + + + } + onClick={handleChangeStream} + data-flx="voice.active-screen-share-menu.change" + > + {i18n._(CHANGE_STREAM_DESCRIPTOR)} + + {additionalActions} + {showLiveSettings && !isWeb && ( + + )} + + {tail} + + ); +}; diff --git a/fluxer_app/src/features/voice/components/StreamSettingsMenuContent.tsx b/fluxer_app/src/features/voice/components/StreamSettingsMenuContent.tsx index 4c6548292..0a859dd80 100644 --- a/fluxer_app/src/features/voice/components/StreamSettingsMenuContent.tsx +++ b/fluxer_app/src/features/voice/components/StreamSettingsMenuContent.tsx @@ -149,6 +149,14 @@ const ADAPTIVE_QUALITY_ACTIVE_DESCRIPTOR = msg({ comment: 'Inline adaptive-quality status in the stream settings menu. Shows the current automatically lowered resolution and frame rate.', }); +const STREAM_QUALITY_DESCRIPTOR = msg({ + message: 'Stream Quality', + comment: 'Compact submenu label for changing the resolution and frame rate of an active stream.', +}); +const SHARE_STREAM_AUDIO_DESCRIPTOR = msg({ + message: 'Share Stream Audio', + comment: 'Toggle label for sharing audio with an active stream.', +}); const logger = new Logger('StreamSettingsMenuContent'); const SCREEN_SHARE_AUDIO_SOURCE = VoiceTrackSource.ScreenShareAudio as Track.Source; @@ -329,6 +337,7 @@ interface StreamSettingsMenuContentProps { shareContext?: StreamSettingsShareContext; shareContextResolved?: boolean; displayShareEnvironment: DisplayShareEnvironment; + variant?: 'full' | 'compactLive'; } export const StreamSettingsMenuContent = observer( @@ -337,6 +346,7 @@ export const StreamSettingsMenuContent = observer( shareContext = 'display', shareContextResolved = true, displayShareEnvironment, + variant = 'full', }: StreamSettingsMenuContentProps) => { const {i18n} = useLingui(); useMediaEngineVersion(); @@ -348,6 +358,18 @@ export const StreamSettingsMenuContent = observer( const currentMode = VoiceSettings.getStreamingMode(); const currentResolution = VoiceSettings.getScreenshareResolution(); const currentFrameRate = resolveScreenShareFrameRate(VoiceSettings.getVideoFrameRate()); + const effectiveMode = normaliseStreamingModeForContext(currentMode, getScreenShareContext(shareContext)); + const effectiveResolution = normaliseResolutionForContext( + currentResolution, + getScreenShareContext(shareContext), + hasHigherVideoQuality, + ); + const effectiveQuality = resolveStreamingModeSettings( + effectiveMode, + effectiveResolution, + currentFrameRate, + hasHigherVideoQuality, + ); const captureAudioEnabled = isAppShare ? VoiceSettings.getShareAppAudio() : isDeviceShare @@ -552,6 +574,117 @@ export const StreamSettingsMenuContent = observer( const selectedAudioDeviceLabel = selectedAudioDevice ? formatVoiceAudioDeviceLabel(i18n, selectedAudioDevice, i18n._(UNNAMED_INPUT_DESCRIPTOR)) : i18n._(SYSTEM_DEFAULT_DESCRIPTOR); + if (variant === 'compactLive') { + const selectCompactResolution = (option: Option) => { + if (option.isPremium && !hasHigherVideoQuality) { + if (showPremiumFeatures) PremiumModalCommands.open(); + return; + } + if (currentMode === 'custom' && effectiveQuality.resolution === option.value) return; + VoiceSettingsCommands.update({ + streamingMode: 'custom', + screenshareResolution: option.value, + videoFrameRate: effectiveQuality.frameRate, + }); + runApply(); + }; + const selectCompactFrameRate = (option: Option) => { + if (option.isPremium && !hasHigherVideoQuality) { + if (showPremiumFeatures) PremiumModalCommands.open(); + return; + } + if (currentMode === 'custom' && effectiveQuality.frameRate === option.value) return; + VoiceSettingsCommands.update({ + streamingMode: 'custom', + screenshareResolution: effectiveQuality.resolution, + videoFrameRate: option.value, + }); + runApply(); + }; + return ( + <> + ( + <> + + + {i18n._(FRAMERATE_DESCRIPTOR)} + + {frameRateOptions.map((option) => { + const premiumLocked = showPremiumFeatures && option.isPremium && !hasHigherVideoQuality; + return ( + selectCompactFrameRate(option)} + data-flx="voice.stream-settings-menu-content.compact-live.frame-rate-option" + > + + + {option.label} + + {premiumLocked && ( + + )} + + + ); + })} + + + + {i18n._(RESOLUTION_DESCRIPTOR)} + + {resolutionOptions.map((option) => { + const premiumLocked = showPremiumFeatures && option.isPremium && !hasHigherVideoQuality; + return ( + selectCompactResolution(option)} + data-flx="voice.stream-settings-menu-content.compact-live.resolution-option" + > + + + {option.label} + + {premiumLocked && ( + + )} + + + ); + })} + + + )} + data-flx="voice.stream-settings-menu-content.compact-live.quality-submenu" + /> + {audioMenuState.control.value === 'toggle' && ( + + {i18n._(SHARE_STREAM_AUDIO_DESCRIPTOR)} + + )} + + ); + } return ( <> diff --git a/fluxer_app/src/features/voice/components/StreamSpectatorsPopout.tsx b/fluxer_app/src/features/voice/components/StreamSpectatorsPopout.tsx index b1b3df7c4..b46534ad5 100644 --- a/fluxer_app/src/features/voice/components/StreamSpectatorsPopout.tsx +++ b/fluxer_app/src/features/voice/components/StreamSpectatorsPopout.tsx @@ -247,6 +247,8 @@ const SpectatorRow = observer(function SpectatorRow({ onClose={onClose} guildId={guildId} connectionId={connectionId} + surface="stream-spectator-list" + source={{kind: 'participant'}} data-flx="voice.stream-spectators-popout.handle-context-menu.voice-participant-context-menu" /> )); diff --git a/fluxer_app/src/features/voice/components/VoiceControlBar.tsx b/fluxer_app/src/features/voice/components/VoiceControlBar.tsx index 927a6b714..90cb12465 100644 --- a/fluxer_app/src/features/voice/components/VoiceControlBar.tsx +++ b/fluxer_app/src/features/voice/components/VoiceControlBar.tsx @@ -19,6 +19,7 @@ import {TooltipWithKeybind} from '@app/features/ui/keybind_hint/KeybindHint'; import MobileLayout from '@app/features/ui/state/MobileLayout'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import {isDesktop} from '@app/features/ui/utils/NativeUtils'; +import {ActiveScreenShareMenu} from '@app/features/voice/components/ActiveScreenShareMenu'; import { VoiceAudioSettingsBottomSheet, VoiceCameraSettingsBottomSheet, @@ -30,7 +31,6 @@ import { } from '@app/features/voice/components/modals/CameraPreviewModal'; import { openScreenSharePickerModal, - openScreenShareSourceSwitcherModal, preloadScreenSharePickerSources, } from '@app/features/voice/components/modals/ScreenSharePickerModal'; import {getStreamKey} from '@app/features/voice/components/StreamKeys'; @@ -105,10 +105,6 @@ import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -const CHANGE_SOURCE_DESCRIPTOR = msg({ - message: 'Change source', - comment: 'Voice control menu action to choose a different screen-share source.', -}); const END_SCREEN_SHARE_DESCRIPTOR = msg({ message: 'End screen share', comment: 'Voice control menu action to stop sharing the screen.', @@ -353,56 +349,10 @@ const VoiceControlBarInner = observer(function VoiceControlBarInner() { } }, [localParticipant, isCameraEnabled, isConnected]); const renderScreenShareMenu = useCallback( - ({onClose}: {onClose: () => void}) => ( - <> - {isScreenShareEnabled && ( - - - } - onClick={async () => { - onClose(); - try { - await openScreenShareSourceSwitcherModal(); - } catch (error) { - logger.error('Failed to open screen share source switcher:', error); - } - }} - data-flx="voice.voice-control-bar.render-screen-share-menu.menu-item.close" - > - {i18n._(CHANGE_SOURCE_DESCRIPTOR)} - - - } - danger - onClick={async () => { - onClose(); - await MediaEngine.setScreenShareEnabled(false); - }} - data-flx="voice.voice-control-bar.render-screen-share-menu.menu-item.close--2" - > - {i18n._(END_SCREEN_SHARE_DESCRIPTOR)} - - - )} - + ({onClose}: {onClose: () => void}) => { + const shareContext = ActiveScreenShareSource.getSourceId()?.startsWith('window:') ? 'app' : 'display'; + const shareContextResolved = ActiveScreenShareSource.getSourceId() != null; + const screenShareSettingsMenu = ( - - ), + ); + if (isScreenShareEnabled) { + return ( + + ); + } + return ( + <> + + {screenShareSettingsMenu} + + ); + }, [displayShareEnvironment, isScreenShareEnabled, i18n], ); const openScreenShareMenu = useCallback( diff --git a/fluxer_app/src/features/voice/components/VoiceParticipantAvatarList.tsx b/fluxer_app/src/features/voice/components/VoiceParticipantAvatarList.tsx index 618cea58b..3940962a2 100644 --- a/fluxer_app/src/features/voice/components/VoiceParticipantAvatarList.tsx +++ b/fluxer_app/src/features/voice/components/VoiceParticipantAvatarList.tsx @@ -279,6 +279,8 @@ function VoiceParticipantPopoutRow({entry, guildId, channelId}: VoiceParticipant onClose={onClose} guildId={guildId ?? undefined} connectionId={entry.connectionId} + surface="participant-avatar-list" + source={{kind: 'participant'}} data-flx="voice.voice-participant-avatar-list.handle-context-menu.voice-participant-context-menu" /> )); @@ -414,6 +416,8 @@ export const VoiceParticipantSpeakingAvatarStack: React.FC )); @@ -563,6 +567,8 @@ export const VoiceParticipantWrappedAvatarList: React.FC )); diff --git a/fluxer_app/src/features/voice/components/VoiceParticipantTile.tsx b/fluxer_app/src/features/voice/components/VoiceParticipantTile.tsx index 13f76187b..0bc621c8a 100644 --- a/fluxer_app/src/features/voice/components/VoiceParticipantTile.tsx +++ b/fluxer_app/src/features/voice/components/VoiceParticipantTile.tsx @@ -5,6 +5,7 @@ import {WATCH_STREAM_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDes import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; import Permission from '@app/features/permissions/state/Permission'; import {dimColor} from '@app/features/theme/utils/ColorUtils'; +import type {VoiceParticipantMenuSource} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes'; import {VoiceParticipantContextMenu} from '@app/features/ui/action_menu/VoiceParticipantContextMenu'; import {Button} from '@app/features/ui/button/Button'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; @@ -624,6 +625,43 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({ other: 'Keep watching # streams and add this one', }, ); + const participantMenuSource = useMemo(() => { + if (!isScreenShare) { + return isCameraTile && isCameraPublicationActive ? {kind: 'camera'} : {kind: 'participant'}; + } + if (isOwnScreenShare) { + return {kind: 'screen-share', streamKey, state: {kind: 'own'}}; + } + if (isWatching) { + return { + kind: 'screen-share', + streamKey, + state: {kind: 'remote-watched', hasAudio: hasScreenShareAudio, onStopWatching: stopWatching}, + }; + } + return { + kind: 'screen-share', + streamKey, + state: { + kind: 'remote-unwatched', + onWatch: () => { + startWatching(); + VoiceCallLayoutCommands.setPinnedParticipant(identity, VoiceTrackSource.ScreenShare); + }, + }, + }; + }, [ + hasScreenShareAudio, + identity, + isCameraPublicationActive, + isCameraTile, + isOwnScreenShare, + isScreenShare, + isWatching, + startWatching, + stopWatching, + streamKey, + ]); const handleContextMenu = useCallback( (event: React.MouseEvent | MouseEvent) => { if (!participantUser) return; @@ -635,13 +673,9 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({ onClose={onClose} guildId={guildId} connectionId={connectionId} + surface="call-tile" + source={participantMenuSource} isGroupedItem={isGroupedItem} - streamKey={streamKey} - isScreenShare={isScreenShare} - isWatching={isWatching} - hasScreenShareAudio={hasScreenShareAudio} - isOwnScreenShare={isOwnScreenShare} - onStopWatching={stopWatching} hiddenConnectionCount={groupHiddenCount} deviceConnectionCount={groupDeviceConnectionCount} isDeviceGroupExpanded={tileGroup?.isExpanded ?? false} @@ -656,12 +690,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({ guildId, connectionId, isCurrentUser, - streamKey, - isScreenShare, - isWatching, - hasScreenShareAudio, - isOwnScreenShare, - stopWatching, + participantMenuSource, groupHiddenCount, groupDeviceConnectionCount, tileGroup, @@ -1395,13 +1424,9 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({ participant={connectionParticipant} guildId={guildId} connectionId={connectionId} + surface="call-tile" + source={participantMenuSource} isConnectionItem - streamKey={streamKey} - isScreenShare={isScreenShare} - isWatching={isWatching} - hasScreenShareAudio={hasScreenShareAudio} - isOwnScreenShare={isOwnScreenShare} - onStopWatching={stopWatching} data-flx="voice.voice-participant-tile.voice-participant-tile-inner.voice-participant-bottom-sheet" /> )} diff --git a/fluxer_app/src/features/voice/components/bottomsheets/VoiceParticipantBottomSheet.tsx b/fluxer_app/src/features/voice/components/bottomsheets/VoiceParticipantBottomSheet.tsx index 2534d6dc3..e967a0230 100644 --- a/fluxer_app/src/features/voice/components/bottomsheets/VoiceParticipantBottomSheet.tsx +++ b/fluxer_app/src/features/voice/components/bottomsheets/VoiceParticipantBottomSheet.tsx @@ -1,6 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {useVoiceParticipantMenuData} from '@app/features/ui/action_menu/items/VoiceParticipantMenuData'; +import type { + VoiceParticipantMenuSource, + VoiceParticipantMenuSurface, +} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes'; import {MenuBottomSheet} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet'; import type {User} from '@app/features/user/models/User'; import {observer} from 'mobx-react-lite'; @@ -12,15 +16,11 @@ interface VoiceParticipantBottomSheetProps { user: User; guildId?: string; connectionId?: string; + surface: VoiceParticipantMenuSurface; + source: VoiceParticipantMenuSource; isConnectionItem?: boolean; isParentGroupedItem?: boolean; participant?: unknown; - streamKey?: string; - isScreenShare?: boolean; - isWatching?: boolean; - hasScreenShareAudio?: boolean; - isOwnScreenShare?: boolean; - onStopWatching?: () => void; } export const VoiceParticipantBottomSheet: React.FC = observer( @@ -30,27 +30,19 @@ export const VoiceParticipantBottomSheet: React.FC { const {groups} = useVoiceParticipantMenuData({ user, guildId, connectionId, + surface, + source, isGroupedItem: isConnectionItem, isParentGroupedItem, - streamKey, - isScreenShare, - isWatching, - hasScreenShareAudio, - isOwnScreenShare, - onStopWatching, onClose, }); return ( diff --git a/fluxer_app/src/features/voice/components/modals/ScreenSharePickerModal.tsx b/fluxer_app/src/features/voice/components/modals/ScreenSharePickerModal.tsx index d043e7641..fe49f45da 100644 --- a/fluxer_app/src/features/voice/components/modals/ScreenSharePickerModal.tsx +++ b/fluxer_app/src/features/voice/components/modals/ScreenSharePickerModal.tsx @@ -527,16 +527,18 @@ const ScreenSharePreviewInfoModal = observer(() => { ); }); +export function openScreenSharePreviewPrivacyModal(): void { + ModalCommands.push( + ModalCommands.modal(() => ( + + )), + ); +} + const ScreenSharePreviewFooterNotice = observer(() => { const {i18n} = useLingui(); const previewsEnabled = !PrivacyPreferences.getDisableStreamPreviews(); - const openInfoModal = useCallback(() => { - ModalCommands.push( - ModalCommands.modal(() => ( - - )), - ); - }, []); + const openInfoModal = useCallback(openScreenSharePreviewPrivacyModal, []); const handleDisable = useCallback(() => { PrivacyPreferences.setDisableStreamPreviews(true); }, []); diff --git a/fluxer_app/src/features/voice/components/voice_connection_status/LocalParticipantControls.tsx b/fluxer_app/src/features/voice/components/voice_connection_status/LocalParticipantControls.tsx index 8902bd4ae..460c5492a 100644 --- a/fluxer_app/src/features/voice/components/voice_connection_status/LocalParticipantControls.tsx +++ b/fluxer_app/src/features/voice/components/voice_connection_status/LocalParticipantControls.tsx @@ -3,24 +3,21 @@ import {TURN_OFF_CAMERA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import Permission from '@app/features/permissions/state/Permission'; import NativePermission from '@app/features/permissions/system/state/NativePermission'; -import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup'; -import {MenuItem} from '@app/features/ui/action_menu/MenuItem'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import {isDesktop} from '@app/features/ui/utils/NativeUtils'; +import {ActiveScreenShareMenu} from '@app/features/voice/components/ActiveScreenShareMenu'; import { CameraPreviewModalInRoom, CameraPreviewModalStandalone, } from '@app/features/voice/components/modals/CameraPreviewModal'; import { openScreenSharePickerModal, - openScreenShareSourceSwitcherModal, preloadScreenSharePickerSources, } from '@app/features/voice/components/modals/ScreenSharePickerModal'; -import {StreamSettingsMenuContent} from '@app/features/voice/components/StreamSettingsMenuContent'; import styles from '@app/features/voice/components/VoiceConnectionStatus.module.css'; import {VoiceCameraSettingsMenu} from '@app/features/voice/components/VoiceSettingsMenus'; import {selectLocalParticipantControlsViewState} from '@app/features/voice/components/voice_connection_status/LocalParticipantControlsStateMachine'; @@ -45,14 +42,6 @@ import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import {type MouseEvent as ReactMouseEvent, useCallback} from 'react'; -const CHANGE_SOURCE_DESCRIPTOR = msg({ - message: 'Change source', - comment: 'Tooltip / button label in the screen-share status pill. Opens the picker to change what is being shared.', -}); -const END_SCREEN_SHARE_DESCRIPTOR = msg({ - message: 'End screen share', - comment: 'Button label that stops the current screen share.', -}); const WAITING_FOR_CONNECTION_DESCRIPTOR = msg({ message: 'Waiting for connection...', comment: @@ -146,56 +135,20 @@ export const LocalParticipantControls = observer(() => { if (!isConnected || !isScreenShareEnabled) return; event.preventDefault(); event.stopPropagation(); + const shareContext = ActiveScreenShareSource.getSourceId()?.startsWith('window:') ? 'app' : 'display'; + const shareContextResolved = ActiveScreenShareSource.getSourceId() != null; ContextMenuCommands.openFromEvent(event, ({onClose}) => ( - <> - - - } - onClick={async () => { - onClose(); - try { - await openScreenShareSourceSwitcherModal(); - } catch (error) { - logger.error('Failed to open screen share source switcher:', error); - } - }} - data-flx="voice.voice-connection-status.open-screen-share-menu.menu-item.close" - > - {i18n._(CHANGE_SOURCE_DESCRIPTOR)} - - - } - danger - onClick={async () => { - onClose(); - await MediaEngine.setScreenShareEnabled(false); - }} - data-flx="voice.voice-connection-status.open-screen-share-menu.menu-item.close--2" - > - {i18n._(END_SCREEN_SHARE_DESCRIPTOR)} - - - - + )); }, - [displayShareEnvironment, isConnected, isScreenShareEnabled, i18n], + [displayShareEnvironment, isConnected, isScreenShareEnabled], ); const handleScreenShare = useCallback(async () => { if (!isConnected) return; diff --git a/fluxer_app/src/features/voice/state/PopoutWindowManager.ts b/fluxer_app/src/features/voice/state/PopoutWindowManager.ts index 2bbe1784a..230348ff9 100644 --- a/fluxer_app/src/features/voice/state/PopoutWindowManager.ts +++ b/fluxer_app/src/features/voice/state/PopoutWindowManager.ts @@ -13,7 +13,7 @@ export const VOICE_TILE_POPOUT_DEFAULT_HEIGHT = 480; export const VOICE_CALL_POPOUT_DEFAULT_WIDTH = 960; export const VOICE_CALL_POPOUT_DEFAULT_HEIGHT = 600; -export type VoiceTilePopoutSource = 'camera' | 'screen_share'; +export type VoiceTilePopoutSource = 'user' | 'camera' | 'screen_share'; export interface VoiceTilePopoutDescriptor { kind: 'tile';