From fc2b6b529939a4b956c6c8deb8c7e2e5289c7fae Mon Sep 17 00:00:00 2001 From: Hampus Date: Tue, 8 Sep 2026 19:09:59 +0200 Subject: [PATCH] fix(app): correct shortcuts, nagbar, stream menu, share audio (#2599) --- .../components/floating/UserAreaPopout.tsx | 11 +- .../app/components/layout/GuildsLayout.tsx | 1 + .../layout/app_layout/AppLayoutHooks.ts | 9 + .../layout/app_layout/AppLayoutTypes.ts | 2 + .../layout/app_layout/NagbarContainer.tsx | 9 + .../nagbars/DesktopUpdateReadyNagbar.tsx | 50 +++ .../src/features/app/state/Updater.test.ts | 112 +++++++ fluxer_app/src/features/app/state/Updater.ts | 20 +- .../member/state/MemberSidebar.test.ts | 113 +++++++ .../features/member/state/MemberSidebar.ts | 2 + .../utils/IsTextInputKeyEvent.test.ts | 52 +++ .../platform/utils/IsTextInputKeyEvent.ts | 7 +- ...VoiceParticipantStreamMenuBuilder.test.tsx | 37 ++- .../VoiceParticipantStreamMenuBuilder.tsx | 64 ++-- .../updater/commands/UpdaterModalCommands.tsx | 6 +- .../StatsForNerdsCopyButton.tsx | 55 ---- .../commands/VoiceDiagnosticsCommands.ts | 22 ++ .../voice/commands/VoiceSettingsCommands.ts | 1 + .../components/AudioSourcePickerLinux.tsx | 18 +- .../StatsForNerdsCopyDescriptors.ts | 6 +- .../voice/components/VoiceCallView.tsx | 2 + .../voice/components/VoiceParticipantTile.tsx | 306 +++++++++--------- .../VoiceParticipantTileStability.test.ts | 17 + .../VoiceParticipantTileStateMachine.test.ts | 46 +++ .../VoiceParticipantTileStateMachine.ts | 19 ++ .../modals/ScreenSharePickerModal.tsx | 1 + .../src/features/voice/state/VoiceSettings.ts | 12 + .../utils/ScreenShareAudioSummary.test.ts | 24 ++ .../voice/utils/ScreenShareAudioSummary.ts | 5 +- .../voice/utils/ScreenShareStartFlow.test.ts | 48 +++ .../voice/utils/ScreenShareStartFlow.ts | 1 + .../voice/utils/StreamSettingsUpdatePolicy.ts | 2 + fluxer_app_proxy/src/csp.rs | 32 +- .../src/main/WindowsShortcuts.test.mjs | 171 ++++++++++ fluxer_desktop/src/main/WindowsShortcuts.ts | 15 +- .../src/guild/guild_data_channels.erl | 4 +- fluxer_gateway/test/guild_data_tests.erl | 40 +++ 37 files changed, 1074 insertions(+), 268 deletions(-) create mode 100644 fluxer_app/src/features/app/components/layout/app_layout/nagbars/DesktopUpdateReadyNagbar.tsx create mode 100644 fluxer_app/src/features/app/state/Updater.test.ts create mode 100644 fluxer_app/src/features/member/state/MemberSidebar.test.ts create mode 100644 fluxer_app/src/features/platform/utils/IsTextInputKeyEvent.test.ts delete mode 100644 fluxer_app/src/features/user/components/modals/tabs/voice_video_tab/StatsForNerdsCopyButton.tsx create mode 100644 fluxer_app/src/features/voice/commands/VoiceDiagnosticsCommands.ts create mode 100644 fluxer_desktop/src/main/WindowsShortcuts.test.mjs diff --git a/fluxer_app/src/features/app/components/floating/UserAreaPopout.tsx b/fluxer_app/src/features/app/components/floating/UserAreaPopout.tsx index 9915897e5..279bff063 100644 --- a/fluxer_app/src/features/app/components/floating/UserAreaPopout.tsx +++ b/fluxer_app/src/features/app/components/floating/UserAreaPopout.tsx @@ -25,7 +25,6 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands'; import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands'; -import * as ToastCommands from '@app/features/ui/commands/ToastCommands'; import {MockAvatar} from '@app/features/ui/components/MockAvatar'; import {StatusIndicator} from '@app/features/ui/components/StatusIndicator'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; @@ -52,9 +51,8 @@ import Users from '@app/features/user/state/Users'; import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; import * as ProfileDisplayUtils from '@app/features/user/utils/ProfileDisplayUtils'; import {createMockProfile} from '@app/features/user/utils/ProfileUtils'; -import {COPIED_STATS_JSON_DESCRIPTOR} from '@app/features/voice/components/StatsForNerdsCopyDescriptors'; +import {copyVoiceDiagnostics} from '@app/features/voice/commands/VoiceDiagnosticsCommands'; import MediaEngine from '@app/features/voice/engine/MediaEngineFacade'; -import {buildStatsForNerdsCopyPayload, collectStatsForNerdsSnapshot} from '@app/features/voice/utils/StatsForNerdsCopy'; import {MEDIA_PROXY_PROFILE_BANNER_SIZE_POPOUT} from '@fluxer/constants/src/MediaProxyAssetSizes'; import {StatusTypes} from '@fluxer/constants/src/StatusConstants'; import type {MessageDescriptor} from '@lingui/core'; @@ -462,12 +460,7 @@ export const UserAreaPopout = observer(() => { }); }, [i18n]); const handleCopyStats = useCallback(() => { - const data = collectStatsForNerdsSnapshot(); - void buildStatsForNerdsCopyPayload(data).then((payload) => { - void navigator.clipboard.writeText(JSON.stringify(payload, null, 2)).then(() => { - ToastCommands.createToast({type: 'success', children: i18n._(COPIED_STATS_JSON_DESCRIPTOR)}); - }); - }); + void copyVoiceDiagnostics(i18n); }, [i18n]); const handleCopyUserTag = useCallback(() => { if (!currentUser) { diff --git a/fluxer_app/src/features/app/components/layout/GuildsLayout.tsx b/fluxer_app/src/features/app/components/layout/GuildsLayout.tsx index 74514e23b..a7fe1b7e0 100644 --- a/fluxer_app/src/features/app/components/layout/GuildsLayout.tsx +++ b/fluxer_app/src/features/app/components/layout/GuildsLayout.tsx @@ -1277,6 +1277,7 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record = { [NagbarType.PREMIUM_ONBOARDING]: {tone: SkeletonNagbarTone.BRAND, hasActions: true}, [NagbarType.GIFT_INVENTORY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true}, [NagbarType.DESKTOP_DOWNLOAD]: {tone: SkeletonNagbarTone.BRAND, hasActions: true}, + [NagbarType.DESKTOP_UPDATE_READY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true}, [NagbarType.GUILD_MEMBERSHIP_CTA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true}, [NagbarType.VISIONARY_MFA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true}, [NagbarType.VOICE_SESSION_RESTORE]: {tone: SkeletonNagbarTone.VOICE, hasActions: true}, diff --git a/fluxer_app/src/features/app/components/layout/app_layout/AppLayoutHooks.ts b/fluxer_app/src/features/app/components/layout/app_layout/AppLayoutHooks.ts index 9eaff77f8..4b00cc11d 100644 --- a/fluxer_app/src/features/app/components/layout/app_layout/AppLayoutHooks.ts +++ b/fluxer_app/src/features/app/components/layout/app_layout/AppLayoutHooks.ts @@ -11,6 +11,7 @@ import Config from '@app/features/app/config/Config'; import {isClientReconnecting} from '@app/features/app/state/ClientReadiness'; import Initialization from '@app/features/app/state/Initialization'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; +import Updater from '@app/features/app/state/Updater'; import Authentication from '@app/features/auth/state/Authentication'; import Channels from '@app/features/channel/state/Channels'; import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; @@ -230,6 +231,7 @@ export const useNagbarConditions = (): NagbarConditions => { const canShowLinuxInputAccess = NativePermission.shouldShowLinuxInputAccessNagbar; const canShowSoftwareEncoder = SoftwareEncoderWarning.showWarning; const canShowStreamerMode = StreamerMode.shouldShowNagbar; + const canShowDesktopUpdateReady = Updater.shouldShowUpdateReadyNagbar; const canShowBuildEnvironment = !BUILD_ENVIRONMENT_HIDDEN_RELEASE_CHANNELS.has(Config.PUBLIC_RELEASE_CHANNEL) && !nagbarState.buildEnvironmentDismissedThisSession; @@ -288,6 +290,7 @@ export const useNagbarConditions = (): NagbarConditions => { canShowLinuxInputAccess, canShowSoftwareEncoder, canShowStreamerMode, + canShowDesktopUpdateReady, }; }; export const useActiveNagbars = (conditions: NagbarConditions): Array => { @@ -407,6 +410,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array = observer(({nagbar data-flx="app.app-layout.nagbar-container.desktop-download-nagbar" /> ); + case NagbarType.DESKTOP_UPDATE_READY: + return ( + + ); case NagbarType.GUILD_MEMBERSHIP_CTA: return ( { + const {i18n} = useLingui(); + const version = Updater.updateInfo.native.version; + return ( + + void Updater.applyUpdate()} + data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.restart-button" + > + {i18n._(RESTART_FLUXER_DESCRIPTOR, {productName: PRODUCT_NAME})} + + } + data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.nagbar-content" + /> + + ); +}); diff --git a/fluxer_app/src/features/app/state/Updater.test.ts b/fluxer_app/src/features/app/state/Updater.test.ts new file mode 100644 index 000000000..d8787909a --- /dev/null +++ b/fluxer_app/src/features/app/state/Updater.test.ts @@ -0,0 +1,112 @@ +// @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 {UpdaterEvent} from '@app/types/electron.d'; +import {afterEach, describe, expect, test, vi} from 'vitest'; + +const {pushUpdateReadyModal} = vi.hoisted(() => ({pushUpdateReadyModal: vi.fn()})); + +vi.mock('@app/features/updater/commands/UpdaterModalCommands', () => ({ + pushDesktopUpdateDownloadFailedModal: vi.fn(), + pushDesktopUpdateInstallFailedModal: vi.fn(), + pushManualUpdateAvailableModal: vi.fn(), + pushUnsupportedUpdateModal: vi.fn(), + pushUpdateAvailableModal: vi.fn(), + pushUpdateCheckFailedModal: vi.fn(), + pushUpdateReadyModal, + pushUpToDateModal: vi.fn(), +})); + +vi.mock('@lingui/core/macro', () => ({ + msg: (descriptor: {message: string}) => descriptor, +})); + +vi.mock('@app/features/platform/utils/ClientInfo', () => ({ + getClientInfo: () => + Promise.resolve({desktopVersion: '1.0.0', desktopChannel: 'canary', desktopArch: 'x64', arch: 'x64'}), +})); + +installVoiceMenuTestBootstrap(); + +let nativeEventListener: ((event: UpdaterEvent) => void) | null = null; +let onUpdaterCheck: (() => void) | null = null; +let loadedUpdater: {dispose: () => void} | null = null; + +function installElectronApi(): void { + nativeEventListener = null; + onUpdaterCheck = null; + (window as unknown as {electron: unknown}).electron = { + platform: 'win32', + buildChannel: 'canary', + onUpdaterEvent: (listener: (event: UpdaterEvent) => void) => { + nativeEventListener = listener; + return () => { + nativeEventListener = null; + }; + }, + updaterCheck: () => { + onUpdaterCheck?.(); + return Promise.resolve(); + }, + updaterDownload: () => Promise.resolve(), + updaterInstall: () => Promise.resolve(), + }; +} + +function emit(event: UpdaterEvent): void { + if (!nativeEventListener) throw new Error('Updater never subscribed to native updater events'); + nativeEventListener(event); +} + +async function loadUpdater() { + vi.resetModules(); + installElectronApi(); + const {default: Updater} = await import('@app/features/app/state/Updater'); + loadedUpdater = Updater; + await vi.waitFor(() => { + expect(nativeEventListener).not.toBeNull(); + expect(Updater.lastCheckedAt).not.toBeNull(); + }); + pushUpdateReadyModal.mockClear(); + return Updater; +} + +function emitUserDownloadCompletion(version: string): void { + emit({type: 'available', context: 'user', version, downloadSize: 1000, downloadStarted: true}); + emit({type: 'downloaded', context: 'user', version}); +} + +afterEach(() => { + loadedUpdater?.dispose(); + loadedUpdater = null; +}); + +describe('updater update-ready surface', () => { + test('does not push a blocking modal when a user-initiated download finishes outside a check', async () => { + const Updater = await loadUpdater(); + emitUserDownloadCompletion('2.0.0'); + expect(Updater.nativeUpdateReady).toBe(true); + expect(pushUpdateReadyModal).not.toHaveBeenCalled(); + }); + + test('announces the ready update through a dismissible nagbar instead', async () => { + const Updater = await loadUpdater(); + expect(Updater.shouldShowUpdateReadyNagbar).toBe(false); + emitUserDownloadCompletion('2.0.0'); + expect(Updater.shouldShowUpdateReadyNagbar).toBe(true); + Updater.dismissUpdateReadyNagbar(); + expect(Updater.shouldShowUpdateReadyNagbar).toBe(false); + emit({type: 'downloaded', context: 'background', version: '2.1.0'}); + expect(Updater.shouldShowUpdateReadyNagbar).toBe(true); + }); + + test('still answers a user-initiated check with the update ready modal', async () => { + const Updater = await loadUpdater(); + emitUserDownloadCompletion('2.0.0'); + pushUpdateReadyModal.mockClear(); + onUpdaterCheck = () => emit({type: 'available', context: 'user', version: '2.0.0', downloadStarted: false}); + await Updater.checkForUpdates(true, true); + expect(pushUpdateReadyModal).toHaveBeenCalledTimes(1); + }); +}); diff --git a/fluxer_app/src/features/app/state/Updater.ts b/fluxer_app/src/features/app/state/Updater.ts index 146978ba5..74c1fd793 100644 --- a/fluxer_app/src/features/app/state/Updater.ts +++ b/fluxer_app/src/features/app/state/Updater.ts @@ -131,6 +131,7 @@ class Updater { private backgroundCheckInterval: number | null = null; private backgroundCheckCleanups: Array<() => void> = []; private unsubscribeNativeEvents: (() => void) | null = null; + private updateReadyNagbarDismissedVersion: string | null = null; constructor() { makeAutoObservable(this, {}, {autoBind: true}); @@ -193,6 +194,18 @@ class Updater { return this.updateInfo.native.available && this.updateInfo.native.downloaded; } + get shouldShowUpdateReadyNagbar(): boolean { + return ( + this.nativeUpdateReady && + !this.updateInfo.native.installing && + this.updateReadyNagbarDismissedVersion !== this.updateReadyNagbarVersionKey + ); + } + + private get updateReadyNagbarVersionKey(): string { + return this.updateInfo.native.version ?? 'unknown'; + } + get nativeDownloadInFlight(): boolean { return this.updateInfo.native.downloading && !this.updateInfo.native.downloaded; } @@ -348,9 +361,6 @@ class Updater { break; } this.transition({type: 'native.downloaded', version: event.version ?? null}); - if (shouldShowImmediateUserResult) { - this.showCurrentUpdateState(); - } break; case 'progress': if (!shouldSurfaceNativeDesktopUpdate || !this.nativeDownloadProgressSupported) { @@ -661,6 +671,10 @@ class Updater { } } + dismissUpdateReadyNagbar(): void { + this.updateReadyNagbarDismissedVersion = this.updateReadyNagbarVersionKey; + } + reset(): void { this.transition({type: 'reset'}); } diff --git a/fluxer_app/src/features/member/state/MemberSidebar.test.ts b/fluxer_app/src/features/member/state/MemberSidebar.test.ts new file mode 100644 index 000000000..140899564 --- /dev/null +++ b/fluxer_app/src/features/member/state/MemberSidebar.test.ts @@ -0,0 +1,113 @@ +// @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 {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; +import type {Guild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas'; +import {describe, expect, it, 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('@app/features/gateway/transport/GatewayConnection', () => ({default: {socket: null}})); +vi.mock('@app/features/channel/state/Channels', () => ({default: {getChannel: () => null}})); +vi.mock('@app/features/guild/state/Guilds', () => ({ + default: { + getGuild: (guildId: string) => ({ + id: guildId, + disabledOperations: 0, + roles: { + '10': {id: '10', permissions: 0n, position: 0}, + '20': {id: '20', permissions: 0n, position: 5}, + '30': {id: '30', permissions: 0n, position: 9}, + }, + }), + }, +})); + +installVoiceMenuTestBootstrap(); + +const {canManageTargetUser} = await import('@app/features/permissions/utils/PermissionUtils'); +const {default: GuildMembers} = await import('@app/features/member/state/GuildMembers'); +const {default: MemberSidebar} = await import('@app/features/member/state/MemberSidebar'); + +const MODERATOR_ROLE = {id: '20', permissions: 0n, position: 5}; + +let nextGuildId = 0; + +function makeMember(userId: string, roles: Array, nick: string | null = null): GuildMemberData { + return { + user: { + id: userId, + username: `user-${userId}`, + discriminator: '0001', + global_name: null, + avatar: null, + avatar_color: null, + flags: 0, + }, + nick, + roles, + joined_at: '2026-01-01T00:00:00.000Z', + }; +} + +function makeGuild(guildId: string): Guild { + return {id: guildId, owner_id: 'owner'} as unknown as Guild; +} + +function syncMemberList(guildId: string, channelId: string, members: Array): void { + MemberSidebar.subscribeToChannel(guildId, channelId, [[0, 99]]); + MemberSidebar.handleListUpdate({ + guildId, + listId: 'everyone', + channelId, + memberCount: members.length, + onlineCount: members.length, + groups: [{id: 'online', count: members.length}], + ops: [ + { + op: 'SYNC', + range: [0, members.length], + items: [{group: {id: 'online', count: members.length}}, ...members.map((member) => ({member}))], + }, + ], + }); +} + +function setupGuild(members: Array): string { + const guildId = `guild-${++nextGuildId}`; + syncMemberList(guildId, `channel-${guildId}`, members); + return guildId; +} + +describe('MemberSidebar member list hydration', () => { + it('makes membership known for members that only ever appeared in the member list', () => { + const guildId = setupGuild([makeMember('target-low', []), makeMember('target-high', ['30'])]); + expect(GuildMembers.isMembershipKnown(guildId, 'target-low')).toBe(true); + expect(GuildMembers.getMember(guildId, 'target-high')?.roles.has('30')).toBe(true); + }); + + it('lets the role hierarchy check pass for a sidebar member who has never posted', () => { + const guildId = setupGuild([makeMember('target-low', [])]); + expect(canManageTargetUser(makeGuild(guildId), 'me', MODERATOR_ROLE, 'target-low')).toBe(true); + }); + + it('still refuses a sidebar member who outranks you', () => { + const guildId = setupGuild([makeMember('target-high', ['30'])]); + expect(canManageTargetUser(makeGuild(guildId), 'me', MODERATOR_ROLE, 'target-high')).toBe(false); + }); + + it('does not create members for group header rows', () => { + const guildId = setupGuild([makeMember('target-low', []), makeMember('target-high', ['30'])]); + expect(GuildMembers.getMemberCount(guildId)).toBe(2); + }); + + it('leaves an already known member untouched', () => { + const guildId = `guild-${++nextGuildId}`; + GuildMembers.hydrateIfMissing(guildId, makeMember('target-low', ['20'], 'authoritative')); + syncMemberList(guildId, `channel-${guildId}`, [makeMember('target-low', [], 'stale')]); + expect(GuildMembers.getMember(guildId, 'target-low')?.nick).toBe('authoritative'); + }); +}); diff --git a/fluxer_app/src/features/member/state/MemberSidebar.ts b/fluxer_app/src/features/member/state/MemberSidebar.ts index edb377e1b..7dbc4e3f6 100644 --- a/fluxer_app/src/features/member/state/MemberSidebar.ts +++ b/fluxer_app/src/features/member/state/MemberSidebar.ts @@ -5,6 +5,7 @@ import Channels from '@app/features/channel/state/Channels'; import GatewayConnection from '@app/features/gateway/transport/GatewayConnection'; import Guilds from '@app/features/guild/state/Guilds'; import {GuildMember} from '@app/features/member/models/GuildMember'; +import GuildMembers from '@app/features/member/state/GuildMembers'; import {getHydratedMemberListRangesFromNormalized} from '@app/features/member/utils/MemberListHydration'; import {deriveMemberListIdentity} from '@app/features/member/utils/MemberListIdentity'; import { @@ -695,6 +696,7 @@ class MemberSidebar { } userIdRowCounts.set(userId, (userIdRowCounts.get(userId) ?? 0) + 1); newMembersByUserId.set(userId, member); + GuildMembers.hydrateIfMissing(guildId, member); const memberItem = this.convertItem(guildId, row); if (memberItem) { newItems.set(rowIndex, memberItem); diff --git a/fluxer_app/src/features/platform/utils/IsTextInputKeyEvent.test.ts b/fluxer_app/src/features/platform/utils/IsTextInputKeyEvent.test.ts new file mode 100644 index 000000000..002aa24b2 --- /dev/null +++ b/fluxer_app/src/features/platform/utils/IsTextInputKeyEvent.test.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {beforeEach, describe, expect, test, vi} from 'vitest'; + +let nativeMacOS = false; + +vi.mock('@app/features/ui/utils/NativeUtils', () => ({ + isNativeMacOS: () => nativeMacOS, +})); + +const {isTextInputKeyEvent} = await import('@app/features/platform/utils/IsTextInputKeyEvent'); + +const keyEvent = (init: Partial): KeyboardEvent => + ({key: '', ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, ...init}) as KeyboardEvent; + +beforeEach(() => { + nativeMacOS = false; +}); + +describe('isTextInputKeyEvent', () => { + test('Alt+digit is a shortcut chord, not typing, off macOS', () => { + for (const key of ['1', '2', '3', '4', '5', '6', '7', '8', '9']) { + expect(isTextInputKeyEvent(keyEvent({key, altKey: true}))).toBe(false); + } + }); + + test('Alt+letter is a shortcut chord, not typing, off macOS', () => { + expect(isTextInputKeyEvent(keyEvent({key: 'a', altKey: true}))).toBe(false); + }); + + test('Option-composed characters stay typing on macOS', () => { + nativeMacOS = true; + expect(isTextInputKeyEvent(keyEvent({key: 'ยก', altKey: true}))).toBe(true); + expect(isTextInputKeyEvent(keyEvent({key: '@', altKey: true}))).toBe(true); + }); + + test('a bare printable key is still typing', () => { + expect(isTextInputKeyEvent(keyEvent({key: 'a'}))).toBe(true); + expect(isTextInputKeyEvent(keyEvent({key: '1'}))).toBe(true); + }); + + test('ctrl and meta chords and named keys are still not typing', () => { + expect(isTextInputKeyEvent(keyEvent({key: '1', ctrlKey: true}))).toBe(false); + expect(isTextInputKeyEvent(keyEvent({key: '1', metaKey: true}))).toBe(false); + expect(isTextInputKeyEvent(keyEvent({key: 'ArrowUp'}))).toBe(false); + expect(isTextInputKeyEvent(keyEvent({key: 'ArrowUp', altKey: true}))).toBe(false); + }); + + test('a dead key still counts as typing while Alt is held', () => { + expect(isTextInputKeyEvent(keyEvent({key: 'Dead', altKey: true}))).toBe(true); + }); +}); diff --git a/fluxer_app/src/features/platform/utils/IsTextInputKeyEvent.ts b/fluxer_app/src/features/platform/utils/IsTextInputKeyEvent.ts index ba511af82..c22d2c06d 100644 --- a/fluxer_app/src/features/platform/utils/IsTextInputKeyEvent.ts +++ b/fluxer_app/src/features/platform/utils/IsTextInputKeyEvent.ts @@ -1,7 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {isNativeMacOS} from '@app/features/ui/utils/NativeUtils'; + export function isTextInputKeyEvent(event: KeyboardEvent): boolean { - const {key, ctrlKey, metaKey} = event; + const {key, ctrlKey, metaKey, altKey} = event; if (!key || key === 'Unidentified') { return false; } @@ -11,6 +13,9 @@ export function isTextInputKeyEvent(event: KeyboardEvent): boolean { if (key === 'Dead') { return true; } + if (altKey && !isNativeMacOS()) { + return false; + } if (key.length > 1 && NAMED_KEY_PATTERN.test(key)) { return false; } diff --git a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.test.tsx b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.test.tsx index c6f1a9d43..a64853aba 100644 --- a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.test.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.test.tsx @@ -4,7 +4,7 @@ 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'; +import {beforeEach, expect, test, vi} from 'vitest'; vi.mock('@lingui/core/macro', () => { const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value); @@ -38,6 +38,9 @@ vi.mock('@app/features/voice/state/StreamAudioPrefs', () => ({ vi.mock('@app/features/voice/state/VoiceSettings', () => ({ default: {showMyOwnScreenShare: false, pauseOwnScreenSharePreviewOnUnfocus: false}, })); +vi.mock('@app/features/voice/commands/VoiceDiagnosticsCommands', () => ({ + copyVoiceDiagnostics: vi.fn(async () => undefined), +})); vi.mock('@app/features/voice/commands/VoiceSettingsCommands', () => ({ update: vi.fn(), })); @@ -47,6 +50,7 @@ installVoiceMenuTestBootstrap(); const {buildVoiceParticipantStreamMenu} = await import( '@app/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder' ); +const {copyVoiceDiagnostics} = await import('@app/features/voice/commands/VoiceDiagnosticsCommands'); const i18n = { locale: 'en', @@ -56,6 +60,7 @@ const i18n = { interface MenuLeaf { label?: string; items?: Array; + onClick?: () => void; } function streamMenu(source: VoiceParticipantMenuScreenShareSource): Array<{items: Array}> { @@ -99,18 +104,44 @@ const WATCHED_REMOTE_STREAM_SOURCE: VoiceParticipantMenuScreenShareSource = { state: {kind: 'remote-watched', hasAudio: true, onStopWatching: () => undefined}, }; +const UNWATCHED_REMOTE_STREAM_SOURCE: VoiceParticipantMenuScreenShareSource = { + kind: 'screen-share', + streamKey: 'stream-key', + state: {kind: 'remote-unwatched', onWatch: () => undefined}, +}; + +beforeEach(() => { + vi.mocked(copyVoiceDiagnostics).mockClear(); +}); + 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, 'Copy stats JSON')).not.toBeNull(); expect(findLeaf(groups, 'Report Problem')).toBeNull(); }); -test('remote watched stream omits the now-empty More options submenu and keeps audio controls', () => { +test('remote watched stream keeps a More options submenu with the diagnostics entry and audio controls', () => { const groups = streamMenu(WATCHED_REMOTE_STREAM_SOURCE); - expect(findLeaf(groups, 'More options')).toBeNull(); + expect(findLeaf(groups, 'More options')).not.toBeNull(); + expect(findLeaf(groups, 'Copy stats JSON')).not.toBeNull(); expect(findLeaf(groups, 'Mute')).not.toBeNull(); expect(findLeaf(groups, 'Stream volume')).not.toBeNull(); expect(findLeaf(groups, 'Report Problem')).toBeNull(); }); + +test('remote unwatched stream keeps the diagnostics entry', () => { + const groups = streamMenu(UNWATCHED_REMOTE_STREAM_SOURCE); + expect(findLeaf(groups, 'More options')).not.toBeNull(); + expect(findLeaf(groups, 'Copy stats JSON')).not.toBeNull(); +}); + +test('the diagnostics entry on a remote stream copies voice diagnostics', () => { + const groups = streamMenu(WATCHED_REMOTE_STREAM_SOURCE); + const copyStats = findLeaf(groups, 'Copy stats JSON'); + expect(copyStats).not.toBeNull(); + copyStats?.onClick?.(); + expect(vi.mocked(copyVoiceDiagnostics)).toHaveBeenCalledTimes(1); +}); 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 6b4ba842f..54b08428c 100644 --- a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder.tsx @@ -30,9 +30,11 @@ import type { MenuSliderType, MenuSubmenuItemType, } from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet'; +import {copyVoiceDiagnostics} from '@app/features/voice/commands/VoiceDiagnosticsCommands'; 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 {COPY_STATS_JSON_DESCRIPTOR} from '@app/features/voice/components/StatsForNerdsCopyDescriptors'; 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'; @@ -211,28 +213,39 @@ function buildStreamPopoutAction(options: VoiceParticipantStreamMenuBuilderOptio function buildStreamMoreOptions(options: VoiceParticipantStreamMenuBuilderOptions): MenuSubmenuItemType | null { const {i18n, guildId, source, showMyOwnScreenShare, pauseOwnScreenSharePreviewOnUnfocus, onClose} = options; - if (source.state.kind !== 'own') return null; - const items: Array = [ - { - 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(); + const items: Array = []; + if (source.state.kind === 'own') { + items.push( + { + 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(); + }, + }); + } } + items.push({ + label: i18n._(COPY_STATS_JSON_DESCRIPTOR), + onClick: () => { + onClose(); + void copyVoiceDiagnostics(i18n).catch((error) => { + logger.error('Failed to copy voice diagnostics from participant menu', error); + }); + }, + }); if (items.length === 0) return null; return { label: i18n._(MORE_OPTIONS_DESCRIPTOR), @@ -334,6 +347,17 @@ export const VoiceParticipantOwnStreamMenuTail: React.FC )} + { + onClose(); + void copyVoiceDiagnostics(i18n).catch((error) => { + logger.error('Failed to copy voice diagnostics from participant menu', error); + }); + }} + data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.voice-participant-own-stream-menu-tail.menu-item.close--2" + > + {i18n._(COPY_STATS_JSON_DESCRIPTOR)} + )} data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.more-options-submenu" diff --git a/fluxer_app/src/features/updater/commands/UpdaterModalCommands.tsx b/fluxer_app/src/features/updater/commands/UpdaterModalCommands.tsx index 329d93181..d1de6d8cf 100644 --- a/fluxer_app/src/features/updater/commands/UpdaterModalCommands.tsx +++ b/fluxer_app/src/features/updater/commands/UpdaterModalCommands.tsx @@ -88,16 +88,16 @@ const DESKTOP_UPDATE_READY_DESCRIPTOR = msg({ message: 'Desktop update ready', comment: 'Modal title shown when a desktop app update has finished downloading.', }); -const DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR = msg({ +export const DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR = msg({ message: 'Desktop version {version} has been downloaded. Restart {productName} to finish installing.', comment: 'Desktop updater modal body. The version placeholder is the downloaded app version; productName is the app name.', }); -const THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR = msg({ +export const THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR = msg({ message: 'The desktop update has been downloaded. Restart {productName} to finish installing.', comment: 'Desktop updater modal body when the downloaded version is unknown. productName is the app name.', }); -const RESTART_FLUXER_DESCRIPTOR = msg({ +export const RESTART_FLUXER_DESCRIPTOR = msg({ message: 'Restart {productName}', comment: 'Button label that restarts the app to apply a desktop update. productName is the app name.', }); diff --git a/fluxer_app/src/features/user/components/modals/tabs/voice_video_tab/StatsForNerdsCopyButton.tsx b/fluxer_app/src/features/user/components/modals/tabs/voice_video_tab/StatsForNerdsCopyButton.tsx deleted file mode 100644 index 7801c0d29..000000000 --- a/fluxer_app/src/features/user/components/modals/tabs/voice_video_tab/StatsForNerdsCopyButton.tsx +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -import {Button} from '@app/features/ui/button/Button'; -import * as ToastCommands from '@app/features/ui/commands/ToastCommands'; -import { - COPIED_STATS_JSON_DESCRIPTOR, - COPY_STATS_JSON_DESCRIPTOR, -} from '@app/features/voice/components/StatsForNerdsCopyDescriptors'; -import {useStatsForNerds} from '@app/features/voice/components/useStatsForNerds'; -import {buildStatsForNerdsCopyPayload} from '@app/features/voice/utils/StatsForNerdsCopy'; -import {useLingui} from '@lingui/react/macro'; -import {CopySimpleIcon} from '@phosphor-icons/react'; -import type React from 'react'; -import {useCallback, useState} from 'react'; - -export const StatsForNerdsCopyButton: React.FC = () => { - const {i18n} = useLingui(); - const data = useStatsForNerds(); - const [copying, setCopying] = useState(false); - const handleCopy = useCallback(async () => { - if (copying) return; - setCopying(true); - try { - let payload: Record; - try { - payload = await buildStatsForNerdsCopyPayload(data); - } catch { - payload = { - schemaVersion: 1, - createdAt: new Date().toISOString(), - statsForNerds: data, - }; - } - await navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); - ToastCommands.createToast({ - type: 'success', - children: i18n._(COPIED_STATS_JSON_DESCRIPTOR), - }); - } finally { - setCopying(false); - } - }, [copying, data, i18n]); - return ( - - ); -}; diff --git a/fluxer_app/src/features/voice/commands/VoiceDiagnosticsCommands.ts b/fluxer_app/src/features/voice/commands/VoiceDiagnosticsCommands.ts new file mode 100644 index 000000000..d0a5ed370 --- /dev/null +++ b/fluxer_app/src/features/voice/commands/VoiceDiagnosticsCommands.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import * as ToastCommands from '@app/features/ui/commands/ToastCommands'; +import {COPIED_STATS_JSON_DESCRIPTOR} from '@app/features/voice/components/StatsForNerdsCopyDescriptors'; +import {buildStatsForNerdsCopyPayload, collectStatsForNerdsSnapshot} from '@app/features/voice/utils/StatsForNerdsCopy'; +import type {I18n} from '@lingui/core'; + +export async function copyVoiceDiagnostics(i18n: I18n): Promise { + const data = collectStatsForNerdsSnapshot(); + let payload: Record; + try { + payload = await buildStatsForNerdsCopyPayload(data); + } catch { + payload = { + schemaVersion: 1, + createdAt: new Date().toISOString(), + statsForNerds: data, + }; + } + await navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); + ToastCommands.createToast({type: 'success', children: i18n._(COPIED_STATS_JSON_DESCRIPTOR)}); +} diff --git a/fluxer_app/src/features/voice/commands/VoiceSettingsCommands.ts b/fluxer_app/src/features/voice/commands/VoiceSettingsCommands.ts index f583c0706..a9de6afa4 100644 --- a/fluxer_app/src/features/voice/commands/VoiceSettingsCommands.ts +++ b/fluxer_app/src/features/voice/commands/VoiceSettingsCommands.ts @@ -79,6 +79,7 @@ type VoiceSettingsPatch = Partial<{ screenShareAudioSourceMode: 'none' | 'system' | 'specific'; screenShareAudioIncludeSources: Array>; screenShareAudioExcludeSources: Array>; + screenShareDeviceAudioUsesMicrophone: boolean; openH264Enabled: boolean; }>; diff --git a/fluxer_app/src/features/voice/components/AudioSourcePickerLinux.tsx b/fluxer_app/src/features/voice/components/AudioSourcePickerLinux.tsx index e0c722c46..de76fcce4 100644 --- a/fluxer_app/src/features/voice/components/AudioSourcePickerLinux.tsx +++ b/fluxer_app/src/features/voice/components/AudioSourcePickerLinux.tsx @@ -141,6 +141,7 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL const sourceMode = VoiceSettings.getScreenShareAudioSourceMode(); const includeSources = VoiceSettings.getScreenShareAudioIncludeSources(); const excludeSources = VoiceSettings.getScreenShareAudioExcludeSources(); + const usesDeviceMicrophone = VoiceSettings.getScreenShareDeviceAudioUsesMicrophone(); const granular = VoiceSettings.getLinuxAudioCaptureGranularSelect(); const deviceSelect = VoiceSettings.getLinuxAudioCaptureDeviceSelect(); const ignoreVirtual = VoiceSettings.getLinuxAudioCaptureIgnoreVirtual(); @@ -167,12 +168,13 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL onSelectionChange?.('window'); }, [onSelectionChange]); const handlePickSystem = useCallback(() => { - VoiceSettingsCommands.update({ - screenShareAudioSourceMode: 'system', - screenShareAudioIncludeSources: [], - }); + VoiceSettingsCommands.update( + isDeviceShare + ? {screenShareDeviceAudioUsesMicrophone: true} + : {screenShareAudioSourceMode: 'system', screenShareAudioIncludeSources: []}, + ); onSelectionChange?.(widenedScope); - }, [onSelectionChange, widenedScope]); + }, [isDeviceShare, onSelectionChange, widenedScope]); const handlePickNone = useCallback(() => { VoiceSettingsCommands.update({ screenShareAudioSourceMode: 'none', @@ -189,10 +191,11 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL VoiceSettingsCommands.update({ screenShareAudioSourceMode: nextSources.length > 0 ? 'specific' : 'system', screenShareAudioIncludeSources: nextSources, + ...(isDeviceShare ? {screenShareDeviceAudioUsesMicrophone: false} : {}), }); onSelectionChange?.(widenedScope); }, - [includeSources, onSelectionChange, widenedScope], + [includeSources, isDeviceShare, onSelectionChange, widenedScope], ); const handleToggleExcludeApp = useCallback( (item: LinuxAudioSourceItem) => { @@ -217,9 +220,10 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL microphoneLabel, displayShareEnvironment, windowAudioScope, + usesDeviceMicrophone, }); const showsWideSourceLists = !offersWindowScope || resolvedScope === 'system'; - const wideSourceIsSelected = isDeviceShare ? !routesSelectedSources : sourceMode === 'system'; + const wideSourceIsSelected = isDeviceShare ? usesDeviceMicrophone || !routesSelectedSources : sourceMode === 'system'; if (!snapshot.available && !snapshot.loading) { return null; } diff --git a/fluxer_app/src/features/voice/components/StatsForNerdsCopyDescriptors.ts b/fluxer_app/src/features/voice/components/StatsForNerdsCopyDescriptors.ts index 02f9eecff..901458b52 100644 --- a/fluxer_app/src/features/voice/components/StatsForNerdsCopyDescriptors.ts +++ b/fluxer_app/src/features/voice/components/StatsForNerdsCopyDescriptors.ts @@ -2,13 +2,9 @@ import {msg} from '@lingui/core/macro'; -export const COPY_AS_JSON_DESCRIPTOR = msg({ - message: 'Copy as JSON', - comment: 'Aria label for a button that copies stats for nerds data as JSON to the clipboard.', -}); export const COPY_STATS_JSON_DESCRIPTOR = msg({ message: 'Copy stats JSON', - comment: 'Button label in audio and video settings. Copies stats for nerds data as JSON to the clipboard.', + comment: 'Menu item label in the screen share menu. Copies stats for nerds data as JSON to the clipboard.', }); export const COPIED_STATS_JSON_DESCRIPTOR = msg({ message: 'Copied stats JSON to clipboard', diff --git a/fluxer_app/src/features/voice/components/VoiceCallView.tsx b/fluxer_app/src/features/voice/components/VoiceCallView.tsx index c6f0b151d..71fd2a918 100644 --- a/fluxer_app/src/features/voice/components/VoiceCallView.tsx +++ b/fluxer_app/src/features/voice/components/VoiceCallView.tsx @@ -15,6 +15,7 @@ import { } from '@app/features/i18n/utils/CommonMessageDescriptors'; import Favorites from '@app/features/messaging/state/Favorites'; import {goBackOr} from '@app/features/platform/components/router/NavigationAdapter'; +import {STREAM_VOLUME_DESCRIPTOR} from '@app/features/ui/action_menu/items/voice_participant_menu_data/shared'; import {BottomSheet} from '@app/features/ui/bottom_sheet/BottomSheet'; import * as ToastCommands from '@app/features/ui/commands/ToastCommands'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; @@ -636,6 +637,7 @@ const VoiceCallViewInner = observer( iconSize={18} className={styles.voiceHeaderIconButton} position="below" + ariaLabel={i18n._(STREAM_VOLUME_DESCRIPTOR)} data-flx="voice.voice-call-view.voice-call-view-inner.hud-stream-volume-control" /> )} diff --git a/fluxer_app/src/features/voice/components/VoiceParticipantTile.tsx b/fluxer_app/src/features/voice/components/VoiceParticipantTile.tsx index 105737e48..87e0b9aa6 100644 --- a/fluxer_app/src/features/voice/components/VoiceParticipantTile.tsx +++ b/fluxer_app/src/features/voice/components/VoiceParticipantTile.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import Accessibility from '@app/features/accessibility/state/Accessibility'; import {LongPressable} from '@app/features/app/components/LongPressable'; import Channels from '@app/features/channel/state/Channels'; import {WATCH_STREAM_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; @@ -7,6 +8,7 @@ import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; import Permission from '@app/features/permissions/state/Permission'; import {dimColor} from '@app/features/theme/utils/ColorUtils'; import type {VoiceParticipantMenuSource} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes'; +import {STREAM_VOLUME_DESCRIPTOR} from '@app/features/ui/action_menu/items/voice_participant_menu_data/shared'; import {UserContextMenu} from '@app/features/ui/action_menu/UserContextMenu'; import {VoiceParticipantContextMenu} from '@app/features/ui/action_menu/VoiceParticipantContextMenu'; import {Button} from '@app/features/ui/button/Button'; @@ -57,6 +59,7 @@ import { selectVoiceParticipantTileCameraActive, selectVoiceParticipantTileScreenShareState, shouldShowCameraBuffering, + shouldShowTileStreamAudioControls, type VoiceParticipantTileScreenShareSignals, } from '@app/features/voice/components/VoiceParticipantTileStateMachine'; import {useVoiceTileGroup} from '@app/features/voice/components/VoiceTileGroupContext'; @@ -296,6 +299,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({ : getVoiceDeafenedStatusLabel(i18n, isCurrentUser); const deafenStatusClassName = isModeratorDeafened ? styles.participantIconRed : styles.participantIconMuted; const isActuallySpeaking = displayState.speaking; + const shouldAnimateTileAvatar = isActuallySpeaking && !Accessibility.useReducedMotion; const isMobileExperience = isMobileExperienceEnabled(); const [bottomSheetOpen, setBottomSheetOpen] = useState(false); const tileGroup = useVoiceTileGroup(); @@ -632,7 +636,21 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({ const participantDisplayName = (participantUser ? NicknameUtils.getNickname(participantUser, guildId, channelId) : participant.name) || i18n._(UNKNOWN_USER_DESCRIPTOR); - const showStreamAudioControls = isScreenShare && !isOwnScreenShare && isWatching && hasScreenShareAudio; + const showStreamAudioControls = shouldShowTileStreamAudioControls({ + isScreenShare, + isOwnScreenShare, + isWatching, + hasScreenShareAudio, + isFocusedPlaceholderTile, + presentation, + }); + const showTileSpectatorPill = !isFocusPresentationTile && isScreenShare && viewerUsers.length > 0; + const showTileControlPill = + !isFocusedPlaceholderTile && + (showStreamAudioControls || + showTileSpectatorPill || + (isGridTile && groupHiddenCount > 0) || + showDeviceCollapseControl); const viewerStreamCount = graphViewerStreamKeys.length; const addStreamTooltipText = plural( {count: viewerStreamCount}, @@ -940,6 +958,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({ user={participantUser} size={TILE_AVATAR_BASE} mediaSize={TILE_AVATAR_MEDIA_SIZE} + forceAnimate={shouldAnimateTileAvatar} className={styles.avatarFlexShrink} style={TILE_AVATAR_STYLE} guildId={guildId} @@ -962,6 +981,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({ placeholderStyle, previewUrl, screenSharePlaceholderStyle, + shouldAnimateTileAvatar, trackRef, shouldHideOwnScreenShareVideo, ]); @@ -1195,154 +1215,150 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({ /> )} - {!isFocusPresentationTile && - !isFocusedPlaceholderTile && - (showStreamAudioControls || - (isScreenShare && viewerUsers.length > 0) || - (isGridTile && groupHiddenCount > 0) || - showDeviceCollapseControl) && ( -
0 && voiceCallStyles.tileControlPillPersistent, - )} - data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-control-pill" - > - {showStreamAudioControls && ( -
e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-audio-volume" + {showTileControlPill && ( +
+ {showStreamAudioControls && ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-audio-volume" + > + +
+ )} + {showDeviceCollapseControl && ( + + - { + if (!isKeyboardActivationKey(event.key)) return; + event.preventDefault(); + handleExpandGroup(event); + }} + aria-label={groupCollapseTooltip} + data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-pill" + > + + - + + {groupDeviceConnectionCount} +
+ + + )} + {isGridTile && groupHiddenCount > 0 && ( + + +
{ + if (!isKeyboardActivationKey(event.key)) return; + event.preventDefault(); + handleExpandGroup(event); + }} + aria-label={groupExpandTooltip} + data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-pill" + > + + + + + {groupHiddenCount} +
+
+
+ )} + {isOwnScreenShare && viewerUsers.length > 0 && streamUnderperformanceReason && ( + +
+
- )} - {showDeviceCollapseControl && ( - + )} + {showTileSpectatorPill && ( + +
- -
{ - if (!isKeyboardActivationKey(event.key)) return; - event.preventDefault(); - handleExpandGroup(event); - }} - aria-label={groupCollapseTooltip} - data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-pill" - > - - - - - {groupDeviceConnectionCount} -
-
- - )} - {isGridTile && groupHiddenCount > 0 && ( - - -
{ - if (!isKeyboardActivationKey(event.key)) return; - event.preventDefault(); - handleExpandGroup(event); - }} - aria-label={groupExpandTooltip} - data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-pill" - > - - + - - {groupHiddenCount} -
-
-
- )} - {isOwnScreenShare && viewerUsers.length > 0 && streamUnderperformanceReason && ( - -
- -
-
- )} - {isScreenShare && viewerUsers.length > 0 && ( - -
- - - {viewerUsers.length} - -
-
- )} -
- )} + + + {viewerUsers.length} + +
+ + )} +
+ )} {showParticipantMetadata && (
{ expect(css).not.toMatch(/transform:\s*scale/); expect(css).not.toContain('will-change: transform'); }); + it('drives the tile avatar animation from the speaking signal so animated avatars play without hover', () => { + const tileSource = sourceFile('VoiceParticipantTile.tsx'); + const avatarElement = tileSource.match(//)?.[0]; + expect(avatarElement).toBeDefined(); + const forceAnimateExpression = avatarElement?.match(/forceAnimate=\{([^}]+)\}/)?.[1]?.trim(); + expect(forceAnimateExpression).toBeDefined(); + const animateFlagSource = + forceAnimateExpression === 'isActuallySpeaking' + ? 'isActuallySpeaking' + : tileSource.match(new RegExp(`const ${forceAnimateExpression} = ([^;]+);`))?.[1]; + expect(animateFlagSource).toContain('isActuallySpeaking'); + expect(animateFlagSource).toContain('!Accessibility.useReducedMotion'); + expect(tileSource).toContain("import Accessibility from '@app/features/accessibility/state/Accessibility';"); + const afterAvatarElement = tileSource.slice(tileSource.indexOf(avatarElement!) + avatarElement!.length); + const mediaNodeDeps = afterAvatarElement.match(/\}, \[([\s\S]*?)\]\);/)?.[1] ?? ''; + expect(mediaNodeDeps).toContain(forceAnimateExpression); + }); it('keeps the fullscreen call surface mounted while the media room catches up to a channel switch', () => { const voiceCallViewSource = sourceFile('VoiceCallView.tsx'); const guildChannelViewSource = appSourceFile('features/channel/components/channel_view/GuildChannelView.tsx'); diff --git a/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.test.ts b/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.test.ts index b51af4d43..6ef5ce10d 100644 --- a/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.test.ts +++ b/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.test.ts @@ -6,10 +6,12 @@ import { selectVoiceParticipantTileCameraActive, selectVoiceParticipantTileScreenShareState, shouldShowCameraBuffering, + shouldShowTileStreamAudioControls, shouldShowWatchFailed, type VoiceParticipantTileCameraActiveSignals, type VoiceParticipantTileCameraBufferingSignals, type VoiceParticipantTileScreenShareSignals, + type VoiceParticipantTileStreamAudioSignals, } from '@app/features/voice/components/VoiceParticipantTileStateMachine'; import { getAppliedScreenShareFrameRate, @@ -84,6 +86,50 @@ function cameraActiveSignals( }; } +function streamAudioSignals( + overrides: Partial = {}, +): VoiceParticipantTileStreamAudioSignals { + return { + isScreenShare: true, + isOwnScreenShare: false, + isWatching: true, + hasScreenShareAudio: true, + isFocusedPlaceholderTile: false, + presentation: 'grid', + ...overrides, + }; +} + +describe('VoiceParticipantTileStateMachine stream audio controls', () => { + it('keeps the per-stream volume control on the watched stream once it becomes the focused tile', () => { + expect(shouldShowTileStreamAudioControls(streamAudioSignals({presentation: 'focus-main'}))).toBe(true); + }); + + it('shows the per-stream volume control on a watched grid tile', () => { + expect(shouldShowTileStreamAudioControls(streamAudioSignals({presentation: 'grid'}))).toBe(true); + }); + + it('leaves the control off carousel thumbnails, own shares, and placeholder tiles', () => { + expect(shouldShowTileStreamAudioControls(streamAudioSignals({presentation: 'focus-secondary'}))).toBe(false); + expect(shouldShowTileStreamAudioControls(streamAudioSignals({isOwnScreenShare: true}))).toBe(false); + expect( + shouldShowTileStreamAudioControls( + streamAudioSignals({presentation: 'focus-main', isFocusedPlaceholderTile: true}), + ), + ).toBe(false); + expect(shouldShowTileStreamAudioControls(streamAudioSignals({isScreenShare: false}))).toBe(false); + }); + + it('leaves the control off streams that are not watched or carry no audio', () => { + expect(shouldShowTileStreamAudioControls(streamAudioSignals({presentation: 'focus-main', isWatching: false}))).toBe( + false, + ); + expect( + shouldShowTileStreamAudioControls(streamAudioSignals({presentation: 'focus-main', hasScreenShareAudio: false})), + ).toBe(false); + }); +}); + describe('VoiceParticipantTileStateMachine camera buffering state', () => { it('shows buffering while an active camera publication has no video', () => { expect(shouldShowCameraBuffering(cameraSignals())).toBe(true); diff --git a/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.ts b/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.ts index fe132d931..0418bc2e0 100644 --- a/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.ts +++ b/fluxer_app/src/features/voice/components/VoiceParticipantTileStateMachine.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import type {VoiceParticipantTilePresentation} from '@app/features/voice/components/voice_participant_tile/shared'; import type {VoiceMediaGraphStreamTileState} from '@app/features/voice/engine/VoiceMediaGraphTileState'; import {assign, getInitialSnapshot, setup, transition} from 'xstate'; @@ -31,6 +32,15 @@ export interface VoiceParticipantTileCameraBufferingSignals { hasRenderedVideoFrame: boolean; } +export interface VoiceParticipantTileStreamAudioSignals { + isScreenShare: boolean; + isOwnScreenShare: boolean; + isWatching: boolean; + hasScreenShareAudio: boolean; + isFocusedPlaceholderTile: boolean; + presentation: VoiceParticipantTilePresentation; +} + export interface VoiceParticipantTileCameraActiveSignals { isCameraTile: boolean; isOwnContent: boolean; @@ -141,6 +151,15 @@ export function shouldShowWatchPrompt(signals: VoiceParticipantTileScreenShareSi return !signals.isFocusPresentationTile; } +export function shouldShowTileStreamAudioControls(signals: VoiceParticipantTileStreamAudioSignals): boolean { + if (!signals.isScreenShare) return false; + if (signals.isOwnScreenShare) return false; + if (signals.isFocusedPlaceholderTile) return false; + if (!signals.isWatching) return false; + if (!signals.hasScreenShareAudio) return false; + return signals.presentation === 'grid' || signals.presentation === 'focus-main'; +} + export function shouldShowCameraBuffering(signals: VoiceParticipantTileCameraBufferingSignals): boolean { if (signals.isScreenShare) return false; if (signals.isFocusedPlaceholderTile) return false; diff --git a/fluxer_app/src/features/voice/components/modals/ScreenSharePickerModal.tsx b/fluxer_app/src/features/voice/components/modals/ScreenSharePickerModal.tsx index e6d70ec09..1ac8768e2 100644 --- a/fluxer_app/src/features/voice/components/modals/ScreenSharePickerModal.tsx +++ b/fluxer_app/src/features/voice/components/modals/ScreenSharePickerModal.tsx @@ -1492,6 +1492,7 @@ const ScreenSharePickerModalLoadedContent = observer( microphoneLabel, displayShareEnvironment, windowAudioScope, + usesDeviceMicrophone: VoiceSettings.getScreenShareDeviceAudioUsesMicrophone(), }) : activeTab === 'devices' ? i18n._(DEVICE_AUDIO_MUTED_DESCRIPTOR) diff --git a/fluxer_app/src/features/voice/state/VoiceSettings.ts b/fluxer_app/src/features/voice/state/VoiceSettings.ts index 3a4638996..e1966f34d 100644 --- a/fluxer_app/src/features/voice/state/VoiceSettings.ts +++ b/fluxer_app/src/features/voice/state/VoiceSettings.ts @@ -122,6 +122,7 @@ type VoiceSettingsUpdate = Partial<{ screenShareAudioSourceMode: 'none' | 'system' | 'specific'; screenShareAudioIncludeSources: Array>; screenShareAudioExcludeSources: Array>; + screenShareDeviceAudioUsesMicrophone: boolean; openH264Enabled: boolean; lastScreenShareSource: LastScreenShareSource | null; }>; @@ -434,6 +435,7 @@ class VoiceSettings { screenShareAudioSourceMode: 'none' | 'system' | 'specific' = 'system'; screenShareAudioIncludeSources: Array> = []; screenShareAudioExcludeSources: Array> = []; + screenShareDeviceAudioUsesMicrophone = false; openH264Enabled = true; lastScreenShareSource: LastScreenShareSource | null = null; prioritizeSpeakingParticipants = false; @@ -510,6 +512,7 @@ class VoiceSettings { getScreenShareAudioSourceMode: false, getScreenShareAudioIncludeSources: false, getScreenShareAudioExcludeSources: false, + getScreenShareDeviceAudioUsesMicrophone: false, getOpenH264Enabled: false, getLastScreenShareSource: false, getPrioritizeSpeakingParticipants: false, @@ -623,6 +626,7 @@ class VoiceSettings { 'screenShareAudioSourceMode', 'screenShareAudioIncludeSources', 'screenShareAudioExcludeSources', + 'screenShareDeviceAudioUsesMicrophone', 'openH264Enabled', 'lastScreenShareSource', 'prioritizeSpeakingParticipants', @@ -1008,6 +1012,10 @@ class VoiceSettings { return this.screenShareAudioExcludeSources; } + getScreenShareDeviceAudioUsesMicrophone(): boolean { + return this.screenShareDeviceAudioUsesMicrophone; + } + getEffectiveScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' { return this.getScreenShareAudioSourceMode(); } @@ -1126,6 +1134,8 @@ class VoiceSettings { this.screenShareAudioIncludeSources = validated.screenShareAudioIncludeSources; if (validated.screenShareAudioExcludeSources !== undefined) this.screenShareAudioExcludeSources = validated.screenShareAudioExcludeSources; + if (validated.screenShareDeviceAudioUsesMicrophone !== undefined) + this.screenShareDeviceAudioUsesMicrophone = validated.screenShareDeviceAudioUsesMicrophone; if (validated.openH264Enabled !== undefined) this.openH264Enabled = validated.openH264Enabled; if (validated.lastScreenShareSource !== undefined) this.lastScreenShareSource = validated.lastScreenShareSource; this.notifyListeners(); @@ -1257,6 +1267,8 @@ class VoiceSettings { validateSourceList(data.screenShareAudioIncludeSources) ?? this.screenShareAudioIncludeSources, screenShareAudioExcludeSources: validateSourceList(data.screenShareAudioExcludeSources) ?? this.screenShareAudioExcludeSources, + screenShareDeviceAudioUsesMicrophone: + data.screenShareDeviceAudioUsesMicrophone ?? this.screenShareDeviceAudioUsesMicrophone, openH264Enabled: data.openH264Enabled ?? this.openH264Enabled, lastScreenShareSource: data.lastScreenShareSource === undefined diff --git a/fluxer_app/src/features/voice/utils/ScreenShareAudioSummary.test.ts b/fluxer_app/src/features/voice/utils/ScreenShareAudioSummary.test.ts index bdd13d9fd..3741608c2 100644 --- a/fluxer_app/src/features/voice/utils/ScreenShareAudioSummary.test.ts +++ b/fluxer_app/src/features/voice/utils/ScreenShareAudioSummary.test.ts @@ -143,6 +143,30 @@ describe('resolveScreenShareAudioSummary', () => { }); }); + it('names the microphone on a device share that picked it over the stored application selection', () => { + expect( + summary({ + shareContext: 'device', + microphoneLabel: 'Elgato 4K X Analog Stereo', + sourceMode: 'specific', + includeSources: [{'application.name': 'mpv'}], + usesDeviceMicrophone: true, + }), + ).toEqual({ + kind: 'message', + descriptor: MICROPHONE_WITH_DEVICE_DESCRIPTOR, + values: {deviceLabel: 'Elgato 4K X Analog Stereo'}, + }); + expect( + summary({ + shareContext: 'display', + sourceMode: 'specific', + includeSources: [{'application.name': 'mpv'}], + usesDeviceMicrophone: true, + }), + ).toEqual({kind: 'sourceName', name: 'mpv'}); + }); + it('keeps a window share on the shared window whatever the stored display selection says', () => { for (const sourceMode of ['none', 'system', 'specific'] as const) { expect(summary({shareContext: 'app', sourceMode, includeSources: [{'application.name': 'mpv'}]})).toEqual({ diff --git a/fluxer_app/src/features/voice/utils/ScreenShareAudioSummary.ts b/fluxer_app/src/features/voice/utils/ScreenShareAudioSummary.ts index 853194e9a..c1ba0f2f2 100644 --- a/fluxer_app/src/features/voice/utils/ScreenShareAudioSummary.ts +++ b/fluxer_app/src/features/voice/utils/ScreenShareAudioSummary.ts @@ -54,6 +54,7 @@ export interface ScreenShareAudioSummaryInput { microphoneLabel?: string | null; displayShareEnvironment?: DisplayShareEnvironment; windowAudioScope?: WindowShareAudioScope; + usesDeviceMicrophone?: boolean; } export type ScreenShareAudioSummary = @@ -83,7 +84,9 @@ export function resolveScreenShareAudioSummary(input: ScreenShareAudioSummaryInp const selected = filterRoutableLinuxAudioSources(input.includeSources); const routesSelectedSources = input.sourceMode === 'specific' && selected.length > 0; if (input.shareContext === 'device') { - return routesSelectedSources ? summariseSelectedSources(selected) : summariseMicrophone(input.microphoneLabel); + return routesSelectedSources && input.usesDeviceMicrophone !== true + ? summariseSelectedSources(selected) + : summariseMicrophone(input.microphoneLabel); } if (supportsWindowShareAudioScope(input)) { const route = selectAppShareAudioRoute({ diff --git a/fluxer_app/src/features/voice/utils/ScreenShareStartFlow.test.ts b/fluxer_app/src/features/voice/utils/ScreenShareStartFlow.test.ts index 1c13568ee..b3d7d3fd0 100644 --- a/fluxer_app/src/features/voice/utils/ScreenShareStartFlow.test.ts +++ b/fluxer_app/src/features/voice/utils/ScreenShareStartFlow.test.ts @@ -9,6 +9,7 @@ const voiceSettings = { includeSources: [] as Array>, excludeSources: [] as Array>, audioDeviceId: 'default', + deviceUsesMicrophone: false, }; const activeShareContext = {current: null as 'app' | 'device' | 'display' | null}; @@ -149,6 +150,7 @@ vi.mock('@app/features/voice/state/VoiceSettings', () => ({ getScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode, getScreenShareAudioIncludeSources: () => voiceSettings.includeSources, getScreenShareAudioExcludeSources: () => voiceSettings.excludeSources, + getScreenShareDeviceAudioUsesMicrophone: () => voiceSettings.deviceUsesMicrophone, getEffectiveScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode, getEffectiveScreenShareAudioIncludeSources: () => voiceSettings.includeSources, getEffectiveScreenShareAudioExcludeSources: () => voiceSettings.excludeSources, @@ -178,6 +180,7 @@ beforeEach(() => { voiceSettings.includeSources = []; voiceSettings.excludeSources = []; voiceSettings.audioDeviceId = 'default'; + voiceSettings.deviceUsesMicrophone = false; activeShareContext.current = null; activeShareSourceId.current = null; activeShareOwnWindow.current = false; @@ -362,6 +365,37 @@ describe('sharing a video device', () => { expect(ensureDeviceScreenShareMicPublication).toHaveBeenCalledTimes(1); }); + test('keeps the microphone the device picker chose while the shared application selection stands', async () => { + platform.current = 'linux'; + voiceSettings.audioSourceMode = 'specific'; + voiceSettings.includeSources = [{'application.name': 'mpv'}]; + voiceSettings.deviceUsesMicrophone = true; + + expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true); + + expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBe('mic-1'); + expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled(); + + expect(await reconfigureActiveDeviceShareAudio()).toBe(true); + expect(ensureDeviceScreenShareMicPublication).toHaveBeenCalledWith('mic-1'); + expect(ensureLinuxScreenShareAudioPublication).not.toHaveBeenCalled(); + + expect(voiceSettings.audioSourceMode).toBe('specific'); + expect(voiceSettings.includeSources).toEqual([{'application.name': 'mpv'}]); + }); + + test('routes the selected applications again once the device picker leaves the microphone', async () => { + platform.current = 'linux'; + voiceSettings.audioSourceMode = 'specific'; + voiceSettings.includeSources = [{'application.name': 'mpv'}]; + voiceSettings.deviceUsesMicrophone = false; + + expect(await startConfiguredDeviceScreenShare('camera-1')).toBe(true); + + expect(deviceShareAudioDeviceId(startDeviceScreenShare.mock.calls[0])).toBeUndefined(); + expect(ensureLinuxScreenShareAudioPublication).toHaveBeenCalledTimes(1); + }); + test('keeps the paired capture card input when the live share rebinds its microphone', async () => { platform.current = 'linux'; activeShareVideoDeviceId.current = 'camera-1'; @@ -418,6 +452,20 @@ describe('sharing a whole display', () => { }); }); + test('keeps the stored include list while a device share of its own is on the microphone', async () => { + platform.current = 'linux'; + voiceSettings.audioSourceMode = 'specific'; + voiceSettings.includeSources = [{'application.name': 'mpv'}]; + voiceSettings.deviceUsesMicrophone = true; + + expect(await startConfiguredDisplayScreenShare('screen:1')).toBe(true); + + expect(armNativeAudioForLinuxRouting.mock.calls[0][0]).toMatchObject({ + include: [{'application.name': 'mpv'}], + ignoreInputMedia: true, + }); + }); + test('arms native system audio on Windows and macOS', async () => { for (const current of ['win32', 'darwin']) { vi.clearAllMocks(); diff --git a/fluxer_app/src/features/voice/utils/ScreenShareStartFlow.ts b/fluxer_app/src/features/voice/utils/ScreenShareStartFlow.ts index ac3d87ae6..1385f3eda 100644 --- a/fluxer_app/src/features/voice/utils/ScreenShareStartFlow.ts +++ b/fluxer_app/src/features/voice/utils/ScreenShareStartFlow.ts @@ -156,6 +156,7 @@ async function getManualAudioSourceSelectionInput(shareContext: StreamSettingsSh nativeAudioAvailability: platform === 'linux' ? await getNativeAudioAvailabilityCached() : null, audioSourceMode: VoiceSettings.getScreenShareAudioSourceMode(), selectedSourceCount: countRoutableAudioSources(), + usesDeviceMicrophone: VoiceSettings.getScreenShareDeviceAudioUsesMicrophone(), }; } diff --git a/fluxer_app/src/features/voice/utils/StreamSettingsUpdatePolicy.ts b/fluxer_app/src/features/voice/utils/StreamSettingsUpdatePolicy.ts index cc15aba58..bfbfa61b2 100644 --- a/fluxer_app/src/features/voice/utils/StreamSettingsUpdatePolicy.ts +++ b/fluxer_app/src/features/voice/utils/StreamSettingsUpdatePolicy.ts @@ -20,6 +20,7 @@ export interface ManualAudioSourceSelectionInput { nativeAudioAvailability?: NativeAudioAvailability | null; audioSourceMode?: ScreenShareAudioSourceMode; selectedSourceCount?: number; + usesDeviceMicrophone?: boolean; } export interface WindowShareAudioScopeInput { @@ -74,6 +75,7 @@ export function canSelectManualAudioSources( } export function routesManualAudioSources(input: ManualAudioSourceSelectionInput): boolean { + if (input.shareContext === 'device' && input.usesDeviceMicrophone === true) return false; return ( canSelectManualAudioSources(input) && input.audioSourceMode === 'specific' && (input.selectedSourceCount ?? 0) > 0 ); diff --git a/fluxer_app_proxy/src/csp.rs b/fluxer_app_proxy/src/csp.rs index db011142c..9d0c6d7ba 100644 --- a/fluxer_app_proxy/src/csp.rs +++ b/fluxer_app_proxy/src/csp.rs @@ -225,7 +225,7 @@ fn build_csp_directives( extend_runtime_sources(&mut font, runtime_sources, true, true); directives.push(format!("font-src {}", font.join(" "))); - let mut connect = vec!["'self'".to_owned(), "data:".to_owned()]; + let mut connect = vec!["'self'".to_owned(), "blob:".to_owned(), "data:".to_owned()]; extend_from(&mut connect, &config.extra_connect_src, CONNECT_SOURCES); extend_runtime_sources(&mut connect, runtime_sources, true, true); extend_runtime_s3_sources(&mut connect, runtime_sources); @@ -355,6 +355,36 @@ mod tests { assert!(csp.contains("frame-ancestors 'none'")); } + #[test] + fn build_csp_allows_blob_connections_for_camera_background_media() { + let config = default_csp_config(); + let csp = build_csp(&config, "testnonce", &runtime_sources()); + let connect = csp + .split("; ") + .find(|directive| directive.starts_with("connect-src ")) + .expect("connect-src directive"); + assert!( + connect.split(' ').any(|source| source == "blob:"), + "connect-src must allow blob: object URLs: {connect}" + ); + } + + #[test] + fn an_asset_header_allows_blob_connections_for_the_camera_effect_worker() { + let policy = CompiledCspPolicy::compile(default_csp_config(), &runtime_sources()).unwrap(); + let asset = policy.asset_header(); + let asset = asset.to_str().unwrap(); + let connect = asset + .split("; ") + .find(|directive| directive.starts_with("connect-src ")) + .expect("connect-src directive"); + assert!( + connect.split(' ').any(|source| source == "blob:"), + "the camera-effect worker is served as /assets/*.worker.js and runs under the asset \ + policy, so that policy must allow blob: object URLs: {connect}" + ); + } + #[test] fn build_csp_includes_nonce_in_script_src() { let config = default_csp_config(); diff --git a/fluxer_desktop/src/main/WindowsShortcuts.test.mjs b/fluxer_desktop/src/main/WindowsShortcuts.test.mjs new file mode 100644 index 000000000..997fc5823 --- /dev/null +++ b/fluxer_desktop/src/main/WindowsShortcuts.test.mjs @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {createRequire} from 'node:module'; +import path from 'node:path'; +import {describe, test} from 'node:test'; +import {fileURLToPath} from 'node:url'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const esbuild = require('esbuild'); + +const sourcePath = fileURLToPath(new URL('./WindowsShortcuts.ts', import.meta.url)); +const source = readFileSync(sourcePath, 'utf8'); +const transformedSource = esbuild.transformSync(source, { + loader: 'ts', + format: 'cjs', + platform: 'node', + target: 'node20', +}).code; + +const APPDATA = 'C:\\Users\\csh\\AppData\\Roaming'; +const USERPROFILE = 'C:\\Users\\csh'; +const PROGRAMS_DIR = path.win32.join(APPDATA, 'Microsoft', 'Windows', 'Start Menu', 'Programs'); +const AUTHOR_DIR = path.win32.join(PROGRAMS_DIR, 'Fluxer Platform AB'); +const ROOT_SHORTCUT = path.win32.join(PROGRAMS_DIR, 'Fluxer Canary.lnk'); +const AUTHOR_SHORTCUT = path.win32.join(AUTHOR_DIR, 'Fluxer Canary.lnk'); +const ROOT_APP_DIR = 'C:\\Users\\csh\\AppData\\Local\\fluxer_desktop_canary'; +const CURRENT_DIR = path.win32.join(ROOT_APP_DIR, 'current'); +const CURRENT_EXE = path.win32.join(CURRENT_DIR, 'Fluxer Canary.exe'); +const STALE_EXE = path.win32.join(ROOT_APP_DIR, 'app-1.0.0', 'Fluxer Canary.exe'); +const LEGACY_APP_USER_MODEL_ID = 'velopack.fluxer_desktop_canary'; + +function lnkBuffer(...values) { + return Buffer.concat([Buffer.from('L\0\0\0', 'utf8'), ...values.map((value) => Buffer.from(value, 'utf16le'))]); +} + +function loadWindowsShortcuts(initialFiles) { + const files = new Map(initialFiles); + const dirs = new Set([PROGRAMS_DIR, ROOT_APP_DIR, CURRENT_DIR]); + for (const filePath of files.keys()) { + dirs.add(path.win32.dirname(filePath)); + } + files.set(path.win32.join(ROOT_APP_DIR, 'Update.exe'), Buffer.from('exe')); + + const fsOperations = []; + const fakeFs = { + existsSync: (target) => files.has(target) || dirs.has(target), + readFileSync: (target) => { + const contents = files.get(target); + if (!contents) throw new Error(`ENOENT ${target}`); + return contents; + }, + mkdirSync: (target) => { + fsOperations.push(['mkdir', target]); + dirs.add(target); + }, + renameSync: (from, to) => { + fsOperations.push(['rename', from, to]); + files.set(to, files.get(from)); + files.delete(from); + }, + rmSync: (target) => { + fsOperations.push(['rm', target]); + files.delete(target); + }, + }; + + const createdShortcuts = []; + const winShell = { + loadError: null, + createShortcut: async (options) => { + createdShortcuts.push(options); + files.set(options.lnkPath, lnkBuffer(options.target, options.appUserModelId)); + }, + }; + + const module = {exports: {}}; + const context = vm.createContext({ + module, + exports: module.exports, + console, + Buffer, + process: {platform: 'win32', execPath: CURRENT_EXE, env: {APPDATA, USERPROFILE}}, + require: (specifier) => { + if (specifier === 'node:fs') { + return {default: fakeFs, ...fakeFs}; + } + if (specifier === 'node:path') { + return {default: path.win32, ...path.win32}; + } + if (specifier === 'node:module') { + return { + createRequire: () => (moduleName) => { + if (moduleName === '@fluxer/win-shell') return winShell; + throw new Error(`unexpected require ${moduleName}`); + }, + }; + } + if (specifier === '@electron/common/DesktopIdentity') { + return { + DESKTOP_APP_NAME: 'Fluxer Canary', + WINDOWS_APP_USER_MODEL_ID: 'Fluxer.Fluxer.Canary', + WINDOWS_LEGACY_APP_USER_MODEL_IDS: [LEGACY_APP_USER_MODEL_ID], + WINDOWS_SHORTCUT_AUTHOR: 'Fluxer Platform AB', + WINDOWS_TOAST_ACTIVATOR_CLSID: '{9CEDB5C0-3552-43B0-A279-2232E0CDF74C}', + }; + } + return require(specifier); + }, + }); + vm.runInContext(transformedSource, context, {filename: sourcePath}); + return {...module.exports, createdShortcuts, dirs, files, fsOperations}; +} + +async function runRepair(harness) { + harness.repairWindowsShortcuts(); + await new Promise((resolve) => setImmediate(resolve)); +} + +describe('Windows Start Menu shortcut repair', () => { + test('leaves a user-placed root shortcut alone and does not recreate the author folder', async () => { + const harness = loadWindowsShortcuts([[ROOT_SHORTCUT, lnkBuffer(CURRENT_EXE)]]); + + await runRepair(harness); + + assert.deepEqual(harness.fsOperations, []); + assert.equal(harness.files.has(ROOT_SHORTCUT), true); + assert.equal(harness.files.has(AUTHOR_SHORTCUT), false); + assert.equal(harness.dirs.has(AUTHOR_DIR), false); + }); + + test('does not delete a root shortcut when the author shortcut also exists', async () => { + const harness = loadWindowsShortcuts([ + [ROOT_SHORTCUT, lnkBuffer(CURRENT_EXE)], + [AUTHOR_SHORTCUT, lnkBuffer(CURRENT_EXE)], + ]); + + await runRepair(harness); + + assert.deepEqual(harness.fsOperations, []); + assert.equal(harness.files.has(ROOT_SHORTCUT), true); + assert.equal(harness.files.has(AUTHOR_SHORTCUT), true); + }); + + test('rewrites a stale root shortcut in place instead of relocating it', async () => { + const harness = loadWindowsShortcuts([[ROOT_SHORTCUT, lnkBuffer(STALE_EXE)]]); + + await runRepair(harness); + + assert.deepEqual(harness.fsOperations, []); + const rewritten = harness.createdShortcuts.filter((options) => options.lnkPath === ROOT_SHORTCUT); + assert.equal(rewritten.length, 1); + assert.equal(rewritten[0].target, CURRENT_EXE); + assert.equal(rewritten[0].appUserModelId, 'Fluxer.Fluxer.Canary'); + assert.equal(harness.files.has(AUTHOR_SHORTCUT), false); + }); + + test('still rewrites the author shortcut carrying the legacy AppUserModelID', async () => { + const harness = loadWindowsShortcuts([[AUTHOR_SHORTCUT, lnkBuffer(CURRENT_EXE, LEGACY_APP_USER_MODEL_ID)]]); + + await runRepair(harness); + + const rewritten = harness.createdShortcuts.filter((options) => options.lnkPath === AUTHOR_SHORTCUT); + assert.equal(rewritten.length, 1); + assert.equal(rewritten[0].target, CURRENT_EXE); + assert.equal(rewritten[0].appUserModelId, 'Fluxer.Fluxer.Canary'); + assert.equal(rewritten[0].toastActivatorClsid, '{9CEDB5C0-3552-43B0-A279-2232E0CDF74C}'); + }); +}); diff --git a/fluxer_desktop/src/main/WindowsShortcuts.ts b/fluxer_desktop/src/main/WindowsShortcuts.ts index b6ad54a87..092af03f5 100644 --- a/fluxer_desktop/src/main/WindowsShortcuts.ts +++ b/fluxer_desktop/src/main/WindowsShortcuts.ts @@ -146,20 +146,7 @@ async function repairWindowsShortcutsAsync(repairPaths: WindowsShortcutRepairPat return; } const {createShortcut} = binding; - try { - if (fs.existsSync(repairPaths.rootShortcut)) { - const authorDir = path.dirname(repairPaths.authorShortcut); - fs.mkdirSync(authorDir, {recursive: true}); - if (fs.existsSync(repairPaths.authorShortcut)) { - fs.rmSync(repairPaths.rootShortcut, {force: true}); - } else { - fs.renameSync(repairPaths.rootShortcut, repairPaths.authorShortcut); - } - } - } catch (error) { - console.warn('[WindowsShortcuts] Failed to migrate root Start-Menu shortcut', error); - } - const shortcutPaths: Array = [repairPaths.authorShortcut]; + const shortcutPaths: Array = [repairPaths.authorShortcut, repairPaths.rootShortcut]; const desktopDir = process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'Desktop') : ''; if (desktopDir) shortcutPaths.push(path.join(desktopDir, `${DESKTOP_APP_NAME}.lnk`)); if (process.env.APPDATA) { diff --git a/fluxer_gateway/src/guild/guild_data_channels.erl b/fluxer_gateway/src/guild/guild_data_channels.erl index f24a91691..c89b1a3c7 100644 --- a/fluxer_gateway/src/guild/guild_data_channels.erl +++ b/fluxer_gateway/src/guild/guild_data_channels.erl @@ -153,7 +153,9 @@ select_first_viewable(Channel, GuildId, BasePerms) -> -spec check_viewable(integer() | undefined, integer() | undefined, map(), integer(), integer()) -> integer() | null. -check_viewable(0, ChannelId, Channel, GuildId, BasePerms) when is_integer(ChannelId) -> +check_viewable(ChannelType, ChannelId, Channel, GuildId, BasePerms) when + is_integer(ChannelId), ChannelType =:= 0 orelse ChannelType =:= 2 +-> case permission_bits:has(BasePerms, constants:administrator_permission()) of true -> ChannelId; false -> check_view_permission(ChannelId, Channel, GuildId, BasePerms) diff --git a/fluxer_gateway/test/guild_data_tests.erl b/fluxer_gateway/test/guild_data_tests.erl index fe3bd9354..ea670eac0 100644 --- a/fluxer_gateway/test/guild_data_tests.erl +++ b/fluxer_gateway/test/guild_data_tests.erl @@ -263,6 +263,46 @@ find_everyone_viewable_text_channel_ignores_user_overwrite_for_guild_id_test() - ChannelId = guild_data:find_everyone_viewable_text_channel(Channels, State), ?assertEqual(12, ChannelId). +find_everyone_viewable_text_channel_accepts_voice_when_no_text_channel_test() -> + GuildId = 100, + ViewPerm = constants:view_channel_permission(), + State = #{ + id => GuildId, + data => #{ + <<"roles">> => [ + #{ + <<"id">> => integer_to_binary(GuildId), + <<"permissions">> => integer_to_binary(ViewPerm) + } + ] + } + }, + Channels = [ + #{<<"id">> => <<"501">>, <<"type">> => 2, <<"permission_overwrites">> => []} + ], + ChannelId = guild_data:find_everyone_viewable_text_channel(Channels, State), + ?assertEqual(501, ChannelId). + +find_everyone_viewable_text_channel_skips_link_channel_test() -> + GuildId = 100, + ViewPerm = constants:view_channel_permission(), + State = #{ + id => GuildId, + data => #{ + <<"roles">> => [ + #{ + <<"id">> => integer_to_binary(GuildId), + <<"permissions">> => integer_to_binary(ViewPerm) + } + ] + } + }, + Channels = [ + #{<<"id">> => <<"998">>, <<"type">> => 998, <<"permission_overwrites">> => []} + ], + ChannelId = guild_data:find_everyone_viewable_text_channel(Channels, State), + ?assertEqual(null, ChannelId). + voice_members_from_states_reads_embedded_member_test() -> EmbeddedMember = #{<<"user">> => #{<<"id">> => <<"300">>}, <<"roles">> => []}, IndexedMember = #{<<"user">> => #{<<"id">> => <<"200">>}, <<"roles">> => []},