mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): keep hydrated state across gateway session resumes (#1862)
This commit is contained in:
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});
|
||||
}
|
||||
|
||||
handleConnectionOpen(authSessionIdHash: string): void {
|
||||
handleGatewayReady(authSessionIdHash: string): void {
|
||||
this.authSessionIdHash = authSessionIdHash;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ class ChannelPins {
|
||||
};
|
||||
}
|
||||
|
||||
handleConnectionOpen(): void {
|
||||
handleGatewayReady(): void {
|
||||
this.channelPins = {};
|
||||
this.channelState = {};
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ class Channels {
|
||||
}
|
||||
|
||||
@action
|
||||
handleConnectionOpen({channels}: {channels: ReadonlyArray<WireChannel>}): void {
|
||||
handleGatewayReady({channels}: {channels: ReadonlyArray<WireChannel>}): void {
|
||||
this.channelsById.clear();
|
||||
this.channelsByGuildId.clear();
|
||||
this.privateChannelList = EMPTY_CHANNELS;
|
||||
|
||||
@@ -62,7 +62,7 @@ class UserConnection {
|
||||
this.fetched = false;
|
||||
}
|
||||
|
||||
handleConnectionOpen(): void {
|
||||
handleGatewayReady(): void {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,7 +447,7 @@ class Emoji {
|
||||
UnicodeEmojis.setDefaultSkinTone(skinTone);
|
||||
}
|
||||
|
||||
handleConnectionOpen({guilds}: {guilds: ReadonlyArray<GuildReadyData>}): void {
|
||||
handleGatewayReady({guilds}: {guilds: ReadonlyArray<GuildReadyData>}): void {
|
||||
emojiGuildRegistry.reset();
|
||||
for (const guild of guilds) {
|
||||
emojiGuildRegistry.updateGuild(guild.id, guild.emojis);
|
||||
|
||||
@@ -91,7 +91,7 @@ class Sticker {
|
||||
return this.sortByFrecency(filtered);
|
||||
}
|
||||
|
||||
handleConnectionOpen(guilds: ReadonlyArray<GuildReadyData>): void {
|
||||
handleGatewayReady(guilds: ReadonlyArray<GuildReadyData>): void {
|
||||
this.guildStickers.clear();
|
||||
this.stickerById.clear();
|
||||
for (const guild of guilds) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -59,7 +59,7 @@ class GuildCount {
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
handleConnectionOpen(guilds: ReadonlyArray<HydratableGuild>): void {
|
||||
handleGatewayReady(guilds: ReadonlyArray<HydratableGuild>): void {
|
||||
this.hydrate(guilds);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class GuildList {
|
||||
}
|
||||
|
||||
@action
|
||||
handleConnectionOpen(guilds: ReadonlyArray<GuildReadyData>): void {
|
||||
handleGatewayReady(guilds: ReadonlyArray<GuildReadyData>): void {
|
||||
const availableGuilds: Array<Guild> = [];
|
||||
for (const guild of guilds) {
|
||||
if (guild.unavailable) continue;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -41,7 +41,7 @@ class GuildVerification {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
}
|
||||
|
||||
handleConnectionOpen(): void {
|
||||
handleGatewayReady(): void {
|
||||
this.recomputeAll();
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class Guilds {
|
||||
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);
|
||||
if (availableGuilds.length === 0) {
|
||||
this.guilds = {};
|
||||
|
||||
@@ -132,7 +132,7 @@ class GuildMembers {
|
||||
return Object.keys(this.members[guildId] ?? {}).length;
|
||||
}
|
||||
|
||||
handleConnectionOpen(guilds: Array<GuildReadyData>): void {
|
||||
handleGatewayReady(guilds: Array<GuildReadyData>): void {
|
||||
this.members = {};
|
||||
this.nonMembers = {};
|
||||
this.loadedGuilds.clear();
|
||||
|
||||
@@ -336,7 +336,7 @@ class MemberSearch {
|
||||
updateMembers(allMembers);
|
||||
}
|
||||
|
||||
handleConnectionOpen(): void {
|
||||
handleGatewayReady(): void {
|
||||
if (worker) {
|
||||
this.terminate();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -185,7 +185,7 @@ class MessageReferences {
|
||||
this.cleanupChannelMessages(channelId);
|
||||
}
|
||||
|
||||
handleConnectionOpen(): void {
|
||||
handleGatewayReady(): void {
|
||||
this.deletedMessageIds.clear();
|
||||
this.cachedMessages.clear();
|
||||
this.referenceVersions.clear();
|
||||
|
||||
@@ -296,7 +296,7 @@ class Messages {
|
||||
}
|
||||
|
||||
@action
|
||||
handleConnectionOpen(): boolean {
|
||||
handleGatewayReady(): boolean {
|
||||
const selectedChannelId = SelectedChannel.currentChannelId;
|
||||
let didHydrateSelectedChannel = false;
|
||||
if (selectedChannelId) {
|
||||
|
||||
@@ -43,7 +43,7 @@ class SavedMessages {
|
||||
this.fetched = false;
|
||||
}
|
||||
|
||||
handleConnectionOpen(): void {
|
||||
handleGatewayReady(): void {
|
||||
this.savedMessages = [];
|
||||
this.missingSavedMessages = [];
|
||||
this.fetched = false;
|
||||
|
||||
@@ -32,7 +32,7 @@ class ScheduledMessages {
|
||||
this.scheduledMessages = [];
|
||||
}
|
||||
|
||||
handleConnectionOpen(): void {
|
||||
handleGatewayReady(): void {
|
||||
this.scheduledMessages = [];
|
||||
this.fetched = false;
|
||||
this.fetching = false;
|
||||
|
||||
@@ -96,7 +96,7 @@ class MentionFeed {
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectionOpen(): void {
|
||||
handleGatewayReady(): void {
|
||||
this.recentMentions = [];
|
||||
this.fetched = false;
|
||||
this.hasMore = true;
|
||||
|
||||
@@ -86,7 +86,7 @@ class Permission {
|
||||
return PermissionUtils.isRoleHigher(wireGuild, me.id, myHighestRole, otherHighestRole);
|
||||
}
|
||||
|
||||
handleConnectionOpen(): void {
|
||||
handleGatewayReady(): void {
|
||||
this.rebuildPermissions();
|
||||
}
|
||||
|
||||
|
||||
@@ -256,11 +256,7 @@ class Presence {
|
||||
this.bumpPresenceVersion();
|
||||
}
|
||||
|
||||
handleConnectionOpen(
|
||||
user: UserPrivate,
|
||||
guilds: Array<GuildReadyData>,
|
||||
presences?: ReadonlyArray<WirePresence>,
|
||||
): void {
|
||||
handleGatewayReady(user: UserPrivate, guilds: Array<GuildReadyData>, presences?: ReadonlyArray<WirePresence>): void {
|
||||
TransientPresence.clear();
|
||||
const localStatus = LocalPresence.getStatus();
|
||||
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 {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<ChannelId> {
|
||||
return Array.from(this.states.keys());
|
||||
}
|
||||
@@ -409,7 +389,7 @@ class ReadStates {
|
||||
this.notifyChange(channelId);
|
||||
}
|
||||
|
||||
handleConnectionOpen(action: {
|
||||
handleGatewayReady(action: {
|
||||
readState: Array<GatewayReadState>;
|
||||
readStateProto?: string;
|
||||
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,
|
||||
updateChannelOverride: action,
|
||||
updateChannelOverrides: action,
|
||||
handleConnectionOpen: action,
|
||||
handleGatewayReady: action,
|
||||
notifyChange: action,
|
||||
});
|
||||
}
|
||||
@@ -564,7 +564,7 @@ class UserGuildSettings {
|
||||
);
|
||||
}
|
||||
|
||||
handleConnectionOpen(userGuildSettings: Array<GatewayGuildSettings>): void {
|
||||
handleGatewayReady(userGuildSettings: Array<GatewayGuildSettings>): void {
|
||||
this.clearAllTimers();
|
||||
this.guildSettings.clear();
|
||||
this.mutedChannelsByGuild.clear();
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -595,7 +595,7 @@ class UserSettingsState {
|
||||
LocalPresence.updatePresence();
|
||||
}
|
||||
|
||||
handleConnectionOpen(userSettings: unknown): void {
|
||||
handleGatewayReady(userSettings: unknown): void {
|
||||
this.updateUserSettings(userSettings);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class WebAuthnCredentials {
|
||||
logger.debug(`Set WebAuthn credentials: ${credentials.length}`);
|
||||
}
|
||||
|
||||
handleConnectionOpen(credentials: ReadonlyArray<WebAuthnCredential> | undefined): void {
|
||||
handleGatewayReady(credentials: ReadonlyArray<WebAuthnCredential> | undefined): void {
|
||||
this.setCredentials(credentials ?? []);
|
||||
}
|
||||
|
||||
|
||||
@@ -1845,8 +1845,8 @@ class MediaEngineFacade extends Store {
|
||||
);
|
||||
}
|
||||
|
||||
handleConnectionOpen(guilds: Array<GuildReadyData>): void {
|
||||
voiceEngineV2AppVoiceStateAdapter.handleConnectionOpen(guilds);
|
||||
handleGatewayReady(guilds: Array<GuildReadyData>): void {
|
||||
voiceEngineV2AppVoiceStateAdapter.handleGatewayReady(guilds);
|
||||
}
|
||||
|
||||
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', () => {
|
||||
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();
|
||||
|
||||
@@ -95,7 +95,7 @@ export class VoiceEngineV2AppVoiceStateAdapter extends Store {
|
||||
return connectionId != null && this.ignoredConnectionIds.has(connectionId);
|
||||
}
|
||||
|
||||
handleConnectionOpen(guilds: Array<GuildReadyData>): void {
|
||||
handleGatewayReady(guilds: Array<GuildReadyData>): void {
|
||||
this.send({
|
||||
type: 'connection.open',
|
||||
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;
|
||||
}
|
||||
|
||||
handleConnectionOpen(user: UserPrivate): void {
|
||||
handleGatewayReady(user: UserPrivate): void {
|
||||
if (this.isUserPremium(user.premium_type)) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user