mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(voice-menus): drive stream actions from the published source (#2396)
This commit is contained in:
1 parent
4b5bdefcb9
commit
17b9821879
6 files changed
+198
-7
No files matched your search
@@ -202,8 +202,8 @@ export const VoiceParticipantContextMenu: React.FC<VoiceParticipantContextMenuPr
|
|||||||
isDesktop(),
|
isDesktop(),
|
||||||
NativePermission.isLinuxWaylandDesktop,
|
NativePermission.isLinuxWaylandDesktop,
|
||||||
);
|
);
|
||||||
const shareContext = ActiveScreenShareSource.getSourceId()?.startsWith('window:') ? 'app' : 'display';
|
const shareContext = ActiveScreenShareSource.getShareContext() ?? 'display';
|
||||||
const shareContextResolved = ActiveScreenShareSource.getSourceId() != null;
|
const shareContextResolved = ActiveScreenShareSource.getPublishedSource() != null;
|
||||||
return (
|
return (
|
||||||
<ActiveScreenShareMenu
|
<ActiveScreenShareMenu
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|||||||
+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();
|
||||||
|
});
|
||||||
@@ -34,9 +34,12 @@ import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettin
|
|||||||
import {changeActiveScreenShare, stopActiveScreenShare} from '@app/features/voice/components/ActiveScreenShareMenu';
|
import {changeActiveScreenShare, stopActiveScreenShare} from '@app/features/voice/components/ActiveScreenShareMenu';
|
||||||
import {openScreenSharePreviewPrivacyModal} from '@app/features/voice/components/modals/ScreenSharePickerModal';
|
import {openScreenSharePreviewPrivacyModal} from '@app/features/voice/components/modals/ScreenSharePickerModal';
|
||||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||||
|
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||||
import PopoutWindowManager, {isVoicePopoutSupported} from '@app/features/voice/state/PopoutWindowManager';
|
import PopoutWindowManager, {isVoicePopoutSupported} from '@app/features/voice/state/PopoutWindowManager';
|
||||||
import StreamAudioPrefs from '@app/features/voice/state/StreamAudioPrefs';
|
import StreamAudioPrefs from '@app/features/voice/state/StreamAudioPrefs';
|
||||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||||
|
import {isScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError';
|
||||||
|
import {handleScreenShareError} from '@app/features/voice/utils/ScreenShareUtils';
|
||||||
import {VOICE_STOP_WATCHING_DESCRIPTOR} from '@app/features/voice/utils/VoiceMessageDescriptors';
|
import {VOICE_STOP_WATCHING_DESCRIPTOR} from '@app/features/voice/utils/VoiceMessageDescriptors';
|
||||||
import {buildVoiceParticipantIdentity} from '@app/features/voice/utils/VoiceParticipantIdentity';
|
import {buildVoiceParticipantIdentity} from '@app/features/voice/utils/VoiceParticipantIdentity';
|
||||||
import type {I18n} from '@lingui/core';
|
import type {I18n} from '@lingui/core';
|
||||||
@@ -78,6 +81,7 @@ function buildStreamStateAction(options: VoiceParticipantStreamMenuBuilderOption
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
onClose();
|
onClose();
|
||||||
void stopActiveScreenShare().catch((error) => {
|
void stopActiveScreenShare().catch((error) => {
|
||||||
|
if (isScreenShareRollbackIncompleteError(error)) handleScreenShareError(error);
|
||||||
logger.error('Failed to stop active screen share from participant menu', error);
|
logger.error('Failed to stop active screen share from participant menu', error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -122,7 +126,7 @@ function buildOwnStreamChangeAction(options: VoiceParticipantStreamMenuBuilderOp
|
|||||||
label: i18n._(CHANGE_STREAM_DESCRIPTOR),
|
label: i18n._(CHANGE_STREAM_DESCRIPTOR),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
onClose();
|
onClose();
|
||||||
void changeActiveScreenShare('display').catch((error) => {
|
void changeActiveScreenShare(ActiveScreenShareSource.getShareContext() ?? 'display').catch((error) => {
|
||||||
logger.error('Failed to change active screen share from participant menu', error);
|
logger.error('Failed to change active screen share from participant menu', error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -350,8 +350,8 @@ 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';
|
const shareContext = ActiveScreenShareSource.getShareContext() ?? 'display';
|
||||||
const shareContextResolved = ActiveScreenShareSource.getSourceId() != null;
|
const shareContextResolved = ActiveScreenShareSource.getPublishedSource() != null;
|
||||||
const screenShareSettingsMenu = (
|
const screenShareSettingsMenu = (
|
||||||
<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
|
||||||
|
|||||||
+2
-2
@@ -135,8 +135,8 @@ 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 shareContext = ActiveScreenShareSource.getShareContext() ?? 'display';
|
||||||
const shareContextResolved = ActiveScreenShareSource.getSourceId() != null;
|
const shareContextResolved = ActiveScreenShareSource.getPublishedSource() != null;
|
||||||
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
|
ContextMenuCommands.openFromEvent(event, ({onClose}) => (
|
||||||
<ActiveScreenShareMenu
|
<ActiveScreenShareMenu
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|||||||
Reference in new issue
Block a user