fix(app): keep hydrated state across gateway session resumes (#1862)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:33:27 +02:00
1 parent b7f75e25ad
commit 943b948de7
41 files changed
+81 -690

No files matched your search

@@ -16,7 +16,7 @@ class AuthSessions {
makeAutoObservable(this, {}, {autoBind: true}); makeAutoObservable(this, {}, {autoBind: true});
} }
handleConnectionOpen(authSessionIdHash: string): void { handleGatewayReady(authSessionIdHash: string): void {
this.authSessionIdHash = authSessionIdHash; this.authSessionIdHash = authSessionIdHash;
} }
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import SessionManager from '@app/features/platform/state/AuthSession'; import SessionManager from '@app/features/platform/state/AuthSession';
import type {ValueOf} from '@fluxer/constants/src/ValueOf'; import type {ValueOf} from '@fluxer/constants/src/ValueOf';
import type {UserPrivate} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import type {UserPrivate} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
@@ -73,7 +72,7 @@ class Authentication {
} }
@action @action
handleConnectionOpen({user}: {user: UserPrivate}): void { handleGatewayReady({user}: {user: UserPrivate}): void {
SessionManager.setUserId(user.id); SessionManager.setUserId(user.id);
SessionManager.handleConnectionReady(); SessionManager.handleConnectionReady();
} }
@@ -128,7 +127,9 @@ class Authentication {
this.mfaTicket = null; this.mfaTicket = null;
this.mfaMethods = null; this.mfaMethods = null;
if (!options?.skipRedirect) { if (!options?.skipRedirect) {
RouterUtils.replaceWith('/login'); void import('@app/features/navigation/utils/RouterUtils').then((module) => {
module.replaceWith('/login');
});
} }
} }
@@ -104,7 +104,7 @@ class ChannelPins {
}; };
} }
handleConnectionOpen(): void { handleGatewayReady(): void {
this.channelPins = {}; this.channelPins = {};
this.channelState = {}; this.channelState = {};
} }
@@ -235,7 +235,7 @@ class Channels {
} }
@action @action
handleConnectionOpen({channels}: {channels: ReadonlyArray<WireChannel>}): void { handleGatewayReady({channels}: {channels: ReadonlyArray<WireChannel>}): void {
this.channelsById.clear(); this.channelsById.clear();
this.channelsByGuildId.clear(); this.channelsByGuildId.clear();
this.privateChannelList = EMPTY_CHANNELS; this.privateChannelList = EMPTY_CHANNELS;
@@ -62,7 +62,7 @@ class UserConnection {
this.fetched = false; this.fetched = false;
} }
handleConnectionOpen(): void { handleGatewayReady(): void {
this.reset(); this.reset();
} }
} }
+1 -1
View File
@@ -447,7 +447,7 @@ class Emoji {
UnicodeEmojis.setDefaultSkinTone(skinTone); UnicodeEmojis.setDefaultSkinTone(skinTone);
} }
handleConnectionOpen({guilds}: {guilds: ReadonlyArray<GuildReadyData>}): void { handleGatewayReady({guilds}: {guilds: ReadonlyArray<GuildReadyData>}): void {
emojiGuildRegistry.reset(); emojiGuildRegistry.reset();
for (const guild of guilds) { for (const guild of guilds) {
emojiGuildRegistry.updateGuild(guild.id, guild.emojis); emojiGuildRegistry.updateGuild(guild.id, guild.emojis);
@@ -91,7 +91,7 @@ class Sticker {
return this.sortByFrecency(filtered); return this.sortByFrecency(filtered);
} }
handleConnectionOpen(guilds: ReadonlyArray<GuildReadyData>): void { handleGatewayReady(guilds: ReadonlyArray<GuildReadyData>): void {
this.guildStickers.clear(); this.guildStickers.clear();
this.stickerById.clear(); this.stickerById.clear();
for (const guild of guilds) { for (const guild of guilds) {
@@ -130,7 +130,7 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext)
if (data.rtc_regions) { if (data.rtc_regions) {
RtcRegions.setRegions(data.rtc_regions); RtcRegions.setRegions(data.rtc_regions);
} }
Users.handleConnectionOpen(data.user); Users.handleGatewayReady(data.user);
if (data.users && data.users.length > 0) { if (data.users && data.users.length > 0) {
Users.cacheUsers(data.users); Users.cacheUsers(data.users);
} }
@@ -146,44 +146,44 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext)
void accountStorage.updateAccountUserData(user.id, userData); void accountStorage.updateAccountUserData(user.id, userData);
void AccountManager.updateAccountUserData(user.id, userData); void AccountManager.updateAccountUserData(user.id, userData);
} }
VoiceSettings.handleConnectionOpen(data.user); VoiceSettings.handleGatewayReady(data.user);
Authentication.handleConnectionOpen({user: data.user}); Authentication.handleGatewayReady({user: data.user});
void PremiumCommands.refreshPremiumState().catch((error) => { void PremiumCommands.refreshPremiumState().catch((error) => {
logger.warn('Failed to refresh premium state after READY', error); logger.warn('Failed to refresh premium state after READY', error);
}); });
Guilds.handleConnectionOpen({guilds}); Guilds.handleGatewayReady({guilds});
UserSettings.handleConnectionOpen(data.user_settings); UserSettings.handleGatewayReady(data.user_settings);
GuildList.handleConnectionOpen(guilds); GuildList.handleGatewayReady(guilds);
GuildCount.handleConnectionOpen(guilds); GuildCount.handleGatewayReady(guilds);
GuildMembers.handleConnectionOpen(guilds); GuildMembers.handleGatewayReady(guilds);
GuildVerification.handleConnectionOpen(); GuildVerification.handleGatewayReady();
Channels.handleConnectionOpen({channels}); Channels.handleGatewayReady({channels});
if (data.auth_session_id_hash) { if (data.auth_session_id_hash) {
AuthSession.handleConnectionOpen(data.auth_session_id_hash); AuthSession.handleGatewayReady(data.auth_session_id_hash);
} else { } else {
logger.warn('READY missing auth_session_id_hash; continuing without AuthSession init'); logger.warn('READY missing auth_session_id_hash; continuing without AuthSession init');
} }
MessageReactions.handleConnectionOpen(); MessageReactions.handleGatewayReady();
Sticker.handleConnectionOpen(guilds); Sticker.handleGatewayReady(guilds);
Emoji.handleConnectionOpen({guilds}); Emoji.handleGatewayReady({guilds});
Permission.handleConnectionOpen(); Permission.handleGatewayReady();
MemberSearch.handleConnectionOpen(); MemberSearch.handleGatewayReady();
SavedMessages.handleConnectionOpen(); SavedMessages.handleGatewayReady();
MentionFeed.handleConnectionOpen(); MentionFeed.handleGatewayReady();
ScheduledMessages.handleConnectionOpen(); ScheduledMessages.handleGatewayReady();
ChannelPins.handleConnectionOpen(); ChannelPins.handleGatewayReady();
UserConnection.handleConnectionOpen(); UserConnection.handleGatewayReady();
UserGuildSettings.handleConnectionOpen(data.user_guild_settings ?? []); UserGuildSettings.handleGatewayReady(data.user_guild_settings ?? []);
WebAuthnCredentials.handleConnectionOpen(data.webauthn_credentials); WebAuthnCredentials.handleGatewayReady(data.webauthn_credentials);
ReadStates.handleConnectionOpen({ ReadStates.handleGatewayReady({
readState: data.read_states ?? [], readState: data.read_states ?? [],
readStateProto: data.read_state_proto, readStateProto: data.read_state_proto,
channels, channels,
}); });
GuildReadState.handleConnectionOpen(); GuildReadState.handleGatewayReady();
Presence.handleConnectionOpen(data.user, guilds, data.presences); Presence.handleGatewayReady(data.user, guilds, data.presences);
MediaEngine.handleConnectionOpen(guilds); MediaEngine.handleGatewayReady(guilds);
Initialization.setReady(); Initialization.setReady();
context.setReady(); context.setReady();
Messages.handleConnectionOpen(); Messages.handleGatewayReady();
} }
@@ -24,6 +24,7 @@ import {
type GatewayVoiceStateUpdateParams, type GatewayVoiceStateUpdateParams,
} from '@app/features/gateway/transport/GatewaySocket'; } from '@app/features/gateway/transport/GatewaySocket';
import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree'; import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree';
import GuildMembers from '@app/features/member/state/GuildMembers';
import MemberSearch from '@app/features/member/state/MemberSearch'; import MemberSearch from '@app/features/member/state/MemberSearch';
import Messages from '@app/features/messaging/state/MessagingMessages'; import Messages from '@app/features/messaging/state/MessagingMessages';
import SelectedGuild from '@app/features/navigation/state/SelectedGuild'; import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
@@ -74,6 +75,7 @@ class GatewayConnection {
private isFatalCrashInProgress: boolean = false; private isFatalCrashInProgress: boolean = false;
private connectionInterrupted: boolean = false; private connectionInterrupted: boolean = false;
private connectionGraceTimer: number | null = null; private connectionGraceTimer: number | null = null;
private previousSessionId: string | null = null;
constructor() { constructor() {
makeAutoObservable< makeAutoObservable<
@@ -87,6 +89,7 @@ class GatewayConnection {
| 'beginConnectionGrace' | 'beginConnectionGrace'
| 'clearConnectionGrace' | 'clearConnectionGrace'
| 'connectionGraceTimer' | 'connectionGraceTimer'
| 'previousSessionId'
>( >(
this, this,
{ {
@@ -94,11 +97,12 @@ class GatewayConnection {
beginConnectionGrace: action.bound, beginConnectionGrace: action.bound,
clearConnectionGrace: action.bound, clearConnectionGrace: action.bound,
connectionGraceTimer: false, connectionGraceTimer: false,
previousSessionId: false,
setToken: action.bound, setToken: action.bound,
sendInvisiblePresenceForCurrentSession: action.bound, sendInvisiblePresenceForCurrentSession: action.bound,
retireCurrentSession: action.bound, retireCurrentSession: action.bound,
logout: action.bound, logout: action.bound,
handleConnectionOpen: action.bound, handleGatewayReady: action.bound,
handleConnectionResumed: action.bound, handleConnectionResumed: action.bound,
handleConnectionClosed: action.bound, handleConnectionClosed: action.bound,
cleanupSocket: action.bound, cleanupSocket: action.bound,
@@ -187,8 +191,10 @@ class GatewayConnection {
private createHandlerContext(): GatewayHandlerContext { private createHandlerContext(): GatewayHandlerContext {
return { return {
socket: this.socket, socket: this.socket,
previousSessionId: null, previousSessionId: this.previousSessionId,
setPreviousSessionId: (_id: string) => {}, setPreviousSessionId: (id: string) => {
this.previousSessionId = id;
},
setReady: () => { setReady: () => {
runInAction(() => { runInAction(() => {
this.isReady = true; this.isReady = true;
@@ -359,7 +365,7 @@ class GatewayConnection {
const readyData = data as { const readyData = data as {
session_id: string; session_id: string;
}; };
this.handleConnectionOpen(readyData.session_id); this.handleGatewayReady(readyData.session_id);
}), }),
); );
socket.on( socket.on(
@@ -581,7 +587,7 @@ class GatewayConnection {
this.completedGuildSyncSessions = {}; this.completedGuildSyncSessions = {};
} }
handleConnectionOpen(sessionId: string): void { handleGatewayReady(sessionId: string): void {
this.isConnected = true; this.isConnected = true;
this.isConnecting = false; this.isConnecting = false;
this.isReady = true; this.isReady = true;
@@ -592,6 +598,7 @@ class GatewayConnection {
TypingIndicator.reset(); TypingIndicator.reset();
QuickSwitcher.recomputeIfOpen(); QuickSwitcher.recomputeIfOpen();
this.flushPendingGuildSync(); this.flushPendingGuildSync();
GuildMembers.handleConnectionResumed();
} }
handleConnectionResumed(): void { handleConnectionResumed(): void {
@@ -604,6 +611,7 @@ class GatewayConnection {
TypingIndicator.reset(); TypingIndicator.reset();
QuickSwitcher.recomputeIfOpen(); QuickSwitcher.recomputeIfOpen();
this.flushPendingGuildSync(); this.flushPendingGuildSync();
GuildMembers.handleConnectionResumed();
} }
handleConnectionClosed(code: number): void { handleConnectionClosed(code: number): void {
@@ -59,7 +59,7 @@ class GuildCount {
this.scheduleFlush(); this.scheduleFlush();
} }
handleConnectionOpen(guilds: ReadonlyArray<HydratableGuild>): void { handleGatewayReady(guilds: ReadonlyArray<HydratableGuild>): void {
this.hydrate(guilds); this.hydrate(guilds);
} }
@@ -27,7 +27,7 @@ class GuildList {
} }
@action @action
handleConnectionOpen(guilds: ReadonlyArray<GuildReadyData>): void { handleGatewayReady(guilds: ReadonlyArray<GuildReadyData>): void {
const availableGuilds: Array<Guild> = []; const availableGuilds: Array<Guild> = [];
for (const guild of guilds) { for (const guild of guilds) {
if (guild.unavailable) continue; if (guild.unavailable) continue;
@@ -112,7 +112,7 @@ class GuildReadState {
() => { () => {
const {all, changes} = ReadStates.consumePendingChanges(); const {all, changes} = ReadStates.consumePendingChanges();
if (all) { if (all) {
this.handleConnectionOpen(); this.handleGatewayReady();
return; return;
} }
if (changes.length === 0) { if (changes.length === 0) {
@@ -384,7 +384,7 @@ class GuildReadState {
return false; return false;
} }
handleConnectionOpen(): void { handleGatewayReady(): void {
this.guildStates.clear(); this.guildStates.clear();
this.unreadGuildIds.clear(); this.unreadGuildIds.clear();
this.updateCounter = 0; this.updateCounter = 0;
@@ -454,7 +454,7 @@ class GuildReadState {
} }
handleRecomputeAll(): void { handleRecomputeAll(): void {
this.handleConnectionOpen(); this.handleGatewayReady();
} }
handleWindowFocused(): void { handleWindowFocused(): void {
@@ -41,7 +41,7 @@ class GuildVerification {
makeAutoObservable(this, {}, {autoBind: true}); makeAutoObservable(this, {}, {autoBind: true});
} }
handleConnectionOpen(): void { handleGatewayReady(): void {
this.recomputeAll(); this.recomputeAll();
} }
@@ -44,7 +44,7 @@ class Guilds {
return Object.values(this.guilds).filter((guild) => guild.ownerId === userId); return Object.values(this.guilds).filter((guild) => guild.ownerId === userId);
} }
handleConnectionOpen({guilds}: {guilds: Array<GuildReadyData>}): void { handleGatewayReady({guilds}: {guilds: Array<GuildReadyData>}): void {
const availableGuilds = guilds.filter((guild) => !guild.unavailable); const availableGuilds = guilds.filter((guild) => !guild.unavailable);
if (availableGuilds.length === 0) { if (availableGuilds.length === 0) {
this.guilds = {}; this.guilds = {};
@@ -132,7 +132,7 @@ class GuildMembers {
return Object.keys(this.members[guildId] ?? {}).length; return Object.keys(this.members[guildId] ?? {}).length;
} }
handleConnectionOpen(guilds: Array<GuildReadyData>): void { handleGatewayReady(guilds: Array<GuildReadyData>): void {
this.members = {}; this.members = {};
this.nonMembers = {}; this.nonMembers = {};
this.loadedGuilds.clear(); this.loadedGuilds.clear();
@@ -336,7 +336,7 @@ class MemberSearch {
updateMembers(allMembers); updateMembers(allMembers);
} }
handleConnectionOpen(): void { handleGatewayReady(): void {
if (worker) { if (worker) {
this.terminate(); this.terminate();
} }
@@ -178,7 +178,7 @@ export class MessageReactionsManager {
return entry; return entry;
} }
handleConnectionOpen(): void { handleGatewayReady(): void {
const messageIds = Array.from(this.messageStates.keys()); const messageIds = Array.from(this.messageStates.keys());
const reactionKeys = Array.from(this.reactors.keys()); const reactionKeys = Array.from(this.reactors.keys());
for (const key of reactionKeys) this.retireReactionEntry(key); for (const key of reactionKeys) this.retireReactionEntry(key);
@@ -185,7 +185,7 @@ class MessageReferences {
this.cleanupChannelMessages(channelId); this.cleanupChannelMessages(channelId);
} }
handleConnectionOpen(): void { handleGatewayReady(): void {
this.deletedMessageIds.clear(); this.deletedMessageIds.clear();
this.cachedMessages.clear(); this.cachedMessages.clear();
this.referenceVersions.clear(); this.referenceVersions.clear();
@@ -296,7 +296,7 @@ class Messages {
} }
@action @action
handleConnectionOpen(): boolean { handleGatewayReady(): boolean {
const selectedChannelId = SelectedChannel.currentChannelId; const selectedChannelId = SelectedChannel.currentChannelId;
let didHydrateSelectedChannel = false; let didHydrateSelectedChannel = false;
if (selectedChannelId) { if (selectedChannelId) {
@@ -43,7 +43,7 @@ class SavedMessages {
this.fetched = false; this.fetched = false;
} }
handleConnectionOpen(): void { handleGatewayReady(): void {
this.savedMessages = []; this.savedMessages = [];
this.missingSavedMessages = []; this.missingSavedMessages = [];
this.fetched = false; this.fetched = false;
@@ -32,7 +32,7 @@ class ScheduledMessages {
this.scheduledMessages = []; this.scheduledMessages = [];
} }
handleConnectionOpen(): void { handleGatewayReady(): void {
this.scheduledMessages = []; this.scheduledMessages = [];
this.fetched = false; this.fetched = false;
this.fetching = false; this.fetching = false;
@@ -96,7 +96,7 @@ class MentionFeed {
} }
} }
handleConnectionOpen(): void { handleGatewayReady(): void {
this.recentMentions = []; this.recentMentions = [];
this.fetched = false; this.fetched = false;
this.hasMore = true; this.hasMore = true;
@@ -86,7 +86,7 @@ class Permission {
return PermissionUtils.isRoleHigher(wireGuild, me.id, myHighestRole, otherHighestRole); return PermissionUtils.isRoleHigher(wireGuild, me.id, myHighestRole, otherHighestRole);
} }
handleConnectionOpen(): void { handleGatewayReady(): void {
this.rebuildPermissions(); this.rebuildPermissions();
} }
@@ -256,11 +256,7 @@ class Presence {
this.bumpPresenceVersion(); this.bumpPresenceVersion();
} }
handleConnectionOpen( handleGatewayReady(user: UserPrivate, guilds: Array<GuildReadyData>, presences?: ReadonlyArray<WirePresence>): void {
user: UserPrivate,
guilds: Array<GuildReadyData>,
presences?: ReadonlyArray<WirePresence>,
): void {
TransientPresence.clear(); TransientPresence.clear();
const localStatus = LocalPresence.getStatus(); const localStatus = LocalPresence.getStatus();
const localCustomStatus = LocalPresence.customStatus; const localCustomStatus = LocalPresence.customStatus;
@@ -11,10 +11,6 @@ import {resolveReadStateAckDecision} from '@app/features/read_state/state/read_s
import {ReadStateEntry} from '@app/features/read_state/state/read_states/ReadStateEntry'; import {ReadStateEntry} from '@app/features/read_state/state/read_states/ReadStateEntry';
import {resolveReadStateIncomingMessageDecision} from '@app/features/read_state/state/read_states/ReadStateIncomingMessageMachine'; import {resolveReadStateIncomingMessageDecision} from '@app/features/read_state/state/read_states/ReadStateIncomingMessageMachine';
import {resolveReadStateServerAckDecision} from '@app/features/read_state/state/read_states/ReadStateServerAckMachine'; import {resolveReadStateServerAckDecision} from '@app/features/read_state/state/read_states/ReadStateServerAckMachine';
import {
resolveUnreadJumpAnchor,
type UnreadJumpAnchor,
} from '@app/features/read_state/state/read_states/ReadStateUnreadAnchor';
import { import {
ACK_BATCH_DELAY_MS, ACK_BATCH_DELAY_MS,
ACK_BATCH_SIZE, ACK_BATCH_SIZE,
@@ -50,8 +46,6 @@ import {observable, runInAction} from 'mobx';
export type {GatewayReadState}; export type {GatewayReadState};
export type {UnreadJumpAnchor};
const logger = new Logger('ReadStates'); const logger = new Logger('ReadStates');
class ReadStates { class ReadStates {
@@ -351,20 +345,6 @@ class ReadStates {
return state?.supportsUnreadTracking() ? state.visualUnreadMessageId : null; return state?.supportsUnreadTracking() ? state.visualUnreadMessageId : null;
} }
getUnreadJumpAnchor(channelId: string): UnreadJumpAnchor | null {
const state = this.getIfExists(channelId);
if (state == null) {
return null;
}
return resolveUnreadJumpAnchor({
canBeUnread: state.canBeUnread(),
canTrackUnreads: state.supportsUnreadTracking(),
hasUnread: state.hasUnread(),
oldestUnreadMessageId: state.oldestUnreadMessageId,
ackMessageId: state.ackMessageId,
});
}
getChannelIds(): Array<ChannelId> { getChannelIds(): Array<ChannelId> {
return Array.from(this.states.keys()); return Array.from(this.states.keys());
} }
@@ -409,7 +389,7 @@ class ReadStates {
this.notifyChange(channelId); this.notifyChange(channelId);
} }
handleConnectionOpen(action: { handleGatewayReady(action: {
readState: Array<GatewayReadState>; readState: Array<GatewayReadState>;
readStateProto?: string; readStateProto?: string;
channels: Array<ChannelPayload>; channels: Array<ChannelPayload>;
@@ -1,46 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {resolveUnreadJumpAnchor, type UnreadJumpAnchorInput} from './ReadStateUnreadAnchor';
function input(overrides: Partial<UnreadJumpAnchorInput> = {}): UnreadJumpAnchorInput {
return {
canBeUnread: true,
canTrackUnreads: true,
hasUnread: true,
oldestUnreadMessageId: '900',
ackMessageId: '100',
...overrides,
};
}
describe('resolveUnreadJumpAnchor', () => {
it('anchors on the oldest unread message when it is known', () => {
expect(resolveUnreadJumpAnchor(input())).toEqual({messageId: '900', offset: 0});
});
it('anchors just after the ack when the oldest unread message is unknown', () => {
expect(resolveUnreadJumpAnchor(input({oldestUnreadMessageId: null}))).toEqual({messageId: '100', offset: 1});
});
it('returns no anchor when the channel has nothing unread', () => {
expect(resolveUnreadJumpAnchor(input({hasUnread: false}))).toBeNull();
});
it('returns no anchor when the channel cannot be unread', () => {
expect(resolveUnreadJumpAnchor(input({canBeUnread: false}))).toBeNull();
});
it('returns no anchor when unreads are not trackable for the channel', () => {
expect(resolveUnreadJumpAnchor(input({canTrackUnreads: false}))).toBeNull();
});
it('returns no anchor when neither an unread boundary nor an ack exists', () => {
expect(resolveUnreadJumpAnchor(input({oldestUnreadMessageId: null, ackMessageId: null}))).toBeNull();
});
it('anchors on the unread boundary regardless of how far behind the ack is', () => {
const anchor = resolveUnreadJumpAnchor(input({ackMessageId: '1', oldestUnreadMessageId: '2'}));
expect(anchor).toEqual({messageId: '2', offset: 0});
});
});
@@ -1,27 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface UnreadJumpAnchorInput {
canBeUnread: boolean;
canTrackUnreads: boolean;
hasUnread: boolean;
oldestUnreadMessageId: string | null;
ackMessageId: string | null;
}
export interface UnreadJumpAnchor {
messageId: string;
offset: number;
}
export function resolveUnreadJumpAnchor(input: UnreadJumpAnchorInput): UnreadJumpAnchor | null {
if (!input.canBeUnread || !input.canTrackUnreads || !input.hasUnread) {
return null;
}
if (input.oldestUnreadMessageId != null) {
return {messageId: input.oldestUnreadMessageId, offset: 0};
}
if (input.ackMessageId == null) {
return null;
}
return {messageId: input.ackMessageId, offset: 1};
}
@@ -128,7 +128,7 @@ class UserGuildSettings {
updateGuildSettings: action, updateGuildSettings: action,
updateChannelOverride: action, updateChannelOverride: action,
updateChannelOverrides: action, updateChannelOverrides: action,
handleConnectionOpen: action, handleGatewayReady: action,
notifyChange: action, notifyChange: action,
}); });
} }
@@ -564,7 +564,7 @@ class UserGuildSettings {
); );
} }
handleConnectionOpen(userGuildSettings: Array<GatewayGuildSettings>): void { handleGatewayReady(userGuildSettings: Array<GatewayGuildSettings>): void {
this.clearAllTimers(); this.clearAllTimers();
this.guildSettings.clear(); this.guildSettings.clear();
this.mutedChannelsByGuild.clear(); this.mutedChannelsByGuild.clear();
@@ -23,7 +23,7 @@ class UserProfile {
return this.profiles[userId]?.[guildId ?? ME] ?? null; return this.profiles[userId]?.[guildId ?? ME] ?? null;
} }
handleConnectionOpen(): void { handleGatewayReady(): void {
Object.values(this.profileTimeouts).forEach(clearTimeout); Object.values(this.profileTimeouts).forEach(clearTimeout);
this.profiles = {}; this.profiles = {};
this.profileTimeouts = {}; this.profileTimeouts = {};
@@ -595,7 +595,7 @@ class UserSettingsState {
LocalPresence.updatePresence(); LocalPresence.updatePresence();
} }
handleConnectionOpen(userSettings: unknown): void { handleGatewayReady(userSettings: unknown): void {
this.updateUserSettings(userSettings); this.updateUserSettings(userSettings);
} }
+2 -2
View File
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {openClaimAccountModal} from '@app/features/auth/components/modals/ClaimAccountModal';
import Authentication from '@app/features/auth/state/Authentication'; import Authentication from '@app/features/auth/state/Authentication';
import {User} from '@app/features/user/models/User'; import {User} from '@app/features/user/models/User';
import type {UserPrivate, User as WireUser} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import type {UserPrivate, User as WireUser} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
@@ -94,7 +93,7 @@ class Users {
} }
@action @action
handleConnectionOpen(currentUser: UserPrivate): void { handleGatewayReady(currentUser: UserPrivate): void {
const userRecord = new User(currentUser); const userRecord = new User(currentUser);
this.users = { this.users = {
[currentUser.id]: userRecord, [currentUser.id]: userRecord,
@@ -102,6 +101,7 @@ class Users {
this.userCount = 1; this.userCount = 1;
if (!userRecord.isClaimed()) { if (!userRecord.isClaimed()) {
setTimeout(async () => { setTimeout(async () => {
const {openClaimAccountModal} = await import('@app/features/auth/components/modals/ClaimAccountModal');
openClaimAccountModal(); openClaimAccountModal();
}, 1000); }, 1000);
} }
@@ -24,7 +24,7 @@ class WebAuthnCredentials {
logger.debug(`Set WebAuthn credentials: ${credentials.length}`); logger.debug(`Set WebAuthn credentials: ${credentials.length}`);
} }
handleConnectionOpen(credentials: ReadonlyArray<WebAuthnCredential> | undefined): void { handleGatewayReady(credentials: ReadonlyArray<WebAuthnCredential> | undefined): void {
this.setCredentials(credentials ?? []); this.setCredentials(credentials ?? []);
} }
@@ -1845,8 +1845,8 @@ class MediaEngineFacade extends Store {
); );
} }
handleConnectionOpen(guilds: Array<GuildReadyData>): void { handleGatewayReady(guilds: Array<GuildReadyData>): void {
voiceEngineV2AppVoiceStateAdapter.handleConnectionOpen(guilds); voiceEngineV2AppVoiceStateAdapter.handleGatewayReady(guilds);
} }
handleGuildCreate(guild: GuildReadyData): void { handleGuildCreate(guild: GuildReadyData): void {
@@ -1,173 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Room} from 'livekit-client';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const updateSettings = vi.fn();
let outputDevices: Array<{deviceId: string}> = [];
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
default: {
getOutputDeviceId: () => 'default',
updateSettings: (update: unknown) => updateSettings(update),
subscribe: () => () => undefined,
},
}));
vi.mock('@app/features/voice/utils/VoiceDeviceManager', () => ({
voiceDeviceManager: {
ensureDevices: async () => ({
inputDevices: [],
outputDevices,
videoDevices: [],
permissionStatus: 'granted',
}),
},
}));
const {applyOutputDeviceToRoom} = await import('./VoiceOutputDeviceSync');
class FakeMediaElement {
setSinkId = vi.fn(async () => undefined);
}
Object.defineProperty(FakeMediaElement.prototype, 'setSinkId', {
configurable: true,
writable: true,
value: async (): Promise<void> => undefined,
});
vi.stubGlobal('HTMLMediaElement', FakeMediaElement);
interface FakeRoomOptions {
webAudioMix?: boolean;
audioContextSupportsSetSinkId?: boolean;
switchActiveDevice?: () => Promise<boolean>;
}
function createRoom(options: FakeRoomOptions = {}) {
const {webAudioMix = true, audioContextSupportsSetSinkId = true} = options;
const switchActiveDevice = vi.fn(options.switchActiveDevice ?? (async () => true));
const mixerSetSinkId = vi.fn(async () => undefined);
const trackSetSinkId = vi.fn(async () => undefined);
const element = new FakeMediaElement();
const audioContext = audioContextSupportsSetSinkId ? {setSinkId: mixerSetSinkId} : {};
const room = {
options: {webAudioMix},
audioContext,
switchActiveDevice,
remoteParticipants: new Map([
[
'participant-with-track-sink',
{
audioTrackPublications: new Map([
['pub-track-sink', {kind: 'audio', track: {kind: 'audio', setSinkId: trackSetSinkId}}],
]),
},
],
[
'participant-with-elements',
{
audioTrackPublications: new Map([
['pub-elements', {kind: 'audio', track: {kind: 'audio', attachedElements: [element]}}],
]),
},
],
]),
};
return {room: room as unknown as Room, switchActiveDevice, mixerSetSinkId, trackSetSinkId, element};
}
describe('applyOutputDeviceToRoom', () => {
beforeEach(() => {
updateSettings.mockClear();
outputDevices = [{deviceId: 'speaker-1'}];
});
it('hands the Web Audio mixer room the literal default id so LiveKit resolves it to a real device', async () => {
const {room, switchActiveDevice} = createRoom();
await applyOutputDeviceToRoom(room, 'default');
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'default');
expect(switchActiveDevice).not.toHaveBeenCalledWith('audiooutput', '');
});
it('passes a concrete device id through verbatim', async () => {
const {room, switchActiveDevice} = createRoom();
await applyOutputDeviceToRoom(room, 'speaker-1');
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'speaker-1');
});
it('applies the normalized sink id to the Web Audio mixer and the attached elements', async () => {
const {room, mixerSetSinkId, trackSetSinkId, element} = createRoom();
await applyOutputDeviceToRoom(room, 'default');
expect(mixerSetSinkId).toHaveBeenCalledWith('');
expect(trackSetSinkId).toHaveBeenCalledWith('');
expect(element.setSinkId).toHaveBeenCalledWith('');
});
it('applies a concrete sink id to the Web Audio mixer and the attached elements', async () => {
const {room, mixerSetSinkId, trackSetSinkId, element} = createRoom();
await applyOutputDeviceToRoom(room, 'speaker-1');
expect(mixerSetSinkId).toHaveBeenCalledWith('speaker-1');
expect(trackSetSinkId).toHaveBeenCalledWith('speaker-1');
expect(element.setSinkId).toHaveBeenCalledWith('speaker-1');
});
it('still runs the mixer and element paths when LiveKit rejects with NotFoundError', async () => {
const notFound = new Error('Requested device not found');
notFound.name = 'NotFoundError';
const {room, switchActiveDevice, mixerSetSinkId, trackSetSinkId, element} = createRoom({
switchActiveDevice: async () => {
throw notFound;
},
});
await expect(applyOutputDeviceToRoom(room, 'default')).resolves.toBeUndefined();
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'default');
expect(mixerSetSinkId).toHaveBeenCalledWith('');
expect(trackSetSinkId).toHaveBeenCalledWith('');
expect(element.setSinkId).toHaveBeenCalledWith('');
});
it('skips the LiveKit switch when the Web Audio mixer context cannot switch sinks', async () => {
const {room, switchActiveDevice, trackSetSinkId, element} = createRoom({
audioContextSupportsSetSinkId: false,
});
await applyOutputDeviceToRoom(room, 'default');
expect(switchActiveDevice).not.toHaveBeenCalled();
expect(trackSetSinkId).toHaveBeenCalledWith('');
expect(element.setSinkId).toHaveBeenCalledWith('');
});
it('normalizes the sink id only when LiveKit forwards it straight to the browser', async () => {
const {room, switchActiveDevice} = createRoom({
webAudioMix: false,
audioContextSupportsSetSinkId: false,
});
await applyOutputDeviceToRoom(room, 'default');
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', '');
});
it('falls back to the default device when the stored output device disappeared', async () => {
outputDevices = [{deviceId: 'speaker-2'}];
const {room, switchActiveDevice} = createRoom();
await applyOutputDeviceToRoom(room, 'speaker-1');
expect(updateSettings).toHaveBeenCalledWith({outputDeviceId: 'default'});
expect(switchActiveDevice).toHaveBeenCalledWith('audiooutput', 'default');
});
});
@@ -1,35 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it, vi} from 'vitest';
import {computeTimeDomainRms} from './VoiceEngineV2AppRemoteSpeakingAdapter';
vi.mock('@app/features/voice/state/ParticipantVolume', () => ({default: {}}));
vi.mock('@app/features/voice/state/VoiceSettings', () => ({default: {getVadThreshold: () => 50}}));
describe('computeTimeDomainRms', () => {
it('returns the amplitude of an alternating full-scale-relative signal', () => {
const samples = new Float32Array(512);
for (let i = 0; i < samples.length; i++) {
samples[i] = i % 2 === 0 ? 0.09 : -0.09;
}
expect(computeTimeDomainRms(samples)).toBe(Math.fround(0.09));
});
it('returns zero for digital silence', () => {
expect(computeTimeDomainRms(new Float32Array(512))).toBe(0);
});
it('returns zero for an empty buffer', () => {
expect(computeTimeDomainRms(new Float32Array(0))).toBe(0);
});
it('spreads a single full-scale sample across the window', () => {
const samples = new Float32Array(512);
samples[17] = 1;
expect(computeTimeDomainRms(samples)).toBeCloseTo(1 / Math.sqrt(512), 12);
});
it('resolves levels far below one 8-bit quantisation step', () => {
expect(computeTimeDomainRms(Float32Array.of(0.0005, -0.0005))).toBe(Math.fround(0.0005));
});
});
@@ -40,7 +40,7 @@ function guild(id: string, voiceStates: ReadonlyArray<VoiceState> = []): GuildRe
describe('VoiceEngineV2AppVoiceStateAdapter', () => { describe('VoiceEngineV2AppVoiceStateAdapter', () => {
it('keeps gateway voice-state indexes as immutable replacement projections', () => { it('keeps gateway voice-state indexes as immutable replacement projections', () => {
const adapter = new VoiceEngineV2AppVoiceStateAdapter(); const adapter = new VoiceEngineV2AppVoiceStateAdapter();
adapter.handleConnectionOpen([ adapter.handleGatewayReady([
guild('guild-1', [voiceState({connection_id: 'connection-1', channel_id: 'channel-1'})]), guild('guild-1', [voiceState({connection_id: 'connection-1', channel_id: 'channel-1'})]),
]); ]);
const before = adapter.getConnectionVoiceStates(); const before = adapter.getConnectionVoiceStates();
@@ -95,7 +95,7 @@ export class VoiceEngineV2AppVoiceStateAdapter extends Store {
return connectionId != null && this.ignoredConnectionIds.has(connectionId); return connectionId != null && this.ignoredConnectionIds.has(connectionId);
} }
handleConnectionOpen(guilds: Array<GuildReadyData>): void { handleGatewayReady(guilds: Array<GuildReadyData>): void {
this.send({ this.send({
type: 'connection.open', type: 'connection.open',
guilds: guilds.map((guild) => filterIgnoredGuildVoiceStates(this.ignoredConnectionIds, guild)), guilds: guilds.map((guild) => filterIgnoredGuildVoiceStates(this.ignoredConnectionIds, guild)),
@@ -1,184 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getStreamKey} from '@app/features/voice/components/StreamKeys';
import {VoiceTrackKind, VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
import {
clearRemoteVoicePlaybackBoost,
setRemoteVoicePlaybackBoost,
} from '@app/features/voice/state/RemoteVoicePlaybackBoost';
import StreamAudioPrefs from '@app/features/voice/state/StreamAudioPrefs';
import type {RemoteParticipant} from 'livekit-client';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const outputVolumePercent = vi.fn<() => number>();
const effectiveDeaf = vi.fn<() => boolean>();
vi.mock('@app/features/platform/utils/MobXPersistence', () => ({
makePersistent: async () => {},
}));
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
default: {getOutputVolume: () => outputVolumePercent()},
}));
vi.mock('@app/features/voice/engine/VoiceEffectiveAudioState', () => ({
getEffectiveAudioState: () => ({effectiveDeaf: effectiveDeaf()}),
}));
const GUILD_ID = 'guild-1';
const CHANNEL_ID = 'channel-1';
const LOCAL_CONNECTION_ID = 'local-connection';
const REMOTE_CONNECTION_ID = 'remote-connection';
const USER_ID = '42';
const IDENTITY = `user_${USER_ID}_${REMOTE_CONNECTION_ID}`;
const STREAM_KEY = getStreamKey(GUILD_ID, CHANNEL_ID, REMOTE_CONNECTION_ID);
interface FakePublication {
source: string;
trackName: string;
trackSid: string;
isDesired: boolean;
track: {kind: string; setVolume: (volume: number) => void};
setEnabled: (enabled: boolean) => void;
volumes: Array<number>;
enabled: Array<boolean>;
}
(
window as typeof window & {
_mediaEngineFacade?: {guildId: string; channelId: string; connectionId: string};
}
)._mediaEngineFacade = {guildId: GUILD_ID, channelId: CHANNEL_ID, connectionId: LOCAL_CONNECTION_ID};
const {default: ParticipantVolume} = await import('@app/features/voice/state/ParticipantVolume');
function createPublication(source: string, trackSid: string, throwOnSetVolume: boolean): FakePublication {
const volumes: Array<number> = [];
const enabled: Array<boolean> = [];
return {
source,
trackName: source,
trackSid,
isDesired: true,
track: {
kind: VoiceTrackKind.Audio,
setVolume: (volume: number) => {
volumes.push(volume);
if (throwOnSetVolume) {
throw new Error('setVolume failed');
}
},
},
setEnabled: (value: boolean) => {
enabled.push(value);
},
volumes,
enabled,
};
}
function applyToParticipant(options?: {throwOnMicrophoneVolume?: boolean}): {
microphone: FakePublication;
screenShareAudio: FakePublication;
} {
const microphone = createPublication(
VoiceTrackSource.Microphone,
'microphone-sid',
options?.throwOnMicrophoneVolume === true,
);
const screenShareAudio = createPublication(VoiceTrackSource.ScreenShareAudio, 'screen-share-audio-sid', false);
const audioTrackPublications = new Map<string, FakePublication>([
[microphone.trackSid, microphone],
[screenShareAudio.trackSid, screenShareAudio],
]);
ParticipantVolume.applySettingsToParticipant({
identity: IDENTITY,
audioTrackPublications,
} as unknown as RemoteParticipant);
return {microphone, screenShareAudio};
}
describe('ParticipantVolume.applySettingsToParticipant', () => {
beforeEach(() => {
outputVolumePercent.mockReturnValue(100);
effectiveDeaf.mockReturnValue(false);
clearRemoteVoicePlaybackBoost(IDENTITY);
ParticipantVolume.resetUserSettings(USER_ID);
StreamAudioPrefs.entries = {};
});
it('gives a saturated auto-leveller participant strictly more gain at output 200% than at 100%', () => {
setRemoteVoicePlaybackBoost(IDENTITY, 3);
const atUnity = applyToParticipant().microphone.volumes;
outputVolumePercent.mockReturnValue(200);
const atCeiling = applyToParticipant().microphone.volumes;
expect(atUnity).toEqual([3]);
expect(atCeiling).toEqual([12]);
expect(atCeiling[0]).toBeGreaterThan(atUnity[0]);
});
it('clamps the total, not each control', () => {
ParticipantVolume.setVolume(USER_ID, 200);
outputVolumePercent.mockReturnValue(200);
setRemoteVoicePlaybackBoost(IDENTITY, 3);
expect(applyToParticipant().microphone.volumes).toEqual([12]);
});
it('the per-user slider still moves with the leveller idle', () => {
ParticipantVolume.setVolume(USER_ID, 100);
expect(applyToParticipant().microphone.volumes).toEqual([1]);
ParticipantVolume.setVolume(USER_ID, 200);
expect(applyToParticipant().microphone.volumes).toEqual([4]);
});
it('screen-share audio composes stream and output volume', () => {
setRemoteVoicePlaybackBoost(IDENTITY, 3);
StreamAudioPrefs.setVolume(STREAM_KEY, 100);
expect(applyToParticipant().screenShareAudio.volumes).toEqual([1]);
StreamAudioPrefs.setVolume(STREAM_KEY, 200);
outputVolumePercent.mockReturnValue(200);
expect(applyToParticipant().screenShareAudio.volumes).toEqual([12]);
});
it('leaves the microphone gain untouched when the stream volume changes', () => {
StreamAudioPrefs.setVolume(STREAM_KEY, 50);
const atQuietStream = applyToParticipant().microphone.volumes;
StreamAudioPrefs.setVolume(STREAM_KEY, 200);
const atLoudStream = applyToParticipant().microphone.volumes;
expect(atQuietStream).toEqual([1]);
expect(atLoudStream).toEqual(atQuietStream);
});
it('leaves the screen share gain untouched when the per-user volume changes', () => {
StreamAudioPrefs.setVolume(STREAM_KEY, 100);
ParticipantVolume.setVolume(USER_ID, 50);
const atQuietUser = applyToParticipant().screenShareAudio.volumes;
ParticipantVolume.setVolume(USER_ID, 200);
const atLoudUser = applyToParticipant().screenShareAudio.volumes;
expect(atQuietUser).toEqual([1]);
expect(atLoudUser).toEqual(atQuietUser);
});
it('leaves the microphone publication enabled when the stream is muted', () => {
StreamAudioPrefs.setMuted(STREAM_KEY, true);
expect(applyToParticipant().microphone.enabled).toEqual([true]);
});
it('leaves screen share audio enabled when the participant is locally muted', () => {
ParticipantVolume.setLocalMute(USER_ID, true);
expect(applyToParticipant().screenShareAudio.enabled).toEqual([true]);
});
it('disables screen share audio while deafened', () => {
effectiveDeaf.mockReturnValue(true);
expect(applyToParticipant().screenShareAudio.enabled).toEqual([false]);
});
it('still applies the enabled state when a track volume write throws', () => {
ParticipantVolume.setLocalMute(USER_ID, true);
const {microphone} = applyToParticipant({throwOnMicrophoneVolume: true});
expect(microphone.volumes).toEqual([1]);
expect(microphone.enabled).toEqual([false]);
});
});
@@ -610,7 +610,7 @@ class VoiceSettings {
this.adaptiveScreenShareQualityPrefV2 = value; this.adaptiveScreenShareQualityPrefV2 = value;
} }
handleConnectionOpen(user: UserPrivate): void { handleGatewayReady(user: UserPrivate): void {
if (this.isUserPremium(user.premium_type)) { if (this.isUserPremium(user.premium_type)) {
return; return;
} }
@@ -1,53 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeEach, describe, expect, it, vi} from 'vitest';
const voiceBackgroundsAvailable = vi.fn<() => boolean>();
vi.mock('@app/features/voice/utils/VoiceBackgroundAvailability', () => ({
areVoiceBackgroundsAvailable: () => voiceBackgroundsAvailable(),
}));
vi.mock('@app/features/app/utils/LimitResolverAdapter', () => ({
LimitResolver: {resolve: () => 0},
}));
vi.mock('@app/features/app/utils/LimitUtils', () => ({
isLimitToggleEnabled: () => false,
}));
const {default: VoiceSettings, NONE_BACKGROUND_ID} = await import('./VoiceSettings');
const SAVED_BACKGROUND = {id: 'saved-background', createdAt: 1, mediaKind: 'static' as const};
describe('VoiceSettings background persistence when voice backgrounds are unavailable', () => {
beforeEach(() => {
voiceBackgroundsAvailable.mockReturnValue(true);
VoiceSettings.updateSettings({backgroundImages: [SAVED_BACKGROUND], backgroundImageId: SAVED_BACKGROUND.id});
voiceBackgroundsAvailable.mockReturnValue(false);
});
it('keeps the uploaded background list across a no-op revalidation', () => {
VoiceSettings.updateSettings({});
expect(VoiceSettings.backgroundImages).toEqual([SAVED_BACKGROUND]);
});
it('keeps the stored selection so it comes back once backgrounds are available again', () => {
VoiceSettings.updateSettings({});
expect(VoiceSettings.backgroundImageId).toBe(SAVED_BACKGROUND.id);
voiceBackgroundsAvailable.mockReturnValue(true);
expect(VoiceSettings.getBackgroundImageId()).toBe(SAVED_BACKGROUND.id);
});
it('never hands out an active background while the feature is unavailable', () => {
VoiceSettings.updateSettings({});
expect(VoiceSettings.getBackgroundImageId()).toBe(NONE_BACKGROUND_ID);
expect(VoiceSettings.getBackgroundImages()).toEqual([SAVED_BACKGROUND]);
});
it('still drops a selection that no longer matches a stored background', () => {
VoiceSettings.updateSettings({backgroundImages: []});
expect(VoiceSettings.backgroundImageId).toBe(NONE_BACKGROUND_ID);
});
});
@@ -1,76 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeAll, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/voice/utils/VoiceBackgroundAvailability', () => ({
areVoiceBackgroundsAvailable: () => true,
}));
vi.mock('@app/features/app/utils/LimitResolverAdapter', () => ({
LimitResolver: {resolve: () => 0},
}));
vi.mock('@app/features/app/utils/LimitUtils', () => ({
isLimitToggleEnabled: () => false,
}));
const STORAGE_KEY = 'VoiceSettings';
async function loadVoiceSettingsWith(stored: Record<string, unknown> | null) {
vi.resetModules();
const {default: AppStorage} = await import('@app/features/platform/state/PersistentStorage');
AppStorage.removeItem(STORAGE_KEY);
if (stored) {
AppStorage.setItem(STORAGE_KEY, JSON.stringify({...stored, __mps__: {version: 1}}));
}
const {default: VoiceSettings} = await import('./VoiceSettings');
const {awaitHydration} = await import('@app/features/platform/utils/MobXPersistence');
await awaitHydration(STORAGE_KEY);
const readStored = (): Record<string, unknown> => {
const raw = AppStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
};
return {VoiceSettings, readStored};
}
describe('VoiceSettings output volume recalibration migration', () => {
beforeAll(async () => {
await import('./VoiceSettings');
});
it('resets a boosted output volume once and records that it ran', async () => {
const {VoiceSettings, readStored} = await loadVoiceSettingsWith({outputVolume: 200});
expect(VoiceSettings.outputVolume).toBe(100);
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
expect(readStored().outputVolumeRecalibratedV1).toBe(true);
});
it('never touches an output volume the user set after the migration ran', async () => {
const {VoiceSettings} = await loadVoiceSettingsWith({
outputVolume: 200,
outputVolumeRecalibratedV1: true,
});
expect(VoiceSettings.outputVolume).toBe(200);
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
});
it('leaves an output volume at or below unity alone', async () => {
const {VoiceSettings, readStored} = await loadVoiceSettingsWith({outputVolume: 60});
expect(VoiceSettings.outputVolume).toBe(60);
expect(readStored().outputVolumeRecalibratedV1).toBe(true);
});
it('marks an empty stored record as recalibrated', async () => {
const {VoiceSettings, readStored} = await loadVoiceSettingsWith({});
expect(VoiceSettings.outputVolume).toBe(100);
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
expect(readStored().outputVolumeRecalibratedV1).toBe(true);
});
it('marks a fresh install as recalibrated so a later launch cannot reset a volume set today', async () => {
const {VoiceSettings} = await loadVoiceSettingsWith(null);
expect(VoiceSettings.outputVolume).toBe(100);
expect(VoiceSettings.outputVolumeRecalibratedV1).toBe(true);
});
});