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 {modal} from '@app/features/ui/commands/ModalCommands';
|
||||||
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
|
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
|
||||||
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
|
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 {MockAvatar} from '@app/features/ui/components/MockAvatar';
|
||||||
import {StatusIndicator} from '@app/features/ui/components/StatusIndicator';
|
import {StatusIndicator} from '@app/features/ui/components/StatusIndicator';
|
||||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
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 NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||||
import * as ProfileDisplayUtils from '@app/features/user/utils/ProfileDisplayUtils';
|
import * as ProfileDisplayUtils from '@app/features/user/utils/ProfileDisplayUtils';
|
||||||
import {createMockProfile} from '@app/features/user/utils/ProfileUtils';
|
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 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 {MEDIA_PROXY_PROFILE_BANNER_SIZE_POPOUT} from '@fluxer/constants/src/MediaProxyAssetSizes';
|
||||||
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
|
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
|
||||||
import type {MessageDescriptor} from '@lingui/core';
|
import type {MessageDescriptor} from '@lingui/core';
|
||||||
@@ -462,12 +460,7 @@ export const UserAreaPopout = observer(() => {
|
|||||||
});
|
});
|
||||||
}, [i18n]);
|
}, [i18n]);
|
||||||
const handleCopyStats = useCallback(() => {
|
const handleCopyStats = useCallback(() => {
|
||||||
const data = collectStatsForNerdsSnapshot();
|
void copyVoiceDiagnostics(i18n);
|
||||||
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)});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [i18n]);
|
}, [i18n]);
|
||||||
const handleCopyUserTag = useCallback(() => {
|
const handleCopyUserTag = useCallback(() => {
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
|
|||||||
@@ -1277,6 +1277,7 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
|
|||||||
[NagbarType.PREMIUM_ONBOARDING]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
[NagbarType.PREMIUM_ONBOARDING]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||||
[NagbarType.GIFT_INVENTORY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
[NagbarType.GIFT_INVENTORY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||||
[NagbarType.DESKTOP_DOWNLOAD]: {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.GUILD_MEMBERSHIP_CTA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||||
[NagbarType.VISIONARY_MFA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
[NagbarType.VISIONARY_MFA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||||
[NagbarType.VOICE_SESSION_RESTORE]: {tone: SkeletonNagbarTone.VOICE, 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 {isClientReconnecting} from '@app/features/app/state/ClientReadiness';
|
||||||
import Initialization from '@app/features/app/state/Initialization';
|
import Initialization from '@app/features/app/state/Initialization';
|
||||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
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 Authentication from '@app/features/auth/state/Authentication';
|
||||||
import Channels from '@app/features/channel/state/Channels';
|
import Channels from '@app/features/channel/state/Channels';
|
||||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||||
@@ -230,6 +231,7 @@ export const useNagbarConditions = (): NagbarConditions => {
|
|||||||
const canShowLinuxInputAccess = NativePermission.shouldShowLinuxInputAccessNagbar;
|
const canShowLinuxInputAccess = NativePermission.shouldShowLinuxInputAccessNagbar;
|
||||||
const canShowSoftwareEncoder = SoftwareEncoderWarning.showWarning;
|
const canShowSoftwareEncoder = SoftwareEncoderWarning.showWarning;
|
||||||
const canShowStreamerMode = StreamerMode.shouldShowNagbar;
|
const canShowStreamerMode = StreamerMode.shouldShowNagbar;
|
||||||
|
const canShowDesktopUpdateReady = Updater.shouldShowUpdateReadyNagbar;
|
||||||
const canShowBuildEnvironment =
|
const canShowBuildEnvironment =
|
||||||
!BUILD_ENVIRONMENT_HIDDEN_RELEASE_CHANNELS.has(Config.PUBLIC_RELEASE_CHANNEL) &&
|
!BUILD_ENVIRONMENT_HIDDEN_RELEASE_CHANNELS.has(Config.PUBLIC_RELEASE_CHANNEL) &&
|
||||||
!nagbarState.buildEnvironmentDismissedThisSession;
|
!nagbarState.buildEnvironmentDismissedThisSession;
|
||||||
@@ -288,6 +290,7 @@ export const useNagbarConditions = (): NagbarConditions => {
|
|||||||
canShowLinuxInputAccess,
|
canShowLinuxInputAccess,
|
||||||
canShowSoftwareEncoder,
|
canShowSoftwareEncoder,
|
||||||
canShowStreamerMode,
|
canShowStreamerMode,
|
||||||
|
canShowDesktopUpdateReady,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarState> => {
|
export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarState> => {
|
||||||
@@ -407,6 +410,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
|||||||
visible: conditions.canShowStreamerMode,
|
visible: conditions.canShowStreamerMode,
|
||||||
dismissible: true,
|
dismissible: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: NagbarType.DESKTOP_UPDATE_READY,
|
||||||
|
priority: -1.5,
|
||||||
|
visible: conditions.canShowDesktopUpdateReady,
|
||||||
|
dismissible: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
return selectVisibleNagbars(nagbars);
|
return selectVisibleNagbars(nagbars);
|
||||||
}, [conditions]);
|
}, [conditions]);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const NagbarType = {
|
|||||||
PREMIUM_ONBOARDING: 'premium-onboarding',
|
PREMIUM_ONBOARDING: 'premium-onboarding',
|
||||||
GIFT_INVENTORY: 'gift-inventory',
|
GIFT_INVENTORY: 'gift-inventory',
|
||||||
DESKTOP_DOWNLOAD: 'desktop-download',
|
DESKTOP_DOWNLOAD: 'desktop-download',
|
||||||
|
DESKTOP_UPDATE_READY: 'desktop-update-ready',
|
||||||
GUILD_MEMBERSHIP_CTA: 'guild-membership-cta',
|
GUILD_MEMBERSHIP_CTA: 'guild-membership-cta',
|
||||||
VISIONARY_MFA: 'visionary-mfa',
|
VISIONARY_MFA: 'visionary-mfa',
|
||||||
VOICE_SESSION_RESTORE: 'voice-session-restore',
|
VOICE_SESSION_RESTORE: 'voice-session-restore',
|
||||||
@@ -50,6 +51,7 @@ export interface NagbarConditions {
|
|||||||
canShowPremiumOnboarding: boolean;
|
canShowPremiumOnboarding: boolean;
|
||||||
canShowGiftInventory: boolean;
|
canShowGiftInventory: boolean;
|
||||||
canShowDesktopDownload: boolean;
|
canShowDesktopDownload: boolean;
|
||||||
|
canShowDesktopUpdateReady: boolean;
|
||||||
canShowGuildMembershipCta: boolean;
|
canShowGuildMembershipCta: boolean;
|
||||||
canShowVisionaryMfa: boolean;
|
canShowVisionaryMfa: boolean;
|
||||||
canShowVoiceSessionRestore: 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 {CorruptedInstallationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/CorruptedInstallationNagbar';
|
||||||
import {DesktopDownloadNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopDownloadNagbar';
|
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 {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 {EmailVerificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/EmailVerificationNagbar';
|
||||||
import {GiftInventoryNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GiftInventoryNagbar';
|
import {GiftInventoryNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GiftInventoryNagbar';
|
||||||
import {GuildMembershipCtaNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GuildMembershipCtaNagbar';
|
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"
|
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:
|
case NagbarType.GUILD_MEMBERSHIP_CTA:
|
||||||
return (
|
return (
|
||||||
<GuildMembershipCtaNagbar
|
<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 backgroundCheckInterval: number | null = null;
|
||||||
private backgroundCheckCleanups: Array<() => void> = [];
|
private backgroundCheckCleanups: Array<() => void> = [];
|
||||||
private unsubscribeNativeEvents: (() => void) | null = null;
|
private unsubscribeNativeEvents: (() => void) | null = null;
|
||||||
|
private updateReadyNagbarDismissedVersion: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this, {}, {autoBind: true});
|
makeAutoObservable(this, {}, {autoBind: true});
|
||||||
@@ -193,6 +194,18 @@ class Updater {
|
|||||||
return this.updateInfo.native.available && this.updateInfo.native.downloaded;
|
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 {
|
get nativeDownloadInFlight(): boolean {
|
||||||
return this.updateInfo.native.downloading && !this.updateInfo.native.downloaded;
|
return this.updateInfo.native.downloading && !this.updateInfo.native.downloaded;
|
||||||
}
|
}
|
||||||
@@ -348,9 +361,6 @@ class Updater {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
this.transition({type: 'native.downloaded', version: event.version ?? null});
|
this.transition({type: 'native.downloaded', version: event.version ?? null});
|
||||||
if (shouldShowImmediateUserResult) {
|
|
||||||
this.showCurrentUpdateState();
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'progress':
|
case 'progress':
|
||||||
if (!shouldSurfaceNativeDesktopUpdate || !this.nativeDownloadProgressSupported) {
|
if (!shouldSurfaceNativeDesktopUpdate || !this.nativeDownloadProgressSupported) {
|
||||||
@@ -661,6 +671,10 @@ class Updater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dismissUpdateReadyNagbar(): void {
|
||||||
|
this.updateReadyNagbarDismissedVersion = this.updateReadyNagbarVersionKey;
|
||||||
|
}
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.transition({type: 'reset'});
|
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 GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||||
import Guilds from '@app/features/guild/state/Guilds';
|
import Guilds from '@app/features/guild/state/Guilds';
|
||||||
import {GuildMember} from '@app/features/member/models/GuildMember';
|
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 {getHydratedMemberListRangesFromNormalized} from '@app/features/member/utils/MemberListHydration';
|
||||||
import {deriveMemberListIdentity} from '@app/features/member/utils/MemberListIdentity';
|
import {deriveMemberListIdentity} from '@app/features/member/utils/MemberListIdentity';
|
||||||
import {
|
import {
|
||||||
@@ -695,6 +696,7 @@ class MemberSidebar {
|
|||||||
}
|
}
|
||||||
userIdRowCounts.set(userId, (userIdRowCounts.get(userId) ?? 0) + 1);
|
userIdRowCounts.set(userId, (userIdRowCounts.get(userId) ?? 0) + 1);
|
||||||
newMembersByUserId.set(userId, member);
|
newMembersByUserId.set(userId, member);
|
||||||
|
GuildMembers.hydrateIfMissing(guildId, member);
|
||||||
const memberItem = this.convertItem(guildId, row);
|
const memberItem = this.convertItem(guildId, row);
|
||||||
if (memberItem) {
|
if (memberItem) {
|
||||||
newItems.set(rowIndex, 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
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {isNativeMacOS} from '@app/features/ui/utils/NativeUtils';
|
||||||
|
|
||||||
export function isTextInputKeyEvent(event: KeyboardEvent): boolean {
|
export function isTextInputKeyEvent(event: KeyboardEvent): boolean {
|
||||||
const {key, ctrlKey, metaKey} = event;
|
const {key, ctrlKey, metaKey, altKey} = event;
|
||||||
if (!key || key === 'Unidentified') {
|
if (!key || key === 'Unidentified') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -11,6 +13,9 @@ export function isTextInputKeyEvent(event: KeyboardEvent): boolean {
|
|||||||
if (key === 'Dead') {
|
if (key === 'Dead') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (altKey && !isNativeMacOS()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (key.length > 1 && NAMED_KEY_PATTERN.test(key)) {
|
if (key.length > 1 && NAMED_KEY_PATTERN.test(key)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-3
@@ -4,7 +4,7 @@
|
|||||||
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
|
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
|
||||||
import type {VoiceParticipantMenuScreenShareSource} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes';
|
import type {VoiceParticipantMenuScreenShareSource} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes';
|
||||||
import type {I18n} from '@lingui/core';
|
import type {I18n} from '@lingui/core';
|
||||||
import {expect, test, vi} from 'vitest';
|
import {beforeEach, expect, test, vi} from 'vitest';
|
||||||
|
|
||||||
vi.mock('@lingui/core/macro', () => {
|
vi.mock('@lingui/core/macro', () => {
|
||||||
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
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', () => ({
|
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
|
||||||
default: {showMyOwnScreenShare: false, pauseOwnScreenSharePreviewOnUnfocus: false},
|
default: {showMyOwnScreenShare: false, pauseOwnScreenSharePreviewOnUnfocus: false},
|
||||||
}));
|
}));
|
||||||
|
vi.mock('@app/features/voice/commands/VoiceDiagnosticsCommands', () => ({
|
||||||
|
copyVoiceDiagnostics: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
vi.mock('@app/features/voice/commands/VoiceSettingsCommands', () => ({
|
vi.mock('@app/features/voice/commands/VoiceSettingsCommands', () => ({
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -47,6 +50,7 @@ installVoiceMenuTestBootstrap();
|
|||||||
const {buildVoiceParticipantStreamMenu} = await import(
|
const {buildVoiceParticipantStreamMenu} = await import(
|
||||||
'@app/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder'
|
'@app/features/ui/action_menu/items/VoiceParticipantStreamMenuBuilder'
|
||||||
);
|
);
|
||||||
|
const {copyVoiceDiagnostics} = await import('@app/features/voice/commands/VoiceDiagnosticsCommands');
|
||||||
|
|
||||||
const i18n = {
|
const i18n = {
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
@@ -56,6 +60,7 @@ const i18n = {
|
|||||||
interface MenuLeaf {
|
interface MenuLeaf {
|
||||||
label?: string;
|
label?: string;
|
||||||
items?: Array<MenuLeaf>;
|
items?: Array<MenuLeaf>;
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamMenu(source: VoiceParticipantMenuScreenShareSource): Array<{items: Array<MenuLeaf>}> {
|
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},
|
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', () => {
|
test('own stream keeps a More options submenu with the screen-share preferences', () => {
|
||||||
const groups = streamMenu(OWN_STREAM_SOURCE);
|
const groups = streamMenu(OWN_STREAM_SOURCE);
|
||||||
const moreOptions = findLeaf(groups, 'More options');
|
const moreOptions = findLeaf(groups, 'More options');
|
||||||
expect(moreOptions).not.toBeNull();
|
expect(moreOptions).not.toBeNull();
|
||||||
expect(findLeaf(groups, 'Show my screen share')).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();
|
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);
|
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, 'Mute')).not.toBeNull();
|
||||||
expect(findLeaf(groups, 'Stream volume')).not.toBeNull();
|
expect(findLeaf(groups, 'Stream volume')).not.toBeNull();
|
||||||
expect(findLeaf(groups, 'Report Problem')).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);
|
||||||
|
});
|
||||||
|
|||||||
+44
-20
@@ -30,9 +30,11 @@ import type {
|
|||||||
MenuSliderType,
|
MenuSliderType,
|
||||||
MenuSubmenuItemType,
|
MenuSubmenuItemType,
|
||||||
} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
|
} 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 * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
|
||||||
import {changeActiveScreenShare, stopActiveScreenShare} from '@app/features/voice/components/ActiveScreenShareMenu';
|
import {changeActiveScreenShare, stopActiveScreenShare} from '@app/features/voice/components/ActiveScreenShareMenu';
|
||||||
import {openScreenSharePreviewPrivacyModal} from '@app/features/voice/components/modals/ScreenSharePickerModal';
|
import {openScreenSharePreviewPrivacyModal} from '@app/features/voice/components/modals/ScreenSharePickerModal';
|
||||||
|
import {COPY_STATS_JSON_DESCRIPTOR} from '@app/features/voice/components/StatsForNerdsCopyDescriptors';
|
||||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||||
import PopoutWindowManager, {isVoicePopoutSupported} from '@app/features/voice/state/PopoutWindowManager';
|
import PopoutWindowManager, {isVoicePopoutSupported} from '@app/features/voice/state/PopoutWindowManager';
|
||||||
@@ -211,28 +213,39 @@ function buildStreamPopoutAction(options: VoiceParticipantStreamMenuBuilderOptio
|
|||||||
|
|
||||||
function buildStreamMoreOptions(options: VoiceParticipantStreamMenuBuilderOptions): MenuSubmenuItemType | null {
|
function buildStreamMoreOptions(options: VoiceParticipantStreamMenuBuilderOptions): MenuSubmenuItemType | null {
|
||||||
const {i18n, guildId, source, showMyOwnScreenShare, pauseOwnScreenSharePreviewOnUnfocus, onClose} = options;
|
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,
|
label: i18n._(SHOW_MY_SCREEN_SHARE_DESCRIPTOR),
|
||||||
onChange: (checked: boolean) => VoiceSettingsCommands.update({showMyOwnScreenShare: checked}),
|
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();
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
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;
|
if (items.length === 0) return null;
|
||||||
return {
|
return {
|
||||||
label: i18n._(MORE_OPTIONS_DESCRIPTOR),
|
label: i18n._(MORE_OPTIONS_DESCRIPTOR),
|
||||||
@@ -334,6 +347,17 @@ export const VoiceParticipantOwnStreamMenuTail: React.FC<VoiceParticipantOwnStre
|
|||||||
{i18n._(SCREEN_SHARE_PRIVACY_DESCRIPTOR)}
|
{i18n._(SCREEN_SHARE_PRIVACY_DESCRIPTOR)}
|
||||||
</MenuItem>
|
</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>
|
</MenuGroup>
|
||||||
)}
|
)}
|
||||||
data-flx="ui.action-menu.items.voice-participant-stream-menu-builder.more-options-submenu"
|
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',
|
message: 'Desktop update ready',
|
||||||
comment: 'Modal title shown when a desktop app update has finished downloading.',
|
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.',
|
message: 'Desktop version {version} has been downloaded. Restart {productName} to finish installing.',
|
||||||
comment:
|
comment:
|
||||||
'Desktop updater modal body. The version placeholder is the downloaded app version; productName is the app name.',
|
'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.',
|
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.',
|
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}',
|
message: 'Restart {productName}',
|
||||||
comment: 'Button label that restarts the app to apply a desktop update. productName is the app name.',
|
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';
|
screenShareAudioSourceMode: 'none' | 'system' | 'specific';
|
||||||
screenShareAudioIncludeSources: Array<Record<string, string>>;
|
screenShareAudioIncludeSources: Array<Record<string, string>>;
|
||||||
screenShareAudioExcludeSources: Array<Record<string, string>>;
|
screenShareAudioExcludeSources: Array<Record<string, string>>;
|
||||||
|
screenShareDeviceAudioUsesMicrophone: boolean;
|
||||||
openH264Enabled: boolean;
|
openH264Enabled: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
|||||||
const sourceMode = VoiceSettings.getScreenShareAudioSourceMode();
|
const sourceMode = VoiceSettings.getScreenShareAudioSourceMode();
|
||||||
const includeSources = VoiceSettings.getScreenShareAudioIncludeSources();
|
const includeSources = VoiceSettings.getScreenShareAudioIncludeSources();
|
||||||
const excludeSources = VoiceSettings.getScreenShareAudioExcludeSources();
|
const excludeSources = VoiceSettings.getScreenShareAudioExcludeSources();
|
||||||
|
const usesDeviceMicrophone = VoiceSettings.getScreenShareDeviceAudioUsesMicrophone();
|
||||||
const granular = VoiceSettings.getLinuxAudioCaptureGranularSelect();
|
const granular = VoiceSettings.getLinuxAudioCaptureGranularSelect();
|
||||||
const deviceSelect = VoiceSettings.getLinuxAudioCaptureDeviceSelect();
|
const deviceSelect = VoiceSettings.getLinuxAudioCaptureDeviceSelect();
|
||||||
const ignoreVirtual = VoiceSettings.getLinuxAudioCaptureIgnoreVirtual();
|
const ignoreVirtual = VoiceSettings.getLinuxAudioCaptureIgnoreVirtual();
|
||||||
@@ -167,12 +168,13 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
|||||||
onSelectionChange?.('window');
|
onSelectionChange?.('window');
|
||||||
}, [onSelectionChange]);
|
}, [onSelectionChange]);
|
||||||
const handlePickSystem = useCallback(() => {
|
const handlePickSystem = useCallback(() => {
|
||||||
VoiceSettingsCommands.update({
|
VoiceSettingsCommands.update(
|
||||||
screenShareAudioSourceMode: 'system',
|
isDeviceShare
|
||||||
screenShareAudioIncludeSources: [],
|
? {screenShareDeviceAudioUsesMicrophone: true}
|
||||||
});
|
: {screenShareAudioSourceMode: 'system', screenShareAudioIncludeSources: []},
|
||||||
|
);
|
||||||
onSelectionChange?.(widenedScope);
|
onSelectionChange?.(widenedScope);
|
||||||
}, [onSelectionChange, widenedScope]);
|
}, [isDeviceShare, onSelectionChange, widenedScope]);
|
||||||
const handlePickNone = useCallback(() => {
|
const handlePickNone = useCallback(() => {
|
||||||
VoiceSettingsCommands.update({
|
VoiceSettingsCommands.update({
|
||||||
screenShareAudioSourceMode: 'none',
|
screenShareAudioSourceMode: 'none',
|
||||||
@@ -189,10 +191,11 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
|||||||
VoiceSettingsCommands.update({
|
VoiceSettingsCommands.update({
|
||||||
screenShareAudioSourceMode: nextSources.length > 0 ? 'specific' : 'system',
|
screenShareAudioSourceMode: nextSources.length > 0 ? 'specific' : 'system',
|
||||||
screenShareAudioIncludeSources: nextSources,
|
screenShareAudioIncludeSources: nextSources,
|
||||||
|
...(isDeviceShare ? {screenShareDeviceAudioUsesMicrophone: false} : {}),
|
||||||
});
|
});
|
||||||
onSelectionChange?.(widenedScope);
|
onSelectionChange?.(widenedScope);
|
||||||
},
|
},
|
||||||
[includeSources, onSelectionChange, widenedScope],
|
[includeSources, isDeviceShare, onSelectionChange, widenedScope],
|
||||||
);
|
);
|
||||||
const handleToggleExcludeApp = useCallback(
|
const handleToggleExcludeApp = useCallback(
|
||||||
(item: LinuxAudioSourceItem) => {
|
(item: LinuxAudioSourceItem) => {
|
||||||
@@ -217,9 +220,10 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
|||||||
microphoneLabel,
|
microphoneLabel,
|
||||||
displayShareEnvironment,
|
displayShareEnvironment,
|
||||||
windowAudioScope,
|
windowAudioScope,
|
||||||
|
usesDeviceMicrophone,
|
||||||
});
|
});
|
||||||
const showsWideSourceLists = !offersWindowScope || resolvedScope === 'system';
|
const showsWideSourceLists = !offersWindowScope || resolvedScope === 'system';
|
||||||
const wideSourceIsSelected = isDeviceShare ? !routesSelectedSources : sourceMode === 'system';
|
const wideSourceIsSelected = isDeviceShare ? usesDeviceMicrophone || !routesSelectedSources : sourceMode === 'system';
|
||||||
if (!snapshot.available && !snapshot.loading) {
|
if (!snapshot.available && !snapshot.loading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,9 @@
|
|||||||
|
|
||||||
import {msg} from '@lingui/core/macro';
|
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({
|
export const COPY_STATS_JSON_DESCRIPTOR = msg({
|
||||||
message: 'Copy stats JSON',
|
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({
|
export const COPIED_STATS_JSON_DESCRIPTOR = msg({
|
||||||
message: 'Copied stats JSON to clipboard',
|
message: 'Copied stats JSON to clipboard',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||||
import Favorites from '@app/features/messaging/state/Favorites';
|
import Favorites from '@app/features/messaging/state/Favorites';
|
||||||
import {goBackOr} from '@app/features/platform/components/router/NavigationAdapter';
|
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 {BottomSheet} from '@app/features/ui/bottom_sheet/BottomSheet';
|
||||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||||
@@ -636,6 +637,7 @@ const VoiceCallViewInner = observer(
|
|||||||
iconSize={18}
|
iconSize={18}
|
||||||
className={styles.voiceHeaderIconButton}
|
className={styles.voiceHeaderIconButton}
|
||||||
position="below"
|
position="below"
|
||||||
|
ariaLabel={i18n._(STREAM_VOLUME_DESCRIPTOR)}
|
||||||
data-flx="voice.voice-call-view.voice-call-view-inner.hud-stream-volume-control"
|
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
|
// 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 {LongPressable} from '@app/features/app/components/LongPressable';
|
||||||
import Channels from '@app/features/channel/state/Channels';
|
import Channels from '@app/features/channel/state/Channels';
|
||||||
import {WATCH_STREAM_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
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 Permission from '@app/features/permissions/state/Permission';
|
||||||
import {dimColor} from '@app/features/theme/utils/ColorUtils';
|
import {dimColor} from '@app/features/theme/utils/ColorUtils';
|
||||||
import type {VoiceParticipantMenuSource} from '@app/features/ui/action_menu/items/VoiceParticipantMenuTypes';
|
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 {UserContextMenu} from '@app/features/ui/action_menu/UserContextMenu';
|
||||||
import {VoiceParticipantContextMenu} from '@app/features/ui/action_menu/VoiceParticipantContextMenu';
|
import {VoiceParticipantContextMenu} from '@app/features/ui/action_menu/VoiceParticipantContextMenu';
|
||||||
import {Button} from '@app/features/ui/button/Button';
|
import {Button} from '@app/features/ui/button/Button';
|
||||||
@@ -57,6 +59,7 @@ import {
|
|||||||
selectVoiceParticipantTileCameraActive,
|
selectVoiceParticipantTileCameraActive,
|
||||||
selectVoiceParticipantTileScreenShareState,
|
selectVoiceParticipantTileScreenShareState,
|
||||||
shouldShowCameraBuffering,
|
shouldShowCameraBuffering,
|
||||||
|
shouldShowTileStreamAudioControls,
|
||||||
type VoiceParticipantTileScreenShareSignals,
|
type VoiceParticipantTileScreenShareSignals,
|
||||||
} from '@app/features/voice/components/VoiceParticipantTileStateMachine';
|
} from '@app/features/voice/components/VoiceParticipantTileStateMachine';
|
||||||
import {useVoiceTileGroup} from '@app/features/voice/components/VoiceTileGroupContext';
|
import {useVoiceTileGroup} from '@app/features/voice/components/VoiceTileGroupContext';
|
||||||
@@ -296,6 +299,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
|||||||
: getVoiceDeafenedStatusLabel(i18n, isCurrentUser);
|
: getVoiceDeafenedStatusLabel(i18n, isCurrentUser);
|
||||||
const deafenStatusClassName = isModeratorDeafened ? styles.participantIconRed : styles.participantIconMuted;
|
const deafenStatusClassName = isModeratorDeafened ? styles.participantIconRed : styles.participantIconMuted;
|
||||||
const isActuallySpeaking = displayState.speaking;
|
const isActuallySpeaking = displayState.speaking;
|
||||||
|
const shouldAnimateTileAvatar = isActuallySpeaking && !Accessibility.useReducedMotion;
|
||||||
const isMobileExperience = isMobileExperienceEnabled();
|
const isMobileExperience = isMobileExperienceEnabled();
|
||||||
const [bottomSheetOpen, setBottomSheetOpen] = useState(false);
|
const [bottomSheetOpen, setBottomSheetOpen] = useState(false);
|
||||||
const tileGroup = useVoiceTileGroup();
|
const tileGroup = useVoiceTileGroup();
|
||||||
@@ -632,7 +636,21 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
|||||||
const participantDisplayName =
|
const participantDisplayName =
|
||||||
(participantUser ? NicknameUtils.getNickname(participantUser, guildId, channelId) : participant.name) ||
|
(participantUser ? NicknameUtils.getNickname(participantUser, guildId, channelId) : participant.name) ||
|
||||||
i18n._(UNKNOWN_USER_DESCRIPTOR);
|
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 viewerStreamCount = graphViewerStreamKeys.length;
|
||||||
const addStreamTooltipText = plural(
|
const addStreamTooltipText = plural(
|
||||||
{count: viewerStreamCount},
|
{count: viewerStreamCount},
|
||||||
@@ -940,6 +958,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
|||||||
user={participantUser}
|
user={participantUser}
|
||||||
size={TILE_AVATAR_BASE}
|
size={TILE_AVATAR_BASE}
|
||||||
mediaSize={TILE_AVATAR_MEDIA_SIZE}
|
mediaSize={TILE_AVATAR_MEDIA_SIZE}
|
||||||
|
forceAnimate={shouldAnimateTileAvatar}
|
||||||
className={styles.avatarFlexShrink}
|
className={styles.avatarFlexShrink}
|
||||||
style={TILE_AVATAR_STYLE}
|
style={TILE_AVATAR_STYLE}
|
||||||
guildId={guildId}
|
guildId={guildId}
|
||||||
@@ -962,6 +981,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
|||||||
placeholderStyle,
|
placeholderStyle,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
screenSharePlaceholderStyle,
|
screenSharePlaceholderStyle,
|
||||||
|
shouldAnimateTileAvatar,
|
||||||
trackRef,
|
trackRef,
|
||||||
shouldHideOwnScreenShareVideo,
|
shouldHideOwnScreenShareVideo,
|
||||||
]);
|
]);
|
||||||
@@ -1195,154 +1215,150 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isFocusPresentationTile &&
|
{showTileControlPill && (
|
||||||
!isFocusedPlaceholderTile &&
|
<div
|
||||||
(showStreamAudioControls ||
|
className={clsx(
|
||||||
(isScreenShare && viewerUsers.length > 0) ||
|
voiceCallStyles.tileControlPill,
|
||||||
(isGridTile && groupHiddenCount > 0) ||
|
showTileSpectatorPill && voiceCallStyles.tileControlPillPersistent,
|
||||||
showDeviceCollapseControl) && (
|
)}
|
||||||
<div
|
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-control-pill"
|
||||||
className={clsx(
|
>
|
||||||
voiceCallStyles.tileControlPill,
|
{showStreamAudioControls && (
|
||||||
isScreenShare && viewerUsers.length > 0 && voiceCallStyles.tileControlPillPersistent,
|
<div
|
||||||
)}
|
className={clsx(voiceCallStyles.tileControlPillSlot, isStreamMuted && styles.streamAudioSlotMuted)}
|
||||||
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-control-pill"
|
role="group"
|
||||||
>
|
onClick={(e) => e.stopPropagation()}
|
||||||
{showStreamAudioControls && (
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
<div
|
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-audio-volume"
|
||||||
className={clsx(voiceCallStyles.tileControlPillSlot, isStreamMuted && styles.streamAudioSlotMuted)}
|
>
|
||||||
role="group"
|
<MediaVerticalVolumeControl
|
||||||
onClick={(e) => e.stopPropagation()}
|
volume={streamVolume / 100}
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
isMuted={isStreamMuted}
|
||||||
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-audio-volume"
|
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
|
<div
|
||||||
volume={streamVolume / 100}
|
role="button"
|
||||||
isMuted={isStreamMuted}
|
tabIndex={0}
|
||||||
maxVolume={VOICE_VOLUME_MAX_SLIDER_VOLUME}
|
className={clsx(voiceCallStyles.tileControlPillSlot, styles.groupExpandPillSlot)}
|
||||||
onVolumeChange={handleStreamVolumeChange}
|
onClick={handleExpandGroup}
|
||||||
onToggleMute={handleStreamAudioToggle}
|
onKeyDown={(event) => {
|
||||||
iconSize={14}
|
if (!isKeyboardActivationKey(event.key)) return;
|
||||||
position="below"
|
event.preventDefault();
|
||||||
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-audio-volume-control"
|
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>
|
</div>
|
||||||
)}
|
</Tooltip>
|
||||||
{showDeviceCollapseControl && (
|
)}
|
||||||
<Tooltip
|
{showTileSpectatorPill && (
|
||||||
text={groupCollapseTooltip}
|
<StreamSpectatorsPopout
|
||||||
position="top"
|
viewerUsers={viewerUsers}
|
||||||
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-tooltip"
|
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
|
<EyeIcon
|
||||||
offset={-2}
|
weight="fill"
|
||||||
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.group-collapse-focus-ring"
|
className={styles.tilePillIcon}
|
||||||
>
|
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-icon"
|
||||||
<div
|
/>
|
||||||
role="button"
|
<span data-flx="voice.voice-participant-tile.voice-participant-tile-inner.viewer-count-text">
|
||||||
tabIndex={0}
|
{viewerUsers.length}
|
||||||
className={clsx(voiceCallStyles.tileControlPillSlot, styles.groupExpandPillSlot)}
|
</span>
|
||||||
onClick={handleExpandGroup}
|
</div>
|
||||||
onKeyDown={(event) => {
|
</StreamSpectatorsPopout>
|
||||||
if (!isKeyboardActivationKey(event.key)) return;
|
)}
|
||||||
event.preventDefault();
|
</div>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
{showParticipantMetadata && (
|
{showParticipantMetadata && (
|
||||||
<div
|
<div
|
||||||
className={voiceCallStyles.lkParticipantMetadata}
|
className={voiceCallStyles.lkParticipantMetadata}
|
||||||
|
|||||||
@@ -30,6 +30,23 @@ describe('VoiceParticipantTile stability', () => {
|
|||||||
expect(css).not.toMatch(/transform:\s*scale/);
|
expect(css).not.toMatch(/transform:\s*scale/);
|
||||||
expect(css).not.toContain('will-change: transform');
|
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', () => {
|
it('keeps the fullscreen call surface mounted while the media room catches up to a channel switch', () => {
|
||||||
const voiceCallViewSource = sourceFile('VoiceCallView.tsx');
|
const voiceCallViewSource = sourceFile('VoiceCallView.tsx');
|
||||||
const guildChannelViewSource = appSourceFile('features/channel/components/channel_view/GuildChannelView.tsx');
|
const guildChannelViewSource = appSourceFile('features/channel/components/channel_view/GuildChannelView.tsx');
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import {
|
|||||||
selectVoiceParticipantTileCameraActive,
|
selectVoiceParticipantTileCameraActive,
|
||||||
selectVoiceParticipantTileScreenShareState,
|
selectVoiceParticipantTileScreenShareState,
|
||||||
shouldShowCameraBuffering,
|
shouldShowCameraBuffering,
|
||||||
|
shouldShowTileStreamAudioControls,
|
||||||
shouldShowWatchFailed,
|
shouldShowWatchFailed,
|
||||||
type VoiceParticipantTileCameraActiveSignals,
|
type VoiceParticipantTileCameraActiveSignals,
|
||||||
type VoiceParticipantTileCameraBufferingSignals,
|
type VoiceParticipantTileCameraBufferingSignals,
|
||||||
type VoiceParticipantTileScreenShareSignals,
|
type VoiceParticipantTileScreenShareSignals,
|
||||||
|
type VoiceParticipantTileStreamAudioSignals,
|
||||||
} from '@app/features/voice/components/VoiceParticipantTileStateMachine';
|
} from '@app/features/voice/components/VoiceParticipantTileStateMachine';
|
||||||
import {
|
import {
|
||||||
getAppliedScreenShareFrameRate,
|
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', () => {
|
describe('VoiceParticipantTileStateMachine camera buffering state', () => {
|
||||||
it('shows buffering while an active camera publication has no video', () => {
|
it('shows buffering while an active camera publication has no video', () => {
|
||||||
expect(shouldShowCameraBuffering(cameraSignals())).toBe(true);
|
expect(shouldShowCameraBuffering(cameraSignals())).toBe(true);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// 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 type {VoiceMediaGraphStreamTileState} from '@app/features/voice/engine/VoiceMediaGraphTileState';
|
||||||
import {assign, getInitialSnapshot, setup, transition} from 'xstate';
|
import {assign, getInitialSnapshot, setup, transition} from 'xstate';
|
||||||
|
|
||||||
@@ -31,6 +32,15 @@ export interface VoiceParticipantTileCameraBufferingSignals {
|
|||||||
hasRenderedVideoFrame: boolean;
|
hasRenderedVideoFrame: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VoiceParticipantTileStreamAudioSignals {
|
||||||
|
isScreenShare: boolean;
|
||||||
|
isOwnScreenShare: boolean;
|
||||||
|
isWatching: boolean;
|
||||||
|
hasScreenShareAudio: boolean;
|
||||||
|
isFocusedPlaceholderTile: boolean;
|
||||||
|
presentation: VoiceParticipantTilePresentation;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VoiceParticipantTileCameraActiveSignals {
|
export interface VoiceParticipantTileCameraActiveSignals {
|
||||||
isCameraTile: boolean;
|
isCameraTile: boolean;
|
||||||
isOwnContent: boolean;
|
isOwnContent: boolean;
|
||||||
@@ -141,6 +151,15 @@ export function shouldShowWatchPrompt(signals: VoiceParticipantTileScreenShareSi
|
|||||||
return !signals.isFocusPresentationTile;
|
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 {
|
export function shouldShowCameraBuffering(signals: VoiceParticipantTileCameraBufferingSignals): boolean {
|
||||||
if (signals.isScreenShare) return false;
|
if (signals.isScreenShare) return false;
|
||||||
if (signals.isFocusedPlaceholderTile) return false;
|
if (signals.isFocusedPlaceholderTile) return false;
|
||||||
|
|||||||
@@ -1492,6 +1492,7 @@ const ScreenSharePickerModalLoadedContent = observer(
|
|||||||
microphoneLabel,
|
microphoneLabel,
|
||||||
displayShareEnvironment,
|
displayShareEnvironment,
|
||||||
windowAudioScope,
|
windowAudioScope,
|
||||||
|
usesDeviceMicrophone: VoiceSettings.getScreenShareDeviceAudioUsesMicrophone(),
|
||||||
})
|
})
|
||||||
: activeTab === 'devices'
|
: activeTab === 'devices'
|
||||||
? i18n._(DEVICE_AUDIO_MUTED_DESCRIPTOR)
|
? i18n._(DEVICE_AUDIO_MUTED_DESCRIPTOR)
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ type VoiceSettingsUpdate = Partial<{
|
|||||||
screenShareAudioSourceMode: 'none' | 'system' | 'specific';
|
screenShareAudioSourceMode: 'none' | 'system' | 'specific';
|
||||||
screenShareAudioIncludeSources: Array<Record<string, string>>;
|
screenShareAudioIncludeSources: Array<Record<string, string>>;
|
||||||
screenShareAudioExcludeSources: Array<Record<string, string>>;
|
screenShareAudioExcludeSources: Array<Record<string, string>>;
|
||||||
|
screenShareDeviceAudioUsesMicrophone: boolean;
|
||||||
openH264Enabled: boolean;
|
openH264Enabled: boolean;
|
||||||
lastScreenShareSource: LastScreenShareSource | null;
|
lastScreenShareSource: LastScreenShareSource | null;
|
||||||
}>;
|
}>;
|
||||||
@@ -434,6 +435,7 @@ class VoiceSettings {
|
|||||||
screenShareAudioSourceMode: 'none' | 'system' | 'specific' = 'system';
|
screenShareAudioSourceMode: 'none' | 'system' | 'specific' = 'system';
|
||||||
screenShareAudioIncludeSources: Array<Record<string, string>> = [];
|
screenShareAudioIncludeSources: Array<Record<string, string>> = [];
|
||||||
screenShareAudioExcludeSources: Array<Record<string, string>> = [];
|
screenShareAudioExcludeSources: Array<Record<string, string>> = [];
|
||||||
|
screenShareDeviceAudioUsesMicrophone = false;
|
||||||
openH264Enabled = true;
|
openH264Enabled = true;
|
||||||
lastScreenShareSource: LastScreenShareSource | null = null;
|
lastScreenShareSource: LastScreenShareSource | null = null;
|
||||||
prioritizeSpeakingParticipants = false;
|
prioritizeSpeakingParticipants = false;
|
||||||
@@ -510,6 +512,7 @@ class VoiceSettings {
|
|||||||
getScreenShareAudioSourceMode: false,
|
getScreenShareAudioSourceMode: false,
|
||||||
getScreenShareAudioIncludeSources: false,
|
getScreenShareAudioIncludeSources: false,
|
||||||
getScreenShareAudioExcludeSources: false,
|
getScreenShareAudioExcludeSources: false,
|
||||||
|
getScreenShareDeviceAudioUsesMicrophone: false,
|
||||||
getOpenH264Enabled: false,
|
getOpenH264Enabled: false,
|
||||||
getLastScreenShareSource: false,
|
getLastScreenShareSource: false,
|
||||||
getPrioritizeSpeakingParticipants: false,
|
getPrioritizeSpeakingParticipants: false,
|
||||||
@@ -623,6 +626,7 @@ class VoiceSettings {
|
|||||||
'screenShareAudioSourceMode',
|
'screenShareAudioSourceMode',
|
||||||
'screenShareAudioIncludeSources',
|
'screenShareAudioIncludeSources',
|
||||||
'screenShareAudioExcludeSources',
|
'screenShareAudioExcludeSources',
|
||||||
|
'screenShareDeviceAudioUsesMicrophone',
|
||||||
'openH264Enabled',
|
'openH264Enabled',
|
||||||
'lastScreenShareSource',
|
'lastScreenShareSource',
|
||||||
'prioritizeSpeakingParticipants',
|
'prioritizeSpeakingParticipants',
|
||||||
@@ -1008,6 +1012,10 @@ class VoiceSettings {
|
|||||||
return this.screenShareAudioExcludeSources;
|
return this.screenShareAudioExcludeSources;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getScreenShareDeviceAudioUsesMicrophone(): boolean {
|
||||||
|
return this.screenShareDeviceAudioUsesMicrophone;
|
||||||
|
}
|
||||||
|
|
||||||
getEffectiveScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' {
|
getEffectiveScreenShareAudioSourceMode(): 'none' | 'system' | 'specific' {
|
||||||
return this.getScreenShareAudioSourceMode();
|
return this.getScreenShareAudioSourceMode();
|
||||||
}
|
}
|
||||||
@@ -1126,6 +1134,8 @@ class VoiceSettings {
|
|||||||
this.screenShareAudioIncludeSources = validated.screenShareAudioIncludeSources;
|
this.screenShareAudioIncludeSources = validated.screenShareAudioIncludeSources;
|
||||||
if (validated.screenShareAudioExcludeSources !== undefined)
|
if (validated.screenShareAudioExcludeSources !== undefined)
|
||||||
this.screenShareAudioExcludeSources = validated.screenShareAudioExcludeSources;
|
this.screenShareAudioExcludeSources = validated.screenShareAudioExcludeSources;
|
||||||
|
if (validated.screenShareDeviceAudioUsesMicrophone !== undefined)
|
||||||
|
this.screenShareDeviceAudioUsesMicrophone = validated.screenShareDeviceAudioUsesMicrophone;
|
||||||
if (validated.openH264Enabled !== undefined) this.openH264Enabled = validated.openH264Enabled;
|
if (validated.openH264Enabled !== undefined) this.openH264Enabled = validated.openH264Enabled;
|
||||||
if (validated.lastScreenShareSource !== undefined) this.lastScreenShareSource = validated.lastScreenShareSource;
|
if (validated.lastScreenShareSource !== undefined) this.lastScreenShareSource = validated.lastScreenShareSource;
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
@@ -1257,6 +1267,8 @@ class VoiceSettings {
|
|||||||
validateSourceList(data.screenShareAudioIncludeSources) ?? this.screenShareAudioIncludeSources,
|
validateSourceList(data.screenShareAudioIncludeSources) ?? this.screenShareAudioIncludeSources,
|
||||||
screenShareAudioExcludeSources:
|
screenShareAudioExcludeSources:
|
||||||
validateSourceList(data.screenShareAudioExcludeSources) ?? this.screenShareAudioExcludeSources,
|
validateSourceList(data.screenShareAudioExcludeSources) ?? this.screenShareAudioExcludeSources,
|
||||||
|
screenShareDeviceAudioUsesMicrophone:
|
||||||
|
data.screenShareDeviceAudioUsesMicrophone ?? this.screenShareDeviceAudioUsesMicrophone,
|
||||||
openH264Enabled: data.openH264Enabled ?? this.openH264Enabled,
|
openH264Enabled: data.openH264Enabled ?? this.openH264Enabled,
|
||||||
lastScreenShareSource:
|
lastScreenShareSource:
|
||||||
data.lastScreenShareSource === undefined
|
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', () => {
|
it('keeps a window share on the shared window whatever the stored display selection says', () => {
|
||||||
for (const sourceMode of ['none', 'system', 'specific'] as const) {
|
for (const sourceMode of ['none', 'system', 'specific'] as const) {
|
||||||
expect(summary({shareContext: 'app', sourceMode, includeSources: [{'application.name': 'mpv'}]})).toEqual({
|
expect(summary({shareContext: 'app', sourceMode, includeSources: [{'application.name': 'mpv'}]})).toEqual({
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface ScreenShareAudioSummaryInput {
|
|||||||
microphoneLabel?: string | null;
|
microphoneLabel?: string | null;
|
||||||
displayShareEnvironment?: DisplayShareEnvironment;
|
displayShareEnvironment?: DisplayShareEnvironment;
|
||||||
windowAudioScope?: WindowShareAudioScope;
|
windowAudioScope?: WindowShareAudioScope;
|
||||||
|
usesDeviceMicrophone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScreenShareAudioSummary =
|
export type ScreenShareAudioSummary =
|
||||||
@@ -83,7 +84,9 @@ export function resolveScreenShareAudioSummary(input: ScreenShareAudioSummaryInp
|
|||||||
const selected = filterRoutableLinuxAudioSources(input.includeSources);
|
const selected = filterRoutableLinuxAudioSources(input.includeSources);
|
||||||
const routesSelectedSources = input.sourceMode === 'specific' && selected.length > 0;
|
const routesSelectedSources = input.sourceMode === 'specific' && selected.length > 0;
|
||||||
if (input.shareContext === 'device') {
|
if (input.shareContext === 'device') {
|
||||||
return routesSelectedSources ? summariseSelectedSources(selected) : summariseMicrophone(input.microphoneLabel);
|
return routesSelectedSources && input.usesDeviceMicrophone !== true
|
||||||
|
? summariseSelectedSources(selected)
|
||||||
|
: summariseMicrophone(input.microphoneLabel);
|
||||||
}
|
}
|
||||||
if (supportsWindowShareAudioScope(input)) {
|
if (supportsWindowShareAudioScope(input)) {
|
||||||
const route = selectAppShareAudioRoute({
|
const route = selectAppShareAudioRoute({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const voiceSettings = {
|
|||||||
includeSources: [] as Array<Record<string, string>>,
|
includeSources: [] as Array<Record<string, string>>,
|
||||||
excludeSources: [] as Array<Record<string, string>>,
|
excludeSources: [] as Array<Record<string, string>>,
|
||||||
audioDeviceId: 'default',
|
audioDeviceId: 'default',
|
||||||
|
deviceUsesMicrophone: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeShareContext = {current: null as 'app' | 'device' | 'display' | null};
|
const activeShareContext = {current: null as 'app' | 'device' | 'display' | null};
|
||||||
@@ -149,6 +150,7 @@ vi.mock('@app/features/voice/state/VoiceSettings', () => ({
|
|||||||
getScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode,
|
getScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode,
|
||||||
getScreenShareAudioIncludeSources: () => voiceSettings.includeSources,
|
getScreenShareAudioIncludeSources: () => voiceSettings.includeSources,
|
||||||
getScreenShareAudioExcludeSources: () => voiceSettings.excludeSources,
|
getScreenShareAudioExcludeSources: () => voiceSettings.excludeSources,
|
||||||
|
getScreenShareDeviceAudioUsesMicrophone: () => voiceSettings.deviceUsesMicrophone,
|
||||||
getEffectiveScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode,
|
getEffectiveScreenShareAudioSourceMode: () => voiceSettings.audioSourceMode,
|
||||||
getEffectiveScreenShareAudioIncludeSources: () => voiceSettings.includeSources,
|
getEffectiveScreenShareAudioIncludeSources: () => voiceSettings.includeSources,
|
||||||
getEffectiveScreenShareAudioExcludeSources: () => voiceSettings.excludeSources,
|
getEffectiveScreenShareAudioExcludeSources: () => voiceSettings.excludeSources,
|
||||||
@@ -178,6 +180,7 @@ beforeEach(() => {
|
|||||||
voiceSettings.includeSources = [];
|
voiceSettings.includeSources = [];
|
||||||
voiceSettings.excludeSources = [];
|
voiceSettings.excludeSources = [];
|
||||||
voiceSettings.audioDeviceId = 'default';
|
voiceSettings.audioDeviceId = 'default';
|
||||||
|
voiceSettings.deviceUsesMicrophone = false;
|
||||||
activeShareContext.current = null;
|
activeShareContext.current = null;
|
||||||
activeShareSourceId.current = null;
|
activeShareSourceId.current = null;
|
||||||
activeShareOwnWindow.current = false;
|
activeShareOwnWindow.current = false;
|
||||||
@@ -362,6 +365,37 @@ describe('sharing a video device', () => {
|
|||||||
expect(ensureDeviceScreenShareMicPublication).toHaveBeenCalledTimes(1);
|
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 () => {
|
test('keeps the paired capture card input when the live share rebinds its microphone', async () => {
|
||||||
platform.current = 'linux';
|
platform.current = 'linux';
|
||||||
activeShareVideoDeviceId.current = 'camera-1';
|
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 () => {
|
test('arms native system audio on Windows and macOS', async () => {
|
||||||
for (const current of ['win32', 'darwin']) {
|
for (const current of ['win32', 'darwin']) {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ async function getManualAudioSourceSelectionInput(shareContext: StreamSettingsSh
|
|||||||
nativeAudioAvailability: platform === 'linux' ? await getNativeAudioAvailabilityCached() : null,
|
nativeAudioAvailability: platform === 'linux' ? await getNativeAudioAvailabilityCached() : null,
|
||||||
audioSourceMode: VoiceSettings.getScreenShareAudioSourceMode(),
|
audioSourceMode: VoiceSettings.getScreenShareAudioSourceMode(),
|
||||||
selectedSourceCount: countRoutableAudioSources(),
|
selectedSourceCount: countRoutableAudioSources(),
|
||||||
|
usesDeviceMicrophone: VoiceSettings.getScreenShareDeviceAudioUsesMicrophone(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface ManualAudioSourceSelectionInput {
|
|||||||
nativeAudioAvailability?: NativeAudioAvailability | null;
|
nativeAudioAvailability?: NativeAudioAvailability | null;
|
||||||
audioSourceMode?: ScreenShareAudioSourceMode;
|
audioSourceMode?: ScreenShareAudioSourceMode;
|
||||||
selectedSourceCount?: number;
|
selectedSourceCount?: number;
|
||||||
|
usesDeviceMicrophone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WindowShareAudioScopeInput {
|
export interface WindowShareAudioScopeInput {
|
||||||
@@ -74,6 +75,7 @@ export function canSelectManualAudioSources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function routesManualAudioSources(input: ManualAudioSourceSelectionInput): boolean {
|
export function routesManualAudioSources(input: ManualAudioSourceSelectionInput): boolean {
|
||||||
|
if (input.shareContext === 'device' && input.usesDeviceMicrophone === true) return false;
|
||||||
return (
|
return (
|
||||||
canSelectManualAudioSources(input) && input.audioSourceMode === 'specific' && (input.selectedSourceCount ?? 0) > 0
|
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);
|
extend_runtime_sources(&mut font, runtime_sources, true, true);
|
||||||
directives.push(format!("font-src {}", font.join(" ")));
|
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_from(&mut connect, &config.extra_connect_src, CONNECT_SOURCES);
|
||||||
extend_runtime_sources(&mut connect, runtime_sources, true, true);
|
extend_runtime_sources(&mut connect, runtime_sources, true, true);
|
||||||
extend_runtime_s3_sources(&mut connect, runtime_sources);
|
extend_runtime_s3_sources(&mut connect, runtime_sources);
|
||||||
@@ -355,6 +355,36 @@ mod tests {
|
|||||||
assert!(csp.contains("frame-ancestors 'none'"));
|
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]
|
#[test]
|
||||||
fn build_csp_includes_nonce_in_script_src() {
|
fn build_csp_includes_nonce_in_script_src() {
|
||||||
let config = default_csp_config();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const {createShortcut} = binding;
|
const {createShortcut} = binding;
|
||||||
try {
|
const shortcutPaths: Array<string> = [repairPaths.authorShortcut, repairPaths.rootShortcut];
|
||||||
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 desktopDir = process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'Desktop') : '';
|
const desktopDir = process.env.USERPROFILE ? path.join(process.env.USERPROFILE, 'Desktop') : '';
|
||||||
if (desktopDir) shortcutPaths.push(path.join(desktopDir, `${DESKTOP_APP_NAME}.lnk`));
|
if (desktopDir) shortcutPaths.push(path.join(desktopDir, `${DESKTOP_APP_NAME}.lnk`));
|
||||||
if (process.env.APPDATA) {
|
if (process.env.APPDATA) {
|
||||||
|
|||||||
@@ -153,7 +153,9 @@ select_first_viewable(Channel, GuildId, BasePerms) ->
|
|||||||
|
|
||||||
-spec check_viewable(integer() | undefined, integer() | undefined, map(), integer(), integer()) ->
|
-spec check_viewable(integer() | undefined, integer() | undefined, map(), integer(), integer()) ->
|
||||||
integer() | null.
|
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
|
case permission_bits:has(BasePerms, constants:administrator_permission()) of
|
||||||
true -> ChannelId;
|
true -> ChannelId;
|
||||||
false -> check_view_permission(ChannelId, Channel, GuildId, BasePerms)
|
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),
|
ChannelId = guild_data:find_everyone_viewable_text_channel(Channels, State),
|
||||||
?assertEqual(12, ChannelId).
|
?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() ->
|
voice_members_from_states_reads_embedded_member_test() ->
|
||||||
EmbeddedMember = #{<<"user">> => #{<<"id">> => <<"300">>}, <<"roles">> => []},
|
EmbeddedMember = #{<<"user">> => #{<<"id">> => <<"300">>}, <<"roles">> => []},
|
||||||
IndexedMember = #{<<"user">> => #{<<"id">> => <<"200">>}, <<"roles">> => []},
|
IndexedMember = #{<<"user">> => #{<<"id">> => <<"200">>}, <<"roles">> => []},
|
||||||
|
|||||||
Reference in New Issue
Block a user