refactor(voice): flatten participant context menu and extract stream menus (#2112)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-30 03:02:30 +02:00
1 parent 3b1eb56713
commit 03641f622f
25 files changed
+1787 -565

No files matched your search

@@ -8,6 +8,7 @@ import {PreloadableUserPopout} from '@app/features/channel/components/Preloadabl
import type {VoiceState} from '@app/features/gateway/types/GatewayVoiceTypes'; import type {VoiceState} from '@app/features/gateway/types/GatewayVoiceTypes';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands'; 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 {VoiceParticipantContextMenu} from '@app/features/ui/action_menu/VoiceParticipantContextMenu';
import {AvatarWithPresence} from '@app/features/ui/avatars/AvatarWithPresence'; import {AvatarWithPresence} from '@app/features/ui/avatars/AvatarWithPresence';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
@@ -70,6 +71,19 @@ export const GroupedVoiceParticipant = observer(function GroupedVoiceParticipant
const isContextMenuOpen = useContextMenuHoverState(rowRef); const isContextMenuOpen = useContextMenuHoverState(rowRef);
const toggleExpanded = useCallback(() => setIsExpanded((prev) => !prev), []); const toggleExpanded = useCallback(() => setIsExpanded((prev) => !prev), []);
const connectionCount = voiceStates.length; const connectionCount = voiceStates.length;
const containsLocalConnection =
currentConnectionId !== null && voiceStates.some((state) => state.connection_id === currentConnectionId);
const participantMenuSource = useMemo<VoiceParticipantMenuSource>(() => {
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( const handleContextMenu = useCallback(
(event: React.MouseEvent) => { (event: React.MouseEvent) => {
event.preventDefault(); event.preventDefault();
@@ -80,13 +94,16 @@ export const GroupedVoiceParticipant = observer(function GroupedVoiceParticipant
participantName={NicknameUtils.getNickname(user, guildId)} participantName={NicknameUtils.getNickname(user, guildId)}
onClose={onClose} onClose={onClose}
guildId={guildId} guildId={guildId}
surface="participant-list"
source={participantMenuSource}
isGroupedItem={true} isGroupedItem={true}
isParentGroupedItem={true} isParentGroupedItem={true}
groupContainsLocalConnection={containsLocalConnection}
data-flx="app.grouped-voice-participant.handle-context-menu.voice-participant-context-menu" data-flx="app.grouped-voice-participant.handle-context-menu.voice-participant-context-menu"
/> />
)); ));
}, },
[user, guildId], [user, guildId, participantMenuSource, containsLocalConnection],
); );
const stateAgg = useMemo(() => { const stateAgg = useMemo(() => {
let anySpeaking = propAnySpeaking ?? false; let anySpeaking = propAnySpeaking ?? false;
@@ -128,7 +145,7 @@ export const GroupedVoiceParticipant = observer(function GroupedVoiceParticipant
allSelfMuted = allSelfMuted && displayState.selfMute; allSelfMuted = allSelfMuted && displayState.selfMute;
allSelfDeaf = allSelfDeaf && displayState.selfDeaf; allSelfDeaf = allSelfDeaf && displayState.selfDeaf;
} }
if (isCurrentUser) { if (isCurrentUser && containsLocalConnection) {
anyCameraOn = anyCameraOn || localSelfVideo; anyCameraOn = anyCameraOn || localSelfVideo;
anyLive = anyLive || localSelfStream; anyLive = anyLive || localSelfStream;
} }
@@ -138,6 +155,7 @@ export const GroupedVoiceParticipant = observer(function GroupedVoiceParticipant
user.id, user.id,
guildId, guildId,
isCurrentUser, isCurrentUser,
containsLocalConnection,
currentConnectionId, currentConnectionId,
localSelfMute, localSelfMute,
localSelfDeaf, localSelfDeaf,
@@ -154,15 +172,19 @@ export const GroupedVoiceParticipant = observer(function GroupedVoiceParticipant
const live = state.self_stream === true || (participant ? participant.isScreenShareEnabled : false); const live = state.self_stream === true || (participant ? participant.isScreenShareEnabled : false);
if (live) return state; if (live) return state;
} }
if (isCurrentUser && localSelfStream) { if (isCurrentUser && containsLocalConnection && localSelfStream) {
return ( return voiceStates.find((state) => state.connection_id === currentConnectionId) ?? null;
voiceStates.find((state) => state.connection_id === currentConnectionId) ??
voiceStates.find((state) => Boolean(state.connection_id)) ??
null
);
} }
return null; return null;
}, [voiceStates, user.id, isCurrentUser, localSelfStream, currentConnectionId, mediaEngineVersion]); }, [
voiceStates,
user.id,
isCurrentUser,
containsLocalConnection,
localSelfStream,
currentConnectionId,
mediaEngineVersion,
]);
const streamKey = activeStreamState?.connection_id const streamKey = activeStreamState?.connection_id
? getStreamKey(guildId, activeStreamState.channel_id ?? null, activeStreamState.connection_id) ? getStreamKey(guildId, activeStreamState.channel_id ?? null, activeStreamState.connection_id)
: ''; : '';
@@ -10,6 +10,7 @@ import {PreloadableUserPopout} from '@app/features/channel/components/Preloadabl
import type {VoiceState} from '@app/features/gateway/types/GatewayVoiceTypes'; import type {VoiceState} from '@app/features/gateway/types/GatewayVoiceTypes';
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands'; import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
import Permission from '@app/features/permissions/state/Permission'; 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 {VoiceParticipantContextMenu} from '@app/features/ui/action_menu/VoiceParticipantContextMenu';
import {AvatarWithPresence} from '@app/features/ui/avatars/AvatarWithPresence'; import {AvatarWithPresence} from '@app/features/ui/avatars/AvatarWithPresence';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
@@ -126,6 +127,11 @@ export const VoiceParticipantItem = observer(function VoiceParticipantItem({
const isActuallySpeaking = displayState.speaking; const isActuallySpeaking = displayState.speaking;
const displayCameraOn = displayState.cameraOn; const displayCameraOn = displayState.cameraOn;
const displayLive = displayState.streaming; const displayLive = displayState.streaming;
const participantMenuSource = useMemo<VoiceParticipantMenuSource>(() => {
if (displayCameraOn) return {kind: 'participant', focusSource: 'camera'};
if (displayLive) return {kind: 'participant', focusSource: 'screen-share'};
return {kind: 'participant'};
}, [displayCameraOn, displayLive]);
const streamKey = useMemo( const streamKey = useMemo(
() => getStreamKey(guildId, currentChannelId, connectionId), () => getStreamKey(guildId, currentChannelId, connectionId),
[guildId, currentChannelId, connectionId], [guildId, currentChannelId, connectionId],
@@ -166,12 +172,14 @@ export const VoiceParticipantItem = observer(function VoiceParticipantItem({
onClose={onClose} onClose={onClose}
guildId={guildId} guildId={guildId}
connectionId={connectionId} connectionId={connectionId}
surface="participant-list"
source={participantMenuSource}
isGroupedItem={isGroupedItem} isGroupedItem={isGroupedItem}
data-flx="app.voice-participant-item.handle-context-menu.voice-participant-context-menu" 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(() => { const handleProfilePopoutOpen = useCallback(() => {
setIsProfilePopoutOpen(true); setIsProfilePopoutOpen(true);
@@ -318,6 +326,8 @@ export const VoiceParticipantItem = observer(function VoiceParticipantItem({
participant={participant} participant={participant}
guildId={guildId} guildId={guildId}
connectionId={connectionId} connectionId={connectionId}
surface="participant-list"
source={participantMenuSource}
isConnectionItem={isGroupedItem} isConnectionItem={isGroupedItem}
data-flx="app.voice-participant-item.voice-participant-bottom-sheet" data-flx="app.voice-participant-item.voice-participant-bottom-sheet"
/> />
@@ -81,6 +81,8 @@ export const CallParticipantsRow = observer(
onClose={onClose} onClose={onClose}
guildId={channel.guildId ?? undefined} guildId={channel.guildId ?? undefined}
connectionId={participantEntry.connectionId} connectionId={participantEntry.connectionId}
surface="call-avatar"
source={{kind: 'participant'}}
data-flx="channel.channel-view.dm-channel-view.handle-context-menu.voice-participant-context-menu" data-flx="channel.channel-view.dm-channel-view.handle-context-menu.voice-participant-context-menu"
/> />
) : ( ) : (
@@ -50,6 +50,7 @@ import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
MicrophoneIcon, MicrophoneIcon,
MicrophoneSlashIcon, MicrophoneSlashIcon,
MonitorArrowUpIcon,
MonitorPlayIcon, MonitorPlayIcon,
NotePencilIcon, NotePencilIcon,
PaperPlaneIcon, PaperPlaneIcon,
@@ -883,6 +884,13 @@ export const TurnOffStreamIcon: React.FC<IconProps> = observer(({size = 16}) =>
data-flx="ui.action-menu.context-menu-icons.turn-off-stream-icon.monitor-play-icon" data-flx="ui.action-menu.context-menu-icons.turn-off-stream-icon.monitor-play-icon"
/> />
)); ));
export const ChangeStreamIcon: React.FC<IconProps> = observer(({size = 16}) => (
<MonitorArrowUpIcon
size={remFromPx(size)}
weight="fill"
data-flx="ui.action-menu.context-menu-icons.change-stream-icon.monitor-arrow-up-icon"
/>
));
export const DisconnectIcon: React.FC<IconProps> = observer(({size = 16}) => ( export const DisconnectIcon: React.FC<IconProps> = observer(({size = 16}) => (
<PhoneXIcon <PhoneXIcon
size={remFromPx(size)} size={remFromPx(size)}
@@ -29,3 +29,24 @@
border-radius: 50%; border-radius: 50%;
background-color: white; background-color: white;
} }
.labelContent {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.labelText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -25,7 +25,7 @@ const textValueFromNode = (node: React.ReactNode): string => {
return ''; return '';
}; };
export const MenuItemRadio = React.forwardRef<HTMLDivElement, MenuItemRadioProps>( export const MenuItemRadio = React.forwardRef<HTMLDivElement, MenuItemRadioProps>(
({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 closeMenu = useContextMenuClose();
const handleAction = useCallback(() => { const handleAction = useCallback(() => {
if (disabled) return; if (disabled) return;
@@ -48,7 +48,16 @@ export const MenuItemRadio = React.forwardRef<HTMLDivElement, MenuItemRadioProps
data-flx="ui.action-menu.menu-item-radio.selectable-menu-item" data-flx="ui.action-menu.menu-item-radio.selectable-menu-item"
> >
<div className={styles.itemLabel} data-flx="ui.action-menu.menu-item-radio.item-label"> <div className={styles.itemLabel} data-flx="ui.action-menu.menu-item-radio.item-label">
{visibleLabel} <div className={radioStyles.labelContent} data-flx="ui.action-menu.menu-item-radio.div">
{icon && (
<span className={radioStyles.icon} data-flx="ui.action-menu.menu-item-radio.span">
{icon}
</span>
)}
<span className={radioStyles.labelText} data-flx="ui.action-menu.menu-item-radio.span--2">
{visibleLabel}
</span>
</div>
</div> </div>
<div <div
className={styles.checkboxIndicator} className={styles.checkboxIndicator}
@@ -1,13 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import Channels from '@app/features/channel/state/Channels';
import Guilds from '@app/features/guild/state/Guilds'; import Guilds from '@app/features/guild/state/Guilds';
import Permission from '@app/features/permissions/state/Permission'; import Permission from '@app/features/permissions/state/Permission';
import NativePermission from '@app/features/permissions/system/state/NativePermission';
import {DataMenuRenderer} from '@app/features/ui/action_menu/DataMenuRenderer'; import {DataMenuRenderer} from '@app/features/ui/action_menu/DataMenuRenderer';
import {MuteDMMenuItem} from '@app/features/ui/action_menu/items/DMMenuItems';
import {ManageRolesMenuItem} from '@app/features/ui/action_menu/items/GuildMemberMenuItems'; import {ManageRolesMenuItem} from '@app/features/ui/action_menu/items/GuildMemberMenuItems';
import {EditCommunityProfileMenuItem} from '@app/features/ui/action_menu/items/GuildMenuItems';
import {InviteToCommunityMenuItem} from '@app/features/ui/action_menu/items/InviteMenuItems';
import {MoveToChannelSubmenu} from '@app/features/ui/action_menu/items/MoveToChannelSubmenu'; import {MoveToChannelSubmenu} from '@app/features/ui/action_menu/items/MoveToChannelSubmenu';
import {useVoiceParticipantMenuData} from '@app/features/ui/action_menu/items/VoiceParticipantMenuData'; import {useVoiceParticipantMenuData} from '@app/features/ui/action_menu/items/VoiceParticipantMenuData';
import type {
VoiceParticipantMenuSource,
VoiceParticipantMenuSurface,
} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes';
import {VoiceParticipantOwnStreamMenuTail} from '@app/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder';
import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup'; import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup';
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import type {User} from '@app/features/user/models/User'; import type {User} from '@app/features/user/models/User';
import {ActiveScreenShareMenu} from '@app/features/voice/components/ActiveScreenShareMenu';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
import {resolveDisplayShareEnvironment} from '@app/features/voice/utils/ScreenShareEnvironment';
import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro'; import {useLingui} from '@lingui/react/macro';
@@ -34,14 +49,11 @@ interface VoiceParticipantContextMenuProps {
onClose: () => void; onClose: () => void;
guildId?: string; guildId?: string;
connectionId?: string; connectionId?: string;
surface: VoiceParticipantMenuSurface;
source: VoiceParticipantMenuSource;
isGroupedItem?: boolean; isGroupedItem?: boolean;
isParentGroupedItem?: boolean; isParentGroupedItem?: boolean;
streamKey?: string; groupContainsLocalConnection?: boolean;
isScreenShare?: boolean;
isWatching?: boolean;
hasScreenShareAudio?: boolean;
isOwnScreenShare?: boolean;
onStopWatching?: () => void;
hiddenConnectionCount?: number; hiddenConnectionCount?: number;
deviceConnectionCount?: number; deviceConnectionCount?: number;
isDeviceGroupExpanded?: boolean; isDeviceGroupExpanded?: boolean;
@@ -51,44 +63,48 @@ interface VoiceParticipantContextMenuProps {
export const VoiceParticipantContextMenu: React.FC<VoiceParticipantContextMenuProps> = observer( export const VoiceParticipantContextMenu: React.FC<VoiceParticipantContextMenuProps> = observer(
({ ({
user, user,
participantName,
onClose, onClose,
guildId, guildId,
connectionId, connectionId,
surface,
source,
isGroupedItem = false, isGroupedItem = false,
isParentGroupedItem = false, isParentGroupedItem = false,
streamKey, groupContainsLocalConnection = false,
isScreenShare = false,
isWatching = false,
hasScreenShareAudio = false,
isOwnScreenShare = false,
onStopWatching,
hiddenConnectionCount = 0, hiddenConnectionCount = 0,
deviceConnectionCount = 0, deviceConnectionCount = 0,
isDeviceGroupExpanded = false, isDeviceGroupExpanded = false,
onToggleDeviceGroup, onToggleDeviceGroup,
}) => { }) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const {groups, member, canMoveMembers, userVoiceStates, hasMultipleConnections, hasVoiceChannels} = const {
useVoiceParticipantMenuData({ groups,
user, guildManagementGroupIndex,
guildId, member,
connectionId, isCurrentUser,
isGroupedItem, canMoveMembers,
isParentGroupedItem, userVoiceStates,
streamKey, hasMultipleConnections,
isScreenShare, hasVoiceChannels,
isWatching, } = useVoiceParticipantMenuData({
hasScreenShareAudio, user,
isOwnScreenShare, guildId,
onStopWatching, connectionId,
onClose, surface,
hiddenConnectionCount, source,
deviceConnectionCount, isGroupedItem,
isDeviceGroupExpanded, isParentGroupedItem,
onToggleDeviceGroup, groupContainsLocalConnection,
}); onClose,
hiddenConnectionCount,
deviceConnectionCount,
isDeviceGroupExpanded,
onToggleDeviceGroup,
});
const connectionIds = useMemo(() => userVoiceStates.map((u) => u.connectionId), [userVoiceStates]); const connectionIds = useMemo(() => userVoiceStates.map((u) => u.connectionId), [userVoiceStates]);
const guild = guildId ? Guilds.getGuild(guildId) : null; 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 hasRoles = guild && Object.values(guild.roles).some((r) => !r.isEveryone);
const canManageRoles = guildId ? Permission.can(Permissions.MANAGE_ROLES, {guildId}) : false; const canManageRoles = guildId ? Permission.can(Permissions.MANAGE_ROLES, {guildId}) : false;
const memberHasVisibleRoles = useMemo(() => { const memberHasVisibleRoles = useMemo(() => {
@@ -98,54 +114,133 @@ export const VoiceParticipantContextMenu: React.FC<VoiceParticipantContextMenuPr
return Object.values(guild.roles).some((role) => !role.isEveryone && member.roles.has(role.id)); return Object.values(guild.roles).some((role) => !role.isEveryone && member.roles.has(role.id));
}, [guild, member]); }, [guild, member]);
const shouldShowManageRoles = hasRoles && (canManageRoles || memberHasVisibleRoles); 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 && (
<MenuGroup data-flx="ui.action-menu.voice-participant-context-menu.menu-group">
{member && shouldShowManageRoles && (
<ManageRolesMenuItem
guildId={guildId}
member={member}
data-flx="ui.action-menu.voice-participant-context-menu.manage-roles-menu-item"
/>
)}
{shouldShowMoveDevice && connectionId && (
<MoveToChannelSubmenu
userId={user.id}
guildId={guildId}
connectionId={connectionId}
onClose={onClose}
label={i18n._(MOVE_DEVICE_TO_DESCRIPTOR)}
data-flx="ui.action-menu.voice-participant-context-menu.move-to-channel-submenu"
/>
)}
{shouldShowMoveAllDevices && (
<MoveToChannelSubmenu
userId={user.id}
guildId={guildId}
connectionIds={connectionIds}
onClose={onClose}
label={i18n._(MOVE_ALL_DEVICES_TO_DESCRIPTOR)}
data-flx="ui.action-menu.voice-participant-context-menu.move-to-channel-submenu--2"
/>
)}
{shouldShowMoveParticipant && (
<MoveToChannelSubmenu
userId={user.id}
guildId={guildId}
connectionId={connectionId}
onClose={onClose}
label={i18n._(MOVE_TO_DESCRIPTOR)}
data-flx="ui.action-menu.voice-participant-context-menu.move-to-channel-submenu--3"
/>
)}
</MenuGroup>
);
const auxiliaryMenu = (guild || privateCallChannel?.isDM() || (!isCurrentUser && !user.bot)) && (
<MenuGroup data-flx="ui.action-menu.voice-participant-context-menu.auxiliary-menu-group">
{guild && isCurrentUser && (
<EditCommunityProfileMenuItem
guild={guild}
onClose={onClose}
data-flx="ui.action-menu.voice-participant-context-menu.edit-community-profile-menu-item"
/>
)}
{!isCurrentUser && !user.bot && (
<InviteToCommunityMenuItem
user={user}
onClose={onClose}
data-flx="ui.action-menu.voice-participant-context-menu.invite-to-community-menu-item"
/>
)}
{privateCallChannel?.isDM() && (
<MuteDMMenuItem
channel={privateCallChannel}
onClose={onClose}
data-flx="ui.action-menu.voice-participant-context-menu.mute-dm-menu-item"
/>
)}
</MenuGroup>
);
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 (
<ActiveScreenShareMenu
onClose={onClose}
displayShareEnvironment={displayShareEnvironment}
shareContext={shareContext}
shareContextResolved={shareContextResolved}
tail={
<VoiceParticipantOwnStreamMenuTail
userId={user.id}
guildId={guildId}
connectionId={connectionId}
displayName={participantName}
onClose={onClose}
data-flx="ui.action-menu.voice-participant-context-menu.voice-participant-own-stream-menu-tail"
/>
}
data-flx="ui.action-menu.voice-participant-context-menu.active-screen-share-menu"
/>
);
}
if (source.kind === 'screen-share') {
return (
<DataMenuRenderer groups={groups} data-flx="ui.action-menu.voice-participant-context-menu.stream-actions" />
);
}
return ( return (
<> <>
<DataMenuRenderer groups={groups} data-flx="ui.action-menu.voice-participant-context-menu.data-menu-renderer" /> <DataMenuRenderer
{isGroupedItem && connectionId && guildId && hasVoiceChannels && ( groups={leadingGroups}
<MenuGroup data-flx="ui.action-menu.voice-participant-context-menu.menu-group"> data-flx="ui.action-menu.voice-participant-context-menu.data-menu-renderer"
<MoveToChannelSubmenu />
userId={user.id} {auxiliaryMenu}
guildId={guildId} {guildManagementMenu}
connectionId={connectionId} <DataMenuRenderer
onClose={onClose} groups={trailingGroups}
label={i18n._(MOVE_DEVICE_TO_DESCRIPTOR)} data-flx="ui.action-menu.voice-participant-context-menu.data-menu-renderer--2"
data-flx="ui.action-menu.voice-participant-context-menu.move-to-channel-submenu" />
/>
</MenuGroup>
)}
{isParentGroupedItem && hasMultipleConnections && guildId && hasVoiceChannels && (
<MenuGroup data-flx="ui.action-menu.voice-participant-context-menu.menu-group--2">
<MoveToChannelSubmenu
userId={user.id}
guildId={guildId}
connectionIds={connectionIds}
onClose={onClose}
label={i18n._(MOVE_ALL_DEVICES_TO_DESCRIPTOR)}
data-flx="ui.action-menu.voice-participant-context-menu.move-to-channel-submenu--2"
/>
</MenuGroup>
)}
{guildId && canMoveMembers && !isParentGroupedItem && !isGroupedItem && hasVoiceChannels && (
<MenuGroup data-flx="ui.action-menu.voice-participant-context-menu.menu-group--3">
<MoveToChannelSubmenu
userId={user.id}
guildId={guildId}
connectionId={connectionId}
onClose={onClose}
label={i18n._(MOVE_TO_DESCRIPTOR)}
data-flx="ui.action-menu.voice-participant-context-menu.move-to-channel-submenu--3"
/>
</MenuGroup>
)}
{guildId && member && shouldShowManageRoles && (
<MenuGroup data-flx="ui.action-menu.voice-participant-context-menu.menu-group--4">
<ManageRolesMenuItem
guildId={guildId}
member={member}
data-flx="ui.action-menu.voice-participant-context-menu.manage-roles-menu-item"
/>
</MenuGroup>
)}
</> </>
); );
}, },
@@ -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<MenuLeaf>;
}
interface MenuGroup {
items: Array<MenuLeaf>;
}
const shadowedMediaEngineKeys: Array<string> = [];
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<MenuGroup>, 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<typeof useVoiceParticipantMenuData>[0],
): Promise<{groups: () => Array<MenuGroup>}> {
const latest: {groups: Array<MenuGroup>} = {groups: []};
const Probe = observer(function Probe() {
latest.groups = useVoiceParticipantMenuData(options).groups as unknown as Array<MenuGroup>;
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<string, unknown>)[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();
}
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it, vi} from 'vitest';
const errors: Array<unknown> = [];
vi.mock('@app/features/platform/utils/AppLogger', () => ({
Logger: class {
error(...args: Array<unknown>) {
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);
});
});
@@ -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;
}
@@ -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<MenuLeaf>;
}
function streamMenu(source: VoiceParticipantMenuScreenShareSource): Array<{items: Array<MenuLeaf>}> {
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<MenuLeaf>}>;
}
function findLeaf(groups: Array<{items: Array<MenuLeaf>}>, 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();
});
@@ -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: (
<TurnOffStreamIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.build-stream-state-action.turn-off-stream-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: (
<TurnOffStreamIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.build-stream-state-action.turn-off-stream-icon--2"
/>
),
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: (
<ChangeStreamIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.build-own-stream-change-action.change-stream-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<MenuCheckboxType | MenuSliderType> {
const {i18n, source, streamVolume, isStreamMuted, userId} = options;
const streamState = source.state;
if (streamState.kind !== 'remote-watched') return [];
if (!streamState.hasAudio) return [];
return [
{
icon: (
<LocalMuteIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.build-stream-audio-actions.local-mute-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: (
<PopOutIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.build-stream-popout-action.pop-out-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<MenuItemType | MenuCheckboxType> = [
{
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<MenuGroupType> {
const stateActions: Array<MenuItemType> = [buildStreamStateAction(options)];
const changeAction = buildOwnStreamChangeAction(options);
if (changeAction) stateActions.push(changeAction);
const groups: Array<MenuGroupType> = [{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<VoiceParticipantOwnStreamMenuTailProps> = 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 && (
<MenuGroup data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.popout-group">
<MenuItem
icon={
<PopOutIcon
size={16}
data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.voice-participant-own-stream-menu-tail.pop-out-icon"
/>
}
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)}
</MenuItem>
</MenuGroup>
)}
<MenuGroup data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.more-options-group">
<MenuItemSubmenu
label={i18n._(MORE_OPTIONS_DESCRIPTOR)}
render={() => (
<MenuGroup data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.more-options">
<CheckboxItem
checked={VoiceSettings.showMyOwnScreenShare}
onCheckedChange={(checked: boolean) => 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)}
</CheckboxItem>
<CheckboxItem
checked={VoiceSettings.pauseOwnScreenSharePreviewOnUnfocus}
onCheckedChange={(checked: boolean) =>
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)}
</CheckboxItem>
{guildId === undefined && (
<MenuItem
onClick={() => {
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)}
</MenuItem>
)}
</MenuGroup>
)}
data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.more-options-submenu"
/>
</MenuGroup>
</>
);
},
);
@@ -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<string, unknown>};
if (typeof host.window === 'undefined') {
host.window = host as unknown as Record<string, unknown>;
}
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: [],
},
};
}
@@ -17,6 +17,22 @@ export const STREAM_VOLUME_DESCRIPTOR = msg({
message: 'Stream volume', message: 'Stream volume',
comment: 'Voice menu slider label that controls volume of an incoming screen share with audio.', 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({ export const MUTE_DESCRIPTOR = msg({
message: 'Mute', message: 'Mute',
comment: comment:
@@ -27,11 +43,19 @@ export const UNFOCUS_DESCRIPTOR = msg({
comment: 'Voice call layout action that removes focus from the currently pinned participant.', comment: 'Voice call layout action that removes focus from the currently pinned participant.',
}); });
export const POP_OUT_CAMERA_DESCRIPTOR = msg({ export const POP_OUT_CAMERA_DESCRIPTOR = msg({
message: 'Pop out camera', message: 'Pop Out Video',
comment: 'Voice menu action on desktop that opens the participant camera feed in a separate window.', 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({ 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.', comment: 'Voice menu action on desktop that opens the participant screen share in a separate window.',
}); });
export const FOCUS_THIS_DEVICE_DESCRIPTOR = msg({ 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.', comment: 'Voice display preference checkbox that controls whether the current user sees their own camera tile.',
}); });
export const SHOW_MY_SCREEN_SHARE_DESCRIPTOR = msg({ 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.', 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({ export const SHOW_NON_VIDEO_PARTICIPANTS_DESCRIPTOR = msg({
@@ -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<void> {
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<void> {
await openScreenShareSourceSwitcherModal({initialTab: getSourceSwitcherTab(shareContext)});
}
export const ActiveScreenShareMenu: React.FC<ActiveScreenShareMenuProps> = ({
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 (
<>
<MenuGroup data-flx="voice.active-screen-share-menu.actions">
<MenuItem
icon={
<StopCircleIcon
weight="fill"
className={iconClassName}
data-flx="voice.active-screen-share-menu.stop-circle-icon"
/>
}
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)}
</MenuItem>
<MenuItem
icon={
<MonitorPlayIcon
weight="fill"
className={iconClassName}
data-flx="voice.active-screen-share-menu.monitor-play-icon"
/>
}
onClick={handleChangeStream}
data-flx="voice.active-screen-share-menu.change"
>
{i18n._(CHANGE_STREAM_DESCRIPTOR)}
</MenuItem>
{additionalActions}
{showLiveSettings && !isWeb && (
<StreamSettingsMenuContent
applyToLiveStream
variant="compactLive"
displayShareEnvironment={displayShareEnvironment}
shareContext={shareContext}
shareContextResolved={shareContextResolved}
data-flx="voice.active-screen-share-menu.live-settings"
/>
)}
</MenuGroup>
{tail}
</>
);
};
@@ -149,6 +149,14 @@ const ADAPTIVE_QUALITY_ACTIVE_DESCRIPTOR = msg({
comment: comment:
'Inline adaptive-quality status in the stream settings menu. Shows the current automatically lowered resolution and frame rate.', '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 logger = new Logger('StreamSettingsMenuContent');
const SCREEN_SHARE_AUDIO_SOURCE = VoiceTrackSource.ScreenShareAudio as Track.Source; const SCREEN_SHARE_AUDIO_SOURCE = VoiceTrackSource.ScreenShareAudio as Track.Source;
@@ -329,6 +337,7 @@ interface StreamSettingsMenuContentProps {
shareContext?: StreamSettingsShareContext; shareContext?: StreamSettingsShareContext;
shareContextResolved?: boolean; shareContextResolved?: boolean;
displayShareEnvironment: DisplayShareEnvironment; displayShareEnvironment: DisplayShareEnvironment;
variant?: 'full' | 'compactLive';
} }
export const StreamSettingsMenuContent = observer( export const StreamSettingsMenuContent = observer(
@@ -337,6 +346,7 @@ export const StreamSettingsMenuContent = observer(
shareContext = 'display', shareContext = 'display',
shareContextResolved = true, shareContextResolved = true,
displayShareEnvironment, displayShareEnvironment,
variant = 'full',
}: StreamSettingsMenuContentProps) => { }: StreamSettingsMenuContentProps) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
useMediaEngineVersion(); useMediaEngineVersion();
@@ -348,6 +358,18 @@ export const StreamSettingsMenuContent = observer(
const currentMode = VoiceSettings.getStreamingMode(); const currentMode = VoiceSettings.getStreamingMode();
const currentResolution = VoiceSettings.getScreenshareResolution(); const currentResolution = VoiceSettings.getScreenshareResolution();
const currentFrameRate = resolveScreenShareFrameRate(VoiceSettings.getVideoFrameRate()); 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 const captureAudioEnabled = isAppShare
? VoiceSettings.getShareAppAudio() ? VoiceSettings.getShareAppAudio()
: isDeviceShare : isDeviceShare
@@ -552,6 +574,117 @@ export const StreamSettingsMenuContent = observer(
const selectedAudioDeviceLabel = selectedAudioDevice const selectedAudioDeviceLabel = selectedAudioDevice
? formatVoiceAudioDeviceLabel(i18n, selectedAudioDevice, i18n._(UNNAMED_INPUT_DESCRIPTOR)) ? formatVoiceAudioDeviceLabel(i18n, selectedAudioDevice, i18n._(UNNAMED_INPUT_DESCRIPTOR))
: i18n._(SYSTEM_DEFAULT_DESCRIPTOR); : i18n._(SYSTEM_DEFAULT_DESCRIPTOR);
if (variant === 'compactLive') {
const selectCompactResolution = (option: Option<ScreenshareResolution>) => {
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<SupportedScreenShareFrameRate>) => {
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 (
<>
<MenuItemSubmenu
label={i18n._(STREAM_QUALITY_DESCRIPTOR)}
render={() => (
<>
<MenuGroup data-flx="voice.stream-settings-menu-content.compact-live.frame-rate-group">
<MenuGroupLabel data-flx="voice.stream-settings-menu-content.compact-live.frame-rate-label">
{i18n._(FRAMERATE_DESCRIPTOR)}
</MenuGroupLabel>
{frameRateOptions.map((option) => {
const premiumLocked = showPremiumFeatures && option.isPremium && !hasHigherVideoQuality;
return (
<MenuItemRadio
key={option.value}
selected={effectiveQuality.frameRate === option.value}
onSelect={() => selectCompactFrameRate(option)}
data-flx="voice.stream-settings-menu-content.compact-live.frame-rate-option"
>
<span
className={styles.row}
data-flx="voice.stream-settings-menu-content.compact-live.frame-rate-row"
>
<span
className={styles.rowLabel}
data-flx="voice.stream-settings-menu-content.compact-live.frame-rate-row-label"
>
{option.label}
</span>
{premiumLocked && (
<PremiumBadge data-flx="voice.stream-settings-menu-content.compact-live.frame-rate-premium-badge" />
)}
</span>
</MenuItemRadio>
);
})}
</MenuGroup>
<MenuGroup data-flx="voice.stream-settings-menu-content.compact-live.resolution-group">
<MenuGroupLabel data-flx="voice.stream-settings-menu-content.compact-live.resolution-label">
{i18n._(RESOLUTION_DESCRIPTOR)}
</MenuGroupLabel>
{resolutionOptions.map((option) => {
const premiumLocked = showPremiumFeatures && option.isPremium && !hasHigherVideoQuality;
return (
<MenuItemRadio
key={option.value}
selected={effectiveQuality.resolution === option.value}
onSelect={() => selectCompactResolution(option)}
data-flx="voice.stream-settings-menu-content.compact-live.resolution-option"
>
<span
className={styles.row}
data-flx="voice.stream-settings-menu-content.compact-live.resolution-row"
>
<span
className={styles.rowLabel}
data-flx="voice.stream-settings-menu-content.compact-live.resolution-row-label"
>
{option.label}
</span>
{premiumLocked && (
<PremiumBadge data-flx="voice.stream-settings-menu-content.compact-live.resolution-premium-badge" />
)}
</span>
</MenuItemRadio>
);
})}
</MenuGroup>
</>
)}
data-flx="voice.stream-settings-menu-content.compact-live.quality-submenu"
/>
{audioMenuState.control.value === 'toggle' && (
<CheckboxItem
checked={audioMenuState.control.checked}
onCheckedChange={handleCaptureAudioToggle}
data-flx="voice.stream-settings-menu-content.compact-live.share-audio"
>
{i18n._(SHARE_STREAM_AUDIO_DESCRIPTOR)}
</CheckboxItem>
)}
</>
);
}
return ( return (
<> <>
<MenuGroup data-flx="voice.stream-settings-menu-content.menu-group"> <MenuGroup data-flx="voice.stream-settings-menu-content.menu-group">
@@ -247,6 +247,8 @@ const SpectatorRow = observer(function SpectatorRow({
onClose={onClose} onClose={onClose}
guildId={guildId} guildId={guildId}
connectionId={connectionId} connectionId={connectionId}
surface="stream-spectator-list"
source={{kind: 'participant'}}
data-flx="voice.stream-spectators-popout.handle-context-menu.voice-participant-context-menu" data-flx="voice.stream-spectators-popout.handle-context-menu.voice-participant-context-menu"
/> />
)); ));
@@ -19,6 +19,7 @@ import {TooltipWithKeybind} from '@app/features/ui/keybind_hint/KeybindHint';
import MobileLayout from '@app/features/ui/state/MobileLayout'; import MobileLayout from '@app/features/ui/state/MobileLayout';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import {isDesktop} from '@app/features/ui/utils/NativeUtils'; import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import {ActiveScreenShareMenu} from '@app/features/voice/components/ActiveScreenShareMenu';
import { import {
VoiceAudioSettingsBottomSheet, VoiceAudioSettingsBottomSheet,
VoiceCameraSettingsBottomSheet, VoiceCameraSettingsBottomSheet,
@@ -30,7 +31,6 @@ import {
} from '@app/features/voice/components/modals/CameraPreviewModal'; } from '@app/features/voice/components/modals/CameraPreviewModal';
import { import {
openScreenSharePickerModal, openScreenSharePickerModal,
openScreenShareSourceSwitcherModal,
preloadScreenSharePickerSources, preloadScreenSharePickerSources,
} from '@app/features/voice/components/modals/ScreenSharePickerModal'; } from '@app/features/voice/components/modals/ScreenSharePickerModal';
import {getStreamKey} from '@app/features/voice/components/StreamKeys'; import {getStreamKey} from '@app/features/voice/components/StreamKeys';
@@ -105,10 +105,6 @@ import {observer} from 'mobx-react-lite';
import type React from 'react'; import type React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} 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({ const END_SCREEN_SHARE_DESCRIPTOR = msg({
message: 'End screen share', message: 'End screen share',
comment: 'Voice control menu action to stop sharing the screen.', comment: 'Voice control menu action to stop sharing the screen.',
@@ -353,56 +349,10 @@ const VoiceControlBarInner = observer(function VoiceControlBarInner() {
} }
}, [localParticipant, isCameraEnabled, isConnected]); }, [localParticipant, isCameraEnabled, isConnected]);
const renderScreenShareMenu = useCallback( const renderScreenShareMenu = useCallback(
({onClose}: {onClose: () => void}) => ( ({onClose}: {onClose: () => void}) => {
<> const shareContext = ActiveScreenShareSource.getSourceId()?.startsWith('window:') ? 'app' : 'display';
{isScreenShareEnabled && ( const shareContextResolved = ActiveScreenShareSource.getSourceId() != null;
<MenuGroup data-flx="voice.voice-control-bar.render-screen-share-menu.menu-group"> const screenShareSettingsMenu = (
<MenuItem
icon={
<MonitorPlayIcon
weight="fill"
className={styles.icon}
data-flx="voice.voice-control-bar.render-screen-share-menu.icon"
/>
}
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)}
</MenuItem>
<MenuItem
icon={
<MonitorPlayIcon
weight="fill"
className={styles.icon}
data-flx="voice.voice-control-bar.render-screen-share-menu.icon--2"
/>
}
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)}
</MenuItem>
</MenuGroup>
)}
<StreamSettingsMenuContent
applyToLiveStream={isScreenShareEnabled}
displayShareEnvironment={displayShareEnvironment}
shareContext={ActiveScreenShareSource.getSourceId()?.startsWith('window:') ? 'app' : 'display'}
shareContextResolved={ActiveScreenShareSource.getSourceId() != null}
data-flx="voice.voice-control-bar.render-screen-share-menu.stream-settings-menu-content"
/>
<MenuGroup data-flx="voice.voice-control-bar.render-screen-share-menu.menu-group--2"> <MenuGroup data-flx="voice.voice-control-bar.render-screen-share-menu.menu-group--2">
<MenuItem <MenuItem
icon={ icon={
@@ -418,8 +368,33 @@ const VoiceControlBarInner = observer(function VoiceControlBarInner() {
{i18n._(VOICE_SCREEN_SHARE_SETTINGS_DESCRIPTOR)} {i18n._(VOICE_SCREEN_SHARE_SETTINGS_DESCRIPTOR)}
</MenuItem> </MenuItem>
</MenuGroup> </MenuGroup>
</> );
), if (isScreenShareEnabled) {
return (
<ActiveScreenShareMenu
onClose={onClose}
displayShareEnvironment={displayShareEnvironment}
shareContext={shareContext}
shareContextResolved={shareContextResolved}
iconClassName={styles.icon}
tail={screenShareSettingsMenu}
data-flx="voice.voice-control-bar.render-screen-share-menu.active-screen-share-menu"
/>
);
}
return (
<>
<StreamSettingsMenuContent
applyToLiveStream={false}
displayShareEnvironment={displayShareEnvironment}
shareContext={shareContext}
shareContextResolved={shareContextResolved}
data-flx="voice.voice-control-bar.render-screen-share-menu.stream-settings-menu-content"
/>
{screenShareSettingsMenu}
</>
);
},
[displayShareEnvironment, isScreenShareEnabled, i18n], [displayShareEnvironment, isScreenShareEnabled, i18n],
); );
const openScreenShareMenu = useCallback( const openScreenShareMenu = useCallback(
@@ -279,6 +279,8 @@ function VoiceParticipantPopoutRow({entry, guildId, channelId}: VoiceParticipant
onClose={onClose} onClose={onClose}
guildId={guildId ?? undefined} guildId={guildId ?? undefined}
connectionId={entry.connectionId} connectionId={entry.connectionId}
surface="participant-avatar-list"
source={{kind: 'participant'}}
data-flx="voice.voice-participant-avatar-list.handle-context-menu.voice-participant-context-menu" data-flx="voice.voice-participant-avatar-list.handle-context-menu.voice-participant-context-menu"
/> />
)); ));
@@ -414,6 +416,8 @@ export const VoiceParticipantSpeakingAvatarStack: React.FC<VoiceParticipantSpeak
onClose={onClose} onClose={onClose}
guildId={guildId ?? undefined} guildId={guildId ?? undefined}
connectionId={entry.connectionId} connectionId={entry.connectionId}
surface="participant-avatar-list"
source={{kind: 'participant'}}
data-flx="voice.voice-participant-avatar-list.handle-user-context-menu.voice-participant-context-menu" data-flx="voice.voice-participant-avatar-list.handle-user-context-menu.voice-participant-context-menu"
/> />
)); ));
@@ -563,6 +567,8 @@ export const VoiceParticipantWrappedAvatarList: React.FC<VoiceParticipantWrapped
onClose={onClose} onClose={onClose}
guildId={guildId ?? undefined} guildId={guildId ?? undefined}
connectionId={entry.connectionId} connectionId={entry.connectionId}
surface="participant-avatar-list"
source={{kind: 'participant'}}
data-flx="voice.voice-participant-avatar-list.voice-participant-wrapped-avatar-list.handle-context-menu.voice-participant-context-menu" data-flx="voice.voice-participant-avatar-list.voice-participant-wrapped-avatar-list.handle-context-menu.voice-participant-context-menu"
/> />
)); ));
@@ -5,6 +5,7 @@ import {WATCH_STREAM_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDes
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import Permission from '@app/features/permissions/state/Permission'; import Permission from '@app/features/permissions/state/Permission';
import {dimColor} from '@app/features/theme/utils/ColorUtils'; 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 {VoiceParticipantContextMenu} from '@app/features/ui/action_menu/VoiceParticipantContextMenu';
import {Button} from '@app/features/ui/button/Button'; import {Button} from '@app/features/ui/button/Button';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; 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', other: 'Keep watching # streams and add this one',
}, },
); );
const participantMenuSource = useMemo<VoiceParticipantMenuSource>(() => {
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( const handleContextMenu = useCallback(
(event: React.MouseEvent | MouseEvent) => { (event: React.MouseEvent | MouseEvent) => {
if (!participantUser) return; if (!participantUser) return;
@@ -635,13 +673,9 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
onClose={onClose} onClose={onClose}
guildId={guildId} guildId={guildId}
connectionId={connectionId} connectionId={connectionId}
surface="call-tile"
source={participantMenuSource}
isGroupedItem={isGroupedItem} isGroupedItem={isGroupedItem}
streamKey={streamKey}
isScreenShare={isScreenShare}
isWatching={isWatching}
hasScreenShareAudio={hasScreenShareAudio}
isOwnScreenShare={isOwnScreenShare}
onStopWatching={stopWatching}
hiddenConnectionCount={groupHiddenCount} hiddenConnectionCount={groupHiddenCount}
deviceConnectionCount={groupDeviceConnectionCount} deviceConnectionCount={groupDeviceConnectionCount}
isDeviceGroupExpanded={tileGroup?.isExpanded ?? false} isDeviceGroupExpanded={tileGroup?.isExpanded ?? false}
@@ -656,12 +690,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
guildId, guildId,
connectionId, connectionId,
isCurrentUser, isCurrentUser,
streamKey, participantMenuSource,
isScreenShare,
isWatching,
hasScreenShareAudio,
isOwnScreenShare,
stopWatching,
groupHiddenCount, groupHiddenCount,
groupDeviceConnectionCount, groupDeviceConnectionCount,
tileGroup, tileGroup,
@@ -1395,13 +1424,9 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
participant={connectionParticipant} participant={connectionParticipant}
guildId={guildId} guildId={guildId}
connectionId={connectionId} connectionId={connectionId}
surface="call-tile"
source={participantMenuSource}
isConnectionItem 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" data-flx="voice.voice-participant-tile.voice-participant-tile-inner.voice-participant-bottom-sheet"
/> />
)} )}
@@ -1,6 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {useVoiceParticipantMenuData} from '@app/features/ui/action_menu/items/VoiceParticipantMenuData'; 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 {MenuBottomSheet} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
import type {User} from '@app/features/user/models/User'; import type {User} from '@app/features/user/models/User';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
@@ -12,15 +16,11 @@ interface VoiceParticipantBottomSheetProps {
user: User; user: User;
guildId?: string; guildId?: string;
connectionId?: string; connectionId?: string;
surface: VoiceParticipantMenuSurface;
source: VoiceParticipantMenuSource;
isConnectionItem?: boolean; isConnectionItem?: boolean;
isParentGroupedItem?: boolean; isParentGroupedItem?: boolean;
participant?: unknown; participant?: unknown;
streamKey?: string;
isScreenShare?: boolean;
isWatching?: boolean;
hasScreenShareAudio?: boolean;
isOwnScreenShare?: boolean;
onStopWatching?: () => void;
} }
export const VoiceParticipantBottomSheet: React.FC<VoiceParticipantBottomSheetProps> = observer( export const VoiceParticipantBottomSheet: React.FC<VoiceParticipantBottomSheetProps> = observer(
@@ -30,27 +30,19 @@ export const VoiceParticipantBottomSheet: React.FC<VoiceParticipantBottomSheetPr
user, user,
guildId, guildId,
connectionId, connectionId,
surface,
source,
isConnectionItem = false, isConnectionItem = false,
isParentGroupedItem = false, isParentGroupedItem = false,
streamKey,
isScreenShare = false,
isWatching = false,
hasScreenShareAudio = false,
isOwnScreenShare = false,
onStopWatching,
}) => { }) => {
const {groups} = useVoiceParticipantMenuData({ const {groups} = useVoiceParticipantMenuData({
user, user,
guildId, guildId,
connectionId, connectionId,
surface,
source,
isGroupedItem: isConnectionItem, isGroupedItem: isConnectionItem,
isParentGroupedItem, isParentGroupedItem,
streamKey,
isScreenShare,
isWatching,
hasScreenShareAudio,
isOwnScreenShare,
onStopWatching,
onClose, onClose,
}); });
return ( return (
@@ -527,16 +527,18 @@ const ScreenSharePreviewInfoModal = observer(() => {
); );
}); });
export function openScreenSharePreviewPrivacyModal(): void {
ModalCommands.push(
ModalCommands.modal(() => (
<ScreenSharePreviewInfoModal data-flx="voice.screen-share-picker-modal.open-preview-privacy-modal.screen-share-preview-info-modal" />
)),
);
}
const ScreenSharePreviewFooterNotice = observer(() => { const ScreenSharePreviewFooterNotice = observer(() => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const previewsEnabled = !PrivacyPreferences.getDisableStreamPreviews(); const previewsEnabled = !PrivacyPreferences.getDisableStreamPreviews();
const openInfoModal = useCallback(() => { const openInfoModal = useCallback(openScreenSharePreviewPrivacyModal, []);
ModalCommands.push(
ModalCommands.modal(() => (
<ScreenSharePreviewInfoModal data-flx="voice.screen-share-picker-modal.open-info-modal.screen-share-preview-info-modal" />
)),
);
}, []);
const handleDisable = useCallback(() => { const handleDisable = useCallback(() => {
PrivacyPreferences.setDisableStreamPreviews(true); PrivacyPreferences.setDisableStreamPreviews(true);
}, []); }, []);
@@ -3,24 +3,21 @@
import {TURN_OFF_CAMERA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {TURN_OFF_CAMERA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import Permission from '@app/features/permissions/state/Permission'; import Permission from '@app/features/permissions/state/Permission';
import NativePermission from '@app/features/permissions/system/state/NativePermission'; 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 ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands';
import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import {isDesktop} from '@app/features/ui/utils/NativeUtils'; import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import {ActiveScreenShareMenu} from '@app/features/voice/components/ActiveScreenShareMenu';
import { import {
CameraPreviewModalInRoom, CameraPreviewModalInRoom,
CameraPreviewModalStandalone, CameraPreviewModalStandalone,
} from '@app/features/voice/components/modals/CameraPreviewModal'; } from '@app/features/voice/components/modals/CameraPreviewModal';
import { import {
openScreenSharePickerModal, openScreenSharePickerModal,
openScreenShareSourceSwitcherModal,
preloadScreenSharePickerSources, preloadScreenSharePickerSources,
} from '@app/features/voice/components/modals/ScreenSharePickerModal'; } 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 styles from '@app/features/voice/components/VoiceConnectionStatus.module.css';
import {VoiceCameraSettingsMenu} from '@app/features/voice/components/VoiceSettingsMenus'; import {VoiceCameraSettingsMenu} from '@app/features/voice/components/VoiceSettingsMenus';
import {selectLocalParticipantControlsViewState} from '@app/features/voice/components/voice_connection_status/LocalParticipantControlsStateMachine'; 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 {observer} from 'mobx-react-lite';
import {type MouseEvent as ReactMouseEvent, useCallback} from 'react'; 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({ const WAITING_FOR_CONNECTION_DESCRIPTOR = msg({
message: 'Waiting for connection...', message: 'Waiting for connection...',
comment: comment:
@@ -146,56 +135,20 @@ export const LocalParticipantControls = observer(() => {
if (!isConnected || !isScreenShareEnabled) return; if (!isConnected || !isScreenShareEnabled) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const shareContext = ActiveScreenShareSource.getSourceId()?.startsWith('window:') ? 'app' : 'display';
const shareContextResolved = ActiveScreenShareSource.getSourceId() != null;
ContextMenuCommands.openFromEvent(event, ({onClose}) => ( ContextMenuCommands.openFromEvent(event, ({onClose}) => (
<> <ActiveScreenShareMenu
<MenuGroup data-flx="voice.voice-connection-status.open-screen-share-menu.menu-group"> onClose={onClose}
<MenuItem displayShareEnvironment={displayShareEnvironment}
icon={ shareContext={shareContext}
<MonitorPlayIcon shareContextResolved={shareContextResolved}
weight="fill" iconClassName={styles.icon}
className={styles.icon} data-flx="voice.voice-connection-status.open-screen-share-menu.active-screen-share-menu"
data-flx="voice.voice-connection-status.open-screen-share-menu.icon" />
/>
}
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)}
</MenuItem>
<MenuItem
icon={
<MonitorPlayIcon
weight="fill"
className={styles.icon}
data-flx="voice.voice-connection-status.open-screen-share-menu.icon--2"
/>
}
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)}
</MenuItem>
</MenuGroup>
<StreamSettingsMenuContent
displayShareEnvironment={displayShareEnvironment}
shareContext={ActiveScreenShareSource.getSourceId()?.startsWith('window:') ? 'app' : 'display'}
data-flx="voice.voice-connection-status.open-screen-share-menu.stream-settings-menu-content"
/>
</>
)); ));
}, },
[displayShareEnvironment, isConnected, isScreenShareEnabled, i18n], [displayShareEnvironment, isConnected, isScreenShareEnabled],
); );
const handleScreenShare = useCallback(async () => { const handleScreenShare = useCallback(async () => {
if (!isConnected) return; if (!isConnected) return;
@@ -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_WIDTH = 960;
export const VOICE_CALL_POPOUT_DEFAULT_HEIGHT = 600; export const VOICE_CALL_POPOUT_DEFAULT_HEIGHT = 600;
export type VoiceTilePopoutSource = 'camera' | 'screen_share'; export type VoiceTilePopoutSource = 'user' | 'camera' | 'screen_share';
export interface VoiceTilePopoutDescriptor { export interface VoiceTilePopoutDescriptor {
kind: 'tile'; kind: 'tile';