fix(app): smooth the fluxer.com migration and expired re-login (#3019)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-28 18:11:18 +02:00
1 parent a728be4062
commit c9ae5b6ee8
75 files changed
+3284 -2368

No files matched your search

@@ -23,6 +23,7 @@
min-width: 0;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
text-align: start;
background-color: var(--background-secondary);
border: 0.0625rem solid var(--background-modifier-accent);
}
@@ -71,7 +71,7 @@ export const AccountRow = observer(
const variantClassName = variant === 'manage' ? styles.manage : variant === 'compact' ? styles.compact : undefined;
const isClickable = typeof onClick === 'function';
const MainButtonComponent = isClickable ? 'button' : 'div';
const showMenuButton = Boolean(onMenuClick && variant !== 'compact' && !showCaretIndicator);
const showMenuButton = Boolean(onMenuClick && variant !== 'compact');
return (
<div
className={clsx(styles.row, variantClassName, showMenuButton && styles.withMenu, className)}
@@ -32,10 +32,6 @@
text-align: center;
}
.accountListWrapper {
min-height: 7.5rem;
}
.scroller {
max-height: 17.5rem;
}
@@ -18,7 +18,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
import {Scroller} from '@app/features/ui/components/Scroller';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {PlusIcon, SignOutIcon} from '@phosphor-icons/react';
import {PlusIcon, SignInIcon, SignOutIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback} from 'react';
@@ -101,17 +101,20 @@ export const AccountSelector = observer(
(account: Account) => (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (disabled) {
return;
}
ContextMenuCommands.openFromEvent(event, (props) => (
<MenuGroup data-flx="auth.accounts.account-selector.open-menu.menu-group">
<MenuItem
icon={<SignOutIcon size={18} data-flx="auth.accounts.account-selector.open-menu.sign-out-icon" />}
icon={<SignInIcon size={18} data-flx="auth.accounts.account-selector.open-menu.sign-in-icon" />}
onClick={() => {
props.onClose();
onSelectAccount(account);
}}
data-flx="auth.accounts.account-selector.open-menu.menu-item.close"
>
<Trans>Select account</Trans>
{account.isValid === false ? <Trans>Sign in again</Trans> : <Trans>Select account</Trans>}
</MenuItem>
<MenuItem
danger
@@ -127,7 +130,7 @@ export const AccountSelector = observer(
</MenuGroup>
));
},
[openSignOutConfirm, onSelectAccount],
[disabled, openSignOutConfirm, onSelectAccount],
);
return (
<div className={styles.container} data-flx="auth.accounts.account-selector.container">
@@ -142,38 +145,36 @@ export const AccountSelector = observer(
{error}
</div>
)}
<div className={styles.accountListWrapper} data-flx="auth.accounts.account-selector.account-list-wrapper">
{accounts.length === 0 ? (
<div className={styles.noAccounts} data-flx="auth.accounts.account-selector.no-accounts">
<Trans>No accounts</Trans>
{accounts.length === 0 ? (
<div className={styles.noAccounts} data-flx="auth.accounts.account-selector.no-accounts">
<Trans>No accounts</Trans>
</div>
) : (
<Scroller
className={styles.scroller}
key={scrollerKey ?? 'account-selector-scroller'}
data-flx="auth.accounts.account-selector.scroller"
>
<div className={styles.accountList} data-flx="auth.accounts.account-selector.account-list">
{accounts.map((account) => {
const isCurrent = account.userId === currentAccountId;
return (
<AccountRow
key={account.userId}
account={account}
variant="manage"
isCurrent={isCurrent}
isExpired={account.isValid === false}
onClick={clickableRows && !disabled ? () => onSelectAccount(account) : undefined}
showCaretIndicator={clickableRows && isCurrent}
onMenuClick={isCurrent ? undefined : openMenu(account)}
data-flx="auth.accounts.account-selector.account-row.select-account"
/>
);
})}
</div>
) : (
<Scroller
className={styles.scroller}
key={scrollerKey ?? 'account-selector-scroller'}
data-flx="auth.accounts.account-selector.scroller"
>
<div className={styles.accountList} data-flx="auth.accounts.account-selector.account-list">
{accounts.map((account) => {
const isCurrent = account.userId === currentAccountId;
return (
<AccountRow
key={account.userId}
account={account}
variant="manage"
isCurrent={isCurrent}
isExpired={account.isValid === false}
onClick={clickableRows && !disabled ? () => onSelectAccount(account) : undefined}
showCaretIndicator={clickableRows}
onMenuClick={!clickableRows && !disabled ? openMenu(account) : undefined}
data-flx="auth.accounts.account-selector.account-row.select-account"
/>
);
})}
</div>
</Scroller>
)}
</div>
</Scroller>
)}
{onAddAccount && (
<Button
variant="secondary"
@@ -66,6 +66,7 @@ const LoginPageMFA = observer(function LoginPageMFA() {
const handleMfaSuccess = useCallback(
async (payload: LoginSuccessPayload) => {
if (isHandoff) {
await AccountManager.refreshStoredAccount(payload.userId, payload.token, payload.userData);
await handoff.start(payload);
return;
} else {
@@ -50,7 +50,7 @@ import {SteppedCarousel} from '@app/features/ui/stepped_carousel/SteppedCarousel
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import * as FormUtils from '@app/lib/forms';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import clsx from 'clsx';
import {observer} from 'mobx-react-lite';
import {cloneElement, type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useState} from 'react';
@@ -151,14 +151,19 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
const [switchError, setSwitchError] = useState<string | null>(null);
const [prefillEmail, setPrefillEmail] = useState<string | null>(() => initialEmail ?? null);
const ssoRedirectPath = desktopHandoff ? `${location.pathname}${location.search}` : redirectPath;
const showLoginFormForAccount = useCallback((account: Account, message?: string | null) => {
setShowAccountSelector(false);
setSwitchError(message ?? null);
setPrefillEmail(account.userData?.email ?? null);
}, []);
const showExpiredLoginForm = useCallback(
(account: Account) => {
const identifier = account.userData?.email ?? account.userData?.username ?? account.userId;
setShowAccountSelector(false);
setSwitchError(i18n._(SESSION_EXPIRED_SIGN_IN_AGAIN_DESCRIPTOR, {identifier}));
setPrefillEmail(account.userData?.email ?? null);
},
[i18n],
);
const handleLoginSuccess = useCallback(
async (payload: LoginSuccessPayload) => {
if (desktopHandoff) {
await AccountManager.refreshStoredAccount(payload.userId, payload.token, payload.userData);
await handoff.start(payload);
return;
}
@@ -235,20 +240,26 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
}, [form.setValue, prefillEmail]);
const handleSelectExistingAccount = useCallback(
async (account: Account) => {
const identifier = account.userData?.email ?? account.userData?.username ?? account.userId;
const expiredMessage = i18n._(SESSION_EXPIRED_SIGN_IN_AGAIN_DESCRIPTOR, {identifier});
if (account.isValid === false || !AccountManager.canSwitchAccounts) {
showLoginFormForAccount(account, expiredMessage);
if (account.isValid === false) {
showExpiredLoginForm(account);
return;
}
setIsSwitching(true);
setSwitchError(null);
try {
await AccountManager.switchToAccount(account.userId);
if (AccountManager.canSwitchAccounts) {
await AccountManager.switchToAccount(account.userId);
} else {
const {token, userId} = await AccountManager.generateTokenForAccount(account.userId);
await handleLoginSuccess({token, userId, userData: account.userData});
if (redirectPath) {
RouterUtils.replaceWith(redirectPath);
}
}
} catch (error) {
const updatedAccount = AccountManager.accounts.get(account.userId);
if (error instanceof SessionExpiredError || updatedAccount?.isValid === false) {
showLoginFormForAccount(updatedAccount ?? account, expiredMessage);
showExpiredLoginForm(updatedAccount ?? account);
return;
}
setSwitchError(
@@ -260,8 +271,26 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
setIsSwitching(false);
}
},
[i18n, showLoginFormForAccount],
[handleLoginSuccess, i18n, redirectPath, showExpiredLoginForm],
);
const handleHandoffReLogin = useCallback(
(account: Account) => {
showExpiredLoginForm(account);
handoff.switchToLogin();
},
[handoff, showExpiredLoginForm],
);
const canChooseSavedAccount = desktopHandoff ? hasHandoffAccounts : hasStoredAccounts;
const handleChooseSavedAccount = useCallback(() => {
setSwitchError(null);
setPrefillEmail(initialEmail ?? null);
form.setValue('email', initialEmail ?? '');
if (desktopHandoff) {
handoff.setMode('selecting');
} else {
setShowAccountSelector(true);
}
}, [desktopHandoff, form.setValue, handoff, initialEmail]);
const handleAddAnotherAccount = useCallback(() => {
setShowAccountSelector(false);
setSwitchError(null);
@@ -292,13 +321,21 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
});
}, [registerLink]);
const authLoginStep: AuthLoginStep = useMemo(() => {
if (desktopHandoff && handoff.mode === 'selecting') return 'desktop_handoff_account';
if (desktopHandoff && handoff.mode === 'selecting' && hasHandoffAccounts) return 'desktop_handoff_account';
if (desktopHandoff && isApprovalFlowMode(handoff.mode)) return 'desktop_handoff_approval';
if (isSsoEnforced) return 'sso';
if (showAccountSelector && hasStoredAccounts && !desktopHandoff) return 'account';
if (ipAuthChallenge) return 'ip_authorization';
return 'credentials';
}, [desktopHandoff, handoff.mode, hasStoredAccounts, ipAuthChallenge, isSsoEnforced, showAccountSelector]);
}, [
desktopHandoff,
handoff.mode,
hasHandoffAccounts,
hasStoredAccounts,
ipAuthChallenge,
isSsoEnforced,
showAccountSelector,
]);
const hasExtraTopContent = extraTopContent !== undefined && extraTopContent !== null;
const startedFromAccountSelector = hasStoredAccounts && !desktopHandoff && !initialEmail;
const showSplitLogo =
@@ -316,6 +353,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
<DesktopHandoffAccountSelector
excludeCurrentUser={excludeCurrentUser}
onSelectNewAccount={handoff.switchToLogin}
onReLoginAccount={handleHandoffReLogin}
onAccountSelected={handoff.start}
data-flx="auth.flow.auth-login-layout.desktop-handoff-account-selector"
/>
@@ -443,6 +481,18 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
data-flx="auth.flow.auth-login-layout.auth-login-passkey-actions"
/>
<div className={styles.footer} data-flx="auth.flow.auth-login-layout.footer">
{canChooseSavedAccount ? (
<div className={styles.footerText} data-flx="auth.flow.auth-login-layout.footer-text--choose-account">
<button
type="button"
className={styles.footerLink}
onClick={handleChooseSavedAccount}
data-flx="auth.flow.auth-login-layout.button.choose-saved-account"
>
<Trans>Choose an account</Trans>
</button>
</div>
) : null}
<div className={styles.footerText} data-flx="auth.flow.auth-login-layout.footer-text">
<span className={styles.footerLabel} data-flx="auth.flow.auth-login-layout.footer-label">
{i18n._(NEED_ACCOUNT_DESCRIPTOR)}{' '}
@@ -9,11 +9,6 @@ import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useState} from 'react';
const SESSION_EXPIRED_PLEASE_SIGN_IN_AGAIN_DESCRIPTOR = msg({
message: 'Session expired. Sign in again.',
comment:
'Toast error shown in the desktop-handoff account picker when the session for the chosen account has expired.',
});
const FAILED_TO_GENERATE_TOKEN_DESCRIPTOR = msg({
message: 'Failed to generate token',
comment: 'Short label in the authentication desktop handoff account selector. Keep the tone plain and specific.',
@@ -22,12 +17,14 @@ const FAILED_TO_GENERATE_TOKEN_DESCRIPTOR = msg({
interface DesktopHandoffAccountSelectorProps {
excludeCurrentUser?: boolean;
onSelectNewAccount: () => void;
onReLoginAccount: (account: Account) => void;
onAccountSelected: (payload: {token: string; userId: string}) => void;
}
const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSelector({
excludeCurrentUser = false,
onSelectNewAccount,
onReLoginAccount,
onAccountSelected,
}: DesktopHandoffAccountSelectorProps) {
const {i18n} = useLingui();
@@ -38,6 +35,10 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
const accounts = excludeCurrentUser ? allAccounts.filter((account) => account.userId !== currentUserId) : allAccounts;
const handleSelectAccount = useCallback(
async (account: Account) => {
if (account.isValid === false) {
onReLoginAccount(account);
return;
}
setIsLoading(true);
setError(null);
try {
@@ -48,7 +49,7 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
onAccountSelected({token, userId});
} catch (err) {
if (err instanceof SessionExpiredError) {
setError(i18n._(SESSION_EXPIRED_PLEASE_SIGN_IN_AGAIN_DESCRIPTOR));
onReLoginAccount(AccountManager.accounts.get(account.userId) ?? account);
} else {
setError(
err && typeof err === 'object' && 'body' in err
@@ -60,11 +61,12 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
setIsLoading(false);
}
},
[onAccountSelected, i18n],
[onAccountSelected, onReLoginAccount, i18n],
);
return (
<AccountSelector
accounts={accounts}
currentAccountId={currentUserId}
title={<Trans>Choose an account</Trans>}
description={<Trans>Select the account you want to sign in with on your new device.</Trans>}
disabled={isLoading}
@@ -2,11 +2,12 @@
import {Routes} from '@app/app/Routes';
import type {UserData} from '@app/features/auth/state/AccountStorage';
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import * as NotificationUtils from '@app/features/notification/utils/NotificationUtils';
import * as PushSubscriptionService from '@app/features/platform/push/PushSubscriptionService';
import SessionManager, {type Account, SessionExpiredError} from '@app/features/platform/state/AuthSession';
import SessionManager, {type Account} from '@app/features/platform/state/AuthSession';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {isInstalledPwa} from '@app/features/ui/utils/PwaUtils';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
@@ -15,6 +16,8 @@ import {computed, makeAutoObservable} from 'mobx';
const logger = new Logger('AccountManager');
class AccountManager {
transitioning = false;
constructor() {
makeAutoObservable(
this,
@@ -82,16 +85,27 @@ class AccountManager {
token: string;
userId: string;
}> {
await SessionManager.initialize();
const account = SessionManager.requireAccountOnCurrentInstance(userId);
const ok = await SessionManager.validateToken(account.token);
if (!ok) {
SessionManager.markAccountInvalid(userId);
throw new SessionExpiredError();
}
const account = await SessionManager.requireUsableAccount(userId);
return {token: account.token, userId};
}
async refreshStoredAccount(userId: string, token: string, userData?: UserData): Promise<void> {
await SessionManager.refreshStoredAccount(userId, token, userData);
}
private setTransitioning(value: boolean): void {
this.transitioning = value;
}
private async runTransition(run: () => Promise<void>): Promise<void> {
this.setTransitioning(true);
try {
await run();
} finally {
this.setTransitioning(false);
}
}
private async leaveActiveVoiceChannel(context: 'account switch' | 'logout'): Promise<void> {
try {
await MediaEngine.disconnectFromVoiceChannel('user');
@@ -108,11 +122,14 @@ class AccountManager {
if (!SessionManager.canSwitchAccount()) {
throw new Error(`Cannot switch from state: ${SessionManager.state}`);
}
await SessionManager.requireUsableAccount(userId);
if (this.shouldManagePushSubscriptions()) {
await PushSubscriptionService.unregisterAllPushSubscriptions();
}
await this.leaveActiveVoiceChannel('account switch');
await SessionManager.switchAccount(userId);
await this.runTransition(async () => {
await this.leaveActiveVoiceChannel('account switch');
await SessionManager.switchAccount(userId);
});
GatewayConnection.startSession(SessionManager.token ?? undefined);
if (redirectPath !== null) {
RouterUtils.replaceWith(redirectPath);
@@ -135,8 +152,10 @@ class AccountManager {
if (SessionManager.userId && SessionManager.userId !== userId) {
SessionManager.prepareForAccountTransition('account-switch');
}
await this.leaveActiveVoiceChannel('account switch');
await SessionManager.login(token, userId, userData);
await this.runTransition(async () => {
await this.leaveActiveVoiceChannel('account switch');
await SessionManager.login(token, userId, userData);
});
GatewayConnection.startSession(token);
if (redirectPath !== null) {
RouterUtils.replaceWith(redirectPath);
@@ -159,8 +178,11 @@ class AccountManager {
}
async logout(): Promise<void> {
await this.leaveActiveVoiceChannel('logout');
await SessionManager.logout();
await this.runTransition(async () => {
await this.leaveActiveVoiceChannel('logout');
await SessionManager.logout();
});
ExperimentAssignments.reset();
RouterUtils.replaceWith('/login');
}
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isDomainMigrationStorageKey} from '@app/features/app/domain_migration/DomainMigrationCore';
import {
normalizeAppPublicConfig,
normalizeInstanceRegistration,
@@ -69,7 +70,7 @@ const MANAGED_KEY_PREFIXES: ReadonlyArray<string> = ['mobx', 'mobx-persist', 'pe
const MANAGED_KEY_PREFIX_PATTERN = new RegExp(`^(?:${MANAGED_KEY_PREFIXES.join('|')})`);
function isManagedKey(key: string): boolean {
if (!key) {
if (!key || isDomainMigrationStorageKey(key)) {
return false;
}
return MANAGED_KEY_EXACT.has(key) || MANAGED_KEY_PREFIX_PATTERN.test(key);
@@ -196,6 +197,9 @@ class AccountStorage {
}
const keysToRemove = collectManagedKeys(browserLocalStorage).filter((key) => snapshot[key] === undefined);
for (const [key, value] of Object.entries(snapshot)) {
if (isDomainMigrationStorageKey(key)) {
continue;
}
try {
browserLocalStorage.setItem(key, value);
} catch (err) {
@@ -500,14 +504,30 @@ class AccountStorage {
}
}
async updateAccountValidity(userId: string, isValid: boolean): Promise<void> {
async refreshAccountCredentials(userId: string, token: string, userData?: UserData): Promise<void> {
await this.ensureDb();
if (!userId || !token) {
return;
}
try {
const record = await this.getRecord(userId);
if (!record) {
return;
}
await this.putRecord({...record, token, userData: userData ?? record.userData, isValid: true});
} catch (err) {
logger.error(`Failed to refresh credentials for account ${userId}`, err);
}
}
async updateAccountValidity(userId: string, isValid: boolean, expectedToken?: string): Promise<void> {
await this.ensureDb();
if (!userId) {
return;
}
try {
const record = await this.getRecord(userId);
if (!record) {
if (!record || (expectedToken !== undefined && record.token !== expectedToken)) {
return;
}
await this.putRecord({...record, isValid});
@@ -2,9 +2,12 @@
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import type {DomainMigrationMediaDeviceKind} from '@app/features/app/domain_migration/DomainMigrationCore';
import {remapMigratedDeviceIds} from '@app/features/app/domain_migration/DomainMigrationDeviceRemap';
import styles from '@app/features/auth/state/NewDeviceMonitoring.module.css';
import {
getNewDevicePromptCandidates,
getRealDeviceIds,
type PendingDevicePrompt,
} from '@app/features/auth/state/NewDeviceMonitoringDevices';
import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -32,6 +35,9 @@ const NOT_NOW_DESCRIPTOR = msg({
message: 'Not now',
comment: 'Short label in the authentication new device monitoring. Keep the tone plain and specific.',
});
const NEW_DEVICE_MODAL_KEY = 'new-audio-device';
const MAX_PROMPTABLE_NEW_DEVICE_GROUPS = 2;
const MIGRATED_AUDIO_DEVICE_KINDS: ReadonlyArray<DomainMigrationMediaDeviceKind> = ['audioinput', 'audiooutput'];
const logger = new Logger('NewDeviceMonitoring');
interface IgnoreDeviceLinkProps {
@@ -60,8 +66,6 @@ class NewDeviceMonitoring {
private isStarted = false;
private startPromise: Promise<void> | null = null;
private startEpoch = 0;
private pendingPrompts: Array<PendingDevicePrompt> = [];
private isShowingPrompt = false;
private unsubscribe: (() => void) | null = null;
private i18n: I18n | null = null;
@@ -91,7 +95,9 @@ class NewDeviceMonitoring {
this.isStarted = true;
const epoch = ++this.startEpoch;
this.startPromise = (async () => {
await makePersistent(this, 'NewDeviceMonitoring', ['knownDeviceIds', 'ignoredDeviceIds', 'suppressAlerts']);
await makePersistent(this, 'NewDeviceMonitoring', ['knownDeviceIds', 'ignoredDeviceIds', 'suppressAlerts'], {
syncAcrossTabs: true,
});
if (!this.isStarted || epoch !== this.startEpoch) return;
await this.refreshDeviceSnapshot();
if (!this.isStarted || epoch !== this.startEpoch) return;
@@ -102,6 +108,12 @@ class NewDeviceMonitoring {
private handleDeviceStateChange(state: VoiceDeviceState): void {
if (!this.isStarted) return;
const ignoredDeviceIds = remapMigratedDeviceIds(this.ignoredDeviceIds, MIGRATED_AUDIO_DEVICE_KINDS);
if (ignoredDeviceIds !== this.ignoredDeviceIds) {
runInAction(() => {
this.ignoredDeviceIds = ignoredDeviceIds;
});
}
if (state.permissionStatus.audio !== 'granted') {
return;
}
@@ -112,27 +124,34 @@ class NewDeviceMonitoring {
const currentOutputIds = state.outputDevices.map((d) => d.deviceId);
const allCurrentIds = [...currentInputIds, ...currentOutputIds];
if (!this.isInitialized) {
const promptCandidates =
this.knownDeviceIds.length > 0
? getNewDevicePromptCandidates(state, this.knownDeviceIds, this.ignoredDeviceIds, {
inputDeviceId: VoiceSettings.getInputDeviceId(),
outputDeviceId: VoiceSettings.getOutputDeviceId(),
})
: [];
const knownDeviceIdSet = new Set(this.knownDeviceIds);
const realDeviceIds = getRealDeviceIds(state);
const isFreshIdSpace =
realDeviceIds.length > 0 && !realDeviceIds.some((deviceId) => knownDeviceIdSet.has(deviceId));
const knownDeviceIds = isFreshIdSpace
? this.knownDeviceIds
: remapMigratedDeviceIds(this.knownDeviceIds, MIGRATED_AUDIO_DEVICE_KINDS);
const promptCandidates = isFreshIdSpace
? []
: getNewDevicePromptCandidates(state, knownDeviceIds, this.ignoredDeviceIds, {
inputDeviceId: VoiceSettings.getInputDeviceId(),
outputDeviceId: VoiceSettings.getOutputDeviceId(),
});
runInAction(() => {
if (promptCandidates.length > 0) {
this.pendingPrompts.push(...promptCandidates);
this.knownDeviceIds = isFreshIdSpace
? [...new Set(allCurrentIds)]
: [...new Set([...knownDeviceIds, ...allCurrentIds])];
if (ignoredDeviceIds !== this.ignoredDeviceIds) {
this.ignoredDeviceIds = ignoredDeviceIds;
}
this.knownDeviceIds = [...new Set([...this.knownDeviceIds, ...allCurrentIds])];
this.isInitialized = true;
});
logger.debug('Initialized with known devices', {
count: this.knownDeviceIds.length,
isFreshIdSpace,
promptCount: promptCandidates.length,
});
if (promptCandidates.length > 0) {
this.processNextPrompt();
}
this.promptForNewDevice(promptCandidates);
return;
}
const promptCandidates = getNewDevicePromptCandidates(state, this.knownDeviceIds, this.ignoredDeviceIds, {
@@ -140,9 +159,6 @@ class NewDeviceMonitoring {
outputDeviceId: VoiceSettings.getOutputDeviceId(),
});
runInAction(() => {
if (promptCandidates.length > 0) {
this.pendingPrompts.push(...promptCandidates);
}
this.knownDeviceIds = [...new Set([...this.knownDeviceIds, ...allCurrentIds])];
});
if (promptCandidates.length > 0) {
@@ -153,21 +169,17 @@ class NewDeviceMonitoring {
deviceType: prompt.deviceType,
})),
});
this.processNextPrompt();
}
this.promptForNewDevice(promptCandidates);
}
private processNextPrompt(): void {
if (!this.isStarted) return;
if (this.isShowingPrompt || this.pendingPrompts.length === 0) {
private promptForNewDevice(promptCandidates: ReadonlyArray<PendingDevicePrompt>): void {
if (promptCandidates.length === 0 || promptCandidates.length > MAX_PROMPTABLE_NEW_DEVICE_GROUPS) {
return;
}
const prompt = this.pendingPrompts.shift();
if (!prompt) {
return;
}
this.isShowingPrompt = true;
this.showNewDeviceModal(prompt);
this.showNewDeviceModal(
promptCandidates.find((prompt) => prompt.inputDeviceId !== undefined) ?? promptCandidates[0],
);
}
private showNewDeviceModal(prompt: PendingDevicePrompt): void {
@@ -176,7 +188,7 @@ class NewDeviceMonitoring {
}
const i18n = this.i18n;
const {deviceIds, deviceName, deviceType, inputDeviceId, outputDeviceId} = prompt;
ModalCommands.push(
ModalCommands.pushWithKey(
modal(() => (
<ConfirmModal
title={i18n._(NEW_AUDIO_DEVICE_DETECTED_DESCRIPTOR)}
@@ -208,8 +220,7 @@ class NewDeviceMonitoring {
deviceName={deviceName}
onClick={() => {
this.addToIgnored(deviceIds);
ModalCommands.pop();
setTimeout(() => this.onModalClosed(), 0);
ModalCommands.popWithKey(NEW_DEVICE_MODAL_KEY);
}}
data-flx="auth.new-device-monitoring.ignore-device-link.add-to-ignored"
/>
@@ -225,25 +236,19 @@ class NewDeviceMonitoring {
if (dontAskAgain) {
this.addToIgnored(deviceIds);
}
setTimeout(() => this.onModalClosed(), 0);
}}
onSecondary={(dontAskAgain) => {
if (dontAskAgain) {
this.addToIgnored(deviceIds);
}
setTimeout(() => this.onModalClosed(), 0);
}}
data-flx="auth.new-device-monitoring.confirm-modal"
/>
)),
NEW_DEVICE_MODAL_KEY,
);
}
private onModalClosed(): void {
this.isShowingPrompt = false;
this.processNextPrompt();
}
private addToIgnored(deviceIds: string | ReadonlyArray<string>): void {
const ids = typeof deviceIds === 'string' ? [deviceIds] : deviceIds;
const newDeviceIds = ids.filter((deviceId) => !this.ignoredDeviceIds.includes(deviceId));
@@ -290,8 +295,6 @@ class NewDeviceMonitoring {
this.isStarted = false;
this.startPromise = null;
this.startEpoch++;
this.pendingPrompts = [];
this.isShowingPrompt = false;
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
@@ -59,6 +59,12 @@ function isVirtualRouteDevice(device: MediaDeviceInfo): boolean {
return getVoiceAudioDeviceMetadata(device) !== null;
}
export function getRealDeviceIds(state: VoiceDeviceState): Array<string> {
return [...state.inputDevices, ...state.outputDevices]
.filter((device) => !isVirtualRouteDevice(device) && device.deviceId.trim().length > 0)
.map((device) => device.deviceId);
}
function isPromptableDevice(device: MediaDeviceInfo, context: PromptableDeviceContext): boolean {
if (isVirtualRouteDevice(device)) {
return false;