diff --git a/fluxer_app/src/features/ui/action_menu/VoiceParticipantContextMenu.tsx b/fluxer_app/src/features/ui/action_menu/VoiceParticipantContextMenu.tsx index 14ed40656..236854cfe 100644 --- a/fluxer_app/src/features/ui/action_menu/VoiceParticipantContextMenu.tsx +++ b/fluxer_app/src/features/ui/action_menu/VoiceParticipantContextMenu.tsx @@ -202,8 +202,8 @@ export const VoiceParticipantContextMenu: React.FC { + const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value); + return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''}; +}); +vi.mock('@lingui/react/macro', () => ({ + Trans: () => null, + useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? '', locale: 'en'}}), +})); +vi.mock('@app/features/voice/components/ActiveScreenShareMenu', () => ({ + changeActiveScreenShare: vi.fn(async () => undefined), + stopActiveScreenShare: vi.fn(async () => undefined), +})); +vi.mock('@app/features/voice/components/modals/ScreenSharePickerModal', () => ({ + openScreenSharePreviewPrivacyModal: vi.fn(), +})); +vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({ + default: { + applyLocalAudioPreferencesForUser: vi.fn(), + getVoiceStateByConnectionId: () => null, + connectionId: null, + }, +})); +vi.mock('@app/features/voice/state/PopoutWindowManager', () => ({ + default: {openTilePopout: vi.fn()}, + isVoicePopoutSupported: () => false, +})); +vi.mock('@app/features/voice/state/StreamAudioPrefs', () => ({ + default: {setMuted: vi.fn(), setVolume: vi.fn()}, +})); +vi.mock('@app/features/voice/state/VoiceSettings', () => ({ + default: {showMyOwnScreenShare: false, pauseOwnScreenSharePreviewOnUnfocus: false}, +})); +vi.mock('@app/features/voice/commands/VoiceSettingsCommands', () => ({ + update: vi.fn(), +})); + +installVoiceMenuTestBootstrap(); + +const {buildVoiceParticipantStreamMenu} = await import( + '@app/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder' +); + +const i18n = { + locale: 'en', + _: (descriptor: {message?: string}) => descriptor.message ?? '', +} as unknown as I18n; + +interface MenuLeaf { + label?: string; + items?: Array; +} + +function streamMenu(source: VoiceParticipantMenuScreenShareSource): Array<{items: Array}> { + return buildVoiceParticipantStreamMenu({ + i18n, + userId: '111', + channelId: null, + participantIdentity: 'user_111_conn', + displayName: 'Alice', + isCurrentUserConnectedToVoice: false, + source, + streamVolume: 100, + isStreamMuted: false, + showMyOwnScreenShare: false, + pauseOwnScreenSharePreviewOnUnfocus: false, + onClose: () => undefined, + }) as unknown as Array<{items: Array}>; +} + +function findLeaf(groups: Array<{items: Array}>, label: string): MenuLeaf | null { + for (const group of groups) { + for (const item of group.items) { + if (item.label === label) return item; + for (const child of item.items ?? []) { + if (child.label === label) return child; + } + } + } + return null; +} + +const OWN_STREAM_SOURCE: VoiceParticipantMenuScreenShareSource = { + kind: 'screen-share', + streamKey: 'stream-key', + state: {kind: 'own'}, +}; + +const WATCHED_REMOTE_STREAM_SOURCE: VoiceParticipantMenuScreenShareSource = { + kind: 'screen-share', + streamKey: 'stream-key', + state: {kind: 'remote-watched', hasAudio: true, onStopWatching: () => undefined}, +}; + +test('own stream keeps a More options submenu with the screen-share preferences', () => { + const groups = streamMenu(OWN_STREAM_SOURCE); + const moreOptions = findLeaf(groups, 'More options'); + expect(moreOptions).not.toBeNull(); + expect(findLeaf(groups, 'Show my screen share')).not.toBeNull(); + expect(findLeaf(groups, 'Report Problem')).toBeNull(); +}); + +test('remote watched stream omits the now-empty More options submenu and keeps audio controls', () => { + const groups = streamMenu(WATCHED_REMOTE_STREAM_SOURCE); + expect(findLeaf(groups, 'More options')).toBeNull(); + expect(findLeaf(groups, 'Mute')).not.toBeNull(); + expect(findLeaf(groups, 'Stream volume')).not.toBeNull(); + expect(findLeaf(groups, 'Report Problem')).toBeNull(); +}); diff --git a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.tsx b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.tsx index 5bbe4c794..6b4ba842f 100644 --- a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.tsx @@ -34,9 +34,12 @@ import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettin 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 ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource'; 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 {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 {buildVoiceParticipantIdentity} from '@app/features/voice/utils/VoiceParticipantIdentity'; import type {I18n} from '@lingui/core'; @@ -78,6 +81,7 @@ function buildStreamStateAction(options: VoiceParticipantStreamMenuBuilderOption onClick: () => { onClose(); void stopActiveScreenShare().catch((error) => { + if (isScreenShareRollbackIncompleteError(error)) handleScreenShareError(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), onClick: () => { 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); }); }, diff --git a/fluxer_app/src/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap.ts b/fluxer_app/src/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap.ts new file mode 100644 index 000000000..d48bc07f7 --- /dev/null +++ b/fluxer_app/src/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +const HARNESS_ENDPOINT = 'https://primary.test/api'; + +export function installVoiceMenuTestBootstrap(): void { + const host = globalThis as unknown as {window?: Record}; + if (typeof host.window === 'undefined') { + host.window = host as unknown as Record; + } + host.window.__FLUXER_BOOTSTRAP__ = { + config: { + releaseChannel: 'stable', + bootstrapApiEndpoint: HARNESS_ENDPOINT, + bootstrapApiPublicEndpoint: HARNESS_ENDPOINT, + }, + instance: { + api_code_version: Number.MAX_SAFE_INTEGER, + endpoints: { + api: HARNESS_ENDPOINT, + api_client: HARNESS_ENDPOINT, + api_public: HARNESS_ENDPOINT, + gateway: 'wss://gateway.primary.test', + media: 'https://media.primary.test', + static_cdn: 'https://cdn.primary.test', + marketing: 'https://primary.test', + admin: 'https://admin.primary.test', + invite: 'https://primary.test/invite', + gift: 'https://primary.test/gift', + webapp: 'https://app.primary.test', + upload_relay: 'https://upload.primary.test', + }, + captcha: {provider: 'none', hcaptcha_site_key: null, turnstile_site_key: null}, + features: { + voice_enabled: false, + stripe_enabled: false, + self_hosted: false, + presigned_attachment_uploads: false, + emails_enabled: false, + }, + gif: {provider: 'klipy', display_name: 'Klipy', attribution_required: false}, + sso: {enabled: false, enforced: false, display_name: null, redirect_uri: ''}, + registration: {mode: 'open', admin_registration_urls_enabled: true}, + community: {single_community: false, single_community_guild_id: null, direct_messages_disabled: false}, + services: {gif_enabled: true, youtube_enabled: false, bluesky_enabled: false}, + limits: undefined, + push: {public_vapid_key: null}, + app_public: { + branding: { + product_name: 'Fluxer', + icon_url: null, + symbol_url: null, + logo_url: null, + wordmark_url: null, + favicon_url: null, + theme_color: null, + }, + setup: {configured: true, admin_url: null}, + legal: {terms_url: null, privacy_url: null}, + registration: {collect_date_of_birth: true}, + }, + }, + geoip: { + countryCode: null, + regionCode: null, + latitude: null, + longitude: null, + ageRestrictedGeos: [], + ageBlockedGeos: [], + }, + }; +} diff --git a/fluxer_app/src/features/voice/components/VoiceControlBar.tsx b/fluxer_app/src/features/voice/components/VoiceControlBar.tsx index 90cb12465..235ee2ad7 100644 --- a/fluxer_app/src/features/voice/components/VoiceControlBar.tsx +++ b/fluxer_app/src/features/voice/components/VoiceControlBar.tsx @@ -350,8 +350,8 @@ const VoiceControlBarInner = observer(function VoiceControlBarInner() { }, [localParticipant, isCameraEnabled, isConnected]); const renderScreenShareMenu = useCallback( ({onClose}: {onClose: () => void}) => { - const shareContext = ActiveScreenShareSource.getSourceId()?.startsWith('window:') ? 'app' : 'display'; - const shareContextResolved = ActiveScreenShareSource.getSourceId() != null; + const shareContext = ActiveScreenShareSource.getShareContext() ?? 'display'; + const shareContextResolved = ActiveScreenShareSource.getPublishedSource() != null; const screenShareSettingsMenu = ( { if (!isConnected || !isScreenShareEnabled) return; event.preventDefault(); event.stopPropagation(); - const shareContext = ActiveScreenShareSource.getSourceId()?.startsWith('window:') ? 'app' : 'display'; - const shareContextResolved = ActiveScreenShareSource.getSourceId() != null; + const shareContext = ActiveScreenShareSource.getShareContext() ?? 'display'; + const shareContextResolved = ActiveScreenShareSource.getPublishedSource() != null; ContextMenuCommands.openFromEvent(event, ({onClose}) => (