diff --git a/fluxer_app/src/features/auth/state/AuthSession.ts b/fluxer_app/src/features/auth/state/AuthSession.ts index 23cefb698..8488848ff 100644 --- a/fluxer_app/src/features/auth/state/AuthSession.ts +++ b/fluxer_app/src/features/auth/state/AuthSession.ts @@ -16,7 +16,7 @@ class AuthSessions { makeAutoObservable(this, {}, {autoBind: true}); } - handleConnectionOpen(authSessionIdHash: string): void { + handleGatewayReady(authSessionIdHash: string): void { this.authSessionIdHash = authSessionIdHash; } diff --git a/fluxer_app/src/features/auth/state/Authentication.ts b/fluxer_app/src/features/auth/state/Authentication.ts index 5effd4f9f..039366e83 100644 --- a/fluxer_app/src/features/auth/state/Authentication.ts +++ b/fluxer_app/src/features/auth/state/Authentication.ts @@ -1,6 +1,5 @@ // 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 type {ValueOf} from '@fluxer/constants/src/ValueOf'; import type {UserPrivate} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; @@ -73,7 +72,7 @@ class Authentication { } @action - handleConnectionOpen({user}: {user: UserPrivate}): void { + handleGatewayReady({user}: {user: UserPrivate}): void { SessionManager.setUserId(user.id); SessionManager.handleConnectionReady(); } @@ -128,7 +127,9 @@ class Authentication { this.mfaTicket = null; this.mfaMethods = null; if (!options?.skipRedirect) { - RouterUtils.replaceWith('/login'); + void import('@app/features/navigation/utils/RouterUtils').then((module) => { + module.replaceWith('/login'); + }); } } diff --git a/fluxer_app/src/features/channel/state/ChannelPins.ts b/fluxer_app/src/features/channel/state/ChannelPins.ts index 455381bc0..0e96fb035 100644 --- a/fluxer_app/src/features/channel/state/ChannelPins.ts +++ b/fluxer_app/src/features/channel/state/ChannelPins.ts @@ -104,7 +104,7 @@ class ChannelPins { }; } - handleConnectionOpen(): void { + handleGatewayReady(): void { this.channelPins = {}; this.channelState = {}; } diff --git a/fluxer_app/src/features/channel/state/Channels.ts b/fluxer_app/src/features/channel/state/Channels.ts index 1c1b6eafa..c95b81574 100644 --- a/fluxer_app/src/features/channel/state/Channels.ts +++ b/fluxer_app/src/features/channel/state/Channels.ts @@ -235,7 +235,7 @@ class Channels { } @action - handleConnectionOpen({channels}: {channels: ReadonlyArray}): void { + handleGatewayReady({channels}: {channels: ReadonlyArray}): void { this.channelsById.clear(); this.channelsByGuildId.clear(); this.privateChannelList = EMPTY_CHANNELS; diff --git a/fluxer_app/src/features/connection/state/UserConnection.ts b/fluxer_app/src/features/connection/state/UserConnection.ts index c549475bf..7f59c35b3 100644 --- a/fluxer_app/src/features/connection/state/UserConnection.ts +++ b/fluxer_app/src/features/connection/state/UserConnection.ts @@ -62,7 +62,7 @@ class UserConnection { this.fetched = false; } - handleConnectionOpen(): void { + handleGatewayReady(): void { this.reset(); } } diff --git a/fluxer_app/src/features/emoji/state/Emoji.ts b/fluxer_app/src/features/emoji/state/Emoji.ts index 51df3915c..7b68b0cba 100644 --- a/fluxer_app/src/features/emoji/state/Emoji.ts +++ b/fluxer_app/src/features/emoji/state/Emoji.ts @@ -447,7 +447,7 @@ class Emoji { UnicodeEmojis.setDefaultSkinTone(skinTone); } - handleConnectionOpen({guilds}: {guilds: ReadonlyArray}): void { + handleGatewayReady({guilds}: {guilds: ReadonlyArray}): void { emojiGuildRegistry.reset(); for (const guild of guilds) { emojiGuildRegistry.updateGuild(guild.id, guild.emojis); diff --git a/fluxer_app/src/features/emoji/state/EmojiSticker.ts b/fluxer_app/src/features/emoji/state/EmojiSticker.ts index fd0e8229c..213ca2757 100644 --- a/fluxer_app/src/features/emoji/state/EmojiSticker.ts +++ b/fluxer_app/src/features/emoji/state/EmojiSticker.ts @@ -91,7 +91,7 @@ class Sticker { return this.sortByFrecency(filtered); } - handleConnectionOpen(guilds: ReadonlyArray): void { + handleGatewayReady(guilds: ReadonlyArray): void { this.guildStickers.clear(); this.stickerById.clear(); for (const guild of guilds) { diff --git a/fluxer_app/src/features/gateway/events/GatewayReady.ts b/fluxer_app/src/features/gateway/events/GatewayReady.ts index f0e54d85c..d58df371f 100644 --- a/fluxer_app/src/features/gateway/events/GatewayReady.ts +++ b/fluxer_app/src/features/gateway/events/GatewayReady.ts @@ -130,7 +130,7 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext) if (data.rtc_regions) { RtcRegions.setRegions(data.rtc_regions); } - Users.handleConnectionOpen(data.user); + Users.handleGatewayReady(data.user); if (data.users && data.users.length > 0) { Users.cacheUsers(data.users); } @@ -146,44 +146,44 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext) void accountStorage.updateAccountUserData(user.id, userData); void AccountManager.updateAccountUserData(user.id, userData); } - VoiceSettings.handleConnectionOpen(data.user); - Authentication.handleConnectionOpen({user: data.user}); + VoiceSettings.handleGatewayReady(data.user); + Authentication.handleGatewayReady({user: data.user}); void PremiumCommands.refreshPremiumState().catch((error) => { logger.warn('Failed to refresh premium state after READY', error); }); - Guilds.handleConnectionOpen({guilds}); - UserSettings.handleConnectionOpen(data.user_settings); - GuildList.handleConnectionOpen(guilds); - GuildCount.handleConnectionOpen(guilds); - GuildMembers.handleConnectionOpen(guilds); - GuildVerification.handleConnectionOpen(); - Channels.handleConnectionOpen({channels}); + Guilds.handleGatewayReady({guilds}); + UserSettings.handleGatewayReady(data.user_settings); + GuildList.handleGatewayReady(guilds); + GuildCount.handleGatewayReady(guilds); + GuildMembers.handleGatewayReady(guilds); + GuildVerification.handleGatewayReady(); + Channels.handleGatewayReady({channels}); if (data.auth_session_id_hash) { - AuthSession.handleConnectionOpen(data.auth_session_id_hash); + AuthSession.handleGatewayReady(data.auth_session_id_hash); } else { logger.warn('READY missing auth_session_id_hash; continuing without AuthSession init'); } - MessageReactions.handleConnectionOpen(); - Sticker.handleConnectionOpen(guilds); - Emoji.handleConnectionOpen({guilds}); - Permission.handleConnectionOpen(); - MemberSearch.handleConnectionOpen(); - SavedMessages.handleConnectionOpen(); - MentionFeed.handleConnectionOpen(); - ScheduledMessages.handleConnectionOpen(); - ChannelPins.handleConnectionOpen(); - UserConnection.handleConnectionOpen(); - UserGuildSettings.handleConnectionOpen(data.user_guild_settings ?? []); - WebAuthnCredentials.handleConnectionOpen(data.webauthn_credentials); - ReadStates.handleConnectionOpen({ + MessageReactions.handleGatewayReady(); + Sticker.handleGatewayReady(guilds); + Emoji.handleGatewayReady({guilds}); + Permission.handleGatewayReady(); + MemberSearch.handleGatewayReady(); + SavedMessages.handleGatewayReady(); + MentionFeed.handleGatewayReady(); + ScheduledMessages.handleGatewayReady(); + ChannelPins.handleGatewayReady(); + UserConnection.handleGatewayReady(); + UserGuildSettings.handleGatewayReady(data.user_guild_settings ?? []); + WebAuthnCredentials.handleGatewayReady(data.webauthn_credentials); + ReadStates.handleGatewayReady({ readState: data.read_states ?? [], readStateProto: data.read_state_proto, channels, }); - GuildReadState.handleConnectionOpen(); - Presence.handleConnectionOpen(data.user, guilds, data.presences); - MediaEngine.handleConnectionOpen(guilds); + GuildReadState.handleGatewayReady(); + Presence.handleGatewayReady(data.user, guilds, data.presences); + MediaEngine.handleGatewayReady(guilds); Initialization.setReady(); context.setReady(); - Messages.handleConnectionOpen(); + Messages.handleGatewayReady(); } diff --git a/fluxer_app/src/features/gateway/transport/GatewayConnection.ts b/fluxer_app/src/features/gateway/transport/GatewayConnection.ts index 6c22bcf13..b85bd1400 100644 --- a/fluxer_app/src/features/gateway/transport/GatewayConnection.ts +++ b/fluxer_app/src/features/gateway/transport/GatewayConnection.ts @@ -24,6 +24,7 @@ import { type GatewayVoiceStateUpdateParams, } from '@app/features/gateway/transport/GatewaySocket'; 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 Messages from '@app/features/messaging/state/MessagingMessages'; import SelectedGuild from '@app/features/navigation/state/SelectedGuild'; @@ -74,6 +75,7 @@ class GatewayConnection { private isFatalCrashInProgress: boolean = false; private connectionInterrupted: boolean = false; private connectionGraceTimer: number | null = null; + private previousSessionId: string | null = null; constructor() { makeAutoObservable< @@ -87,6 +89,7 @@ class GatewayConnection { | 'beginConnectionGrace' | 'clearConnectionGrace' | 'connectionGraceTimer' + | 'previousSessionId' >( this, { @@ -94,11 +97,12 @@ class GatewayConnection { beginConnectionGrace: action.bound, clearConnectionGrace: action.bound, connectionGraceTimer: false, + previousSessionId: false, setToken: action.bound, sendInvisiblePresenceForCurrentSession: action.bound, retireCurrentSession: action.bound, logout: action.bound, - handleConnectionOpen: action.bound, + handleGatewayReady: action.bound, handleConnectionResumed: action.bound, handleConnectionClosed: action.bound, cleanupSocket: action.bound, @@ -187,8 +191,10 @@ class GatewayConnection { private createHandlerContext(): GatewayHandlerContext { return { socket: this.socket, - previousSessionId: null, - setPreviousSessionId: (_id: string) => {}, + previousSessionId: this.previousSessionId, + setPreviousSessionId: (id: string) => { + this.previousSessionId = id; + }, setReady: () => { runInAction(() => { this.isReady = true; @@ -359,7 +365,7 @@ class GatewayConnection { const readyData = data as { session_id: string; }; - this.handleConnectionOpen(readyData.session_id); + this.handleGatewayReady(readyData.session_id); }), ); socket.on( @@ -581,7 +587,7 @@ class GatewayConnection { this.completedGuildSyncSessions = {}; } - handleConnectionOpen(sessionId: string): void { + handleGatewayReady(sessionId: string): void { this.isConnected = true; this.isConnecting = false; this.isReady = true; @@ -592,6 +598,7 @@ class GatewayConnection { TypingIndicator.reset(); QuickSwitcher.recomputeIfOpen(); this.flushPendingGuildSync(); + GuildMembers.handleConnectionResumed(); } handleConnectionResumed(): void { @@ -604,6 +611,7 @@ class GatewayConnection { TypingIndicator.reset(); QuickSwitcher.recomputeIfOpen(); this.flushPendingGuildSync(); + GuildMembers.handleConnectionResumed(); } handleConnectionClosed(code: number): void { diff --git a/fluxer_app/src/features/guild/state/GuildCount.ts b/fluxer_app/src/features/guild/state/GuildCount.ts index beb978932..7499f318e 100644 --- a/fluxer_app/src/features/guild/state/GuildCount.ts +++ b/fluxer_app/src/features/guild/state/GuildCount.ts @@ -59,7 +59,7 @@ class GuildCount { this.scheduleFlush(); } - handleConnectionOpen(guilds: ReadonlyArray): void { + handleGatewayReady(guilds: ReadonlyArray): void { this.hydrate(guilds); } diff --git a/fluxer_app/src/features/guild/state/GuildList.ts b/fluxer_app/src/features/guild/state/GuildList.ts index d4b39d609..e7fe2c0ac 100644 --- a/fluxer_app/src/features/guild/state/GuildList.ts +++ b/fluxer_app/src/features/guild/state/GuildList.ts @@ -27,7 +27,7 @@ class GuildList { } @action - handleConnectionOpen(guilds: ReadonlyArray): void { + handleGatewayReady(guilds: ReadonlyArray): void { const availableGuilds: Array = []; for (const guild of guilds) { if (guild.unavailable) continue; diff --git a/fluxer_app/src/features/guild/state/GuildReadState.ts b/fluxer_app/src/features/guild/state/GuildReadState.ts index 46dbdf028..3cbb855d6 100644 --- a/fluxer_app/src/features/guild/state/GuildReadState.ts +++ b/fluxer_app/src/features/guild/state/GuildReadState.ts @@ -112,7 +112,7 @@ class GuildReadState { () => { const {all, changes} = ReadStates.consumePendingChanges(); if (all) { - this.handleConnectionOpen(); + this.handleGatewayReady(); return; } if (changes.length === 0) { @@ -384,7 +384,7 @@ class GuildReadState { return false; } - handleConnectionOpen(): void { + handleGatewayReady(): void { this.guildStates.clear(); this.unreadGuildIds.clear(); this.updateCounter = 0; @@ -454,7 +454,7 @@ class GuildReadState { } handleRecomputeAll(): void { - this.handleConnectionOpen(); + this.handleGatewayReady(); } handleWindowFocused(): void { diff --git a/fluxer_app/src/features/guild/state/GuildVerification.ts b/fluxer_app/src/features/guild/state/GuildVerification.ts index 534e82c70..4fb5a5fdd 100644 --- a/fluxer_app/src/features/guild/state/GuildVerification.ts +++ b/fluxer_app/src/features/guild/state/GuildVerification.ts @@ -41,7 +41,7 @@ class GuildVerification { makeAutoObservable(this, {}, {autoBind: true}); } - handleConnectionOpen(): void { + handleGatewayReady(): void { this.recomputeAll(); } diff --git a/fluxer_app/src/features/guild/state/Guilds.ts b/fluxer_app/src/features/guild/state/Guilds.ts index bffb72fdd..19d6bbb28 100644 --- a/fluxer_app/src/features/guild/state/Guilds.ts +++ b/fluxer_app/src/features/guild/state/Guilds.ts @@ -44,7 +44,7 @@ class Guilds { return Object.values(this.guilds).filter((guild) => guild.ownerId === userId); } - handleConnectionOpen({guilds}: {guilds: Array}): void { + handleGatewayReady({guilds}: {guilds: Array}): void { const availableGuilds = guilds.filter((guild) => !guild.unavailable); if (availableGuilds.length === 0) { this.guilds = {}; diff --git a/fluxer_app/src/features/member/state/GuildMembers.ts b/fluxer_app/src/features/member/state/GuildMembers.ts index 4f4c8be36..ace7756fc 100644 --- a/fluxer_app/src/features/member/state/GuildMembers.ts +++ b/fluxer_app/src/features/member/state/GuildMembers.ts @@ -132,7 +132,7 @@ class GuildMembers { return Object.keys(this.members[guildId] ?? {}).length; } - handleConnectionOpen(guilds: Array): void { + handleGatewayReady(guilds: Array): void { this.members = {}; this.nonMembers = {}; this.loadedGuilds.clear(); diff --git a/fluxer_app/src/features/member/state/MemberSearch.ts b/fluxer_app/src/features/member/state/MemberSearch.ts index 5db6150f2..4c9d87c30 100644 --- a/fluxer_app/src/features/member/state/MemberSearch.ts +++ b/fluxer_app/src/features/member/state/MemberSearch.ts @@ -336,7 +336,7 @@ class MemberSearch { updateMembers(allMembers); } - handleConnectionOpen(): void { + handleGatewayReady(): void { if (worker) { this.terminate(); } diff --git a/fluxer_app/src/features/messaging/state/MessageReactions.ts b/fluxer_app/src/features/messaging/state/MessageReactions.ts index d53ca7664..83045998b 100644 --- a/fluxer_app/src/features/messaging/state/MessageReactions.ts +++ b/fluxer_app/src/features/messaging/state/MessageReactions.ts @@ -178,7 +178,7 @@ export class MessageReactionsManager { return entry; } - handleConnectionOpen(): void { + handleGatewayReady(): void { const messageIds = Array.from(this.messageStates.keys()); const reactionKeys = Array.from(this.reactors.keys()); for (const key of reactionKeys) this.retireReactionEntry(key); diff --git a/fluxer_app/src/features/messaging/state/MessageReferences.ts b/fluxer_app/src/features/messaging/state/MessageReferences.ts index 7ef417419..440d5790e 100644 --- a/fluxer_app/src/features/messaging/state/MessageReferences.ts +++ b/fluxer_app/src/features/messaging/state/MessageReferences.ts @@ -185,7 +185,7 @@ class MessageReferences { this.cleanupChannelMessages(channelId); } - handleConnectionOpen(): void { + handleGatewayReady(): void { this.deletedMessageIds.clear(); this.cachedMessages.clear(); this.referenceVersions.clear(); diff --git a/fluxer_app/src/features/messaging/state/MessagingMessages.ts b/fluxer_app/src/features/messaging/state/MessagingMessages.ts index 8e7bf4981..3bf72ab12 100644 --- a/fluxer_app/src/features/messaging/state/MessagingMessages.ts +++ b/fluxer_app/src/features/messaging/state/MessagingMessages.ts @@ -296,7 +296,7 @@ class Messages { } @action - handleConnectionOpen(): boolean { + handleGatewayReady(): boolean { const selectedChannelId = SelectedChannel.currentChannelId; let didHydrateSelectedChannel = false; if (selectedChannelId) { diff --git a/fluxer_app/src/features/messaging/state/SavedMessages.ts b/fluxer_app/src/features/messaging/state/SavedMessages.ts index 55be48bfe..1f8427cef 100644 --- a/fluxer_app/src/features/messaging/state/SavedMessages.ts +++ b/fluxer_app/src/features/messaging/state/SavedMessages.ts @@ -43,7 +43,7 @@ class SavedMessages { this.fetched = false; } - handleConnectionOpen(): void { + handleGatewayReady(): void { this.savedMessages = []; this.missingSavedMessages = []; this.fetched = false; diff --git a/fluxer_app/src/features/messaging/state/ScheduledMessages.ts b/fluxer_app/src/features/messaging/state/ScheduledMessages.ts index eac62816c..81ca0cfea 100644 --- a/fluxer_app/src/features/messaging/state/ScheduledMessages.ts +++ b/fluxer_app/src/features/messaging/state/ScheduledMessages.ts @@ -32,7 +32,7 @@ class ScheduledMessages { this.scheduledMessages = []; } - handleConnectionOpen(): void { + handleGatewayReady(): void { this.scheduledMessages = []; this.fetched = false; this.fetching = false; diff --git a/fluxer_app/src/features/notification/state/MentionFeed.ts b/fluxer_app/src/features/notification/state/MentionFeed.ts index f2df3c5c6..0989573c7 100644 --- a/fluxer_app/src/features/notification/state/MentionFeed.ts +++ b/fluxer_app/src/features/notification/state/MentionFeed.ts @@ -96,7 +96,7 @@ class MentionFeed { } } - handleConnectionOpen(): void { + handleGatewayReady(): void { this.recentMentions = []; this.fetched = false; this.hasMore = true; diff --git a/fluxer_app/src/features/permissions/state/Permission.ts b/fluxer_app/src/features/permissions/state/Permission.ts index 81f25f0b2..4f0985bdc 100644 --- a/fluxer_app/src/features/permissions/state/Permission.ts +++ b/fluxer_app/src/features/permissions/state/Permission.ts @@ -86,7 +86,7 @@ class Permission { return PermissionUtils.isRoleHigher(wireGuild, me.id, myHighestRole, otherHighestRole); } - handleConnectionOpen(): void { + handleGatewayReady(): void { this.rebuildPermissions(); } diff --git a/fluxer_app/src/features/presence/state/Presence.ts b/fluxer_app/src/features/presence/state/Presence.ts index 859740d45..ad536d083 100644 --- a/fluxer_app/src/features/presence/state/Presence.ts +++ b/fluxer_app/src/features/presence/state/Presence.ts @@ -256,11 +256,7 @@ class Presence { this.bumpPresenceVersion(); } - handleConnectionOpen( - user: UserPrivate, - guilds: Array, - presences?: ReadonlyArray, - ): void { + handleGatewayReady(user: UserPrivate, guilds: Array, presences?: ReadonlyArray): void { TransientPresence.clear(); const localStatus = LocalPresence.getStatus(); const localCustomStatus = LocalPresence.customStatus; diff --git a/fluxer_app/src/features/read_state/state/ReadStates.ts b/fluxer_app/src/features/read_state/state/ReadStates.ts index 211729e7c..39c15eb99 100644 --- a/fluxer_app/src/features/read_state/state/ReadStates.ts +++ b/fluxer_app/src/features/read_state/state/ReadStates.ts @@ -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 {resolveReadStateIncomingMessageDecision} from '@app/features/read_state/state/read_states/ReadStateIncomingMessageMachine'; 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 { ACK_BATCH_DELAY_MS, ACK_BATCH_SIZE, @@ -50,8 +46,6 @@ import {observable, runInAction} from 'mobx'; export type {GatewayReadState}; -export type {UnreadJumpAnchor}; - const logger = new Logger('ReadStates'); class ReadStates { @@ -351,20 +345,6 @@ class ReadStates { 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 { return Array.from(this.states.keys()); } @@ -409,7 +389,7 @@ class ReadStates { this.notifyChange(channelId); } - handleConnectionOpen(action: { + handleGatewayReady(action: { readState: Array; readStateProto?: string; channels: Array; diff --git a/fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.test.ts b/fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.test.ts deleted file mode 100644 index 2b020c345..000000000 --- a/fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.test.ts +++ /dev/null @@ -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 { - 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}); - }); -}); diff --git a/fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.ts b/fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.ts deleted file mode 100644 index b7c4433b5..000000000 --- a/fluxer_app/src/features/read_state/state/read_states/ReadStateUnreadAnchor.ts +++ /dev/null @@ -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}; -} diff --git a/fluxer_app/src/features/user/state/UserGuildSettings.ts b/fluxer_app/src/features/user/state/UserGuildSettings.ts index 8b90d6437..424464e54 100644 --- a/fluxer_app/src/features/user/state/UserGuildSettings.ts +++ b/fluxer_app/src/features/user/state/UserGuildSettings.ts @@ -128,7 +128,7 @@ class UserGuildSettings { updateGuildSettings: action, updateChannelOverride: action, updateChannelOverrides: action, - handleConnectionOpen: action, + handleGatewayReady: action, notifyChange: action, }); } @@ -564,7 +564,7 @@ class UserGuildSettings { ); } - handleConnectionOpen(userGuildSettings: Array): void { + handleGatewayReady(userGuildSettings: Array): void { this.clearAllTimers(); this.guildSettings.clear(); this.mutedChannelsByGuild.clear(); diff --git a/fluxer_app/src/features/user/state/UserProfile.ts b/fluxer_app/src/features/user/state/UserProfile.ts index f84619057..75354d071 100644 --- a/fluxer_app/src/features/user/state/UserProfile.ts +++ b/fluxer_app/src/features/user/state/UserProfile.ts @@ -23,7 +23,7 @@ class UserProfile { return this.profiles[userId]?.[guildId ?? ME] ?? null; } - handleConnectionOpen(): void { + handleGatewayReady(): void { Object.values(this.profileTimeouts).forEach(clearTimeout); this.profiles = {}; this.profileTimeouts = {}; diff --git a/fluxer_app/src/features/user/state/UserSettings.ts b/fluxer_app/src/features/user/state/UserSettings.ts index 62a15e3c6..ac93205db 100644 --- a/fluxer_app/src/features/user/state/UserSettings.ts +++ b/fluxer_app/src/features/user/state/UserSettings.ts @@ -595,7 +595,7 @@ class UserSettingsState { LocalPresence.updatePresence(); } - handleConnectionOpen(userSettings: unknown): void { + handleGatewayReady(userSettings: unknown): void { this.updateUserSettings(userSettings); } diff --git a/fluxer_app/src/features/user/state/Users.ts b/fluxer_app/src/features/user/state/Users.ts index bf9c21c7e..29a95e553 100644 --- a/fluxer_app/src/features/user/state/Users.ts +++ b/fluxer_app/src/features/user/state/Users.ts @@ -1,6 +1,5 @@ // 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 {User} from '@app/features/user/models/User'; import type {UserPrivate, User as WireUser} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; @@ -94,7 +93,7 @@ class Users { } @action - handleConnectionOpen(currentUser: UserPrivate): void { + handleGatewayReady(currentUser: UserPrivate): void { const userRecord = new User(currentUser); this.users = { [currentUser.id]: userRecord, @@ -102,6 +101,7 @@ class Users { this.userCount = 1; if (!userRecord.isClaimed()) { setTimeout(async () => { + const {openClaimAccountModal} = await import('@app/features/auth/components/modals/ClaimAccountModal'); openClaimAccountModal(); }, 1000); } diff --git a/fluxer_app/src/features/user/state/WebAuthnCredentials.ts b/fluxer_app/src/features/user/state/WebAuthnCredentials.ts index 01488a12f..f7d16af85 100644 --- a/fluxer_app/src/features/user/state/WebAuthnCredentials.ts +++ b/fluxer_app/src/features/user/state/WebAuthnCredentials.ts @@ -24,7 +24,7 @@ class WebAuthnCredentials { logger.debug(`Set WebAuthn credentials: ${credentials.length}`); } - handleConnectionOpen(credentials: ReadonlyArray | undefined): void { + handleGatewayReady(credentials: ReadonlyArray | undefined): void { this.setCredentials(credentials ?? []); } diff --git a/fluxer_app/src/features/voice/engine/MediaEngineFacade.ts b/fluxer_app/src/features/voice/engine/MediaEngineFacade.ts index a499204ad..890435c46 100644 --- a/fluxer_app/src/features/voice/engine/MediaEngineFacade.ts +++ b/fluxer_app/src/features/voice/engine/MediaEngineFacade.ts @@ -1845,8 +1845,8 @@ class MediaEngineFacade extends Store { ); } - handleConnectionOpen(guilds: Array): void { - voiceEngineV2AppVoiceStateAdapter.handleConnectionOpen(guilds); + handleGatewayReady(guilds: Array): void { + voiceEngineV2AppVoiceStateAdapter.handleGatewayReady(guilds); } handleGuildCreate(guild: GuildReadyData): void { diff --git a/fluxer_app/src/features/voice/engine/VoiceOutputDeviceSync.test.ts b/fluxer_app/src/features/voice/engine/VoiceOutputDeviceSync.test.ts deleted file mode 100644 index ac91899c5..000000000 --- a/fluxer_app/src/features/voice/engine/VoiceOutputDeviceSync.test.ts +++ /dev/null @@ -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 => undefined, -}); - -vi.stubGlobal('HTMLMediaElement', FakeMediaElement); - -interface FakeRoomOptions { - webAudioMix?: boolean; - audioContextSupportsSetSinkId?: boolean; - switchActiveDevice?: () => Promise; -} - -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'); - }); -}); diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppRemoteSpeakingAdapter.test.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppRemoteSpeakingAdapter.test.ts deleted file mode 100644 index 383f2c420..000000000 --- a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppRemoteSpeakingAdapter.test.ts +++ /dev/null @@ -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)); - }); -}); diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppVoiceStateAdapter.test.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppVoiceStateAdapter.test.ts index 24c4b1e1e..96c5b9fef 100644 --- a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppVoiceStateAdapter.test.ts +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppVoiceStateAdapter.test.ts @@ -40,7 +40,7 @@ function guild(id: string, voiceStates: ReadonlyArray = []): GuildRe describe('VoiceEngineV2AppVoiceStateAdapter', () => { it('keeps gateway voice-state indexes as immutable replacement projections', () => { const adapter = new VoiceEngineV2AppVoiceStateAdapter(); - adapter.handleConnectionOpen([ + adapter.handleGatewayReady([ guild('guild-1', [voiceState({connection_id: 'connection-1', channel_id: 'channel-1'})]), ]); const before = adapter.getConnectionVoiceStates(); diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppVoiceStateAdapter.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppVoiceStateAdapter.ts index c64f4bcf9..983cab71d 100644 --- a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppVoiceStateAdapter.ts +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppVoiceStateAdapter.ts @@ -95,7 +95,7 @@ export class VoiceEngineV2AppVoiceStateAdapter extends Store { return connectionId != null && this.ignoredConnectionIds.has(connectionId); } - handleConnectionOpen(guilds: Array): void { + handleGatewayReady(guilds: Array): void { this.send({ type: 'connection.open', guilds: guilds.map((guild) => filterIgnoredGuildVoiceStates(this.ignoredConnectionIds, guild)), diff --git a/fluxer_app/src/features/voice/state/ParticipantVolume.test.ts b/fluxer_app/src/features/voice/state/ParticipantVolume.test.ts deleted file mode 100644 index 92079bb16..000000000 --- a/fluxer_app/src/features/voice/state/ParticipantVolume.test.ts +++ /dev/null @@ -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; - enabled: Array; -} - -( - 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 = []; - const enabled: Array = []; - 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([ - [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]); - }); -}); diff --git a/fluxer_app/src/features/voice/state/VoiceSettings.ts b/fluxer_app/src/features/voice/state/VoiceSettings.ts index 52ecdf5b8..afa9a6d2e 100644 --- a/fluxer_app/src/features/voice/state/VoiceSettings.ts +++ b/fluxer_app/src/features/voice/state/VoiceSettings.ts @@ -610,7 +610,7 @@ class VoiceSettings { this.adaptiveScreenShareQualityPrefV2 = value; } - handleConnectionOpen(user: UserPrivate): void { + handleGatewayReady(user: UserPrivate): void { if (this.isUserPremium(user.premium_type)) { return; } diff --git a/fluxer_app/src/features/voice/state/VoiceSettingsBackgroundPersistence.test.ts b/fluxer_app/src/features/voice/state/VoiceSettingsBackgroundPersistence.test.ts deleted file mode 100644 index 5a342fbd3..000000000 --- a/fluxer_app/src/features/voice/state/VoiceSettingsBackgroundPersistence.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/fluxer_app/src/features/voice/state/VoiceSettingsOutputVolumeMigration.test.ts b/fluxer_app/src/features/voice/state/VoiceSettingsOutputVolumeMigration.test.ts deleted file mode 100644 index f49389dac..000000000 --- a/fluxer_app/src/features/voice/state/VoiceSettingsOutputVolumeMigration.test.ts +++ /dev/null @@ -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 | 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 => { - 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); - }); -});