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 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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+2
@@ -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;
|
||||||
|
}
|
||||||
+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',
|
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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+10
-18
@@ -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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
+12
-59
@@ -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';
|
||||||
|
|||||||
Reference in new issue
Block a user