mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(app): correct shortcuts, nagbar, stream menu, share audio (#2599)
This commit is contained in:
@@ -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
|
||||
|
||||
+50
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+34
-3
@@ -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,8 +213,9 @@ 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> = [
|
||||
const items: Array<MenuItemType | MenuCheckboxType> = [];
|
||||
if (source.state.kind === 'own') {
|
||||
items.push(
|
||||
{
|
||||
label: i18n._(SHOW_MY_SCREEN_SHARE_DESCRIPTOR),
|
||||
checked: showMyOwnScreenShare,
|
||||
@@ -223,7 +226,7 @@ function buildStreamMoreOptions(options: VoiceParticipantStreamMenuBuilderOption
|
||||
checked: pauseOwnScreenSharePreviewOnUnfocus,
|
||||
onChange: (checked: boolean) => VoiceSettingsCommands.update({pauseOwnScreenSharePreviewOnUnfocus: checked}),
|
||||
},
|
||||
];
|
||||
);
|
||||
if (guildId === undefined) {
|
||||
items.push({
|
||||
label: i18n._(SCREEN_SHARE_PRIVACY_DESCRIPTOR),
|
||||
@@ -233,6 +236,16 @@ function buildStreamMoreOptions(options: VoiceParticipantStreamMenuBuilderOption
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
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.',
|
||||
});
|
||||
|
||||
-55
@@ -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,16 +1215,11 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isFocusPresentationTile &&
|
||||
!isFocusedPlaceholderTile &&
|
||||
(showStreamAudioControls ||
|
||||
(isScreenShare && viewerUsers.length > 0) ||
|
||||
(isGridTile && groupHiddenCount > 0) ||
|
||||
showDeviceCollapseControl) && (
|
||||
{showTileControlPill && (
|
||||
<div
|
||||
className={clsx(
|
||||
voiceCallStyles.tileControlPill,
|
||||
isScreenShare && viewerUsers.length > 0 && voiceCallStyles.tileControlPillPersistent,
|
||||
showTileSpectatorPill && voiceCallStyles.tileControlPillPersistent,
|
||||
)}
|
||||
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-control-pill"
|
||||
>
|
||||
@@ -1224,6 +1239,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
||||
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>
|
||||
@@ -1316,7 +1332,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isScreenShare && viewerUsers.length > 0 && (
|
||||
{showTileSpectatorPill && (
|
||||
<StreamSpectatorsPopout
|
||||
viewerUsers={viewerUsers}
|
||||
spectatorEntries={spectatorEntries}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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}');
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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">> => []},
|
||||
|
||||
Reference in New Issue
Block a user