fix(app): correct shortcuts, nagbar, stream menu, share audio (#2599)

This commit is contained in:
Hampus
2026-09-08 19:09:59 +02:00
committed by GitHub
parent 55846b24ea
commit fc2b6b5299
37 changed files with 1074 additions and 268 deletions
@@ -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) {
@@ -1277,6 +1277,7 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
[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},
@@ -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<NagbarState> => {
@@ -407,6 +410,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
visible: conditions.canShowStreamerMode,
dismissible: true,
},
{
type: NagbarType.DESKTOP_UPDATE_READY,
priority: -1.5,
visible: conditions.canShowDesktopUpdateReady,
dismissible: true,
},
];
return selectVisibleNagbars(nagbars);
}, [conditions]);
@@ -15,6 +15,7 @@ export const NagbarType = {
PREMIUM_ONBOARDING: 'premium-onboarding',
GIFT_INVENTORY: 'gift-inventory',
DESKTOP_DOWNLOAD: 'desktop-download',
DESKTOP_UPDATE_READY: 'desktop-update-ready',
GUILD_MEMBERSHIP_CTA: 'guild-membership-cta',
VISIONARY_MFA: 'visionary-mfa',
VOICE_SESSION_RESTORE: 'voice-session-restore',
@@ -50,6 +51,7 @@ export interface NagbarConditions {
canShowPremiumOnboarding: boolean;
canShowGiftInventory: boolean;
canShowDesktopDownload: boolean;
canShowDesktopUpdateReady: boolean;
canShowGuildMembershipCta: boolean;
canShowVisionaryMfa: boolean;
canShowVoiceSessionRestore: boolean;
@@ -7,6 +7,7 @@ import {ConnectionNagbar} from '@app/features/app/components/layout/app_layout/n
import {CorruptedInstallationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/CorruptedInstallationNagbar';
import {DesktopDownloadNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopDownloadNagbar';
import {DesktopNotificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopNotificationNagbar';
import {DesktopUpdateReadyNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopUpdateReadyNagbar';
import {EmailVerificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/EmailVerificationNagbar';
import {GiftInventoryNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GiftInventoryNagbar';
import {GuildMembershipCtaNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GuildMembershipCtaNagbar';
@@ -153,6 +154,14 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
data-flx="app.app-layout.nagbar-container.desktop-download-nagbar"
/>
);
case NagbarType.DESKTOP_UPDATE_READY:
return (
<DesktopUpdateReadyNagbar
key={nagbar.type}
isMobile={mobileLayout.enabled}
data-flx="app.app-layout.nagbar-container.desktop-update-ready-nagbar"
/>
);
case NagbarType.GUILD_MEMBERSHIP_CTA:
return (
<GuildMembershipCtaNagbar
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import Updater from '@app/features/app/state/Updater';
import {
DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR,
RESTART_FLUXER_DESCRIPTOR,
THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR,
} from '@app/features/updater/commands/UpdaterModalCommands';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
export const DesktopUpdateReadyNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const {i18n} = useLingui();
const version = Updater.updateInfo.native.version;
return (
<Nagbar
isMobile={isMobile}
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
dismissible
onDismiss={Updater.dismissUpdateReadyNagbar}
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.nagbar"
>
<NagbarContent
isMobile={isMobile}
onDismiss={Updater.dismissUpdateReadyNagbar}
message={
version
? i18n._(DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR, {version, productName: PRODUCT_NAME})
: i18n._(THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR, {productName: PRODUCT_NAME})
}
actions={
<NagbarButton
isMobile={isMobile}
onClick={() => void Updater.applyUpdate()}
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.restart-button"
>
{i18n._(RESTART_FLUXER_DESCRIPTOR, {productName: PRODUCT_NAME})}
</NagbarButton>
}
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.nagbar-content"
/>
</Nagbar>
);
});
@@ -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);
});
});
+17 -3
View File
@@ -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'});
}
@@ -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<string>, 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<GuildMemberData>): 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<GuildMemberData>): 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');
});
});
@@ -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);
@@ -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>): 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);
});
});
@@ -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;
}
@@ -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<MenuLeaf>;
onClick?: () => void;
}
function streamMenu(source: VoiceParticipantMenuScreenShareSource): Array<{items: Array<MenuLeaf>}> {
@@ -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);
});
@@ -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<MenuItemType | MenuCheckboxType> = [
{
label: i18n._(SHOW_MY_SCREEN_SHARE_DESCRIPTOR),
checked: showMyOwnScreenShare,
onChange: (checked: boolean) => VoiceSettingsCommands.update({showMyOwnScreenShare: checked}),
},
{
label: i18n._(PAUSE_OWN_STREAM_PREVIEW_DESCRIPTOR),
checked: pauseOwnScreenSharePreviewOnUnfocus,
onChange: (checked: boolean) => VoiceSettingsCommands.update({pauseOwnScreenSharePreviewOnUnfocus: checked}),
},
];
if (guildId === undefined) {
items.push({
label: i18n._(SCREEN_SHARE_PRIVACY_DESCRIPTOR),
onClick: () => {
onClose();
openScreenSharePreviewPrivacyModal();
const items: Array<MenuItemType | MenuCheckboxType> = [];
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<VoiceParticipantOwnStre
{i18n._(SCREEN_SHARE_PRIVACY_DESCRIPTOR)}
</MenuItem>
)}
<MenuItem
onClick={() => {
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)}
</MenuItem>
</MenuGroup>
)}
data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.more-options-submenu"
@@ -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.',
});
@@ -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<string, unknown>;
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 (
<Button
variant="secondary"
fitContent
leftIcon={<CopySimpleIcon size={16} data-flx="user.stats-for-nerds-copy-button.copy-icon" />}
submitting={copying}
onClick={() => void handleCopy()}
data-flx="user.stats-for-nerds-copy-button.copy"
>
{i18n._(COPY_STATS_JSON_DESCRIPTOR)}
</Button>
);
};
@@ -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<void> {
const data = collectStatsForNerdsSnapshot();
let payload: Record<string, unknown>;
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)});
}
@@ -79,6 +79,7 @@ type VoiceSettingsPatch = Partial<{
screenShareAudioSourceMode: 'none' | 'system' | 'specific';
screenShareAudioIncludeSources: Array<Record<string, string>>;
screenShareAudioExcludeSources: Array<Record<string, string>>;
screenShareDeviceAudioUsesMicrophone: boolean;
openH264Enabled: boolean;
}>;
@@ -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;
}
@@ -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',
@@ -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"
/>
)}
@@ -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({
/>
</div>
)}
{!isFocusPresentationTile &&
!isFocusedPlaceholderTile &&
(showStreamAudioControls ||
(isScreenShare && viewerUsers.length > 0) ||
(isGridTile && groupHiddenCount > 0) ||
showDeviceCollapseControl) && (
<div
className={clsx(
voiceCallStyles.tileControlPill,
isScreenShare && viewerUsers.length > 0 && voiceCallStyles.tileControlPillPersistent,
)}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-control-pill"
>
{showStreamAudioControls && (
<div
className={clsx(voiceCallStyles.tileControlPillSlot, isStreamMuted && styles.streamAudioSlotMuted)}
role="group"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-audio-volume"
{showTileControlPill && (
<div
className={clsx(
voiceCallStyles.tileControlPill,
showTileSpectatorPill && voiceCallStyles.tileControlPillPersistent,
)}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-control-pill"
>
{showStreamAudioControls && (
<div
className={clsx(voiceCallStyles.tileControlPillSlot, isStreamMuted && styles.streamAudioSlotMuted)}
role="group"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-audio-volume"
>
<MediaVerticalVolumeControl
volume={streamVolume / 100}
isMuted={isStreamMuted}
maxVolume={VOICE_VOLUME_MAX_SLIDER_VOLUME}
onVolumeChange={handleStreamVolumeChange}
onToggleMute={handleStreamAudioToggle}
iconSize={14}
position="below"
ariaLabel={i18n._(STREAM_VOLUME_DESCRIPTOR)}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-audio-volume-control"
/>
</div>
)}
{showDeviceCollapseControl && (
<Tooltip
text={groupCollapseTooltip}
position="top"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-tooltip"
>
<FocusRing
offset={-2}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-focus-ring"
>
<MediaVerticalVolumeControl
volume={streamVolume / 100}
isMuted={isStreamMuted}
maxVolume={VOICE_VOLUME_MAX_SLIDER_VOLUME}
onVolumeChange={handleStreamVolumeChange}
onToggleMute={handleStreamAudioToggle}
iconSize={14}
position="below"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-audio-volume-control"
<div
role="button"
tabIndex={0}
className={clsx(voiceCallStyles.tileControlPillSlot, styles.groupExpandPillSlot)}
onClick={handleExpandGroup}
onKeyDown={(event) => {
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"
>
<span
className={styles.groupExpandPillSign}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-pill-sign"
>
-
</span>
{groupDeviceConnectionCount}
</div>
</FocusRing>
</Tooltip>
)}
{isGridTile && groupHiddenCount > 0 && (
<Tooltip
text={groupExpandTooltip}
position="top"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-tooltip"
>
<FocusRing
offset={-2}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-focus-ring"
>
<div
role="button"
tabIndex={0}
className={clsx(voiceCallStyles.tileControlPillSlot, styles.groupExpandPillSlot)}
onClick={handleExpandGroup}
onKeyDown={(event) => {
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"
>
<span
className={styles.groupExpandPillSign}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-pill-sign--2"
>
+
</span>
{groupHiddenCount}
</div>
</FocusRing>
</Tooltip>
)}
{isOwnScreenShare && viewerUsers.length > 0 && streamUnderperformanceReason && (
<Tooltip
text={getStreamUnderperformanceLabel(i18n, streamUnderperformanceReason)}
position="top"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tooltip"
>
<div
className={clsx(voiceCallStyles.tileControlPillSlot, styles.streamUnderperformanceSlot)}
role="img"
aria-label={i18n._(STREAM_NOT_KEEPING_UP_DESCRIPTOR)}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-underperformance-slot"
>
<WarningIcon
weight="fill"
className={styles.tilePillIcon}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-pill-icon"
/>
</div>
)}
{showDeviceCollapseControl && (
<Tooltip
text={groupCollapseTooltip}
position="top"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-tooltip"
</Tooltip>
)}
{showTileSpectatorPill && (
<StreamSpectatorsPopout
viewerUsers={viewerUsers}
spectatorEntries={spectatorEntries}
guildId={guildId}
channelId={channelId}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-spectators-popout"
>
<div
className={clsx(voiceCallStyles.tileControlPillSlot, voiceCallStyles.tileControlPillViewerSlot)}
role="img"
aria-label={i18n._(WATCHING_DESCRIPTOR, {length: viewerUsers.length})}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-count"
>
<FocusRing
offset={-2}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-focus-ring"
>
<div
role="button"
tabIndex={0}
className={clsx(voiceCallStyles.tileControlPillSlot, styles.groupExpandPillSlot)}
onClick={handleExpandGroup}
onKeyDown={(event) => {
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"
>
<span
className={styles.groupExpandPillSign}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-pill-sign"
>
-
</span>
{groupDeviceConnectionCount}
</div>
</FocusRing>
</Tooltip>
)}
{isGridTile && groupHiddenCount > 0 && (
<Tooltip
text={groupExpandTooltip}
position="top"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-tooltip"
>
<FocusRing
offset={-2}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-focus-ring"
>
<div
role="button"
tabIndex={0}
className={clsx(voiceCallStyles.tileControlPillSlot, styles.groupExpandPillSlot)}
onClick={handleExpandGroup}
onKeyDown={(event) => {
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"
>
<span
className={styles.groupExpandPillSign}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-expand-pill-sign--2"
>
+
</span>
{groupHiddenCount}
</div>
</FocusRing>
</Tooltip>
)}
{isOwnScreenShare && viewerUsers.length > 0 && streamUnderperformanceReason && (
<Tooltip
text={getStreamUnderperformanceLabel(i18n, streamUnderperformanceReason)}
position="top"
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tooltip"
>
<div
className={clsx(voiceCallStyles.tileControlPillSlot, styles.streamUnderperformanceSlot)}
role="img"
aria-label={i18n._(STREAM_NOT_KEEPING_UP_DESCRIPTOR)}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-underperformance-slot"
>
<WarningIcon
weight="fill"
className={styles.tilePillIcon}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-pill-icon"
/>
</div>
</Tooltip>
)}
{isScreenShare && viewerUsers.length > 0 && (
<StreamSpectatorsPopout
viewerUsers={viewerUsers}
spectatorEntries={spectatorEntries}
guildId={guildId}
channelId={channelId}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-spectators-popout"
>
<div
className={clsx(voiceCallStyles.tileControlPillSlot, voiceCallStyles.tileControlPillViewerSlot)}
role="img"
aria-label={i18n._(WATCHING_DESCRIPTOR, {length: viewerUsers.length})}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-count"
>
<EyeIcon
weight="fill"
className={styles.tilePillIcon}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-icon"
/>
<span data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-count-text">
{viewerUsers.length}
</span>
</div>
</StreamSpectatorsPopout>
)}
</div>
)}
<EyeIcon
weight="fill"
className={styles.tilePillIcon}
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-icon"
/>
<span data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-count-text">
{viewerUsers.length}
</span>
</div>
</StreamSpectatorsPopout>
)}
</div>
)}
{showParticipantMetadata && (
<div
className={voiceCallStyles.lkParticipantMetadata}
@@ -30,6 +30,23 @@ describe('VoiceParticipantTile stability', () => {
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(/<Avatar\b[\s\S]*?\/>/)?.[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');
@@ -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> = {},
): 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);
@@ -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;
@@ -1492,6 +1492,7 @@ const ScreenSharePickerModalLoadedContent = observer(
microphoneLabel,
displayShareEnvironment,
windowAudioScope,
usesDeviceMicrophone: VoiceSettings.getScreenShareDeviceAudioUsesMicrophone(),
})
: activeTab === 'devices'
? i18n._(DEVICE_AUDIO_MUTED_DESCRIPTOR)
@@ -122,6 +122,7 @@ type VoiceSettingsUpdate = Partial<{
screenShareAudioSourceMode: 'none' | 'system' | 'specific';
screenShareAudioIncludeSources: Array<Record<string, string>>;
screenShareAudioExcludeSources: Array<Record<string, string>>;
screenShareDeviceAudioUsesMicrophone: boolean;
openH264Enabled: boolean;
lastScreenShareSource: LastScreenShareSource | null;
}>;
@@ -434,6 +435,7 @@ class VoiceSettings {
screenShareAudioSourceMode: 'none' | 'system' | 'specific' = 'system';
screenShareAudioIncludeSources: Array<Record<string, string>> = [];
screenShareAudioExcludeSources: Array<Record<string, string>> = [];
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
@@ -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({
@@ -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({
@@ -9,6 +9,7 @@ const voiceSettings = {
includeSources: [] as Array<Record<string, string>>,
excludeSources: [] as Array<Record<string, string>>,
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();
@@ -156,6 +156,7 @@ async function getManualAudioSourceSelectionInput(shareContext: StreamSettingsSh
nativeAudioAvailability: platform === 'linux' ? await getNativeAudioAvailabilityCached() : null,
audioSourceMode: VoiceSettings.getScreenShareAudioSourceMode(),
selectedSourceCount: countRoutableAudioSources(),
usesDeviceMicrophone: VoiceSettings.getScreenShareDeviceAudioUsesMicrophone(),
};
}
@@ -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
);
+31 -1
View File
@@ -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();
@@ -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}');
});
});
+1 -14
View File
@@ -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<string> = [repairPaths.authorShortcut];
const shortcutPaths: Array<string> = [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) {
@@ -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)
+40
View File
@@ -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">> => []},