/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {action, makeAutoObservable, reaction, runInAction} from 'mobx'; import Config from '~/Config'; import {FAVORITES_GUILD_ID, GatewayIdentifyFlags} from '~/Constants'; import {getPreferredCompression} from '~/lib/GatewayCompression'; import {type GatewayErrorData, GatewaySocket, type GatewaySocketProperties, GatewayState} from '~/lib/GatewaySocket'; import {Logger} from '~/lib/Logger'; import SessionManager from '~/lib/SessionManager'; import FavoriteMemeStore from '~/stores/FavoriteMemeStore'; import GeoIPStore from '~/stores/GeoIPStore'; import GuildNSFWAgreeStore from '~/stores/GuildNSFWAgreeStore'; import InitializationStore from '~/stores/InitializationStore'; import LayerManager from '~/stores/LayerManager'; import LocalPresenceStore from '~/stores/LocalPresenceStore'; import MemberSearchStore from '~/stores/MemberSearchStore'; import MessageStore from '~/stores/MessageStore'; import PermissionStore from '~/stores/PermissionStore'; import QuickSwitcherStore from '~/stores/QuickSwitcherStore'; import RuntimeConfigStore from '~/stores/RuntimeConfigStore'; import SelectedGuildStore from '~/stores/SelectedGuildStore'; import TypingStore from '~/stores/TypingStore'; import MediaEngineStore from '~/stores/voice/MediaEngineFacade'; import {getGatewayClientProperties} from '~/utils/ClientInfoUtils'; import {createHandlerRegistry, type GatewayHandlerContext, type GatewayHandlerRegistry} from './handlers'; const logger = new Logger('ConnectionStore'); interface DesiredSession { token: string | null; userIdHint: string | null; } class ConnectionStore { socket: GatewaySocket | null = null; isConnected: boolean = false; isConnecting: boolean = false; isReady: boolean = false; sessionId: string | null = null; private handlerRegistry: GatewayHandlerRegistry; private onlineListener: (() => void) | null = null; private offlineListener: (() => void) | null = null; private netInfoUnsubscribe: (() => void) | null = null; private generation: number = 0; private desired: DesiredSession | null = null; private pendingGuildSyncId: string | null = null; private syncedGuildSessions: Record = {}; private initialGuildIdAtIdentify: string | null = null; constructor() { makeAutoObservable< this, 'cleanupSocket' | 'handleGatewayDispatch' | 'ensureGuildActiveAndSynced' | 'flushPendingGuildSync' >( this, { startSession: action.bound, logout: action.bound, handleConnectionOpen: action.bound, handleConnectionResumed: action.bound, handleConnectionClosed: action.bound, cleanupSocket: action.bound, handleGatewayDispatch: action.bound, ensureGuildActiveAndSynced: action.bound, flushPendingGuildSync: action.bound, syncGuildIfNeeded: action.bound, }, {autoBind: true}, ); this.handlerRegistry = createHandlerRegistry(); this.setupPresenceSync(); this.setupSelectedGuildSync(); } private setupPresenceSync(): void { reaction( () => LocalPresenceStore.presenceFingerprint, () => { const presence = LocalPresenceStore.getPresence(); this.socket?.updatePresence(presence.status, presence.afk, presence.mobile, presence.custom_status); }, ); } private setupSelectedGuildSync(): void { reaction( () => ({ guildId: SelectedGuildStore.selectedGuildId, nonce: SelectedGuildStore.selectionNonce, }), ({guildId}) => { if (!guildId || guildId === FAVORITES_GUILD_ID) { this.pendingGuildSyncId = null; return; } this.ensureGuildActiveAndSynced(guildId, {reason: 'select'}); }, ); reaction( () => this.isReady, (ready) => { if (ready) { this.flushPendingGuildSync(); } }, ); } private createHandlerContext(): GatewayHandlerContext { return { socket: this.socket, previousSessionId: null, setPreviousSessionId: (_id: string) => {}, setReady: () => { runInAction(() => { this.isReady = true; this.isConnecting = false; }); this.flushPendingGuildSync(); }, }; } private removeNetworkListeners(): void { if (this.netInfoUnsubscribe) { this.netInfoUnsubscribe(); this.netInfoUnsubscribe = null; } if (this.onlineListener) { window.removeEventListener('online', this.onlineListener); this.onlineListener = null; } if (this.offlineListener) { window.removeEventListener('offline', this.offlineListener); this.offlineListener = null; } } private cleanupSocket(): void { const socket = this.socket; this.socket = null; if (socket) { try { socket.disconnect(1000, 'Cleaning up socket', false); } catch (err) { logger.warn('Error while disconnecting socket during cleanup', err); } } this.removeNetworkListeners(); } private createGatewaySocket(token: string, properties: GatewaySocketProperties, generation: number): GatewaySocket { const gatewayUrl = RuntimeConfigStore.gatewayEndpoint; const presence = LocalPresenceStore.getPresence(); const compression = getPreferredCompression(); logger.info(`Using gateway compression: ${compression}`); const identifyFlags = Config.PUBLIC_PROJECT_ENV === 'canary' ? GatewayIdentifyFlags.USE_CANARY_API : 0; const initialGuildId = SelectedGuildStore.selectedGuildId ?? null; this.initialGuildIdAtIdentify = initialGuildId; const socket = new GatewaySocket( gatewayUrl, { apiVersion: Config.PUBLIC_API_VERSION, token, properties, presence: { status: presence.status, afk: presence.afk, mobile: presence.mobile, custom_status: presence.custom_status, }, compression, identifyFlags, initialGuildId, }, (url: string) => RuntimeConfigStore.wrapGatewayUrlWithProxy(url), ); const isCurrent = (): boolean => this.socket === socket && this.generation === generation; socket.on('dispatch', (eventType: string, data: unknown) => { if (!isCurrent()) { return; } this.handleGatewayDispatch(eventType, data); }); socket.on('gatewayError', (error: GatewayErrorData) => { if (!isCurrent()) { return; } this.handleGatewayError(error); }); this.removeNetworkListeners(); this.onlineListener = () => { if (!isCurrent()) { return; } socket.handleNetworkStatusChange(true); }; this.offlineListener = () => { if (!isCurrent()) { return; } socket.handleNetworkStatusChange(false); }; window.addEventListener('online', this.onlineListener); window.addEventListener('offline', this.offlineListener); socket.on( 'stateChange', action((newState: GatewayState) => { if (!isCurrent()) { return; } this.isConnected = newState === GatewayState.Connected; this.isConnecting = newState === GatewayState.Connecting || newState === GatewayState.Reconnecting; if (newState === GatewayState.Disconnected) { this.isReady = false; SessionManager.handleConnectionFailed(); this.syncedGuildSessions = {}; } }), ); socket.on( 'ready', action((data: unknown) => { if (!isCurrent()) { return; } const readyData = data as {session_id: string}; this.handleConnectionOpen(readyData.session_id); }), ); socket.on( 'resumed', action(() => { if (!isCurrent()) { return; } this.handleConnectionResumed(); }), ); return socket; } private ensureGuildActiveAndSynced(guildId: string, options: {force?: boolean; reason?: string} = {}): void { if (!guildId || guildId === FAVORITES_GUILD_ID) { return; } const socket = this.socket; if (!socket || !this.isReady) { this.pendingGuildSyncId = guildId; return; } const sessionId = this.sessionId ?? null; const force = options.force ?? false; const alreadySyncedSession = this.syncedGuildSessions[guildId] ?? null; if (!force && alreadySyncedSession === sessionId) { return; } try { socket.updateGuildSubscriptions({ subscriptions: { [guildId]: { active: true, sync: true, }, }, }); this.syncedGuildSessions[guildId] = sessionId; this.pendingGuildSyncId = null; } catch (err) { logger.warn('Failed to update guild subscriptions; will retry when possible', err); this.pendingGuildSyncId = guildId; } } syncGuildIfNeeded(guildId: string, reason?: string): void { this.ensureGuildActiveAndSynced(guildId, {reason}); } private flushPendingGuildSync(): void { const guildId = this.pendingGuildSyncId ?? SelectedGuildStore.selectedGuildId; if (!guildId || guildId === FAVORITES_GUILD_ID) { return; } this.ensureGuildActiveAndSynced(guildId, {reason: 'flush'}); } async startSession(token?: string): Promise { const userIdHint = SessionManager.userId ?? null; const desired: DesiredSession = { token: token ?? null, userIdHint, }; if (this.isConnecting && this.desired) { const sameToken = this.desired.token === desired.token; const sameUser = this.desired.userIdHint === desired.userIdHint; if (sameToken && sameUser) { return; } } this.desired = desired; const generation = ++this.generation; runInAction(() => { this.isConnecting = true; this.isReady = false; }); SessionManager.handleConnectionStarted(); if (this.socket) { this.cleanupSocket(); } InitializationStore.setConnecting(); let gatewayToken: string | null = desired.token; try { if (!gatewayToken) { const stored = SessionManager.token; if (!stored) { runInAction(() => { this.isConnecting = false; this.isReady = false; }); SessionManager.handleConnectionFailed(); return; } gatewayToken = stored; } if (this.generation !== generation) { return; } let properties: GatewaySocketProperties; try { properties = await getGatewayClientProperties({ latitude: GeoIPStore.latitude, longitude: GeoIPStore.longitude, }); } catch (err) { logger.error('Failed to gather client metadata for gateway identification', err); runInAction(() => { this.isConnecting = false; this.isReady = false; }); SessionManager.handleConnectionFailed(); return; } if (this.generation !== generation) { return; } const socket = this.createGatewaySocket(gatewayToken, properties, generation); runInAction(() => { if (this.generation === generation) { this.socket = socket; } }); if (this.socket) { this.socket.connect(); } } catch (err) { logger.error('Failed to connect to gateway', err); runInAction(() => { this.isConnecting = false; this.isReady = false; }); SessionManager.handleConnectionFailed(); } } logout(): void { this.cleanupSocket(); MessageStore.handleSessionInvalidated(); FavoriteMemeStore.reset(); GuildNSFWAgreeStore.reset(); InitializationStore.reset(); MemberSearchStore.handleLogout(); this.isConnected = false; this.isConnecting = false; this.isReady = false; this.sessionId = null; this.desired = null; this.pendingGuildSyncId = null; this.syncedGuildSessions = {}; } handleConnectionOpen(sessionId: string): void { this.isConnected = true; this.isConnecting = false; this.isReady = true; this.sessionId = sessionId; this.markInitialGuildSynced(sessionId); SessionManager.handleConnectionReady(); LocalPresenceStore.updatePresence(); TypingStore.reset(); QuickSwitcherStore.recomputeIfOpen(); this.flushPendingGuildSync(); } handleConnectionResumed(): void { this.isConnected = true; this.isConnecting = false; this.isReady = true; SessionManager.handleConnectionReady(); this.markInitialGuildSynced(this.sessionId); LocalPresenceStore.updatePresence(); TypingStore.reset(); QuickSwitcherStore.recomputeIfOpen(); this.flushPendingGuildSync(); } handleConnectionClosed(code: number): void { SessionManager.handleConnectionClosed(code); this.isConnected = false; this.isConnecting = false; this.isReady = false; LocalPresenceStore.updatePresence(); PermissionStore.handleConnectionClose(); this.syncedGuildSessions = {}; if (code === 4004) { LayerManager.closeAll(); MessageStore.handleConnectionClosed(); this.cleanupSocket(); this.sessionId = null; } } private markInitialGuildSynced(sessionId: string | null): void { const guildId = this.initialGuildIdAtIdentify; if (!guildId || !sessionId) { return; } this.syncedGuildSessions[guildId] = sessionId; } private handleGatewayError(error: GatewayErrorData): void { logger.warn(`Gateway error: [${error.code}] ${error.message}`); MediaEngineStore.handleGatewayError(error); } private handleGatewayDispatch(eventType: string, data: unknown): void { const handler = this.handlerRegistry.get(eventType); if (!handler) { return; } const context = this.createHandlerContext(); handler(data, context); } } export default new ConnectionStore();