mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
refactor(voice): flatten participant context menu and extract stream menus (#2112)
This commit is contained in:
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 {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<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(
|
||||
(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)
|
||||
: '';
|
||||
|
||||
@@ -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<VoiceParticipantMenuSource>(() => {
|
||||
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"
|
||||
/>
|
||||
|
||||
+2
@@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
MagnifyingGlassIcon,
|
||||
MicrophoneIcon,
|
||||
MicrophoneSlashIcon,
|
||||
MonitorArrowUpIcon,
|
||||
MonitorPlayIcon,
|
||||
NotePencilIcon,
|
||||
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"
|
||||
/>
|
||||
));
|
||||
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}) => (
|
||||
<PhoneXIcon
|
||||
size={remFromPx(size)}
|
||||
|
||||
@@ -29,3 +29,24 @@
|
||||
border-radius: 50%;
|
||||
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 '';
|
||||
};
|
||||
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 handleAction = useCallback(() => {
|
||||
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"
|
||||
>
|
||||
<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
|
||||
className={styles.checkboxIndicator}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
// 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 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 {MuteDMMenuItem} from '@app/features/ui/action_menu/items/DMMenuItems';
|
||||
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 {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 {isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
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 {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -34,14 +49,11 @@ interface VoiceParticipantContextMenuProps {
|
||||
onClose: () => 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<VoiceParticipantContextMenuProps> = 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<VoiceParticipantContextMenuPr
|
||||
return Object.values(guild.roles).some((role) => !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 && (
|
||||
<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 (
|
||||
<>
|
||||
<DataMenuRenderer groups={groups} data-flx="ui.action-menu.voice-participant-context-menu.data-menu-renderer" />
|
||||
{isGroupedItem && connectionId && guildId && hasVoiceChannels && (
|
||||
<MenuGroup data-flx="ui.action-menu.voice-participant-context-menu.menu-group">
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
<DataMenuRenderer
|
||||
groups={leadingGroups}
|
||||
data-flx="ui.action-menu.voice-participant-context-menu.data-menu-renderer"
|
||||
/>
|
||||
{auxiliaryMenu}
|
||||
{guildManagementMenu}
|
||||
<DataMenuRenderer
|
||||
groups={trailingGroups}
|
||||
data-flx="ui.action-menu.voice-participant-context-menu.data-menu-renderer--2"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+116
@@ -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',
|
||||
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({
|
||||
|
||||
@@ -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:
|
||||
'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<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 (
|
||||
<>
|
||||
<MenuGroup data-flx="voice.stream-settings-menu-content.menu-group">
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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 && (
|
||||
<MenuGroup data-flx="voice.voice-control-bar.render-screen-share-menu.menu-group">
|
||||
<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"
|
||||
/>
|
||||
({onClose}: {onClose: () => void}) => {
|
||||
const shareContext = ActiveScreenShareSource.getSourceId()?.startsWith('window:') ? 'app' : 'display';
|
||||
const shareContextResolved = ActiveScreenShareSource.getSourceId() != null;
|
||||
const screenShareSettingsMenu = (
|
||||
<MenuGroup data-flx="voice.voice-control-bar.render-screen-share-menu.menu-group--2">
|
||||
<MenuItem
|
||||
icon={
|
||||
@@ -418,8 +368,33 @@ const VoiceControlBarInner = observer(function VoiceControlBarInner() {
|
||||
{i18n._(VOICE_SCREEN_SHARE_SETTINGS_DESCRIPTOR)}
|
||||
</MenuItem>
|
||||
</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],
|
||||
);
|
||||
const openScreenShareMenu = useCallback(
|
||||
|
||||
@@ -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<VoiceParticipantSpeak
|
||||
onClose={onClose}
|
||||
guildId={guildId ?? undefined}
|
||||
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"
|
||||
/>
|
||||
));
|
||||
@@ -563,6 +567,8 @@ export const VoiceParticipantWrappedAvatarList: React.FC<VoiceParticipantWrapped
|
||||
onClose={onClose}
|
||||
guildId={guildId ?? undefined}
|
||||
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"
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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<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(
|
||||
(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"
|
||||
/>
|
||||
)}
|
||||
|
||||
+10
-18
@@ -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<VoiceParticipantBottomSheetProps> = observer(
|
||||
@@ -30,27 +30,19 @@ export const VoiceParticipantBottomSheet: React.FC<VoiceParticipantBottomSheetPr
|
||||
user,
|
||||
guildId,
|
||||
connectionId,
|
||||
surface,
|
||||
source,
|
||||
isConnectionItem = false,
|
||||
isParentGroupedItem = false,
|
||||
streamKey,
|
||||
isScreenShare = false,
|
||||
isWatching = false,
|
||||
hasScreenShareAudio = false,
|
||||
isOwnScreenShare = false,
|
||||
onStopWatching,
|
||||
}) => {
|
||||
const {groups} = useVoiceParticipantMenuData({
|
||||
user,
|
||||
guildId,
|
||||
connectionId,
|
||||
surface,
|
||||
source,
|
||||
isGroupedItem: isConnectionItem,
|
||||
isParentGroupedItem,
|
||||
streamKey,
|
||||
isScreenShare,
|
||||
isWatching,
|
||||
hasScreenShareAudio,
|
||||
isOwnScreenShare,
|
||||
onStopWatching,
|
||||
onClose,
|
||||
});
|
||||
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 {i18n} = useLingui();
|
||||
const previewsEnabled = !PrivacyPreferences.getDisableStreamPreviews();
|
||||
const openInfoModal = useCallback(() => {
|
||||
ModalCommands.push(
|
||||
ModalCommands.modal(() => (
|
||||
<ScreenSharePreviewInfoModal data-flx="voice.screen-share-picker-modal.open-info-modal.screen-share-preview-info-modal" />
|
||||
)),
|
||||
);
|
||||
}, []);
|
||||
const openInfoModal = useCallback(openScreenSharePreviewPrivacyModal, []);
|
||||
const handleDisable = useCallback(() => {
|
||||
PrivacyPreferences.setDisableStreamPreviews(true);
|
||||
}, []);
|
||||
|
||||
+12
-59
@@ -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}) => (
|
||||
<>
|
||||
<MenuGroup data-flx="voice.voice-connection-status.open-screen-share-menu.menu-group">
|
||||
<MenuItem
|
||||
icon={
|
||||
<MonitorPlayIcon
|
||||
weight="fill"
|
||||
className={styles.icon}
|
||||
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"
|
||||
/>
|
||||
</>
|
||||
<ActiveScreenShareMenu
|
||||
onClose={onClose}
|
||||
displayShareEnvironment={displayShareEnvironment}
|
||||
shareContext={shareContext}
|
||||
shareContextResolved={shareContextResolved}
|
||||
iconClassName={styles.icon}
|
||||
data-flx="voice.voice-connection-status.open-screen-share-menu.active-screen-share-menu"
|
||||
/>
|
||||
));
|
||||
},
|
||||
[displayShareEnvironment, isConnected, isScreenShareEnabled, i18n],
|
||||
[displayShareEnvironment, isConnected, isScreenShareEnabled],
|
||||
);
|
||||
const handleScreenShare = useCallback(async () => {
|
||||
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_HEIGHT = 600;
|
||||
|
||||
export type VoiceTilePopoutSource = 'camera' | 'screen_share';
|
||||
export type VoiceTilePopoutSource = 'user' | 'camera' | 'screen_share';
|
||||
|
||||
export interface VoiceTilePopoutDescriptor {
|
||||
kind: 'tile';
|
||||
|
||||
Reference in new issue
Block a user