initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, reaction, runInAction} from 'mobx';
import {StickerAnimationOptions} from '~/Constants';
import {Logger} from '~/lib/Logger';
import {makePersistent} from '~/lib/MobXPersistence';
import AccessibilityStore from '~/stores/AccessibilityStore';
import UserSettingsStore, {type UserSettings} from '~/stores/UserSettingsStore';
const logger = new Logger('AccessibilityOverrideStore');
export type AnimationOverrides = Readonly<{
gifAutoPlayDirty: boolean;
animateEmojiDirty: boolean;
animateStickersDirty: boolean;
}>;
class AccessibilityOverrideStore {
gifAutoPlayDirty = false;
animateEmojiDirty = false;
animateStickersDirty = false;
private overrideTimeout: number | null = null;
private initialized = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
this.bindToAccessibilityStore();
setTimeout(() => {
runInAction(() => {
this.initialized = true;
logger.debug('AccessibilityOverrideStore initialized');
if (AccessibilityStore.useReducedMotion && UserSettingsStore.isHydrated()) {
this.applyReducedMotionOverrides();
}
});
}, 1000);
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'AccessibilityOverrideStore', [
'gifAutoPlayDirty',
'animateEmojiDirty',
'animateStickersDirty',
]);
}
private bindToAccessibilityStore(): void {
reaction(
() => AccessibilityStore.useReducedMotion,
(isReducedMotion, previous) => {
if (previous === false && isReducedMotion) {
this.scheduleReducedMotionOverrides();
}
},
{fireImmediately: false},
);
}
private scheduleReducedMotionOverrides(): void {
const schedule = () => {
if (this.overrideTimeout != null) {
clearTimeout(this.overrideTimeout);
}
this.overrideTimeout = window.setTimeout(() => {
this.applyReducedMotionOverrides();
this.overrideTimeout = null;
}, 50);
};
schedule();
window.setTimeout(schedule, 0);
window.setTimeout(schedule, 10);
}
private applyReducedMotionOverrides(): void {
if (!UserSettingsStore.isHydrated()) {
logger.debug('Skipping reduced motion overrides before user settings hydration');
return;
}
if (!this.initialized) {
logger.debug('Skipping reduced motion overrides during initialization');
return;
}
const updates: Partial<UserSettings> = {};
const {gifAutoPlay, animateEmoji, animateStickers} = UserSettingsStore;
if (!this.gifAutoPlayDirty && gifAutoPlay) {
updates.gifAutoPlay = false;
}
if (!this.animateEmojiDirty && animateEmoji) {
updates.animateEmoji = false;
}
if (!this.animateStickersDirty && animateStickers === StickerAnimationOptions.ALWAYS_ANIMATE) {
updates.animateStickers = StickerAnimationOptions.ANIMATE_ON_INTERACTION;
}
if (Object.keys(updates).length > 0) {
void UserSettingsStore.saveSettings(updates).catch((error) => {
logger.error('Failed to apply reduced motion overrides', error);
});
}
this.gifAutoPlayDirty = false;
this.animateEmojiDirty = false;
this.animateStickersDirty = false;
logger.debug('Applied reduced motion overrides');
}
updateUserSettings(userSettings: unknown): void {
if (!this.initialized) {
logger.debug('Skipping updateUserSettings during initialization');
return;
}
const currentUserSettings = UserSettingsStore;
const isReducedMotion = AccessibilityStore.useReducedMotion;
if (!isReducedMotion) return;
if (!isRecord(userSettings)) {
return;
}
const gifAutoPlayValue = userSettings.gif_auto_play;
const animateEmojiValue = userSettings.animate_emoji;
const animateStickersValue = userSettings.animate_stickers;
if (typeof gifAutoPlayValue === 'boolean' && gifAutoPlayValue !== currentUserSettings.gifAutoPlay) {
this.gifAutoPlayDirty = true;
logger.debug('Marked gifAutoPlay as dirty');
}
if (typeof animateEmojiValue === 'boolean' && animateEmojiValue !== currentUserSettings.animateEmoji) {
this.animateEmojiDirty = true;
logger.debug('Marked animateEmoji as dirty');
}
if (typeof animateStickersValue === 'number' && animateStickersValue !== currentUserSettings.animateStickers) {
this.animateStickersDirty = true;
logger.debug('Marked animateStickers as dirty');
}
this.applyReducedMotionOverrides();
}
markDirty(setting: keyof AnimationOverrides): void {
this[setting] = true;
logger.debug(`Marked ${setting} as dirty`);
}
isOverriddenByReducedMotion(setting: 'gif_auto_play' | 'animate_emoji' | 'animate_stickers'): boolean {
const isReducedMotion = AccessibilityStore.useReducedMotion;
if (!isReducedMotion) return false;
switch (setting) {
case 'gif_auto_play':
return !this.gifAutoPlayDirty;
case 'animate_emoji':
return !this.animateEmojiDirty;
case 'animate_stickers':
return !this.animateStickersDirty;
default:
return false;
}
}
}
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
export default new AccessibilityOverrideStore();

View File

@@ -0,0 +1,468 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, reaction} from 'mobx';
import {StickerAnimationOptions} from '~/Constants';
import {makePersistent} from '~/lib/MobXPersistence';
import {loadTheme, persistTheme} from '~/lib/themePersistence';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
export enum ChannelTypingIndicatorMode {
AVATARS = 0,
INDICATOR_ONLY = 1,
HIDDEN = 2,
}
export enum MediaDimensionSize {
SMALL = 'small',
LARGE = 'large',
}
export enum DMMessagePreviewMode {
ALL = 0,
UNREAD_ONLY = 1,
NONE = 2,
}
export interface AccessibilitySettings {
saturationFactor: number;
alwaysUnderlineLinks: boolean;
enableTextSelection: boolean;
showMessageSendButton: boolean;
showTextareaFocusRing: boolean;
hideKeyboardHints: boolean;
escapeExitsKeyboardMode: boolean;
syncReducedMotionWithSystem: boolean;
reducedMotionOverride: boolean | null;
messageGroupSpacing: number;
messageGutter: number;
fontSize: number;
showUserAvatarsInCompactMode: boolean;
mobileStickerAnimationOverridden: boolean;
mobileGifAutoPlayOverridden: boolean;
mobileAnimateEmojiOverridden: boolean;
mobileStickerAnimationValue: number;
mobileGifAutoPlayValue: boolean;
mobileAnimateEmojiValue: boolean;
syncThemeAcrossDevices: boolean;
localThemeOverride: string | null;
showMessageDividers: boolean;
autoSendTenorGifs: boolean;
showGiftButton: boolean;
showGifButton: boolean;
showMemesButton: boolean;
showStickersButton: boolean;
showEmojiButton: boolean;
showUploadButton: boolean;
showMediaFavoriteButton: boolean;
showMediaDownloadButton: boolean;
showMediaDeleteButton: boolean;
showSuppressEmbedsButton: boolean;
showGifIndicator: boolean;
showAttachmentExpiryIndicator: boolean;
useBrowserLocaleForTimeFormat: boolean;
channelTypingIndicatorMode: ChannelTypingIndicatorMode;
showSelectedChannelTypingIndicator: boolean;
showMessageActionBar: boolean;
showMessageActionBarQuickReactions: boolean;
showMessageActionBarShiftExpand: boolean;
showMessageActionBarOnlyMoreButton: boolean;
showDefaultEmojisInExpressionAutocomplete: boolean;
showCustomEmojisInExpressionAutocomplete: boolean;
showStickersInExpressionAutocomplete: boolean;
showMemesInExpressionAutocomplete: boolean;
attachmentMediaDimensionSize: MediaDimensionSize;
embedMediaDimensionSize: MediaDimensionSize;
voiceChannelJoinRequiresDoubleClick: boolean;
customThemeCss: string | null;
showFavorites: boolean;
zoomLevel: number;
dmMessagePreviewMode: DMMessagePreviewMode;
enableTTSCommand: boolean;
ttsRate: number;
}
const getDefaultDmMessagePreviewMode = (): DMMessagePreviewMode =>
MobileLayoutStore.isMobileLayout() ? DMMessagePreviewMode.ALL : DMMessagePreviewMode.NONE;
class AccessibilityStore {
saturationFactor = 1;
alwaysUnderlineLinks = false;
enableTextSelection = false;
showMessageSendButton = true;
showTextareaFocusRing = true;
hideKeyboardHints = false;
escapeExitsKeyboardMode = false;
syncReducedMotionWithSystem = true;
reducedMotionOverride: boolean | null = null;
messageGroupSpacing = 16;
messageGutter = 16;
fontSize = 16;
showUserAvatarsInCompactMode = false;
mobileStickerAnimationOverridden = false;
mobileGifAutoPlayOverridden = false;
mobileAnimateEmojiOverridden = false;
mobileStickerAnimationValue: number = StickerAnimationOptions.ANIMATE_ON_INTERACTION;
mobileGifAutoPlayValue = false;
mobileAnimateEmojiValue = true;
syncThemeAcrossDevices = true;
localThemeOverride: string | null = null;
showMessageDividers = false;
autoSendTenorGifs = true;
showGiftButton = true;
showGifButton = true;
showMemesButton = true;
showStickersButton = true;
showEmojiButton = true;
showUploadButton = true;
showMediaFavoriteButton = true;
showMediaDownloadButton = true;
showMediaDeleteButton = true;
showSuppressEmbedsButton = true;
showGifIndicator = true;
showAttachmentExpiryIndicator = true;
useBrowserLocaleForTimeFormat = false;
channelTypingIndicatorMode: ChannelTypingIndicatorMode = ChannelTypingIndicatorMode.AVATARS;
showSelectedChannelTypingIndicator = false;
showMessageActionBar = true;
showMessageActionBarQuickReactions = true;
showMessageActionBarShiftExpand = true;
showMessageActionBarOnlyMoreButton = false;
showDefaultEmojisInExpressionAutocomplete = true;
showCustomEmojisInExpressionAutocomplete = true;
showStickersInExpressionAutocomplete = true;
showMemesInExpressionAutocomplete = true;
attachmentMediaDimensionSize = MediaDimensionSize.LARGE;
embedMediaDimensionSize = MediaDimensionSize.SMALL;
voiceChannelJoinRequiresDoubleClick = false;
systemReducedMotion = false;
customThemeCss: string | null = null;
showFavorites = true;
zoomLevel = 1.0;
dmMessagePreviewMode: DMMessagePreviewMode = getDefaultDmMessagePreviewMode();
enableTTSCommand = true;
ttsRate = 1.0;
mediaQuery: MediaQueryList | null = null;
constructor() {
makeAutoObservable(this, {mediaQuery: false}, {autoBind: true});
this.bootstrapThemeOverride();
this.initPersistence();
this.initializeMotionDetection();
}
private bootstrapThemeOverride(): void {
void loadTheme().then((explicitTheme) => {
if (explicitTheme) {
this.syncThemeAcrossDevices = false;
this.localThemeOverride = explicitTheme;
}
});
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'AccessibilityStore', [
'saturationFactor',
'alwaysUnderlineLinks',
'enableTextSelection',
'showMessageSendButton',
'showTextareaFocusRing',
'hideKeyboardHints',
'escapeExitsKeyboardMode',
'syncReducedMotionWithSystem',
'reducedMotionOverride',
'messageGroupSpacing',
'messageGutter',
'fontSize',
'showUserAvatarsInCompactMode',
'mobileStickerAnimationOverridden',
'mobileGifAutoPlayOverridden',
'mobileAnimateEmojiOverridden',
'mobileStickerAnimationValue',
'mobileGifAutoPlayValue',
'mobileAnimateEmojiValue',
'syncThemeAcrossDevices',
'localThemeOverride',
'showMessageDividers',
'autoSendTenorGifs',
'showGiftButton',
'showGifButton',
'showMemesButton',
'showStickersButton',
'showEmojiButton',
'showUploadButton',
'showMediaFavoriteButton',
'showMediaDownloadButton',
'showMediaDeleteButton',
'showSuppressEmbedsButton',
'showGifIndicator',
'showAttachmentExpiryIndicator',
'useBrowserLocaleForTimeFormat',
'channelTypingIndicatorMode',
'showSelectedChannelTypingIndicator',
'showMessageActionBar',
'showMessageActionBarQuickReactions',
'showMessageActionBarShiftExpand',
'showMessageActionBarOnlyMoreButton',
'showDefaultEmojisInExpressionAutocomplete',
'showCustomEmojisInExpressionAutocomplete',
'showStickersInExpressionAutocomplete',
'showMemesInExpressionAutocomplete',
'attachmentMediaDimensionSize',
'embedMediaDimensionSize',
'voiceChannelJoinRequiresDoubleClick',
'customThemeCss',
'showFavorites',
'zoomLevel',
'dmMessagePreviewMode',
'enableTTSCommand',
'ttsRate',
]);
}
private initializeMotionDetection() {
if (window.matchMedia) {
this.mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
this.systemReducedMotion = this.mediaQuery.matches;
this.mediaQuery.addEventListener('change', this.handleSystemMotionChange);
}
}
private handleSystemMotionChange = (event: MediaQueryListEvent) => {
this.systemReducedMotion = event.matches;
};
dispose() {
if (this.mediaQuery) {
this.mediaQuery.removeEventListener('change', this.handleSystemMotionChange);
this.mediaQuery = null;
}
}
get textSelectionEnabled(): boolean {
return MobileLayoutStore.isMobileLayout() ? false : this.enableTextSelection;
}
get useReducedMotion(): boolean {
return this.syncReducedMotionWithSystem ? this.systemReducedMotion : (this.reducedMotionOverride ?? false);
}
get messageGroupSpacingValue(): number {
return MobileLayoutStore.isMobileLayout() ? 16 : this.messageGroupSpacing;
}
get messageGutterValue(): number {
return MobileLayoutStore.isMobileLayout() ? 12 : this.messageGutter;
}
updateSettings(data: Readonly<Partial<AccessibilitySettings>>): void {
const validated = this.validateSettings(data);
if (validated.saturationFactor !== undefined) this.saturationFactor = validated.saturationFactor;
if (validated.alwaysUnderlineLinks !== undefined) this.alwaysUnderlineLinks = validated.alwaysUnderlineLinks;
if (validated.enableTextSelection !== undefined) this.enableTextSelection = validated.enableTextSelection;
if (validated.showMessageSendButton !== undefined) this.showMessageSendButton = validated.showMessageSendButton;
if (validated.showTextareaFocusRing !== undefined) this.showTextareaFocusRing = validated.showTextareaFocusRing;
if (validated.hideKeyboardHints !== undefined) this.hideKeyboardHints = validated.hideKeyboardHints;
if (validated.escapeExitsKeyboardMode !== undefined)
this.escapeExitsKeyboardMode = validated.escapeExitsKeyboardMode;
if (validated.syncReducedMotionWithSystem !== undefined)
this.syncReducedMotionWithSystem = validated.syncReducedMotionWithSystem;
if (validated.reducedMotionOverride !== undefined) this.reducedMotionOverride = validated.reducedMotionOverride;
if (validated.messageGroupSpacing !== undefined) this.messageGroupSpacing = validated.messageGroupSpacing;
if (validated.messageGutter !== undefined) this.messageGutter = validated.messageGutter;
if (validated.fontSize !== undefined) this.fontSize = validated.fontSize;
if (validated.showUserAvatarsInCompactMode !== undefined)
this.showUserAvatarsInCompactMode = validated.showUserAvatarsInCompactMode;
if (validated.mobileStickerAnimationOverridden !== undefined)
this.mobileStickerAnimationOverridden = validated.mobileStickerAnimationOverridden;
if (validated.mobileGifAutoPlayOverridden !== undefined)
this.mobileGifAutoPlayOverridden = validated.mobileGifAutoPlayOverridden;
if (validated.mobileAnimateEmojiOverridden !== undefined)
this.mobileAnimateEmojiOverridden = validated.mobileAnimateEmojiOverridden;
if (validated.mobileStickerAnimationValue !== undefined)
this.mobileStickerAnimationValue = validated.mobileStickerAnimationValue;
if (validated.mobileGifAutoPlayValue !== undefined) this.mobileGifAutoPlayValue = validated.mobileGifAutoPlayValue;
if (validated.mobileAnimateEmojiValue !== undefined)
this.mobileAnimateEmojiValue = validated.mobileAnimateEmojiValue;
if (validated.syncThemeAcrossDevices !== undefined) this.syncThemeAcrossDevices = validated.syncThemeAcrossDevices;
if (validated.localThemeOverride !== undefined) {
this.localThemeOverride = validated.localThemeOverride;
persistTheme(this.localThemeOverride);
}
if (validated.showMessageDividers !== undefined) this.showMessageDividers = validated.showMessageDividers;
if (validated.autoSendTenorGifs !== undefined) this.autoSendTenorGifs = validated.autoSendTenorGifs;
if (validated.showGiftButton !== undefined) this.showGiftButton = validated.showGiftButton;
if (validated.showGifButton !== undefined) this.showGifButton = validated.showGifButton;
if (validated.showMemesButton !== undefined) this.showMemesButton = validated.showMemesButton;
if (validated.showStickersButton !== undefined) this.showStickersButton = validated.showStickersButton;
if (validated.showEmojiButton !== undefined) this.showEmojiButton = validated.showEmojiButton;
if (validated.showUploadButton !== undefined) this.showUploadButton = validated.showUploadButton;
if (validated.showMediaFavoriteButton !== undefined)
this.showMediaFavoriteButton = validated.showMediaFavoriteButton;
if (validated.showMediaDownloadButton !== undefined)
this.showMediaDownloadButton = validated.showMediaDownloadButton;
if (validated.showMediaDeleteButton !== undefined) this.showMediaDeleteButton = validated.showMediaDeleteButton;
if (validated.showSuppressEmbedsButton !== undefined)
this.showSuppressEmbedsButton = validated.showSuppressEmbedsButton;
if (validated.showGifIndicator !== undefined) this.showGifIndicator = validated.showGifIndicator;
if (validated.showAttachmentExpiryIndicator !== undefined)
this.showAttachmentExpiryIndicator = validated.showAttachmentExpiryIndicator;
if (validated.useBrowserLocaleForTimeFormat !== undefined)
this.useBrowserLocaleForTimeFormat = validated.useBrowserLocaleForTimeFormat;
if (validated.channelTypingIndicatorMode !== undefined)
this.channelTypingIndicatorMode = validated.channelTypingIndicatorMode;
if (validated.showSelectedChannelTypingIndicator !== undefined)
this.showSelectedChannelTypingIndicator = validated.showSelectedChannelTypingIndicator;
if (validated.showMessageActionBar !== undefined) this.showMessageActionBar = validated.showMessageActionBar;
if (validated.showMessageActionBarQuickReactions !== undefined)
this.showMessageActionBarQuickReactions = validated.showMessageActionBarQuickReactions;
if (validated.showMessageActionBarShiftExpand !== undefined)
this.showMessageActionBarShiftExpand = validated.showMessageActionBarShiftExpand;
if (validated.showMessageActionBarOnlyMoreButton !== undefined)
this.showMessageActionBarOnlyMoreButton = validated.showMessageActionBarOnlyMoreButton;
if (validated.showDefaultEmojisInExpressionAutocomplete !== undefined)
this.showDefaultEmojisInExpressionAutocomplete = validated.showDefaultEmojisInExpressionAutocomplete;
if (validated.showCustomEmojisInExpressionAutocomplete !== undefined)
this.showCustomEmojisInExpressionAutocomplete = validated.showCustomEmojisInExpressionAutocomplete;
if (validated.showStickersInExpressionAutocomplete !== undefined)
this.showStickersInExpressionAutocomplete = validated.showStickersInExpressionAutocomplete;
if (validated.showMemesInExpressionAutocomplete !== undefined)
this.showMemesInExpressionAutocomplete = validated.showMemesInExpressionAutocomplete;
if (validated.attachmentMediaDimensionSize !== undefined)
this.attachmentMediaDimensionSize = validated.attachmentMediaDimensionSize;
if (validated.embedMediaDimensionSize !== undefined)
this.embedMediaDimensionSize = validated.embedMediaDimensionSize;
if (validated.voiceChannelJoinRequiresDoubleClick !== undefined)
this.voiceChannelJoinRequiresDoubleClick = validated.voiceChannelJoinRequiresDoubleClick;
if (validated.customThemeCss !== undefined) this.customThemeCss = validated.customThemeCss;
if (validated.showFavorites !== undefined) this.showFavorites = validated.showFavorites;
if (validated.zoomLevel !== undefined) {
this.zoomLevel = validated.zoomLevel;
void this.applyZoom(validated.zoomLevel);
}
if (validated.dmMessagePreviewMode !== undefined) this.dmMessagePreviewMode = validated.dmMessagePreviewMode;
if (validated.enableTTSCommand !== undefined) this.enableTTSCommand = validated.enableTTSCommand;
if (validated.ttsRate !== undefined) this.ttsRate = validated.ttsRate;
}
private validateSettings(data: Readonly<Partial<AccessibilitySettings>>): Partial<AccessibilitySettings> {
return {
saturationFactor: Math.max(0, Math.min(1, data.saturationFactor ?? this.saturationFactor)),
alwaysUnderlineLinks: data.alwaysUnderlineLinks ?? this.alwaysUnderlineLinks,
enableTextSelection: data.enableTextSelection ?? this.enableTextSelection,
showMessageSendButton: data.showMessageSendButton ?? this.showMessageSendButton,
showTextareaFocusRing: data.showTextareaFocusRing ?? this.showTextareaFocusRing,
hideKeyboardHints: data.hideKeyboardHints ?? this.hideKeyboardHints,
escapeExitsKeyboardMode: data.escapeExitsKeyboardMode ?? this.escapeExitsKeyboardMode,
syncReducedMotionWithSystem: data.syncReducedMotionWithSystem ?? this.syncReducedMotionWithSystem,
reducedMotionOverride: data.reducedMotionOverride ?? this.reducedMotionOverride,
messageGroupSpacing: data.messageGroupSpacing ?? this.messageGroupSpacing,
messageGutter: Math.max(0, Math.min(200, data.messageGutter ?? this.messageGutter)),
fontSize: data.fontSize ?? this.fontSize,
showUserAvatarsInCompactMode: data.showUserAvatarsInCompactMode ?? this.showUserAvatarsInCompactMode,
mobileStickerAnimationOverridden: data.mobileStickerAnimationOverridden ?? this.mobileStickerAnimationOverridden,
mobileGifAutoPlayOverridden: data.mobileGifAutoPlayOverridden ?? this.mobileGifAutoPlayOverridden,
mobileAnimateEmojiOverridden: data.mobileAnimateEmojiOverridden ?? this.mobileAnimateEmojiOverridden,
mobileStickerAnimationValue: data.mobileStickerAnimationValue ?? this.mobileStickerAnimationValue,
mobileGifAutoPlayValue: data.mobileGifAutoPlayValue ?? this.mobileGifAutoPlayValue,
mobileAnimateEmojiValue: data.mobileAnimateEmojiValue ?? this.mobileAnimateEmojiValue,
syncThemeAcrossDevices: data.syncThemeAcrossDevices ?? this.syncThemeAcrossDevices,
localThemeOverride: data.localThemeOverride ?? this.localThemeOverride,
showMessageDividers: data.showMessageDividers ?? this.showMessageDividers,
autoSendTenorGifs: data.autoSendTenorGifs ?? this.autoSendTenorGifs,
showGiftButton: data.showGiftButton ?? this.showGiftButton,
showGifButton: data.showGifButton ?? this.showGifButton,
showMemesButton: data.showMemesButton ?? this.showMemesButton,
showStickersButton: data.showStickersButton ?? this.showStickersButton,
showEmojiButton: data.showEmojiButton ?? this.showEmojiButton,
showUploadButton: data.showUploadButton ?? this.showUploadButton,
showMediaFavoriteButton: data.showMediaFavoriteButton ?? this.showMediaFavoriteButton,
showMediaDownloadButton: data.showMediaDownloadButton ?? this.showMediaDownloadButton,
showMediaDeleteButton: data.showMediaDeleteButton ?? this.showMediaDeleteButton,
showSuppressEmbedsButton: data.showSuppressEmbedsButton ?? this.showSuppressEmbedsButton,
showGifIndicator: data.showGifIndicator ?? this.showGifIndicator,
showAttachmentExpiryIndicator:
typeof data.showAttachmentExpiryIndicator === 'boolean'
? data.showAttachmentExpiryIndicator
: this.showAttachmentExpiryIndicator,
useBrowserLocaleForTimeFormat: data.useBrowserLocaleForTimeFormat ?? this.useBrowserLocaleForTimeFormat,
channelTypingIndicatorMode: data.channelTypingIndicatorMode ?? this.channelTypingIndicatorMode,
showSelectedChannelTypingIndicator:
data.showSelectedChannelTypingIndicator ?? this.showSelectedChannelTypingIndicator,
showMessageActionBar: data.showMessageActionBar ?? this.showMessageActionBar,
showMessageActionBarQuickReactions:
data.showMessageActionBarQuickReactions ?? this.showMessageActionBarQuickReactions,
showMessageActionBarShiftExpand: data.showMessageActionBarShiftExpand ?? this.showMessageActionBarShiftExpand,
showMessageActionBarOnlyMoreButton:
data.showMessageActionBarOnlyMoreButton ?? this.showMessageActionBarOnlyMoreButton,
showDefaultEmojisInExpressionAutocomplete:
data.showDefaultEmojisInExpressionAutocomplete ?? this.showDefaultEmojisInExpressionAutocomplete,
showCustomEmojisInExpressionAutocomplete:
data.showCustomEmojisInExpressionAutocomplete ?? this.showCustomEmojisInExpressionAutocomplete,
showStickersInExpressionAutocomplete:
data.showStickersInExpressionAutocomplete ?? this.showStickersInExpressionAutocomplete,
showMemesInExpressionAutocomplete:
data.showMemesInExpressionAutocomplete ?? this.showMemesInExpressionAutocomplete,
attachmentMediaDimensionSize: data.attachmentMediaDimensionSize ?? this.attachmentMediaDimensionSize,
embedMediaDimensionSize: data.embedMediaDimensionSize ?? this.embedMediaDimensionSize,
voiceChannelJoinRequiresDoubleClick:
data.voiceChannelJoinRequiresDoubleClick ?? this.voiceChannelJoinRequiresDoubleClick,
customThemeCss: data.customThemeCss !== undefined ? data.customThemeCss : this.customThemeCss,
showFavorites: data.showFavorites ?? this.showFavorites,
zoomLevel: Math.max(0.5, Math.min(2.0, data.zoomLevel ?? this.zoomLevel)),
dmMessagePreviewMode: data.dmMessagePreviewMode ?? this.dmMessagePreviewMode,
enableTTSCommand: data.enableTTSCommand ?? this.enableTTSCommand,
ttsRate: Math.max(0.1, Math.min(2.0, data.ttsRate ?? this.ttsRate)),
};
}
subscribe(callback: () => void): () => void {
return reaction(
() => ({
messageGroupSpacing: this.messageGroupSpacing,
showMessageDividers: this.showMessageDividers,
}),
() => callback(),
{fireImmediately: true},
);
}
async adjustZoom(delta: number): Promise<void> {
const newZoom = Math.min(2.0, Math.max(0.5, this.zoomLevel + delta));
this.updateSettings({zoomLevel: newZoom});
}
async applyZoom(level: number): Promise<void> {
const electronApi = (window as {electron?: {setZoomFactor: (factor: number) => void}}).electron;
if (electronApi) {
electronApi.setZoomFactor(level);
} else {
document.documentElement.style.setProperty('zoom', `${level * 100}%`);
}
}
async applyStoredZoom(): Promise<void> {
if (this.zoomLevel !== 1.0) {
await this.applyZoom(this.zoomLevel);
}
}
}
export default new AccessibilityStore();

View File

@@ -0,0 +1,161 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {computed, makeAutoObservable} from 'mobx';
import type {UserData} from '~/lib/AccountStorage';
import SessionManager, {type Account, SessionExpiredError} from '~/lib/SessionManager';
import {Routes} from '~/Routes';
import * as PushSubscriptionService from '~/services/push/PushSubscriptionService';
import ConnectionStore from '~/stores/ConnectionStore';
import * as NotificationUtils from '~/utils/NotificationUtils';
import {isInstalledPwa} from '~/utils/PwaUtils';
import * as RouterUtils from '~/utils/RouterUtils';
export type AccountSummary = Account;
class AccountManager {
constructor() {
makeAutoObservable(
this,
{
currentUserId: computed,
currentAccount: computed,
orderedAccounts: computed,
canSwitchAccounts: computed,
isSwitching: computed,
isLoading: computed,
},
{autoBind: true},
);
}
private shouldManagePushSubscriptions(): boolean {
return isInstalledPwa();
}
get currentUserId(): string | null {
return SessionManager.userId;
}
get accounts(): Map<string, AccountSummary> {
return new Map(SessionManager.accounts.map((a) => [a.userId, a]));
}
get isSwitching(): boolean {
return SessionManager.isSwitching;
}
get isLoading(): boolean {
return SessionManager.isLoggingOut || SessionManager.isSwitching;
}
get currentAccount(): AccountSummary | null {
return SessionManager.currentAccount;
}
get orderedAccounts(): Array<AccountSummary> {
return SessionManager.accounts;
}
get canSwitchAccounts(): boolean {
return SessionManager.canSwitchAccount();
}
getAllAccounts(): Array<AccountSummary> {
return this.orderedAccounts;
}
async bootstrap(): Promise<void> {
await SessionManager.initialize();
}
async stashCurrentAccount(): Promise<void> {
await SessionManager.stashCurrentAccount();
}
markAccountAsInvalid(userId: string): void {
SessionManager.markAccountInvalid(userId);
}
async generateTokenForAccount(userId: string): Promise<{token: string; userId: string}> {
await SessionManager.initialize();
const account = SessionManager.accounts.find((a) => a.userId === userId);
if (!account) {
throw new Error(`No stored data found for account ${userId}`);
}
const ok = await SessionManager.validateToken(account.token, account.instance);
if (!ok) {
SessionManager.markAccountInvalid(userId);
throw new SessionExpiredError();
}
return {token: account.token, userId};
}
async switchToAccount(userId: string): Promise<void> {
if (this.shouldManagePushSubscriptions()) {
await PushSubscriptionService.unregisterAllPushSubscriptions();
}
await SessionManager.switchAccount(userId);
ConnectionStore.startSession(SessionManager.token ?? undefined);
RouterUtils.replaceWith(Routes.ME);
if (this.shouldManagePushSubscriptions()) {
void (async () => {
if (await NotificationUtils.isGranted()) {
await PushSubscriptionService.registerPushSubscription();
}
})();
}
}
async switchToNewAccount(userId: string, token: string, userData?: UserData, skipReload = false): Promise<void> {
await SessionManager.login(token, userId, userData);
ConnectionStore.startSession(token);
if (!skipReload) {
RouterUtils.replaceWith(Routes.ME);
}
if (this.shouldManagePushSubscriptions()) {
void (async () => {
if (await NotificationUtils.isGranted()) {
await PushSubscriptionService.registerPushSubscription();
}
})();
}
}
async removeStoredAccount(userId: string): Promise<void> {
await SessionManager.removeAccount(userId);
}
updateAccountUserData(userId: string, userData: UserData): void {
SessionManager.updateAccountUserData(userId, userData);
}
async logout(): Promise<void> {
await SessionManager.logout();
RouterUtils.replaceWith('/login');
}
}
export default new AccountManager();

View File

@@ -0,0 +1,76 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
const DEFAULT_VOLUME = 1;
class AudioVolumeStore {
volume = DEFAULT_VOLUME;
isMuted = false;
private previousVolume = DEFAULT_VOLUME;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'AudioVolumeStore', ['volume', 'isMuted']);
if (this.volume > 0) {
this.previousVolume = this.volume;
}
}
setVolume(newVolume: number): void {
const clamped = Math.max(0, Math.min(1, newVolume));
this.volume = clamped;
if (clamped > 0) {
this.previousVolume = clamped;
}
if (this.isMuted && clamped > 0) {
this.isMuted = false;
}
}
toggleMute(): void {
if (this.isMuted) {
this.isMuted = false;
if (this.volume === 0) {
this.volume = this.previousVolume;
}
} else {
this.isMuted = true;
}
}
setMuted(muted: boolean): void {
this.isMuted = muted;
if (!muted && this.volume === 0) {
this.volume = this.previousVolume;
}
}
get effectiveVolume(): number {
return this.isMuted ? 0 : this.volume;
}
}
export default new AudioVolumeStore();

View File

@@ -0,0 +1,69 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {type AuthSession, AuthSessionRecord} from '~/records/AuthSessionRecord';
type FetchStatus = 'idle' | 'pending' | 'success' | 'error';
class AuthSessionStore {
authSessionIdHash: string | null = null;
authSessions: Array<AuthSessionRecord> = [];
fetchStatus: FetchStatus = 'idle';
isDeleteError = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
handleConnectionOpen(authSessionIdHash: string): void {
this.authSessionIdHash = authSessionIdHash;
}
handleAuthSessionChange(authSessionIdHash: string): void {
this.authSessionIdHash = authSessionIdHash;
}
fetchPending(): void {
this.fetchStatus = 'pending';
}
fetchSuccess(authSessions: ReadonlyArray<AuthSession>): void {
this.authSessions = authSessions.map((session) => new AuthSessionRecord(session));
this.fetchStatus = 'success';
}
fetchError(): void {
this.fetchStatus = 'error';
}
logoutPending(): void {
this.isDeleteError = false;
}
logoutSuccess(sessionIdHashes: ReadonlyArray<string>): void {
this.authSessions = this.authSessions.filter((session) => !sessionIdHashes.includes(session.id));
}
logoutError(): void {
this.isDeleteError = true;
}
}
export default new AuthSessionStore();

View File

@@ -0,0 +1,147 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {action, computed, makeAutoObservable} from 'mobx';
import SessionManager from '~/lib/SessionManager';
import type {UserPrivate} from '~/records/UserRecord';
import * as RouterUtils from '~/utils/RouterUtils';
const LoginState = {
Default: 'default',
Mfa: 'mfa',
} as const;
export type LoginState = (typeof LoginState)[keyof typeof LoginState];
export type MfaMethods = {sms: boolean; totp: boolean; webauthn: boolean};
class AuthenticationStore {
loginState: LoginState = LoginState.Default;
mfaTicket: string | null = null;
mfaMethods: MfaMethods | null = null;
constructor() {
makeAutoObservable(
this,
{
isAuthenticated: computed,
authToken: computed,
currentUserId: computed,
},
{autoBind: true},
);
}
get isInMfaState(): boolean {
return this.loginState === LoginState.Mfa;
}
get isAuthenticated(): boolean {
return SessionManager.isAuthenticated;
}
get authToken(): string | null {
return SessionManager.token;
}
get token(): string | null {
return SessionManager.token;
}
get currentMfaTicket(): string | null {
return this.mfaTicket;
}
get availableMfaMethods(): MfaMethods | null {
return this.mfaMethods;
}
get currentUserId(): string | null {
return SessionManager.userId;
}
get userId(): string | null {
return SessionManager.userId;
}
@action
setUserId(userId: string | null): void {
SessionManager.setUserId(userId);
}
@action
handleConnectionOpen({user}: {user: UserPrivate}): void {
SessionManager.setUserId(user.id);
SessionManager.handleConnectionReady();
}
@action
handleAuthSessionChange({token}: {token: string}): void {
SessionManager.setToken(token || null);
}
handleConnectionClosed({code}: {code: number}): void {
SessionManager.handleConnectionClosed(code);
if (code === 4004) {
this.handleLogout();
}
}
@action
handleSessionStart({token}: {token: string | null | undefined}): void {
if (token) {
SessionManager.setToken(token);
} else {
SessionManager.setToken(null);
}
this.loginState = LoginState.Default;
this.mfaTicket = null;
this.mfaMethods = null;
}
@action
handleMfaTicketSet({ticket, sms, totp, webauthn}: {ticket: string} & MfaMethods): void {
this.loginState = LoginState.Mfa;
this.mfaTicket = ticket;
this.mfaMethods = {sms, totp, webauthn};
}
@action
handleMfaTicketClear(): void {
this.loginState = LoginState.Default;
this.mfaTicket = null;
this.mfaMethods = null;
}
@action
handleLogout(options?: {skipRedirect?: boolean}): void {
this.loginState = LoginState.Default;
this.mfaTicket = null;
this.mfaMethods = null;
if (!options?.skipRedirect) {
RouterUtils.replaceWith('/login');
}
}
async fetchGatewayToken(): Promise<string | null> {
return SessionManager.token;
}
}
export default new AuthenticationStore();

View File

@@ -0,0 +1,171 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {action, makeAutoObservable, reaction} from 'mobx';
import {Logger} from '~/lib/Logger';
import ChannelStore from './ChannelStore';
import DimensionStore from './DimensionStore';
import ReadStateStore from './ReadStateStore';
import SelectedChannelStore from './SelectedChannelStore';
import WindowStore from './WindowStore';
const logger = new Logger('AutoAckStore');
type ChannelId = string;
class AutoAckStore {
private readonly windowChannels = new Map<string, Set<ChannelId>>();
private readonly windowConditions = new Map<
string,
{
channelId: ChannelId | null;
isAtBottom: boolean;
canAutoAck: boolean;
}
>();
constructor() {
makeAutoObservable<this, 'windowChannels' | 'windowConditions'>(
this,
{
windowChannels: false,
windowConditions: false,
},
{autoBind: true},
);
this.setupReactions();
}
private setupReactions(): void {
reaction(
() => {
const windowId = WindowStore.windowId;
const channelId = SelectedChannelStore.currentChannelId;
const isWindowFocused = WindowStore.focused;
if (!channelId) {
return {windowId, channelId: null, isAtBottom: false, canAutoAck: false};
}
const isAtBottom = DimensionStore.isAtBottom(channelId) ?? false;
const readState = ReadStateStore.getIfExists(channelId);
const isManualAck = readState?.isManualAck ?? false;
const canAutoAck = !isManualAck && isWindowFocused;
return {windowId, channelId, isAtBottom, canAutoAck};
},
(conditions) => {
this.updateAutoAckState(conditions);
},
{
name: 'AutoAckStore.updateAutoAckState',
fireImmediately: true,
},
);
}
@action
private updateAutoAckState(conditions: {
windowId: string;
channelId: ChannelId | null;
isAtBottom: boolean;
canAutoAck: boolean;
}): void {
const {windowId, channelId, isAtBottom, canAutoAck} = conditions;
const prevConditions = this.windowConditions.get(windowId);
this.windowConditions.set(windowId, {channelId, isAtBottom, canAutoAck});
if (prevConditions?.channelId && prevConditions.channelId !== channelId) {
this.disableAutomaticAckInternal(prevConditions.channelId, windowId);
}
if (!channelId) {
return;
}
const shouldEnable = isAtBottom && canAutoAck;
if (shouldEnable) {
this.enableAutomaticAckInternal(channelId, windowId);
} else {
this.disableAutomaticAckInternal(channelId, windowId);
}
}
@action
private enableAutomaticAckInternal(channelId: ChannelId, windowId: string): void {
const channel = ChannelStore.getChannel(channelId);
if (channel == null) {
logger.debug(`Ignoring enableAutomaticAck for non-existent channel ${channelId}`);
return;
}
let channels = this.windowChannels.get(windowId);
if (channels == null) {
channels = new Set();
this.windowChannels.set(windowId, channels);
}
if (!channels.has(channelId)) {
channels.add(channelId);
logger.debug(`Enabled automatic ack for ${channelId} in window ${windowId}`);
}
}
@action
private disableAutomaticAckInternal(channelId: ChannelId, windowId: string): void {
const channels = this.windowChannels.get(windowId);
if (channels == null) return;
if (channels.has(channelId)) {
channels.delete(channelId);
logger.debug(`Disabled automatic ack for ${channelId} in window ${windowId}`);
}
if (channels.size === 0) {
this.windowChannels.delete(windowId);
}
}
isAutomaticAckEnabled(channelId: ChannelId): boolean {
for (const channels of this.windowChannels.values()) {
if (channels.has(channelId)) return true;
}
return false;
}
@action
disableForChannel(channelId: ChannelId): void {
for (const [windowId, channels] of this.windowChannels.entries()) {
if (channels.has(channelId)) {
channels.delete(channelId);
logger.debug(`Force-disabled automatic ack for ${channelId} in window ${windowId}`);
}
if (channels.size === 0) {
this.windowChannels.delete(windowId);
}
}
}
}
export default new AutoAckStore();

View File

@@ -0,0 +1,51 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {Logger} from '~/lib/Logger';
const logger = new Logger('AutocompleteStore');
class AutocompleteStore {
highlightChannelId: string | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
highlightChannel(channelId: string): void {
if (!channelId || this.highlightChannelId === channelId) {
return;
}
this.highlightChannelId = channelId;
logger.debug(`Highlighted channel: ${channelId}`);
}
highlightChannelClear(): void {
if (this.highlightChannelId == null) {
return;
}
this.highlightChannelId = null;
logger.debug('Cleared channel highlight');
}
}
export default new AutocompleteStore();

View File

@@ -0,0 +1,82 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {type BetaCode, BetaCodeRecord} from '~/records/BetaCodeRecord';
type FetchStatus = 'idle' | 'pending' | 'success' | 'error';
class BetaCodeStore {
betaCodes: Array<BetaCodeRecord> = [];
fetchStatus: FetchStatus = 'idle';
isCreateError = false;
isDeleteError = false;
allowance = 3;
nextResetAt: Date | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
fetchPending(): void {
this.fetchStatus = 'pending';
}
fetchSuccess(betaCodes: ReadonlyArray<BetaCode>, allowance: number, nextResetAt: string | null): void {
this.betaCodes = betaCodes.map((betaCode) => new BetaCodeRecord(betaCode));
this.fetchStatus = 'success';
this.allowance = allowance;
this.nextResetAt = nextResetAt ? new Date(nextResetAt) : null;
}
fetchError(): void {
this.fetchStatus = 'error';
}
createPending(): void {
this.isCreateError = false;
}
createSuccess(betaCode: BetaCodeRecord): void {
this.betaCodes = [...this.betaCodes, betaCode];
this.allowance = Math.max(0, this.allowance - 1);
}
createError(): void {
this.isCreateError = true;
}
deletePending(): void {
this.isDeleteError = false;
}
deleteSuccess(code: string): void {
const removed = this.betaCodes.find((betaCode) => betaCode.code === code);
this.betaCodes = this.betaCodes.filter((betaCode) => betaCode.code !== code);
if (removed && !removed.redeemer) {
this.allowance = this.allowance + 1;
}
}
deleteError(): void {
this.isDeleteError = true;
}
}
export default new BetaCodeStore();

View File

@@ -0,0 +1,64 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, observable} from 'mobx';
class CallAvailabilityStore {
unavailableCalls: Set<string> = observable.set();
constructor() {
makeAutoObservable(
this,
{
unavailableCalls: false,
},
{autoBind: true},
);
}
setCallAvailable(channelId: string): void {
if (this.unavailableCalls.has(channelId)) {
this.unavailableCalls.delete(channelId);
}
}
setCallUnavailable(channelId: string): void {
if (!this.unavailableCalls.has(channelId)) {
this.unavailableCalls.add(channelId);
}
}
handleCallAvailability(channelId: string, unavailable = false): void {
if (unavailable) {
this.setCallUnavailable(channelId);
} else {
this.setCallAvailable(channelId);
}
}
get totalUnavailableCalls(): number {
return this.unavailableCalls.size;
}
isCallUnavailable(channelId: string): boolean {
return this.unavailableCalls.has(channelId);
}
}
export default new CallAvailabilityStore();

View File

@@ -0,0 +1,49 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
class CallInitiatorStore {
private initiatedRecipients = new Map<string, Set<string>>();
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
markInitiated(channelId: string, recipients: ReadonlyArray<string>): void {
const filtered = recipients.filter(Boolean);
if (filtered.length === 0) {
this.initiatedRecipients.delete(channelId);
return;
}
this.initiatedRecipients.set(channelId, new Set(filtered));
}
getInitiatedRecipients(channelId: string): Array<string> {
const recipients = this.initiatedRecipients.get(channelId);
return recipients ? Array.from(recipients) : [];
}
clearChannel(channelId: string): void {
this.initiatedRecipients.delete(channelId);
}
}
export default new CallInitiatorStore();

View File

@@ -0,0 +1,55 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
interface CallScopedPrefs {
disabledVideoByIdentity: Record<string, boolean>;
}
class CallMediaPrefsStore {
private byCall: Record<string, CallScopedPrefs> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
private ensure(callId: string): CallScopedPrefs {
return (this.byCall[callId] ||= {disabledVideoByIdentity: {}});
}
isVideoDisabled(callId: string, identity: string): boolean {
return !!this.byCall[callId]?.disabledVideoByIdentity[identity];
}
setVideoDisabled(callId: string, identity: string, disabled: boolean): void {
const scope = this.ensure(callId);
scope.disabledVideoByIdentity = {
...scope.disabledVideoByIdentity,
[identity]: disabled,
};
}
clearForCall(callId: string): void {
delete this.byCall[callId];
}
}
export default new CallMediaPrefsStore();
export {CallMediaPrefsStore};

View File

@@ -0,0 +1,265 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, observable} from 'mobx';
import {ME} from '~/Constants';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import VoiceStateManager from '~/stores/voice/VoiceStateManager';
export enum CallLayout {
MINIMUM = 'MINIMUM',
NORMAL = 'NORMAL',
FULL_SCREEN = 'FULL_SCREEN',
}
interface VoiceState {
user_id: string;
channel_id?: string | null;
session_id?: string;
self_mute?: boolean;
self_deaf?: boolean;
self_video?: boolean;
self_stream?: boolean;
}
export interface GatewayCallData {
channel_id: string;
message_id?: string;
region?: string;
ringing?: Array<string>;
voice_states?: Array<VoiceState>;
}
export interface Call {
channelId: string;
messageId: string | null;
region: string | null;
ringing: Array<string>;
layout: CallLayout;
participants: Array<string>;
}
class CallStateStore {
calls = observable.map<string, Call>();
private pendingRinging = observable.map<string, Set<string>>();
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
getCall(channelId: string): Call | undefined {
return this.calls.get(channelId);
}
getActiveCalls(): Array<Call> {
return Array.from(this.calls.values()).filter((call) => this.hasActiveCall(call.channelId));
}
hasActiveCall(channelId: string): boolean {
const call = this.calls.get(channelId);
if (!call) return false;
const participants = this.getParticipants(channelId);
return participants.length > 0 || call.ringing.length > 0;
}
isCallActive(channelId: string, messageId?: string): boolean {
const call = this.calls.get(channelId);
if (!call) return false;
if (messageId) return call.messageId === messageId;
return call.region != null;
}
getCallLayout(channelId: string): CallLayout {
const call = this.calls.get(channelId);
const connectedChannelId = MediaEngineStore.channelId;
if (call?.layout && channelId === connectedChannelId) {
return call.layout;
}
return CallLayout.MINIMUM;
}
getMessageId(channelId: string): string | null {
const call = this.calls.get(channelId);
return call?.messageId ?? null;
}
getParticipants(channelId: string): Array<string> {
const voiceStates = VoiceStateManager.getAllVoiceStatesInChannel(ME, channelId);
return Object.values(voiceStates)
.map((state) => state.user_id)
.filter(Boolean);
}
clearPendingRinging(channelId: string, userIds?: Array<string>): void {
if (!userIds || userIds.length === 0) {
if (!this.pendingRinging.has(channelId)) return;
this.pendingRinging.delete(channelId);
this.syncCallRinging(channelId, new Set());
return;
}
const normalized = this.normalizeUserIds(userIds);
if (normalized.length === 0) return;
const existing = this.pendingRinging.get(channelId);
if (!existing) return;
const nextSet = new Set(existing);
let changed = false;
for (const id of normalized) {
if (nextSet.delete(id)) {
changed = true;
}
}
if (!changed) return;
if (nextSet.size === 0) {
this.pendingRinging.delete(channelId);
this.syncCallRinging(channelId, new Set());
} else {
this.pendingRinging.set(channelId, nextSet);
this.syncCallRinging(channelId, nextSet);
}
}
isUserPendingRinging(channelId: string, userId?: string | null): boolean {
if (!userId) return false;
const set = this.pendingRinging.get(channelId);
return Boolean(set?.has(userId));
}
handleCallCreate(data: {channelId: string; call?: GatewayCallData}): void {
if (!data.call) return;
const {ringing = [], message_id, region, voice_states = []} = data.call;
const normalizedRinging = this.normalizeUserIds(ringing);
const participants = this.extractParticipantsFromVoiceStates(voice_states);
const existingCall = this.calls.get(data.channelId);
const layout = existingCall?.layout ?? CallLayout.MINIMUM;
const call: Call = {
channelId: data.channelId,
messageId: message_id ?? null,
region: region ?? null,
ringing: normalizedRinging,
layout,
participants,
};
this.calls.set(data.channelId, call);
this.recordIncomingRinging(data.channelId, normalizedRinging);
}
handleCallUpdate(data: GatewayCallData): void {
const {channel_id, ringing, message_id, region, voice_states} = data;
const call = this.calls.get(channel_id);
if (!call) {
this.handleCallCreate({channelId: channel_id, call: data});
return;
}
const normalizedRinging = this.normalizeUserIds(ringing);
const hasRingingPayload = ringing !== undefined;
const hasVoiceStatesPayload = voice_states !== undefined;
const participants = hasVoiceStatesPayload
? this.extractParticipantsFromVoiceStates(voice_states)
: call.participants;
const updatedCall: Call = {
...call,
ringing: hasRingingPayload ? normalizedRinging : call.ringing,
messageId: message_id !== undefined ? message_id : call.messageId,
region: region !== undefined ? region : call.region,
participants,
};
this.calls.set(channel_id, updatedCall);
if (hasRingingPayload) {
if (normalizedRinging.length > 0) {
this.recordIncomingRinging(channel_id, normalizedRinging);
} else {
this.clearPendingRinging(channel_id);
}
}
}
private normalizeUserIds(userIds?: Array<string>): Array<string> {
if (!userIds || userIds.length === 0) return [];
return userIds.map(String).filter(Boolean);
}
private extractParticipantsFromVoiceStates(voiceStates?: Array<VoiceState>): Array<string> {
if (!voiceStates || voiceStates.length === 0) return [];
return voiceStates.map((state) => state.user_id).filter((id): id is string => Boolean(id));
}
private recordIncomingRinging(channelId: string, userIds: Array<string>): void {
if (userIds.length === 0) return;
const existing = this.pendingRinging.get(channelId);
const nextSet = existing ? new Set(existing) : new Set<string>();
for (const id of userIds) {
nextSet.add(id);
}
this.pendingRinging.set(channelId, nextSet);
this.syncCallRinging(channelId, nextSet);
}
private syncCallRinging(channelId: string, ringSet?: Set<string>): void {
const call = this.calls.get(channelId);
if (!call) return;
const nextSet = ringSet ?? this.pendingRinging.get(channelId) ?? new Set<string>();
const nextRinging = Array.from(nextSet);
const isSame =
nextRinging.length === call.ringing.length && nextRinging.every((id, index) => call.ringing[index] === id);
if (isSame) return;
this.calls.set(channelId, {...call, ringing: nextRinging});
}
handleCallDelete(data: {channelId: string}): void {
this.calls.delete(data.channelId);
this.clearPendingRinging(data.channelId);
}
handleCallLayoutUpdate(channelId: string, layout: CallLayout): void {
const call = this.calls.get(channelId);
if (!call) return;
call.layout = layout;
}
handleCallParticipants(channelId: string, participants: Array<string>): void {
const call = this.calls.get(channelId);
if (!call) return;
const uniqueParticipants = Array.from(new Set(participants));
call.participants = uniqueParticipants;
}
}
export default new CallStateStore();

View File

@@ -0,0 +1,176 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {action, makeAutoObservable, reaction} from 'mobx';
import {ChannelTypes} from '~/Constants';
import type {UserRecord} from '~/records/UserRecord';
import UserStore from '~/stores/UserStore';
interface ChannelSnapshot {
readonly id: string;
readonly name?: string;
readonly recipientIds: ReadonlyArray<string>;
readonly type: number;
readonly nicks: Readonly<Record<string, string>>;
}
class ChannelDisplayNameStore {
private readonly channelSnapshots = new Map<string, ChannelSnapshot>();
private readonly displayNames = new Map<string, string>();
private i18n: I18n | null = null;
constructor() {
makeAutoObservable<this, 'recomputeChannel' | 'recomputeAll'>(
this,
{
syncChannel: action,
removeChannel: action,
recomputeChannel: action,
recomputeAll: action,
clear: action,
},
{autoBind: true},
);
reaction(
() => {
if (!UserStore) return [];
return UserStore.usersList.map((user) => ({
id: user.id,
username: user.username,
globalName: user.globalName,
}));
},
() => this.recomputeAll(),
);
}
setI18n(i18n: I18n): void {
this.i18n = i18n;
}
getDisplayName(channelId: string): string | undefined {
return this.displayNames.get(channelId);
}
clear(): void {
this.channelSnapshots.clear();
this.displayNames.clear();
}
syncChannel(channel: ChannelSnapshot): void {
if (!this.shouldTrackChannel(channel)) {
this.channelSnapshots.delete(channel.id);
this.displayNames.delete(channel.id);
return;
}
this.channelSnapshots.set(channel.id, channel);
this.recomputeChannel(channel);
}
removeChannel(channelId: string): void {
this.channelSnapshots.delete(channelId);
this.displayNames.delete(channelId);
}
private recomputeAll(): void {
for (const snapshot of this.channelSnapshots.values()) {
this.recomputeChannel(snapshot);
}
}
private recomputeChannel(snapshot: ChannelSnapshot): void {
const displayName = this.computeGroupDMDisplayName(snapshot);
this.displayNames.set(snapshot.id, displayName);
}
private shouldTrackChannel(snapshot: ChannelSnapshot): boolean {
if (snapshot.type !== ChannelTypes.GROUP_DM) {
return false;
}
return !(snapshot.name && snapshot.name.trim().length > 0);
}
private computeGroupDMDisplayName(snapshot: ChannelSnapshot): string {
if (!this.i18n) {
throw new Error('ChannelDisplayNameStore: i18n not initialized');
}
const currentUser = UserStore.getCurrentUser();
const currentUserId = currentUser?.id ?? null;
const otherIds = snapshot.recipientIds.filter((id) => id !== currentUserId);
if (otherIds.length === 0) {
if (currentUser) {
const resolvedName = this.getBaseName(currentUser, snapshot);
if (resolvedName && resolvedName.length > 0) {
const translatedGroupName = this.i18n._(msg`${resolvedName}'s Group`);
if (translatedGroupName.includes(resolvedName)) {
return translatedGroupName;
}
return `${resolvedName}'s Group`;
}
}
return this.i18n._(msg`Unnamed Group`);
}
if (otherIds.length === 1) {
const displayName = this.getUserDisplayName(snapshot, otherIds[0]);
if (displayName) {
return displayName;
}
return this.i18n._(msg`Unnamed Group`);
}
if (otherIds.length <= 4) {
const names = [...otherIds]
.sort((a, b) => b.localeCompare(a))
.map((userId) => this.getUserDisplayName(snapshot, userId))
.filter((name): name is string => Boolean(name));
return names.length > 0 ? names.join(', ') : this.i18n._(msg`Unnamed Group`);
}
return this.i18n._(msg`Unnamed Group`);
}
private getBaseName(user: UserRecord, snapshot: ChannelSnapshot): string {
const overrideNick = snapshot.nicks?.[user.id];
return overrideNick ?? user.displayName;
}
private getUserDisplayName(snapshot: ChannelSnapshot, userId: string): string | null {
const user = UserStore.getUser(userId);
if (!user) {
return null;
}
const overrideNick = snapshot.nicks?.[user.id];
const baseName = overrideNick ?? user.displayName;
return baseName || null;
}
}
export default new ChannelDisplayNameStore();

View File

@@ -0,0 +1,253 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import type {Channel} from '~/records/ChannelRecord';
import {type Message, MessageRecord} from '~/records/MessageRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import type {ReactionEmoji} from '~/utils/ReactionUtils';
interface ChannelPinEntry {
message: MessageRecord;
pinnedAt: string;
}
interface ChannelPinState {
fetched: boolean;
hasMore: boolean;
isLoading: boolean;
lastPinTimestamp?: string | null;
error?: string | null;
}
class ChannelPinsStore {
private channelPins: Record<string, Array<ChannelPinEntry>> = {};
private channelState: Record<string, ChannelPinState> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
isFetched(channelId: string): boolean {
return this.channelState[channelId]?.fetched ?? false;
}
getPins(channelId: string): ReadonlyArray<ChannelPinEntry> {
return this.channelPins[channelId] ?? [];
}
getHasMore(channelId: string): boolean {
return this.channelState[channelId]?.hasMore ?? true;
}
getIsLoading(channelId: string): boolean {
return this.channelState[channelId]?.isLoading ?? false;
}
getOldestPinnedAt(channelId: string): string | undefined {
const pins = this.channelPins[channelId];
if (!pins || pins.length === 0) {
return undefined;
}
return pins[pins.length - 1]?.pinnedAt;
}
getLastPinnedMessageId(channelId: string): string | undefined {
const pins = this.channelPins[channelId];
if (!pins || pins.length === 0) {
return undefined;
}
return pins[pins.length - 1]?.message.id;
}
handleFetchPending(channelId: string): void {
const state = this.channelState[channelId] ?? this.createDefaultState();
this.channelState = {
...this.channelState,
[channelId]: {...state, isLoading: true, error: null},
};
}
handleChannelPinsFetchSuccess(
channelId: string,
pins: ReadonlyArray<{message: Message; pinned_at: string}>,
hasMore: boolean,
): void {
const existingPins = this.channelPins[channelId] ?? [];
const newPins = pins.map(({message, pinned_at}) => ({
message: new MessageRecord(message),
pinnedAt: pinned_at ?? message.timestamp ?? new Date().toISOString(),
}));
const isLoadMore = this.channelState[channelId]?.fetched && this.channelState[channelId]?.isLoading;
this.channelPins = {
...this.channelPins,
[channelId]: isLoadMore ? [...existingPins, ...newPins] : newPins,
};
this.channelState = {
...this.channelState,
[channelId]: {
fetched: true,
hasMore,
isLoading: false,
lastPinTimestamp: pins.at(0)?.pinned_at ?? this.channelState[channelId]?.lastPinTimestamp ?? null,
error: null,
},
};
}
handleChannelPinsFetchError(channelId: string): void {
const state = this.channelState[channelId] ?? this.createDefaultState();
this.channelState = {
...this.channelState,
[channelId]: {...state, isLoading: false, hasMore: false, fetched: true, error: 'fetch_error'},
};
}
handleChannelDelete(channel: Channel): void {
const {[channel.id]: _, ...remainingChannels} = this.channelPins;
const {[channel.id]: __, ...remainingState} = this.channelState;
this.channelPins = remainingChannels;
this.channelState = remainingState;
}
handleChannelPinsUpdate(channelId: string, lastPinTimestamp: string | null): void {
this.channelState = {
...this.channelState,
[channelId]: {
fetched: false,
hasMore: true,
isLoading: false,
lastPinTimestamp,
},
};
this.channelPins = {
...this.channelPins,
[channelId]: [],
};
}
handleMessageUpdate(message: Message): void {
const channelId = message.channel_id;
const existingPins = this.channelPins[channelId] ?? [];
const existingIndex = existingPins.findIndex((pin) => pin.message.id === message.id);
if (existingIndex === -1 && !('flags' in message && message.pinned)) {
return;
}
let updatedPins: Array<ChannelPinEntry>;
if (existingIndex !== -1) {
if ('flags' in message && !message.pinned) {
updatedPins = [...existingPins.slice(0, existingIndex), ...existingPins.slice(existingIndex + 1)];
} else {
updatedPins = [
...existingPins.slice(0, existingIndex),
{
...existingPins[existingIndex],
message: existingPins[existingIndex].message.withUpdates(message),
},
...existingPins.slice(existingIndex + 1),
];
}
} else {
updatedPins = [
{
message: new MessageRecord(message as Message),
pinnedAt: this.channelState[channelId]?.lastPinTimestamp ?? new Date().toISOString(),
},
...existingPins,
];
}
this.channelPins = {
...this.channelPins,
[channelId]: updatedPins,
};
}
handleMessageDelete(channelId: string, messageId: string): void {
const existingPins = this.channelPins[channelId] ?? [];
const existingIndex = existingPins.findIndex((pin) => pin.message.id === messageId);
if (existingIndex === -1) {
return;
}
this.channelPins = {
...this.channelPins,
[channelId]: [...existingPins.slice(0, existingIndex), ...existingPins.slice(existingIndex + 1)],
};
}
private updateMessageInChannel(
channelId: string,
messageId: string,
updater: (message: MessageRecord) => MessageRecord,
): void {
const existingPins = this.channelPins[channelId] ?? [];
const existingIndex = existingPins.findIndex((pin) => pin.message.id === messageId);
if (existingIndex === -1) {
return;
}
this.channelPins = {
...this.channelPins,
[channelId]: [
...existingPins.slice(0, existingIndex),
{...existingPins[existingIndex], message: updater(existingPins[existingIndex].message)},
...existingPins.slice(existingIndex + 1),
],
};
}
handleMessageReactionAdd(channelId: string, messageId: string, userId: string, emoji: ReactionEmoji): void {
this.updateMessageInChannel(channelId, messageId, (message) =>
message.withReaction(emoji, true, userId === AuthenticationStore.currentUserId),
);
}
handleMessageReactionRemove(channelId: string, messageId: string, userId: string, emoji: ReactionEmoji): void {
this.updateMessageInChannel(channelId, messageId, (message) =>
message.withReaction(emoji, false, userId === AuthenticationStore.currentUserId),
);
}
handleMessageReactionRemoveAll(channelId: string, messageId: string): void {
this.updateMessageInChannel(channelId, messageId, (message) => message.withUpdates({reactions: []}));
}
handleMessageReactionRemoveEmoji(channelId: string, messageId: string, emoji: ReactionEmoji): void {
this.updateMessageInChannel(channelId, messageId, (message) => message.withoutReactionEmoji(emoji));
}
private createDefaultState(): ChannelPinState {
return {
fetched: false,
hasMore: true,
isLoading: false,
lastPinTimestamp: undefined,
error: null,
};
}
}
export default new ChannelPinsStore();

View File

@@ -0,0 +1,141 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, observable} from 'mobx';
import {ME} from '~/Constants';
import type {SearchMachineState} from '~/components/channel/SearchResultsUtils';
import {cloneMachineState} from '~/components/channel/SearchResultsUtils';
import type {ChannelRecord} from '~/records/ChannelRecord';
import SelectedGuildStore from '~/stores/SelectedGuildStore';
import type {SearchSegment} from '~/utils/SearchSegmentManager';
import type {MessageSearchScope} from '~/utils/SearchUtils';
class ChannelSearchContext {
searchQuery: string = '';
searchSegments: Array<SearchSegment> = [];
activeSearchQuery: string = '';
activeSearchSegments: Array<SearchSegment> = [];
isSearchActive = false;
searchRefreshKey = 0;
machineState: SearchMachineState = {status: 'idle'};
scrollPosition = 0;
lastSearchQuery = '';
lastSearchSegments: Array<SearchSegment> = [];
lastSearchRefreshKey: number | null = null;
scope: MessageSearchScope = 'current';
constructor() {
makeAutoObservable(this);
}
}
class ChannelSearchStore {
private contexts = new Map<string, ChannelSearchContext>();
constructor() {
makeAutoObservable<this, 'contexts'>(this, {
contexts: observable.shallow,
});
}
getContext(contextId: string): ChannelSearchContext {
let context = this.contexts.get(contextId);
if (!context) {
context = new ChannelSearchContext();
this.contexts.set(contextId, context);
}
return context;
}
setSearchInput(contextId: string, query: string, segments: Array<SearchSegment>): void {
const context = this.getContext(contextId);
context.searchQuery = query;
context.searchSegments = [...segments];
}
setActiveSearch(contextId: string, query: string, segments: Array<SearchSegment>): void {
const context = this.getContext(contextId);
context.activeSearchQuery = query;
context.activeSearchSegments = [...segments];
context.isSearchActive = true;
context.searchRefreshKey += 1;
}
setIsSearchActive(contextId: string, value: boolean): void {
const context = this.getContext(contextId);
context.isSearchActive = value;
}
closeSearch(contextId: string): void {
const context = this.getContext(contextId);
context.searchQuery = '';
context.searchSegments = [];
context.activeSearchQuery = '';
context.activeSearchSegments = [];
context.isSearchActive = false;
context.searchRefreshKey = 0;
context.lastSearchRefreshKey = null;
}
setMachineState(
contextId: string,
machineState: SearchMachineState,
query: string,
segments: Array<SearchSegment>,
refreshKey: number | null,
): void {
const context = this.getContext(contextId);
context.machineState = cloneMachineState(machineState);
if (machineState.status === 'success') {
context.lastSearchQuery = query;
context.lastSearchSegments = segments.map((segment) => ({...segment}));
context.lastSearchRefreshKey = refreshKey;
}
}
setScrollPosition(contextId: string, position: number): void {
const context = this.getContext(contextId);
context.scrollPosition = position;
}
setScope(contextId: string, scope: MessageSearchScope): void {
const context = this.getContext(contextId);
context.scope = scope;
}
}
export const getChannelSearchContextId = (
channel?: ChannelRecord | null,
selectedGuildId?: string | null,
): string | null => {
if (!channel) {
return null;
}
const resolvedGuildId = selectedGuildId ?? SelectedGuildStore.selectedGuildId;
const isDmContext = !resolvedGuildId || resolvedGuildId === ME || !channel.guildId || channel.guildId === ME;
if (isDmContext) {
return channel.id;
}
return channel.guildId ?? resolvedGuildId ?? channel.id;
};
export default new ChannelSearchStore();

View File

@@ -0,0 +1,55 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, observable} from 'mobx';
import type {GuildStickerRecord} from '~/records/GuildStickerRecord';
class ChannelStickerStore {
pendingStickers: Map<string, GuildStickerRecord> = observable.map();
constructor() {
makeAutoObservable(
this,
{
pendingStickers: false,
},
{autoBind: true},
);
}
setPendingSticker(channelId: string, sticker: GuildStickerRecord): void {
this.pendingStickers.set(channelId, sticker);
}
removePendingSticker(channelId: string): void {
this.pendingStickers.delete(channelId);
}
clearPendingStickerOnMessageSend(channelId: string): void {
if (this.pendingStickers.has(channelId)) {
this.pendingStickers.delete(channelId);
}
}
getPendingSticker(channelId: string): GuildStickerRecord | null {
return this.pendingStickers.get(channelId) ?? null;
}
}
export default new ChannelStickerStore();

View File

@@ -0,0 +1,322 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {action, makeAutoObservable} from 'mobx';
import {ChannelTypes, ME} from '~/Constants';
import {Routes} from '~/Routes';
import {type Channel, ChannelRecord} from '~/records/ChannelRecord';
import type {GuildReadyData} from '~/records/GuildRecord';
import type {Message} from '~/records/MessageRecord';
import type {UserPartial} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ChannelDisplayNameStore from '~/stores/ChannelDisplayNameStore';
import UserStore from '~/stores/UserStore';
import * as ChannelUtils from '~/utils/ChannelUtils';
import * as RouterUtils from '~/utils/RouterUtils';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
const sortDMs = (a: ChannelRecord, b: ChannelRecord) => {
const aTimestamp = a.lastMessageId ? SnowflakeUtils.extractTimestamp(a.lastMessageId) : null;
const bTimestamp = b.lastMessageId ? SnowflakeUtils.extractTimestamp(b.lastMessageId) : null;
if (aTimestamp != null && bTimestamp != null) {
return bTimestamp - aTimestamp;
}
if (aTimestamp != null) return -1;
if (bTimestamp != null) return 1;
return b.createdAt.getTime() - a.createdAt.getTime();
};
class ChannelStore {
private readonly channelsById = new Map<string, ChannelRecord>();
private readonly optimisticChannelBackups = new Map<string, ChannelRecord>();
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
get channels(): ReadonlyArray<ChannelRecord> {
return Array.from(this.channelsById.values());
}
get allChannels(): ReadonlyArray<ChannelRecord> {
return this.channels;
}
get dmChannels(): ReadonlyArray<ChannelRecord> {
return this.channels
.filter(
(channel) => !channel.guildId && (channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM),
)
.sort(sortDMs);
}
getChannel(channelId: string): ChannelRecord | undefined {
return this.channelsById.get(channelId);
}
getGuildChannels(guildId: string): ReadonlyArray<ChannelRecord> {
return this.channels.filter((channel) => channel.guildId === guildId).sort(ChannelUtils.compareChannels);
}
getPrivateChannels(): ReadonlyArray<ChannelRecord> {
return this.channels.filter(
(channel) => !channel.guildId && (channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM),
);
}
@action
removeChannelOptimistically(channelId: string): void {
if (this.optimisticChannelBackups.has(channelId)) {
return;
}
const channel = this.channelsById.get(channelId);
if (!channel) {
return;
}
this.optimisticChannelBackups.set(channelId, channel);
this.channelsById.delete(channelId);
ChannelDisplayNameStore.removeChannel(channelId);
}
@action
rollbackChannelDeletion(channelId: string): void {
const channel = this.optimisticChannelBackups.get(channelId);
if (!channel) {
return;
}
this.setChannel(channel);
this.optimisticChannelBackups.delete(channelId);
}
@action
clearOptimisticallyRemovedChannel(channelId: string): void {
this.optimisticChannelBackups.delete(channelId);
}
@action
private setChannel(channel: ChannelRecord | Channel): void {
const record = channel instanceof ChannelRecord ? channel : new ChannelRecord(channel);
this.channelsById.set(record.id, record);
ChannelDisplayNameStore.syncChannel(record);
}
@action
handleConnectionOpen({channels}: {channels: ReadonlyArray<Channel>}): void {
this.channelsById.clear();
ChannelDisplayNameStore.clear();
const allRecipients = channels
.filter((channel) => channel.recipients && channel.recipients.length > 0)
.flatMap((channel) => channel.recipients!);
if (allRecipients.length > 0) {
UserStore.cacheUsers(allRecipients);
}
for (const channel of channels) {
this.setChannel(channel);
}
const userId = AuthenticationStore.currentUserId;
if (!userId) {
return;
}
const personalNotesChannel: Channel = {
id: userId,
type: ChannelTypes.DM_PERSONAL_NOTES,
name: undefined,
topic: null,
url: null,
last_message_id: null,
last_pin_timestamp: null,
recipients: undefined,
parent_id: null,
bitrate: null,
user_limit: null,
};
this.setChannel(personalNotesChannel);
}
@action
handleGuildCreate(guild: GuildReadyData): void {
if (guild.unavailable) {
return;
}
for (const channel of guild.channels) {
this.setChannel(channel);
}
}
@action
handleGuildDelete({guildId}: {guildId: string}): void {
for (const [channelId, channel] of Array.from(this.channelsById.entries())) {
if (channel.guildId === guildId) {
this.channelsById.delete(channelId);
}
}
}
@action
handleChannelCreate({channel}: {channel: Channel}): void {
this.setChannel(channel);
}
@action
handleChannelUpdateBulk({channels}: {channels: Array<Channel>}): void {
for (const channel of channels) {
this.setChannel(channel);
}
}
@action
handleChannelPinsUpdate({channelId, lastPinTimestamp}: {channelId: string; lastPinTimestamp: string}): void {
const channel = this.channelsById.get(channelId);
if (!channel) {
return;
}
this.setChannel(
new ChannelRecord({
...channel.toJSON(),
last_pin_timestamp: lastPinTimestamp,
}),
);
}
@action
handleChannelRecipientAdd({channelId, user}: {channelId: string; user: UserPartial}): void {
const channel = this.channelsById.get(channelId);
if (!channel) {
return;
}
UserStore.cacheUsers([user]);
const newRecipients = [...channel.recipientIds, user.id];
this.setChannel(
channel.withUpdates({
recipients: newRecipients.map((id) => UserStore.getUser(id)!.toJSON()),
}),
);
}
@action
handleChannelRecipientRemove({channelId, user}: {channelId: string; user: UserPartial}): void {
const channel = this.channelsById.get(channelId);
if (!channel) {
return;
}
if (user.id === AuthenticationStore.currentUserId) {
this.channelsById.delete(channelId);
ChannelDisplayNameStore.removeChannel(channelId);
const history = RouterUtils.getHistory();
const currentPath = history?.location.pathname ?? '';
const expectedPath = Routes.dmChannel(channelId);
if (currentPath.startsWith(expectedPath)) {
RouterUtils.transitionTo(Routes.ME);
}
return;
}
const newRecipients = channel.recipientIds.filter((id) => id !== user.id);
this.setChannel(
channel.withUpdates({
recipients: newRecipients.map((id) => UserStore.getUser(id)!.toJSON()),
}),
);
}
@action
handleChannelDelete({channel}: {channel: Channel}): void {
this.clearOptimisticallyRemovedChannel(channel.id);
this.channelsById.delete(channel.id);
ChannelDisplayNameStore.removeChannel(channel.id);
const history = RouterUtils.getHistory();
const currentPath = history?.location.pathname ?? '';
const guildId = channel.guild_id ?? ME;
const expectedPath = guildId === ME ? Routes.dmChannel(channel.id) : Routes.guildChannel(guildId, channel.id);
if (!currentPath.startsWith(expectedPath)) {
return;
}
if (guildId === ME) {
RouterUtils.transitionTo(Routes.ME);
} else {
const guildChannels = this.getGuildChannels(guildId);
const selectableChannel = guildChannels.find((c) => c.type !== ChannelTypes.GUILD_CATEGORY);
if (selectableChannel) {
RouterUtils.transitionTo(Routes.guildChannel(guildId, selectableChannel.id));
} else {
RouterUtils.transitionTo(Routes.ME);
}
}
}
@action
handleMessageCreate({message}: {message: Message}): void {
const channel = this.channelsById.get(message.channel_id);
if (!channel) {
return;
}
this.setChannel(
new ChannelRecord({
...channel.toJSON(),
last_message_id: message.id,
}),
);
}
@action
handleGuildRoleDelete({guildId, roleId}: {guildId: string; roleId: string}): void {
for (const [, channel] of this.channelsById) {
if (channel.guildId !== guildId) {
continue;
}
if (!(roleId in channel.permissionOverwrites)) {
continue;
}
const filteredOverwrites = Object.entries(channel.permissionOverwrites)
.filter(([id]) => id !== roleId)
.map(([, overwrite]) => overwrite.toJSON());
this.setChannel(
new ChannelRecord({
...channel.toJSON(),
permission_overwrites: filteredOverwrites,
}),
);
}
}
}
export default new ChannelStore();

View File

@@ -0,0 +1,20 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
export {default} from './gateway/ConnectionStore';

View File

@@ -0,0 +1,130 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {Logger} from '~/lib/Logger';
import KeyboardModeStore from './KeyboardModeStore';
const logger = new Logger('ContextMenuStore');
export interface FocusableContextMenuTarget {
tagName: string;
isConnected: boolean;
focus: (options?: FocusOptions) => void;
addEventListener: HTMLElement['addEventListener'];
removeEventListener: HTMLElement['removeEventListener'];
}
export type ContextMenuTargetElement = HTMLElement | FocusableContextMenuTarget;
export const isContextMenuNodeTarget = (target: ContextMenuTargetElement | null | undefined): target is HTMLElement => {
if (!target || typeof Node === 'undefined') {
return false;
}
return target instanceof HTMLElement;
};
export interface ContextMenuTarget {
x: number;
y: number;
target: ContextMenuTargetElement;
}
export interface ContextMenuConfig {
onClose?: () => void;
noBlurEvent?: boolean;
returnFocus?: boolean;
returnFocusTarget?: ContextMenuTargetElement | null;
align?: 'top-left' | 'top-right';
}
export interface ContextMenu {
id: string;
target: ContextMenuTarget;
render: (props: {onClose: () => void}) => React.ReactNode;
config?: ContextMenuConfig;
}
export interface FocusRestoreState {
target: ContextMenuTargetElement | null;
keyboardModeEnabled: boolean;
}
class ContextMenuStore {
contextMenu: ContextMenu | null = null;
private focusRestoreState: FocusRestoreState | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
open(contextMenu: ContextMenu): void {
logger.debug(`Opening context menu: ${contextMenu.id}`);
this.contextMenu = contextMenu;
const requestedTarget = contextMenu.config?.returnFocusTarget ?? contextMenu.target.target;
this.focusRestoreState = {
target: requestedTarget ?? null,
keyboardModeEnabled: KeyboardModeStore.keyboardModeEnabled,
};
}
close(): void {
if (this.contextMenu) {
logger.debug(`Closing context menu: ${this.contextMenu.id}`);
const {config, target} = this.contextMenu;
const shouldReturnFocus = config?.returnFocus ?? true;
const fallbackTarget = target.target;
const restoreState = shouldReturnFocus ? this.focusRestoreState : null;
const focusTarget = config?.returnFocusTarget ?? restoreState?.target ?? fallbackTarget ?? null;
const resumeKeyboardMode = Boolean(restoreState?.keyboardModeEnabled);
config?.onClose?.();
this.contextMenu = null;
this.focusRestoreState = null;
if (shouldReturnFocus) {
this.restoreFocus(focusTarget, resumeKeyboardMode);
}
}
}
private restoreFocus(target: ContextMenuTargetElement | null, resumeKeyboardMode: boolean): void {
logger.debug(
`ContextMenuStore.restoreFocus target=${target ? target.tagName : 'null'} resumeKeyboardMode=${resumeKeyboardMode}`,
);
if (!target) return;
queueMicrotask(() => {
if (!target.isConnected) {
logger.debug('ContextMenuStore.restoreFocus aborted: target disconnected');
return;
}
try {
target.focus({preventScroll: true});
logger.debug('ContextMenuStore.restoreFocus applied focus to target');
} catch (error) {
logger.error('ContextMenuStore.restoreFocus failed to focus target', error as Error);
return;
}
if (resumeKeyboardMode) {
logger.debug('ContextMenuStore.restoreFocus re-entering keyboard mode');
KeyboardModeStore.enterKeyboardMode(false);
}
});
}
}
export default new ContextMenuStore();

View File

@@ -0,0 +1,38 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {Logger} from '~/lib/Logger';
const logger = new Logger('CountryCodeStore');
class CountryCodeStore {
countryCode = 'US';
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
setCountryCode(countryCode: string): void {
this.countryCode = countryCode;
logger.debug(`Set country code: ${countryCode}`);
}
}
export default new CountryCodeStore();

View File

@@ -0,0 +1,80 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {IS_DEV} from '~/lib/env';
import {makePersistent} from '~/lib/MobXPersistence';
import UserStore from '~/stores/UserStore';
const UNLOCK_TAP_THRESHOLD = 7;
const MAX_TAP_INTERVAL_MS = 1200;
class DeveloperModeStore {
manuallyEnabled = false;
private tapCount = 0;
private lastTapAt: number | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'DeveloperModeStore', ['manuallyEnabled']);
}
get isDeveloper(): boolean {
if (IS_DEV) return true;
if (UserStore.currentUser?.isStaff?.()) return true;
return this.manuallyEnabled;
}
private resetTaps(): void {
this.tapCount = 0;
this.lastTapAt = null;
}
registerBuildTap(): boolean {
if (this.isDeveloper) {
this.resetTaps();
return false;
}
const now = Date.now();
if (this.lastTapAt && now - this.lastTapAt <= MAX_TAP_INTERVAL_MS) {
this.tapCount += 1;
} else {
this.tapCount = 1;
}
this.lastTapAt = now;
if (this.tapCount >= UNLOCK_TAP_THRESHOLD) {
this.manuallyEnabled = true;
this.resetTaps();
return true;
}
return false;
}
}
export default new DeveloperModeStore();

View File

@@ -0,0 +1,258 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import AppStorage from '~/lib/AppStorage';
import {makePersistent} from '~/lib/MobXPersistence';
export type DeveloperOptionsState = Readonly<{
bypassSplashScreen: boolean;
forceFailUploads: boolean;
forceFailMessageSends: boolean;
forceRenderPlaceholders: boolean;
forceEmbedSkeletons: boolean;
forceMediaLoading: boolean;
forceUpdateReady: boolean;
forceNativeUpdateReady: boolean;
mockNativeUpdateProgress: number | null;
forceWebUpdateReady: boolean;
mockUpdaterState: 'none' | 'checking' | 'available' | 'downloading' | 'ready' | 'installing' | 'error';
showMyselfTyping: boolean;
slowAttachmentUpload: boolean;
slowMessageLoad: boolean;
slowMessageSend: boolean;
slowMessageEdit: boolean;
slowProfileLoad: boolean;
forceProfileDataWarning: boolean;
useCloudUpload: boolean;
debugLogging: boolean;
forceGifPickerLoading: boolean;
forceUnknownMessageType: boolean;
selfHostedModeOverride: boolean;
forceShowVanityURLDisclaimer: boolean;
forceShowVoiceConnection: boolean;
premiumTypeOverride: number | null;
premiumSinceOverride: Date | null;
premiumUntilOverride: Date | null;
premiumBillingCycleOverride: string | null;
premiumWillCancelOverride: boolean | null;
hasEverPurchasedOverride: boolean | null;
hasUnreadGiftInventoryOverride: boolean | null;
unreadGiftInventoryCountOverride: number | null;
emailVerifiedOverride: boolean | null;
unclaimedAccountOverride: boolean | null;
mockVerificationBarrier:
| 'none'
| 'unclaimed_account'
| 'unverified_email'
| 'account_too_new'
| 'not_member_long'
| 'no_phone'
| 'send_message_disabled';
mockBarrierTimeRemaining: number | null;
mockNSFWGateReason: 'none' | 'geo_restricted' | 'age_restricted' | 'consent_required';
mockNSFWMediaGateReason: 'none' | 'geo_restricted' | 'age_restricted';
mockGeoBlocked: boolean;
mockRequiredActionsOverlay: boolean;
mockRequiredActionsMode: 'email' | 'phone' | 'email_or_phone';
mockRequiredActionsSelectedTab: 'email' | 'phone';
mockRequiredActionsPhoneStep: 'phone' | 'code';
mockRequiredActionsResending: boolean;
mockRequiredActionsResendOutcome: 'success' | 'rate_limited' | 'server_error';
mockRequiredActionsReverify: boolean;
forceNoSendMessages: boolean;
forceNoAttachFiles: boolean;
mockSlowmodeActive: boolean;
mockSlowmodeRemaining: number;
mockVisionarySoldOut: boolean;
mockVisionaryRemaining: number | null;
mockGiftInventory: boolean | null;
mockGiftDurationMonths: number | null;
mockGiftRedeemed: boolean | null;
mockTitlebarPlatformOverride: 'auto' | 'macos' | 'windows' | 'linux';
mockAttachmentStates: Record<
string,
{
expired?: boolean;
expiresAt?: string | null;
}
>;
}>;
type MutableDeveloperOptionsState = {
-readonly [K in keyof DeveloperOptionsState]: DeveloperOptionsState[K];
};
class DeveloperOptionsStore implements DeveloperOptionsState {
bypassSplashScreen = false;
forceFailUploads = false;
forceFailMessageSends = false;
forceRenderPlaceholders = false;
forceEmbedSkeletons = false;
forceMediaLoading = false;
forceUpdateReady = false;
forceNativeUpdateReady = false;
mockNativeUpdateProgress: number | null = null;
forceWebUpdateReady = false;
mockUpdaterState: DeveloperOptionsState['mockUpdaterState'] = 'none';
showMyselfTyping = false;
slowAttachmentUpload = false;
slowMessageLoad = false;
slowMessageSend = false;
slowMessageEdit = false;
slowProfileLoad = false;
forceProfileDataWarning = false;
useCloudUpload = false;
debugLogging = false;
forceGifPickerLoading = false;
forceUnknownMessageType = false;
selfHostedModeOverride = false;
forceShowVanityURLDisclaimer = false;
forceShowVoiceConnection = false;
premiumTypeOverride: number | null = null;
premiumSinceOverride: Date | null = null;
premiumUntilOverride: Date | null = null;
premiumBillingCycleOverride: string | null = null;
premiumWillCancelOverride: boolean | null = null;
hasEverPurchasedOverride: boolean | null = null;
hasUnreadGiftInventoryOverride: boolean | null = null;
unreadGiftInventoryCountOverride: number | null = null;
emailVerifiedOverride: boolean | null = null;
unclaimedAccountOverride: boolean | null = null;
mockVerificationBarrier:
| 'none'
| 'unclaimed_account'
| 'unverified_email'
| 'account_too_new'
| 'not_member_long'
| 'no_phone'
| 'send_message_disabled' = 'none';
mockBarrierTimeRemaining: number | null = null;
mockNSFWGateReason: 'none' | 'geo_restricted' | 'age_restricted' | 'consent_required' = 'none';
mockNSFWMediaGateReason: 'none' | 'geo_restricted' | 'age_restricted' = 'none';
mockGeoBlocked = false;
mockRequiredActionsOverlay = false;
mockRequiredActionsMode: 'email' | 'phone' | 'email_or_phone' = 'email';
mockRequiredActionsSelectedTab: 'email' | 'phone' = 'email';
mockRequiredActionsPhoneStep: 'phone' | 'code' = 'phone';
mockRequiredActionsResending = false;
mockRequiredActionsResendOutcome: 'success' | 'rate_limited' | 'server_error' = 'success';
mockRequiredActionsReverify = false;
forceNoSendMessages = false;
forceNoAttachFiles = false;
mockSlowmodeActive = false;
mockSlowmodeRemaining = 10000;
mockAttachmentStates: Record<
string,
{
expired?: boolean;
expiresAt?: string | null;
}
> = {};
mockVisionarySoldOut = false;
mockVisionaryRemaining: number | null = null;
mockGiftInventory: boolean | null = null;
mockGiftDurationMonths: number | null = 12;
mockGiftRedeemed: boolean | null = null;
mockTitlebarPlatformOverride: DeveloperOptionsState['mockTitlebarPlatformOverride'] = 'auto';
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'DeveloperOptionsStore', [
'bypassSplashScreen',
'forceFailUploads',
'forceFailMessageSends',
'forceRenderPlaceholders',
'forceEmbedSkeletons',
'forceMediaLoading',
'forceUpdateReady',
'forceNativeUpdateReady',
'mockNativeUpdateProgress',
'forceWebUpdateReady',
'mockUpdaterState',
'showMyselfTyping',
'slowAttachmentUpload',
'slowMessageLoad',
'slowMessageSend',
'slowMessageEdit',
'slowProfileLoad',
'forceProfileDataWarning',
'useCloudUpload',
'debugLogging',
'forceGifPickerLoading',
'forceUnknownMessageType',
'selfHostedModeOverride',
'forceShowVanityURLDisclaimer',
'forceShowVoiceConnection',
'premiumTypeOverride',
'premiumSinceOverride',
'premiumUntilOverride',
'premiumBillingCycleOverride',
'premiumWillCancelOverride',
'hasEverPurchasedOverride',
'hasUnreadGiftInventoryOverride',
'unreadGiftInventoryCountOverride',
'emailVerifiedOverride',
'unclaimedAccountOverride',
'mockVerificationBarrier',
'mockBarrierTimeRemaining',
'mockNSFWGateReason',
'mockNSFWMediaGateReason',
'mockGeoBlocked',
'mockRequiredActionsOverlay',
'mockRequiredActionsMode',
'mockRequiredActionsSelectedTab',
'mockRequiredActionsPhoneStep',
'mockRequiredActionsResending',
'mockRequiredActionsResendOutcome',
'mockRequiredActionsReverify',
'forceNoSendMessages',
'forceNoAttachFiles',
'mockSlowmodeActive',
'mockSlowmodeRemaining',
'mockVisionarySoldOut',
'mockVisionaryRemaining',
'mockGiftInventory',
'mockGiftDurationMonths',
'mockGiftRedeemed',
'mockTitlebarPlatformOverride',
'mockAttachmentStates',
]);
}
updateOption<K extends keyof DeveloperOptionsStore & keyof DeveloperOptionsState>(
key: K,
value: DeveloperOptionsState[K],
): void {
(this as MutableDeveloperOptionsState)[key] = value;
if (key === 'debugLogging') {
AppStorage.setItem('debugLoggingEnabled', value?.toString() ?? 'false');
}
}
}
export default new DeveloperOptionsStore();

View File

@@ -0,0 +1,174 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {action, makeObservable, observable} from 'mobx';
const BOTTOM_TOLERANCE = 2;
interface ChannelDimensions {
channelId: string;
scrollTop: number;
scrollHeight: number;
offsetHeight: number;
}
interface GuildDimensions {
guildId: string;
scrollTop: number | null;
scrollTo: number | null;
}
interface GuildListDimensions {
scrollTop: number;
}
function createDefaultGuildDimensions(guildId: string): GuildDimensions {
return {
guildId,
scrollTop: null,
scrollTo: null,
};
}
class DimensionStore {
channelDimensions = observable.map(new Map<string, ChannelDimensions>());
guildDimensions = observable.map(new Map<string, GuildDimensions>());
guildListDimensions = observable.box({
scrollTop: 0,
});
constructor() {
makeObservable(this, {
updateChannelDimensions: action,
updateGuildDimensions: action,
updateGuildListDimensions: action,
clearChannelDimensions: action,
});
}
percentageScrolled(channelId: string): number {
const dimensions = this.channelDimensions.get(channelId);
if (dimensions != null) {
const {scrollTop, scrollHeight} = dimensions;
return scrollTop / scrollHeight;
}
return 1;
}
getChannelDimensions(channelId: string): ChannelDimensions | undefined {
return this.channelDimensions.get(channelId);
}
getGuildDimensions(guildId: string): GuildDimensions {
const existing = this.guildDimensions.get(guildId);
if (existing != null) {
return existing;
}
return createDefaultGuildDimensions(guildId);
}
getGuildListDimensions(): GuildListDimensions {
return this.guildListDimensions.get();
}
isAtBottom(channelId: string): boolean {
const dimensions = this.channelDimensions.get(channelId);
if (dimensions == null) {
return true;
}
const {scrollTop, scrollHeight, offsetHeight} = dimensions;
return scrollTop >= scrollHeight - offsetHeight - BOTTOM_TOLERANCE;
}
updateChannelDimensions(
channelId: string,
scrollTop: number | null,
scrollHeight: number | null,
offsetHeight: number | null,
callback?: () => void,
): void {
const existing = this.channelDimensions.get(channelId);
if (scrollTop == null || scrollHeight == null || offsetHeight == null) {
if (existing != null) {
this.channelDimensions.delete(channelId);
}
callback?.();
return;
}
const newDimensions: ChannelDimensions = {
channelId,
scrollTop,
scrollHeight,
offsetHeight,
};
if (
existing == null ||
existing.scrollTop !== scrollTop ||
existing.scrollHeight !== scrollHeight ||
existing.offsetHeight !== offsetHeight
) {
this.channelDimensions.set(channelId, newDimensions);
}
callback?.();
}
updateGuildDimensions(guildId: string, scrollTop?: number | null, scrollTo?: number | null): void {
let dimensions = this.guildDimensions.get(guildId);
if (dimensions == null) {
dimensions = createDefaultGuildDimensions(guildId);
}
const updated: GuildDimensions = {
...dimensions,
scrollTop: scrollTop !== undefined ? scrollTop : dimensions.scrollTop,
scrollTo: scrollTo !== undefined ? scrollTo : dimensions.scrollTo,
};
this.guildDimensions.set(guildId, updated);
}
updateGuildListDimensions(scrollTop: number): void {
this.guildListDimensions.set({scrollTop});
}
clearChannelDimensions(channelId: string): void {
this.channelDimensions.delete(channelId);
}
scrollGuildListTo(guildId: string, scrollTo: number): void {
this.updateGuildDimensions(guildId, undefined, scrollTo);
}
clearGuildListScrollTo(guildId: string): void {
this.updateGuildDimensions(guildId, undefined, null);
}
handleCallCreate(channelId: string): void {
this.clearChannelDimensions(channelId);
}
}
export default new DimensionStore();

View File

@@ -0,0 +1,40 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
class DismissedUpsellStore {
pickerPremiumUpsellDismissed = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'DismissedUpsellStore', ['pickerPremiumUpsellDismissed']);
}
dismissPickerPremiumUpsell(): void {
this.pickerPremiumUpsellDismissed = true;
}
}
export default new DismissedUpsellStore();

View File

@@ -0,0 +1,84 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {action, makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
class DraftStore {
drafts: Record<string, string> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'DraftStore', ['drafts']);
}
@action
createDraft(channelId: string, content: string): void {
if (!content || content === this.drafts[channelId]) {
return;
}
this.drafts = {
...this.drafts,
[channelId]: content,
};
}
@action
deleteDraft(channelId: string): void {
if (!this.drafts[channelId]) {
return;
}
const {[channelId]: _, ...remainingDrafts} = this.drafts;
this.drafts = remainingDrafts;
}
@action
deleteChannelDraft(channelId: string): void {
this.deleteDraft(channelId);
}
getDraft(channelId: string): string {
return this.drafts[channelId] ?? '';
}
@action
cleanupEmptyDrafts(): void {
this.drafts = Object.fromEntries(Object.entries(this.drafts).filter(([_, content]) => content.trim().length > 0));
}
getAllDrafts(): ReadonlyArray<[string, string]> {
return Object.entries(this.drafts);
}
hasDraft(channelId: string): boolean {
return channelId in this.drafts;
}
getDraftCount(): number {
return Object.keys(this.drafts).length;
}
}
export default new DraftStore();

View File

@@ -0,0 +1,179 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import {Logger} from '~/lib/Logger';
import {makePersistent} from '~/lib/MobXPersistence';
import type {Emoji} from '~/stores/EmojiStore';
const logger = new Logger('EmojiPickerStore');
type EmojiUsageEntry = Readonly<{
count: number;
lastUsed: number;
}>;
const MAX_FRECENT_EMOJIS = 42;
const FRECENCY_TIME_DECAY_HOURS = 24 * 7;
const DEFAULT_QUICK_EMOJIS = [
{name: 'thumbsup', uniqueName: 'thumbsup'},
{name: 'ok_hand', uniqueName: 'ok_hand'},
{name: 'tada', uniqueName: 'tada'},
];
class EmojiPickerStore {
emojiUsage: Record<string, EmojiUsageEntry> = {};
favoriteEmojis: Array<string> = [];
collapsedCategories: Array<string> = [];
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'EmojiPickerStore', ['emojiUsage', 'favoriteEmojis', 'collapsedCategories']);
}
trackEmojiUsage(emojiKey: string): void {
const now = Date.now();
const currentUsage = this.emojiUsage[emojiKey];
const newCount = (currentUsage?.count ?? 0) + 1;
this.emojiUsage[emojiKey] = {
count: newCount,
lastUsed: now,
};
logger.debug(`Tracked emoji usage: ${emojiKey}`);
}
toggleFavorite(emojiKey: string): void {
if (this.favoriteEmojis.includes(emojiKey)) {
const index = this.favoriteEmojis.indexOf(emojiKey);
if (index > -1) {
this.favoriteEmojis.splice(index, 1);
}
} else {
this.favoriteEmojis.push(emojiKey);
}
ComponentDispatch.dispatch('EMOJI_PICKER_RERENDER');
logger.debug(`Toggled favorite emoji: ${emojiKey}`);
}
toggleCategory(category: string): void {
if (this.collapsedCategories.includes(category)) {
const index = this.collapsedCategories.indexOf(category);
if (index > -1) {
this.collapsedCategories.splice(index, 1);
}
} else {
this.collapsedCategories.push(category);
}
ComponentDispatch.dispatch('EMOJI_PICKER_RERENDER');
logger.debug(`Toggled category: ${category}`);
}
isFavorite(emoji: Emoji): boolean {
return this.favoriteEmojis.includes(this.getEmojiKey(emoji));
}
isCategoryCollapsed(categoryId: string): boolean {
return this.collapsedCategories.includes(categoryId);
}
private getFrecencyScore(entry: EmojiUsageEntry): number {
const now = Date.now();
const hoursSinceLastUse = (now - entry.lastUsed) / (1000 * 60 * 60);
const timeDecay = Math.max(0, 1 - hoursSinceLastUse / FRECENCY_TIME_DECAY_HOURS);
return entry.count * (1 + timeDecay);
}
getFrecentEmojis(allEmojis: ReadonlyArray<Emoji>, limit: number = MAX_FRECENT_EMOJIS): Array<Emoji> {
const emojiScores: Array<{emoji: Emoji; score: number}> = [];
for (const emoji of allEmojis) {
const emojiKey = this.getEmojiKey(emoji);
const usage = this.emojiUsage[emojiKey];
if (usage) {
const score = this.getFrecencyScore(usage);
emojiScores.push({emoji, score});
}
}
emojiScores.sort((a, b) => b.score - a.score);
return emojiScores.slice(0, limit).map((item) => item.emoji);
}
getFavoriteEmojis(allEmojis: ReadonlyArray<Emoji>): Array<Emoji> {
const favorites: Array<Emoji> = [];
for (const emoji of allEmojis) {
if (this.isFavorite(emoji)) {
favorites.push(emoji);
}
}
return favorites;
}
getFrecencyScoreForEmoji(emoji: Emoji): number {
const usage = this.emojiUsage[this.getEmojiKey(emoji)];
return usage ? this.getFrecencyScore(usage) : 0;
}
getQuickReactionEmojis(allEmojis: ReadonlyArray<Emoji>, count: number): Array<Emoji> {
const frecent = this.getFrecentEmojis(allEmojis, count);
if (frecent.length >= count) {
return frecent.slice(0, count);
}
const result = [...frecent];
const needed = count - frecent.length;
for (let i = 0; i < needed && i < DEFAULT_QUICK_EMOJIS.length; i++) {
const defaultEmoji = DEFAULT_QUICK_EMOJIS[i];
const found = allEmojis.find((e) => e.uniqueName === defaultEmoji.uniqueName);
if (found && !result.some((r) => this.getEmojiKey(r) === this.getEmojiKey(found))) {
result.push(found);
}
}
return result.slice(0, count);
}
private getEmojiKey(emoji: Emoji): string {
if (emoji.id) {
return `custom:${emoji.guildId}:${emoji.id}`;
}
return `unicode:${emoji.uniqueName}`;
}
trackEmoji(emoji: Emoji): void {
this.trackEmojiUsage(this.getEmojiKey(emoji));
}
}
export default new EmojiPickerStore();

View File

@@ -0,0 +1,56 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
export type EmojiLayout = 'list' | 'grid';
export type StickerViewMode = 'cozy' | 'compact';
class EmojiStickerLayoutStore {
emojiLayout: EmojiLayout = 'list';
stickerViewMode: StickerViewMode = 'cozy';
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'EmojiStickerLayoutStore', ['emojiLayout', 'stickerViewMode']);
}
getEmojiLayout(): EmojiLayout {
return this.emojiLayout;
}
setEmojiLayout(layout: EmojiLayout): void {
this.emojiLayout = layout;
}
getStickerViewMode(): StickerViewMode {
return this.stickerViewMode;
}
setStickerViewMode(mode: StickerViewMode): void {
this.stickerViewMode = mode;
}
}
export default new EmojiStickerLayoutStore();

View File

@@ -0,0 +1,391 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {i18n} from '@lingui/core';
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
import type {UnicodeEmoji} from '~/lib/UnicodeEmojis';
import UnicodeEmojis from '~/lib/UnicodeEmojis';
import type {ChannelRecord} from '~/records/ChannelRecord';
import {type GuildEmoji, GuildEmojiRecord} from '~/records/GuildEmojiRecord';
import type {GuildMember} from '~/records/GuildMemberRecord';
import type {Guild, GuildReadyData} from '~/records/GuildRecord';
import EmojiPickerStore from '~/stores/EmojiPickerStore';
import {patchGuildEmojiCacheFromGateway} from '~/stores/GuildExpressionTabCache';
import GuildListStore from '~/stores/GuildListStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import UserStore from '~/stores/UserStore';
import {filterEmojisForAutocomplete} from '~/utils/ExpressionPermissionUtils';
import * as RegexUtils from '~/utils/RegexUtils';
import {sortBySnowflakeDesc} from '~/utils/SnowflakeUtils';
export type Emoji = Readonly<
Partial<GuildEmojiRecord> &
Partial<UnicodeEmoji> & {
name: string;
allNamesString: string;
uniqueName: string;
useSpriteSheet?: boolean;
index?: number;
diversityIndex?: number;
hasDiversity?: boolean;
}
>;
type GuildEmojiContext = Readonly<{
emojis: ReadonlyArray<GuildEmojiRecord>;
usableEmojis: ReadonlyArray<GuildEmojiRecord>;
}>;
class EmojiDisambiguations {
private static _lastInstance: EmojiDisambiguations | null = null;
private readonly guildId: string | null;
private disambiguatedEmoji: ReadonlyArray<Emoji> | null = null;
private customEmojis: ReadonlyMap<string, Emoji> | null = null;
private emojisByName: ReadonlyMap<string, Emoji> | null = null;
private emojisById: ReadonlyMap<string, Emoji> | null = null;
private constructor(guildId?: string | null) {
this.guildId = guildId ?? null;
}
static getInstance(guildId?: string | null): EmojiDisambiguations {
if (!EmojiDisambiguations._lastInstance || EmojiDisambiguations._lastInstance.guildId !== guildId) {
EmojiDisambiguations._lastInstance = new EmojiDisambiguations(guildId);
}
return EmojiDisambiguations._lastInstance;
}
static reset(): void {
EmojiDisambiguations._lastInstance = null;
}
static clear(guildId?: string | null): void {
if (EmojiDisambiguations._lastInstance?.guildId === guildId) {
EmojiDisambiguations._lastInstance = null;
}
}
getDisambiguatedEmoji(): ReadonlyArray<Emoji> {
this.ensureDisambiguated();
return this.disambiguatedEmoji ?? [];
}
getCustomEmoji(): ReadonlyMap<string, Emoji> {
this.ensureDisambiguated();
return this.customEmojis ?? new Map();
}
getByName(disambiguatedEmojiName: string): Emoji | undefined {
this.ensureDisambiguated();
return this.emojisByName?.get(disambiguatedEmojiName);
}
getById(emojiId: string): Emoji | undefined {
this.ensureDisambiguated();
return this.emojisById?.get(emojiId);
}
nameMatchesChain(testName: (name: string) => boolean): ReadonlyArray<Emoji> {
return this.getDisambiguatedEmoji().filter(({names, name}) => (names ? names.some(testName) : testName(name)));
}
private ensureDisambiguated(): void {
if (!this.disambiguatedEmoji) {
const result = this.buildDisambiguatedCustomEmoji();
this.disambiguatedEmoji = result.disambiguatedEmoji;
this.customEmojis = result.customEmojis;
this.emojisByName = result.emojisByName;
this.emojisById = result.emojisById;
}
}
private buildDisambiguatedCustomEmoji() {
const emojiCountByName = new Map<string, number>();
const disambiguatedEmoji: Array<Emoji> = [];
const customEmojis = new Map<string, Emoji>();
const emojisByName = new Map<string, Emoji>();
const emojisById = new Map<string, Emoji>();
const disambiguateEmoji = (emoji: Emoji): void => {
const uniqueName = emoji.name;
const existingCount = emojiCountByName.get(uniqueName) ?? 0;
emojiCountByName.set(uniqueName, existingCount + 1);
const finalEmoji =
existingCount > 0
? {
...emoji,
name: `${uniqueName}~${existingCount}`,
uniqueName,
allNamesString: `:${uniqueName}~${existingCount}:`,
}
: emoji;
emojisByName.set(finalEmoji.name, finalEmoji);
if (finalEmoji.id) {
emojisById.set(finalEmoji.id, finalEmoji);
customEmojis.set(finalEmoji.name, finalEmoji);
}
disambiguatedEmoji.push(finalEmoji);
};
UnicodeEmojis.forEachEmoji((unicodeEmoji) => {
const compatibleEmoji: Emoji = {
...unicodeEmoji,
name: unicodeEmoji.uniqueName,
url: unicodeEmoji.url || undefined,
useSpriteSheet: unicodeEmoji.useSpriteSheet,
index: unicodeEmoji.index,
diversityIndex: unicodeEmoji.diversityIndex,
hasDiversity: unicodeEmoji.hasDiversity,
};
disambiguateEmoji(compatibleEmoji);
});
const processGuildEmojis = (guildId: string) => {
const guildEmoji = emojiGuildRegistry.get(guildId);
if (!guildEmoji) return;
guildEmoji.usableEmojis.forEach((emoji) => {
const emojiForDisambiguation: Emoji = {
...emoji,
name: emoji.name,
uniqueName: emoji.name,
allNamesString: emoji.allNamesString,
url: emoji.url,
useSpriteSheet: false,
};
disambiguateEmoji(emojiForDisambiguation);
});
};
if (this.guildId) {
processGuildEmojis(this.guildId);
}
for (const guild of GuildListStore.guilds.filter((guild) => guild.id !== this.guildId)) {
processGuildEmojis(guild.id);
}
return {
disambiguatedEmoji: Object.freeze(disambiguatedEmoji),
customEmojis: new Map(customEmojis),
emojisByName: new Map(emojisByName),
emojisById: new Map(emojisById),
};
}
}
class EmojiGuildRegistry {
private guilds = new Map<string, GuildEmojiContext>();
private customEmojisById = new Map<string, GuildEmojiRecord>();
reset(): void {
this.guilds.clear();
this.customEmojisById.clear();
EmojiDisambiguations.reset();
}
deleteGuild(guildId: string): void {
this.guilds.delete(guildId);
}
get(guildId: string): GuildEmojiContext | undefined {
return this.guilds.get(guildId);
}
rebuildRegistry(): void {
this.customEmojisById.clear();
for (const guild of this.guilds.values()) {
for (const emoji of guild.usableEmojis) {
this.customEmojisById.set(emoji.id, emoji);
}
}
EmojiDisambiguations.reset();
}
updateGuild(guildId: string, guildEmojis?: ReadonlyArray<GuildEmoji>): void {
this.deleteGuild(guildId);
EmojiDisambiguations.clear(guildId);
if (!guildEmojis) return;
const currentUser = UserStore.getCurrentUser();
if (!currentUser) return;
const localUser = GuildMemberStore.getMember(guildId, currentUser.id);
if (!localUser) return;
const emojiRecords = guildEmojis.map((emoji) => new GuildEmojiRecord(guildId, emoji));
const sortedEmojis = sortBySnowflakeDesc(emojiRecords);
const frozenEmojis = Object.freeze(sortedEmojis);
this.guilds.set(guildId, {
emojis: frozenEmojis,
usableEmojis: frozenEmojis,
});
}
getGuildEmojis(guildId: string): ReadonlyArray<GuildEmojiRecord> {
return this.guilds.get(guildId)?.usableEmojis ?? [];
}
}
const emojiGuildRegistry = new EmojiGuildRegistry();
class EmojiStore {
skinTone = '';
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'EmojiStore', ['skinTone']);
UnicodeEmojis.setDefaultSkinTone(this.skinTone);
}
get categories(): ReadonlyArray<string> {
return Object.freeze(['custom', ...UnicodeEmojis.getCategories()]);
}
getGuildEmoji(guildId: string): ReadonlyArray<GuildEmojiRecord> {
return emojiGuildRegistry.getGuildEmojis(guildId);
}
getEmojiById(emojiId: string): Emoji | undefined {
return this.getDisambiguatedEmojiContext(null).getById(emojiId);
}
getDisambiguatedEmojiContext(guildId?: string | null): EmojiDisambiguations {
return EmojiDisambiguations.getInstance(guildId);
}
getEmojiMarkdown(emoji: Emoji): string {
return emoji.id ? `<${emoji.animated ? 'a' : ''}:${emoji.uniqueName}:${emoji.id}>` : `:${emoji.uniqueName}:`;
}
filterExternal(
channel: ChannelRecord | null,
nameTest: (name: string) => boolean,
count: number,
): ReadonlyArray<Emoji> {
const results = EmojiDisambiguations.getInstance(channel?.guildId).nameMatchesChain(nameTest);
const filtered = filterEmojisForAutocomplete(i18n, results, channel);
return count > 0 ? filtered.slice(0, count) : filtered;
}
getAllEmojis(channel: ChannelRecord | null): ReadonlyArray<Emoji> {
return this.getDisambiguatedEmojiContext(channel?.guildId).getDisambiguatedEmoji();
}
search(channel: ChannelRecord | null, query: string, count = 0): ReadonlyArray<Emoji> {
const lowerCasedQuery = query.toLowerCase();
if (!lowerCasedQuery) {
const allEmojis = this.getAllEmojis(channel);
const sorted = [...allEmojis].sort(
(a, b) => EmojiPickerStore.getFrecencyScoreForEmoji(b) - EmojiPickerStore.getFrecencyScoreForEmoji(a),
);
return count > 0 ? sorted.slice(0, count) : sorted;
}
const escapedQuery = RegexUtils.escapeRegex(lowerCasedQuery);
const containsRegex = new RegExp(escapedQuery, 'i');
const startsWithRegex = new RegExp(`^${escapedQuery}`, 'i');
const boundaryRegex = new RegExp(`(^|_|[A-Z])${escapedQuery}s?([A-Z]|_|$)`);
const searchResults = this.filterExternal(channel, containsRegex.test.bind(containsRegex), 0);
if (searchResults.length === 0) return searchResults;
const getScore = (name: string): number => {
const nameLower = name.toLowerCase();
return (
1 +
(nameLower === lowerCasedQuery ? 4 : 0) +
(boundaryRegex.test(nameLower) || boundaryRegex.test(name) ? 2 : 0) +
(startsWithRegex.test(name) ? 1 : 0)
);
};
const sortedResults = [...searchResults].sort((a, b) => {
const frecencyDiff = EmojiPickerStore.getFrecencyScoreForEmoji(b) - EmojiPickerStore.getFrecencyScoreForEmoji(a);
if (frecencyDiff !== 0) {
return frecencyDiff;
}
const aName = a.names?.[0] ?? a.name;
const bName = b.names?.[0] ?? b.name;
const scoreDiff = getScore(bName) - getScore(aName);
return scoreDiff || aName.localeCompare(bName);
});
return count > 0 ? sortedResults.slice(0, count) : sortedResults;
}
setSkinTone(skinTone: string): void {
this.skinTone = skinTone;
UnicodeEmojis.setDefaultSkinTone(skinTone);
}
handleConnectionOpen({guilds}: {guilds: ReadonlyArray<GuildReadyData>}): void {
emojiGuildRegistry.reset();
for (const guild of guilds) {
emojiGuildRegistry.updateGuild(guild.id, guild.emojis);
}
emojiGuildRegistry.rebuildRegistry();
}
handleGuildUpdate({guild}: {guild: Guild | GuildReadyData}): void {
emojiGuildRegistry.updateGuild(guild.id, 'emojis' in guild ? guild.emojis : undefined);
emojiGuildRegistry.rebuildRegistry();
}
handleGuildEmojiUpdated({guildId, emojis}: {guildId: string; emojis: ReadonlyArray<GuildEmoji>}): void {
emojiGuildRegistry.updateGuild(guildId, emojis);
emojiGuildRegistry.rebuildRegistry();
patchGuildEmojiCacheFromGateway(guildId, emojis);
}
handleGuildDelete({guildId}: {guildId: string}): void {
emojiGuildRegistry.deleteGuild(guildId);
emojiGuildRegistry.rebuildRegistry();
}
handleGuildMemberUpdate({guildId, member}: {guildId: string; member: GuildMember}): void {
if (member.user.id !== UserStore.getCurrentUser()?.id) {
return;
}
const currentGuildEmojis = emojiGuildRegistry.getGuildEmojis(guildId).map((emoji) => ({
...emoji.toJSON(),
guild_id: guildId,
}));
emojiGuildRegistry.updateGuild(guildId, currentGuildEmojis);
emojiGuildRegistry.rebuildRegistry();
}
}
export default new EmojiStore();

View File

@@ -0,0 +1,71 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, runInAction} from 'mobx';
import type {ExpressionPickerTabType} from '~/components/popouts/ExpressionPickerPopout';
class ExpressionPickerStore {
isOpen = false;
selectedTab: ExpressionPickerTabType = 'emojis';
channelId: string | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
open(channelId: string, tab?: ExpressionPickerTabType): void {
runInAction(() => {
this.isOpen = true;
if (tab !== undefined) {
this.selectedTab = tab;
}
this.channelId = channelId;
});
}
close(): void {
runInAction(() => {
if (this.isOpen) {
this.isOpen = false;
}
});
}
toggle(channelId: string, tab: ExpressionPickerTabType): void {
runInAction(() => {
if (this.isOpen && this.selectedTab === tab && this.channelId === channelId) {
this.isOpen = false;
} else {
this.isOpen = true;
this.selectedTab = tab;
this.channelId = channelId;
}
});
}
setTab(tab: ExpressionPickerTabType): void {
runInAction(() => {
if (this.selectedTab !== tab) {
this.selectedTab = tab;
}
});
}
}
export default new ExpressionPickerStore();

View File

@@ -0,0 +1,69 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {type FavoriteMeme, FavoriteMemeRecord} from '~/records/FavoriteMemeRecord';
class FavoriteMemeStore {
memes: ReadonlyArray<FavoriteMemeRecord> = [];
fetched: boolean = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
loadFavoriteMemes(favoriteMemes: ReadonlyArray<FavoriteMeme>): void {
this.memes = Object.freeze((favoriteMemes || []).map((meme) => new FavoriteMemeRecord(meme)));
this.fetched = true;
}
reset(): void {
this.memes = [];
this.fetched = false;
}
createMeme(meme: FavoriteMeme): void {
this.memes = Object.freeze([new FavoriteMemeRecord(meme), ...this.memes]);
}
updateMeme(meme: FavoriteMeme): void {
const index = this.memes.findIndex((m) => m.id === meme.id);
if (index === -1) return;
this.memes = Object.freeze([
...this.memes.slice(0, index),
new FavoriteMemeRecord(meme),
...this.memes.slice(index + 1),
]);
}
deleteMeme(memeId: string): void {
this.memes = Object.freeze(this.memes.filter((meme) => meme.id !== memeId));
}
getAllMemes(): ReadonlyArray<FavoriteMemeRecord> {
return this.memes;
}
getMeme(memeId: string): FavoriteMemeRecord | undefined {
return this.memes.find((meme) => meme.id === memeId);
}
}
export default new FavoriteMemeStore();

View File

@@ -0,0 +1,250 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
import ChannelStore from './ChannelStore';
import UserGuildSettingsStore from './UserGuildSettingsStore';
export interface FavoriteChannel {
channelId: string;
guildId: string;
parentId: string | null;
position: number;
nickname: string | null;
}
export interface FavoriteCategory {
id: string;
name: string;
position: number;
}
class FavoritesStore {
channels: Array<FavoriteChannel> = [];
categories: Array<FavoriteCategory> = [];
collapsedCategories = new Set<string>();
hideMutedChannels: boolean = false;
isMuted: boolean = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'FavoritesStore', [
'channels',
'categories',
'collapsedCategories',
'hideMutedChannels',
'isMuted',
]);
}
get hasAnyFavorites(): boolean {
return this.channels.length > 0;
}
get sortedCategories(): ReadonlyArray<FavoriteCategory> {
return [...this.categories].sort((a, b) => a.position - b.position);
}
get sortedChannels(): ReadonlyArray<FavoriteChannel> {
return [...this.channels].sort((a, b) => a.position - b.position);
}
getChannel(channelId: string): FavoriteChannel | undefined {
return this.channels.find((ch) => ch.channelId === channelId);
}
getCategory(categoryId: string): FavoriteCategory | undefined {
return this.categories.find((cat) => cat.id === categoryId);
}
getChannelsInCategory(categoryId: string | null): ReadonlyArray<FavoriteChannel> {
return this.sortedChannels.filter((ch) => ch.parentId === categoryId);
}
isCategoryCollapsed(categoryId: string): boolean {
return this.collapsedCategories.has(categoryId);
}
getFirstAccessibleChannel(): FavoriteChannel | undefined {
for (const fav of this.sortedChannels) {
const channel = ChannelStore.getChannel(fav.channelId);
if (!channel) continue;
if (this.hideMutedChannels && channel.guildId) {
if (UserGuildSettingsStore.isGuildOrChannelMuted(channel.guildId, channel.id)) {
continue;
}
}
return fav;
}
return undefined;
}
isChannelAccessible(channelId: string): boolean {
const fav = this.getChannel(channelId);
if (!fav) return false;
const channel = ChannelStore.getChannel(fav.channelId);
if (!channel) return false;
if (this.hideMutedChannels && channel.guildId) {
if (UserGuildSettingsStore.isGuildOrChannelMuted(channel.guildId, channel.id)) {
return false;
}
}
return true;
}
addChannel(channelId: string, guildId: string, parentId: string | null = null): void {
const existing = this.channels.find((ch) => ch.channelId === channelId);
if (existing) return;
const position = this.channels.length;
this.channels.push({
channelId,
guildId,
parentId,
position,
nickname: null,
});
}
addChannels(channelIds: Array<string>, guildId: string, parentId: string | null = null): void {
for (const channelId of channelIds) {
this.addChannel(channelId, guildId, parentId);
}
}
removeChannel(channelId: string): void {
const index = this.channels.findIndex((ch) => ch.channelId === channelId);
if (index === -1) return;
this.channels.splice(index, 1);
this.reorderChannels();
}
setChannelNickname(channelId: string, nickname: string | null): void {
const channel = this.channels.find((ch) => ch.channelId === channelId);
if (!channel) return;
channel.nickname = nickname;
}
moveChannel(channelId: string, newParentId: string | null, newIndexInCategory: number): void {
const channel = this.channels.find((ch) => ch.channelId === channelId);
if (!channel) return;
const sorted = [...this.sortedChannels];
const currentIndex = sorted.findIndex((ch) => ch.channelId === channelId);
if (currentIndex === -1) return;
sorted.splice(currentIndex, 1);
channel.parentId = newParentId;
const categoryChannels = sorted.filter((ch) => ch.parentId === newParentId);
if (newIndexInCategory >= categoryChannels.length) {
const lastInCat = categoryChannels[categoryChannels.length - 1];
if (lastInCat) {
const lastIndex = sorted.indexOf(lastInCat);
sorted.splice(lastIndex + 1, 0, channel);
} else {
sorted.push(channel);
}
} else {
const targetChannel = categoryChannels[newIndexInCategory];
const targetIndex = sorted.indexOf(targetChannel);
sorted.splice(targetIndex, 0, channel);
}
this.channels = sorted;
this.reorderChannels();
}
createCategory(name: string): string {
const id = `favorite-category-${Date.now()}`;
const position = this.categories.length;
this.categories.push({id, name, position});
return id;
}
renameCategory(categoryId: string, name: string): void {
const category = this.categories.find((cat) => cat.id === categoryId);
if (!category) return;
category.name = name;
}
removeCategory(categoryId: string): void {
const index = this.categories.findIndex((cat) => cat.id === categoryId);
if (index === -1) return;
this.categories.splice(index, 1);
for (const channel of this.channels) {
if (channel.parentId === categoryId) {
channel.parentId = null;
}
}
this.collapsedCategories.delete(categoryId);
this.reorderCategories();
}
toggleCategoryCollapsed(categoryId: string): void {
if (this.collapsedCategories.has(categoryId)) {
this.collapsedCategories.delete(categoryId);
} else {
this.collapsedCategories.add(categoryId);
}
}
setHideMutedChannels(value: boolean): void {
this.hideMutedChannels = value;
}
toggleMuted(): void {
this.isMuted = !this.isMuted;
}
private reorderChannels(): void {
this.channels = this.channels.map((ch, index) => ({
...ch,
position: index,
}));
}
private reorderCategories(): void {
this.categories = this.categories.map((cat, index) => ({
...cat,
position: index,
}));
}
}
export default new FavoritesStore();

View File

@@ -0,0 +1,58 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import type {FeatureFlag} from '~/Constants';
import {makePersistent} from '~/lib/MobXPersistence';
type FeatureFlagOverrides = Partial<Record<FeatureFlag, boolean>>;
class FeatureFlagOverridesStore {
overrides: FeatureFlagOverrides = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'FeatureFlagOverridesStore', ['overrides']);
}
getOverride(flag: FeatureFlag): boolean | null {
if (Object.hasOwn(this.overrides, flag)) {
return this.overrides[flag] ?? null;
}
return null;
}
setOverride(flag: FeatureFlag, value: boolean | null): void {
const next = {...this.overrides};
if (value === null) {
delete next[flag];
} else {
next[flag] = value;
}
this.overrides = next;
}
}
export default new FeatureFlagOverridesStore();

View File

@@ -0,0 +1,93 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {ALL_FEATURE_FLAGS, type FeatureFlag, FeatureFlags} from '~/Constants';
import FeatureFlagOverridesStore from '~/stores/FeatureFlagOverridesStore';
type FeatureFlagGuildMap = Record<FeatureFlag, Set<string>>;
class FeatureFlagStore {
private featureFlagGuilds: FeatureFlagGuildMap;
constructor() {
this.featureFlagGuilds = FeatureFlagStore.createEmptyMap();
makeAutoObservable(this, {}, {autoBind: true});
}
private static createEmptyMap(): FeatureFlagGuildMap {
const map: FeatureFlagGuildMap = {} as FeatureFlagGuildMap;
for (const flag of ALL_FEATURE_FLAGS) {
map[flag] = new Set();
}
return map;
}
handleConnectionOpen(featureFlags?: Record<FeatureFlag, Array<string>>): void {
this.featureFlagGuilds = FeatureFlagStore.createEmptyMap();
if (!featureFlags) {
return;
}
for (const flag of ALL_FEATURE_FLAGS) {
const guildIds = featureFlags[flag] ?? [];
this.featureFlagGuilds[flag] = new Set(guildIds);
}
}
private getGuildSet(flag: FeatureFlag): Set<string> {
return this.featureFlagGuilds[flag] ?? new Set();
}
isFeatureEnabled(flag: FeatureFlag, guildId?: string): boolean {
const overrideEnabled = FeatureFlagOverridesStore.getOverride(flag);
if (overrideEnabled !== null) {
return overrideEnabled;
}
if (!guildId) {
return false;
}
return this.getGuildSet(flag).has(guildId);
}
isMessageSchedulingEnabled(guildId?: string): boolean {
return this.isFeatureEnabled(FeatureFlags.MESSAGE_SCHEDULING, guildId);
}
isExpressionPacksEnabled(guildId?: string): boolean {
return this.isFeatureEnabled(FeatureFlags.EXPRESSION_PACKS, guildId);
}
getGuildIdsForFlag(flag: FeatureFlag): Array<string> {
return Array.from(this.getGuildSet(flag));
}
hasAccessToAnyEnabledGuild(flag: FeatureFlag, guildIds: Array<string>): boolean {
const guildSet = this.getGuildSet(flag);
if (guildSet.size === 0 || guildIds.length === 0) {
return false;
}
return guildIds.some((guildId) => guildSet.has(guildId));
}
}
export default new FeatureFlagStore();

View File

@@ -0,0 +1,42 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
export type FriendsTab = 'online' | 'all' | 'pending' | 'add';
class FriendsTabStore {
pendingTab: FriendsTab | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
setTab(tab: FriendsTab): void {
this.pendingTab = tab;
}
consumeTab(): FriendsTab | null {
const tab = this.pendingTab;
this.pendingTab = null;
return tab;
}
}
export default new FriendsTabStore();

View File

@@ -0,0 +1,91 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, runInAction} from 'mobx';
interface GeoIPData {
countryCode: string;
regionCode: string | null;
latitude: string;
longitude: string;
ageRestrictedGeos: Array<{countryCode: string; regionCode: string | null}>;
ageBlockedGeos: Array<{countryCode: string; regionCode: string | null}>;
}
class GeoIPStore {
countryCode: string | null = null;
regionCode: string | null = null;
latitude: string | null = null;
longitude: string | null = null;
ageRestrictedGeos: Array<{countryCode: string; regionCode: string | null}> = [];
ageBlockedGeos: Array<{countryCode: string; regionCode: string | null}> = [];
loaded = false;
error: string | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
async fetchGeoData(): Promise<void> {
try {
const response = await fetch('https://ip.fluxer.workers.dev/');
if (!response.ok) {
throw new Error(`Failed to fetch geo data: ${response.statusText}`);
}
const data: GeoIPData = await response.json();
runInAction(() => {
this.countryCode = data.countryCode;
this.regionCode = data.regionCode;
this.latitude = data.latitude;
this.longitude = data.longitude;
this.ageRestrictedGeos = data.ageRestrictedGeos;
this.ageBlockedGeos = data.ageBlockedGeos;
this.loaded = true;
this.error = null;
});
} catch (error) {
runInAction(() => {
this.countryCode = null;
this.regionCode = null;
this.latitude = null;
this.longitude = null;
this.ageRestrictedGeos = [];
this.ageBlockedGeos = [];
this.loaded = true;
this.error = error instanceof Error ? error.message : 'Unknown error';
});
throw error;
}
}
isBlocked(): boolean {
if (!this.countryCode) return false;
return this.ageBlockedGeos.some((geo) => {
if (geo.countryCode !== this.countryCode) return false;
if (geo.regionCode === null) return true;
return geo.regionCode === this.regionCode;
});
}
}
export default new GeoIPStore();

View File

@@ -0,0 +1,111 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, observable, runInAction} from 'mobx';
import type {Gift} from '~/actions/GiftActionCreators';
import * as GiftActionCreators from '~/actions/GiftActionCreators';
interface GiftState {
loading: boolean;
error: Error | null;
data: Gift | null;
invalid?: boolean;
}
class GiftStore {
gifts: Map<string, GiftState> = observable.map();
pendingRequests: Map<string, Promise<Gift>> = observable.map();
constructor() {
makeAutoObservable(
this,
{
gifts: false,
pendingRequests: false,
},
{autoBind: true},
);
}
markAsRedeemed(code: string): void {
const existingGift = this.gifts.get(code);
if (existingGift?.data) {
const updatedGift: Gift = {
...existingGift.data,
redeemed: true,
};
this.gifts.set(code, {
...existingGift,
data: updatedGift,
});
}
}
markAsInvalid(code: string): void {
this.gifts.set(code, {
loading: false,
error: new Error('Gift code not found'),
data: null,
invalid: true,
});
}
async fetchGift(code: string): Promise<Gift> {
const existingGift = this.gifts.get(code);
if (existingGift?.invalid) {
throw new Error('Gift code not found');
}
const existingRequest = this.pendingRequests.get(code);
if (existingRequest) {
return existingRequest;
}
if (existingGift?.data) {
return existingGift.data;
}
this.gifts.set(code, {loading: true, error: null, data: null});
const promise = GiftActionCreators.fetch(code);
this.pendingRequests.set(code, promise);
try {
const gift = await promise;
runInAction(() => {
this.pendingRequests.delete(code);
this.gifts.set(code, {loading: false, error: null, data: gift});
});
return gift;
} catch (error) {
runInAction(() => {
this.pendingRequests.delete(code);
this.gifts.set(code, {
loading: false,
error: error as Error,
data: null,
});
});
throw error;
}
}
}
export default new GiftStore();

View File

@@ -0,0 +1,67 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, observable} from 'mobx';
import type {GuildReadyData} from '~/records/GuildRecord';
class GuildAvailabilityStore {
unavailableGuilds: Set<string> = observable.set();
constructor() {
makeAutoObservable(
this,
{
unavailableGuilds: false,
},
{autoBind: true},
);
}
setGuildAvailable(guildId: string): void {
if (this.unavailableGuilds.has(guildId)) {
this.unavailableGuilds.delete(guildId);
}
}
setGuildUnavailable(guildId: string): void {
if (!this.unavailableGuilds.has(guildId)) {
this.unavailableGuilds.add(guildId);
}
}
handleGuildAvailability(guildId: string, unavailable = false): void {
if (unavailable) {
this.setGuildUnavailable(guildId);
} else {
this.setGuildAvailable(guildId);
}
}
loadUnavailableGuilds(guilds: ReadonlyArray<GuildReadyData>): void {
const unavailableGuildIds = guilds.filter((guild) => guild.unavailable).map((guild) => guild.id);
this.unavailableGuilds.clear();
unavailableGuildIds.forEach((id) => this.unavailableGuilds.add(id));
}
get totalUnavailableGuilds(): number {
return this.unavailableGuilds.size;
}
}
export default new GuildAvailabilityStore();

View File

@@ -0,0 +1,121 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {GuildEmoji, GuildEmojiWithUser} from '~/records/GuildEmojiRecord';
import type {GuildSticker, GuildStickerWithUser} from '~/records/GuildStickerRecord';
import {sortBySnowflakeDesc} from '~/utils/SnowflakeUtils';
type EmojiUpdateListener = (emojis: ReadonlyArray<GuildEmojiWithUser>) => void;
type StickerUpdateListener = (stickers: ReadonlyArray<GuildStickerWithUser>) => void;
const emojiCache = new Map<string, ReadonlyArray<GuildEmojiWithUser>>();
const stickerCache = new Map<string, ReadonlyArray<GuildStickerWithUser>>();
const emojiListeners = new Map<string, Set<EmojiUpdateListener>>();
const stickerListeners = new Map<string, Set<StickerUpdateListener>>();
const freezeList = <T>(items: ReadonlyArray<T>): ReadonlyArray<T> => Object.freeze([...items]);
const notifyListeners = <T>(
listeners: Map<string, Set<(items: ReadonlyArray<T>) => void>>,
guildId: string,
value: ReadonlyArray<T>,
) => {
const listenersForGuild = listeners.get(guildId);
if (!listenersForGuild) return;
for (const listener of listenersForGuild) {
listener(value);
}
};
const setCache = <T extends {id: string}>(
cache: Map<string, ReadonlyArray<T>>,
listeners: Map<string, Set<(items: ReadonlyArray<T>) => void>>,
guildId: string,
value: ReadonlyArray<T>,
shouldNotify: boolean,
) => {
const frozen = freezeList(sortBySnowflakeDesc(value));
cache.set(guildId, frozen);
if (shouldNotify) {
notifyListeners(listeners, guildId, frozen);
}
};
export const seedGuildEmojiCache = (guildId: string, emojis: ReadonlyArray<GuildEmojiWithUser>): void => {
setCache(emojiCache, emojiListeners, guildId, emojis, false);
};
export const seedGuildStickerCache = (guildId: string, stickers: ReadonlyArray<GuildStickerWithUser>): void => {
setCache(stickerCache, stickerListeners, guildId, stickers, false);
};
export const subscribeToGuildEmojiUpdates = (guildId: string, listener: EmojiUpdateListener): (() => void) => {
let listenersForGuild = emojiListeners.get(guildId);
if (!listenersForGuild) {
listenersForGuild = new Set();
emojiListeners.set(guildId, listenersForGuild);
}
listenersForGuild.add(listener);
return () => {
listenersForGuild?.delete(listener);
if (listenersForGuild && listenersForGuild.size === 0) {
emojiListeners.delete(guildId);
}
};
};
export const subscribeToGuildStickerUpdates = (guildId: string, listener: StickerUpdateListener): (() => void) => {
let listenersForGuild = stickerListeners.get(guildId);
if (!listenersForGuild) {
listenersForGuild = new Set();
stickerListeners.set(guildId, listenersForGuild);
}
listenersForGuild.add(listener);
return () => {
listenersForGuild?.delete(listener);
if (listenersForGuild && listenersForGuild.size === 0) {
stickerListeners.delete(guildId);
}
};
};
export const patchGuildEmojiCacheFromGateway = (guildId: string, updates: ReadonlyArray<GuildEmoji>) => {
const previous = emojiCache.get(guildId) ?? [];
const previousUserById = new Map(previous.map((emoji) => [emoji.id, emoji.user]));
const next = updates.map((emoji) => ({
...emoji,
user: emoji.user ?? previousUserById.get(emoji.id),
}));
setCache(emojiCache, emojiListeners, guildId, next, true);
};
export const patchGuildStickerCacheFromGateway = (guildId: string, updates: ReadonlyArray<GuildSticker>) => {
const previous = stickerCache.get(guildId) ?? [];
const previousUserById = new Map(previous.map((sticker) => [sticker.id, sticker.user]));
const next = updates.map((sticker) => ({
...sticker,
user: sticker.user ?? previousUserById.get(sticker.id),
}));
setCache(stickerCache, stickerListeners, guildId, next, true);
};

View File

@@ -0,0 +1,112 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {action, makeAutoObservable} from 'mobx';
import {type Guild, type GuildReadyData, GuildRecord} from '~/records/GuildRecord';
import GuildStore from '~/stores/GuildStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
class GuildListStore {
guilds: Array<GuildRecord> = [];
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
@action
handleConnectionOpen(guilds: ReadonlyArray<GuildReadyData>): void {
this.guilds = [];
const availableGuilds = guilds
.filter((guild) => !guild.unavailable)
.map((guild) => GuildStore.getGuild(guild.id))
.filter((guild): guild is GuildRecord => guild !== undefined);
if (availableGuilds.length > 0) {
this.guilds = [...this.sortGuildArray(availableGuilds)];
}
}
@action
handleGuild(guild: Guild | GuildReadyData): void {
if (guild.unavailable) {
return;
}
const guildRecord = GuildStore.getGuild(guild.id);
if (!guildRecord) {
return;
}
const index = this.guilds.findIndex((s) => s.id === guild.id);
if (index === -1) {
this.guilds = [...this.sortGuildArray([...this.guilds, guildRecord])];
} else {
this.guilds = [
...this.sortGuildArray([...this.guilds.slice(0, index), guildRecord, ...this.guilds.slice(index + 1)]),
];
}
}
@action
handleGuildDelete(guildId: string, unavailable?: boolean): void {
const index = this.guilds.findIndex((s) => s.id === guildId);
if (index === -1) {
return;
}
if (unavailable) {
const existingGuild = this.guilds[index];
const updatedGuild = new GuildRecord({
...existingGuild.toJSON(),
unavailable: true,
});
this.guilds = [...this.guilds.slice(0, index), updatedGuild, ...this.guilds.slice(index + 1)];
} else {
this.guilds = [...this.guilds.slice(0, index), ...this.guilds.slice(index + 1)];
}
}
@action
sortGuilds(): void {
this.guilds = [...this.sortGuildArray([...this.guilds])];
}
private sortGuildArray(guilds: ReadonlyArray<GuildRecord>): ReadonlyArray<GuildRecord> {
const guildPositions = UserSettingsStore.guildPositions;
return [...guilds].sort((a, b) => {
const aIndex = guildPositions.indexOf(a.id);
const bIndex = guildPositions.indexOf(b.id);
if (aIndex === -1 && bIndex === -1) {
return a.name.localeCompare(b.name);
}
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
}
}
export default new GuildListStore();

View File

@@ -0,0 +1,46 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
export type GuildMemberViewMode = 'table' | 'grid';
class GuildMemberLayoutStore {
memberViewMode: GuildMemberViewMode = 'table';
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'GuildMemberLayoutStore', ['memberViewMode']);
}
getViewMode(): GuildMemberViewMode {
return this.memberViewMode;
}
setViewMode(mode: GuildMemberViewMode): void {
this.memberViewMode = mode;
}
}
export default new GuildMemberLayoutStore();

View File

@@ -0,0 +1,282 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {type GuildMember, GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {GuildReadyData, GuildRecord} from '~/records/GuildRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ConnectionStore from '~/stores/ConnectionStore';
import GuildStore from '~/stores/GuildStore';
type Members = Record<string, GuildMemberRecord>;
interface PendingMemberRequest {
resolve: (members: Array<GuildMemberRecord>) => void;
reject: (error: Error) => void;
members: Array<GuildMemberRecord>;
receivedChunks: number;
expectedChunks: number;
}
const MEMBER_REQUEST_TIMEOUT = 30000;
const MEMBER_NONCE_LENGTH = 32;
const MEMBER_NONCE_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
function generateMemberNonce(): string {
let nonce = '';
const charsLength = MEMBER_NONCE_CHARS.length;
for (let i = 0; i < MEMBER_NONCE_LENGTH; i += 1) {
nonce += MEMBER_NONCE_CHARS[Math.floor(Math.random() * charsLength)];
}
return nonce;
}
class GuildMemberStore {
members: Record<string, Members> = {};
pendingRequests: Map<string, PendingMemberRequest> = new Map();
loadedGuilds: Set<string> = new Set();
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
getMember(guildId: string, userId?: string | null): GuildMemberRecord | null {
if (!userId) {
return null;
}
return this.members[guildId]?.[userId] ?? null;
}
isUserTimedOut(guildId: string | null, userId?: string | null): boolean {
if (!guildId || !userId) {
return false;
}
const member = this.getMember(guildId, userId);
return member?.isTimedOut() ?? false;
}
getMembers(guildId: string): Array<GuildMemberRecord> {
return Object.values(this.members[guildId] ?? {});
}
getMemberCount(guildId: string): number {
return Object.keys(this.members[guildId] ?? {}).length;
}
getMutualGuilds(userId: string): Array<GuildRecord> {
const currentUserId = AuthenticationStore.currentUserId;
if (!currentUserId || !userId || currentUserId === userId) {
return [];
}
const guilds = GuildStore.getGuilds();
return guilds.filter((guild) => {
const userIsMember = this.getMember(guild.id, userId) != null;
const currentUserIsMember = this.getMember(guild.id, currentUserId) != null;
return userIsMember && currentUserIsMember;
});
}
handleConnectionOpen(guilds: Array<GuildReadyData>): void {
this.members = {};
for (const guild of guilds) {
this.handleGuildCreate(guild);
}
}
handleGuildCreate(guild: GuildReadyData): void {
if (guild.unavailable) {
return;
}
const ownMember = guild.members.find((m) => m.user.id === AuthenticationStore.currentUserId);
this.members = {
...this.members,
[guild.id]: ownMember ? {[ownMember.user.id]: new GuildMemberRecord(guild.id, ownMember)} : {},
};
}
handleGuildDelete(guildId: string): void {
this.members = Object.fromEntries(Object.entries(this.members).filter(([id]) => id !== guildId));
}
handleMemberAdd(guildId: string, member: GuildMember): void {
this.members = {
...this.members,
[guildId]: {
...(this.members[guildId] ?? {}),
[member.user.id]: new GuildMemberRecord(guildId, member),
},
};
}
handleMemberRemove(guildId: string, userId: string): void {
const existingMembers = this.members[guildId];
if (!existingMembers) {
return;
}
const updatedGuildMembers = Object.fromEntries(Object.entries(existingMembers).filter(([id]) => id !== userId));
this.members = {
...this.members,
...(Object.keys(updatedGuildMembers).length > 0 ? {[guildId]: updatedGuildMembers} : {}),
};
}
handleGuildRoleDelete(guildId: string, roleId: string): void {
const existingMembers = this.members[guildId];
if (!existingMembers) {
return;
}
const updatedGuildMembers = Object.fromEntries(
Object.entries(existingMembers).map(([memberId, member]) => {
if (member.roles.has(roleId)) {
const newRoles = new Set(member.roles);
newRoles.delete(roleId);
return [
memberId,
new GuildMemberRecord(guildId, {
...member.toJSON(),
roles: Array.from(newRoles),
}),
];
}
return [memberId, member];
}),
);
this.members = {
...this.members,
[guildId]: updatedGuildMembers,
};
}
handleMembersChunk(params: {
guildId: string;
members: Array<GuildMember>;
chunkIndex: number;
chunkCount: number;
nonce?: string;
}): void {
const {guildId, members, chunkCount, nonce} = params;
const newMembers: Array<GuildMemberRecord> = [];
for (const member of members) {
const record = new GuildMemberRecord(guildId, member);
newMembers.push(record);
}
this.members = {
...this.members,
[guildId]: {
...(this.members[guildId] ?? {}),
...newMembers.reduce((acc, member) => {
acc[member.user.id] = member;
return acc;
}, {} as Members),
},
};
if (nonce) {
const pending = this.pendingRequests.get(nonce);
if (pending) {
pending.members.push(...newMembers);
pending.receivedChunks++;
if (pending.receivedChunks >= chunkCount) {
pending.resolve(pending.members);
this.pendingRequests.delete(nonce);
}
}
}
}
async fetchMembers(
guildId: string,
options?: {
query?: string;
limit?: number;
userIds?: Array<string>;
},
): Promise<Array<GuildMemberRecord>> {
const nonce = generateMemberNonce();
return new Promise((resolve, reject) => {
this.pendingRequests.set(nonce, {
resolve,
reject,
members: [],
receivedChunks: 0,
expectedChunks: 1,
});
const socket = ConnectionStore.socket;
const requestOptions: {
guildId: string;
nonce: string;
query?: string;
limit?: number;
userIds?: Array<string>;
} = {
guildId,
nonce,
};
if (options?.query) {
requestOptions.query = options.query;
}
if (options?.limit !== undefined) {
requestOptions.limit = options.limit;
}
if (options?.userIds && options.userIds.length > 0) {
requestOptions.userIds = options.userIds;
}
socket?.requestGuildMembers(requestOptions);
setTimeout(() => {
if (this.pendingRequests.has(nonce)) {
this.pendingRequests.delete(nonce);
reject(new Error('Request timed out'));
}
}, MEMBER_REQUEST_TIMEOUT);
});
}
async ensureMembersLoaded(guildId: string, userIds: Array<string>): Promise<void> {
const missingIds = userIds.filter((id) => !this.members[guildId]?.[id]);
if (missingIds.length === 0) {
return;
}
await this.fetchMembers(guildId, {userIds: missingIds});
}
isGuildFullyLoaded(guildId: string): boolean {
return this.loadedGuilds.has(guildId);
}
}
export default new GuildMemberStore();

View File

@@ -0,0 +1,105 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import GeoIPStore from '~/stores/GeoIPStore';
import UserStore from '~/stores/UserStore';
export enum NSFWGateReason {
NONE = 0,
GEO_RESTRICTED = 1,
AGE_RESTRICTED = 2,
CONSENT_REQUIRED = 3,
}
class GuildNSFWAgreeStore {
agreedChannelIds: Array<string> = [];
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'GuildNSFWAgreeStore', ['agreedChannelIds']);
}
agreeToChannel(channelId: string): void {
if (!this.agreedChannelIds.includes(channelId)) {
this.agreedChannelIds.push(channelId);
}
}
reset(): void {
this.agreedChannelIds = [];
}
hasAgreedToChannel(channelId: string): boolean {
return this.agreedChannelIds.includes(channelId);
}
getGateReason(channelId: string): NSFWGateReason {
const mockReason = DeveloperOptionsStore.mockNSFWGateReason;
if (mockReason !== 'none') {
switch (mockReason) {
case 'geo_restricted':
return NSFWGateReason.GEO_RESTRICTED;
case 'age_restricted':
return NSFWGateReason.AGE_RESTRICTED;
case 'consent_required':
return NSFWGateReason.CONSENT_REQUIRED;
}
}
const countryCode = GeoIPStore.countryCode;
const regionCode = GeoIPStore.regionCode;
const ageRestrictedGeos = GeoIPStore.ageRestrictedGeos;
if (countryCode) {
const isAgeRestricted = ageRestrictedGeos.some((geo) => {
if (geo.countryCode !== countryCode) return false;
if (geo.regionCode === null) return true;
return geo.regionCode === regionCode;
});
if (isAgeRestricted) {
return NSFWGateReason.GEO_RESTRICTED;
}
}
const currentUser = UserStore.getCurrentUser();
if (currentUser && !currentUser.nsfwAllowed) {
return NSFWGateReason.AGE_RESTRICTED;
}
if (!this.hasAgreedToChannel(channelId)) {
return NSFWGateReason.CONSENT_REQUIRED;
}
return NSFWGateReason.NONE;
}
shouldShowGate(channelId: string): boolean {
return this.getGateReason(channelId) !== NSFWGateReason.NONE;
}
}
export default new GuildNSFWAgreeStore();

View File

@@ -0,0 +1,516 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, observable, reaction, runInAction} from 'mobx';
import {ChannelTypes, ME, Permissions} from '~/Constants';
import {Logger} from '~/lib/Logger';
import ChannelStore from './ChannelStore';
import GuildStore from './GuildStore';
import PermissionStore from './PermissionStore';
import ReadStateStore from './ReadStateStore';
import UserGuildSettingsStore from './UserGuildSettingsStore';
type GuildId = string;
type ChannelId = string;
const PRIVATE_CHANNEL_SENTINEL = ME;
const CAN_READ_PERMISSIONS = Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY;
const _logger = new Logger('GuildReadStateStore');
class GuildReadState {
unread = observable.box(false);
unreadChannelId = observable.box<ChannelId | null>(null);
mentionCount = observable.box(0);
mentionChannels = observable.set(new Set<ChannelId>());
sentinel = observable.box(0);
incrementSentinel(): void {
this.sentinel.set(this.sentinel.get() + 1);
}
reset(): void {
this.unread.set(false);
this.unreadChannelId.set(null);
this.mentionCount.set(0);
this.mentionChannels.clear();
}
clone(): GuildReadState {
const state = new GuildReadState();
state.unread.set(this.unread.get());
state.unreadChannelId.set(this.unreadChannelId.get());
state.mentionCount.set(this.mentionCount.get());
state.mentionChannels = observable.set(new Set(this.mentionChannels));
state.sentinel.set(this.sentinel.get());
return state;
}
}
function canContributeToGuildUnread(
channel: {
id: string;
type: number;
guildId?: string | null;
parentId?: string | null;
isPrivate(): boolean;
isGuildVocal?(): boolean;
},
mentionCount: number,
): boolean {
if (channel.type === ChannelTypes.GUILD_VOICE && mentionCount === 0) {
return false;
}
if (channel.isPrivate()) {
return true;
}
if (
!PermissionStore.can(CAN_READ_PERMISSIONS, {
channelId: channel.id,
guildId: channel.guildId ?? undefined,
})
) {
return false;
}
const isMuted = UserGuildSettingsStore.isGuildOrCategoryOrChannelMuted(channel.guildId ?? null, channel.id);
if (isMuted && mentionCount === 0) {
return false;
}
return true;
}
class GuildReadStateStore {
private readonly guildStates = observable.map(new Map<GuildId, GuildReadState>());
private readonly unreadGuilds = observable.set(new Set<GuildId>());
updateCounter = 0;
private readStateReactionInstalled = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.installReadStateReaction();
reaction(
() => UserGuildSettingsStore.version,
() => {
this.processUserGuildSettingsUpdates();
},
);
}
private installReadStateReaction(): void {
if (this.readStateReactionInstalled) return;
if (ReadStateStore == null) {
setTimeout(() => this.installReadStateReaction(), 0);
return;
}
this.readStateReactionInstalled = true;
reaction(
() => ReadStateStore.version,
() => {
const {all, changes} = ReadStateStore.consumePendingChanges();
if (all) {
this.handleConnectionOpen();
return;
}
if (changes.length === 0) {
return;
}
const byGuild = new Map<GuildId | null, Array<ChannelId>>();
for (const {channelId, guildId} of changes) {
let list = byGuild.get(guildId);
if (list == null) {
list = [];
byGuild.set(guildId, list);
}
list.push(channelId);
}
for (const [guildId, ids] of byGuild.entries()) {
if (guildId == null) {
this.recomputeAll(null);
} else {
this.recomputeChannels(guildId, ids);
}
}
},
);
}
get version(): number {
return this.updateCounter;
}
private getOrCreate(guildId: GuildId | null): GuildReadState {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
let state = this.guildStates.get(id);
if (state == null) {
state = new GuildReadState();
this.guildStates.set(id, state);
}
return state;
}
private notifyChange(): void {
this.updateCounter++;
}
private incrementSentinel(guildId: GuildId | null): void {
const state = this.getOrCreate(guildId);
state.incrementSentinel();
this.notifyChange();
}
private recomputeChannels(guildId: GuildId | null, channelIds: Array<ChannelId>): boolean {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
const prevState = this.getOrCreate(id);
const newState = prevState.clone();
let foundUnread = false;
let shouldClearUnreadChannelId = false;
for (const channelId of channelIds) {
const channel = ChannelStore.getChannel(channelId);
if (channel == null) {
newState.mentionChannels.delete(channelId);
if (newState.unreadChannelId.get() === channelId) {
shouldClearUnreadChannelId = true;
}
continue;
}
const channelGuildId = channel.guildId ?? null;
if (channelGuildId !== guildId) {
if (channelGuildId != null) {
this.recomputeChannels(channelGuildId, [channelId]);
} else if (guildId != null) {
this.recomputeChannels(null, [channelId]);
}
continue;
}
const mentionCount = ReadStateStore.getMentionCount(channelId);
const hasUnread = ReadStateStore.hasUnread(channelId);
const canContribute = canContributeToGuildUnread(channel, mentionCount);
if (mentionCount > 0 && canContribute) {
newState.mentionChannels.add(channelId);
} else {
newState.mentionChannels.delete(channelId);
}
if (guildId != null && !foundUnread && hasUnread && canContribute) {
foundUnread = true;
newState.unreadChannelId.set(channelId);
} else if (!hasUnread && newState.unreadChannelId.get() === channelId) {
shouldClearUnreadChannelId = true;
}
}
newState.unread.set(foundUnread);
if (!foundUnread && shouldClearUnreadChannelId) {
newState.unreadChannelId.set(null);
}
let mentionTotal = 0;
for (const channelId of newState.mentionChannels) {
mentionTotal += ReadStateStore.getMentionCount(channelId);
}
newState.mentionCount.set(mentionTotal);
if (newState.unread.get() !== prevState.unread.get() && !newState.unread.get()) {
const oldUnreadChannelId = prevState.unreadChannelId.get();
const oldUnreadChannel = oldUnreadChannelId != null ? ChannelStore.getChannel(oldUnreadChannelId) : null;
if (
oldUnreadChannel != null &&
!channelIds.includes(oldUnreadChannel.id) &&
ReadStateStore.hasUnread(oldUnreadChannel.id) &&
canContributeToGuildUnread(oldUnreadChannel, 0)
) {
return this.recomputeAll(guildId);
}
}
return this.commitState(id, newState, prevState);
}
private recomputeAll(guildId: GuildId | null, skipIfMuted = false): boolean {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
const newState = new GuildReadState();
if (guildId == null) {
const privateChannels = ChannelStore.getPrivateChannels();
for (const channel of privateChannels) {
const mentionCount = ReadStateStore.getMentionCount(channel.id);
const canContribute = canContributeToGuildUnread(channel, mentionCount);
if (mentionCount > 0 && canContribute) {
newState.mentionCount.set(newState.mentionCount.get() + mentionCount);
newState.mentionChannels.add(channel.id);
}
if (!newState.unread.get() && ReadStateStore.hasUnread(channel.id) && canContributeToGuildUnread(channel, 0)) {
newState.unread.set(true);
newState.unreadChannelId.set(channel.id);
}
}
} else {
const isGuildMuted = UserGuildSettingsStore.isMuted(guildId);
if (isGuildMuted && skipIfMuted) {
return false;
}
const mutedChannels = UserGuildSettingsStore.getMutedChannels(guildId);
const channels = ChannelStore.getGuildChannels(guildId);
for (const channel of channels) {
const isChannelMuted =
isGuildMuted ||
mutedChannels.has(channel.id) ||
(channel.parentId != null && mutedChannels.has(channel.parentId));
const mentionCount = ReadStateStore.getMentionCount(channel.id);
const hasUnread = ReadStateStore.hasUnread(channel.id);
const hasMention = mentionCount > 0;
if (!hasMention && isChannelMuted) {
continue;
}
const shouldShowUnread = !newState.unread.get() && (!isChannelMuted || hasMention) && hasUnread;
if ((shouldShowUnread || hasMention) && canContributeToGuildUnread(channel, mentionCount)) {
if (shouldShowUnread) {
newState.unread.set(true);
newState.unreadChannelId.set(channel.id);
}
if (hasMention) {
newState.mentionCount.set(newState.mentionCount.get() + mentionCount);
newState.mentionChannels.add(channel.id);
}
}
}
}
const prevState = this.getOrCreate(id);
return this.commitState(id, newState, prevState);
}
private commitState(guildId: string, newState: GuildReadState, prevState: GuildReadState): boolean {
if (
newState.unread.get() === prevState.unread.get() &&
newState.unreadChannelId.get() === prevState.unreadChannelId.get() &&
newState.mentionCount.get() === prevState.mentionCount.get()
) {
return false;
}
runInAction(() => {
this.guildStates.set(guildId, newState);
if (guildId !== PRIVATE_CHANNEL_SENTINEL) {
if (newState.unread.get()) {
this.unreadGuilds.add(guildId);
} else {
this.unreadGuilds.delete(guildId);
}
}
this.incrementSentinel(guildId === PRIVATE_CHANNEL_SENTINEL ? null : guildId);
});
return true;
}
private processUserGuildSettingsUpdates(): void {
const updatedGuilds = UserGuildSettingsStore.consumePendingGuildUpdates();
if (updatedGuilds.length === 0) {
return;
}
const processed = new Set<GuildId | null>();
for (const guildId of updatedGuilds) {
if (processed.has(guildId)) continue;
processed.add(guildId);
if (guildId == null) {
this.recomputeAll(null);
} else {
this.recomputeAll(guildId);
}
}
}
get hasAnyUnread(): boolean {
return this.unreadGuilds.size > 0;
}
hasUnread(guildId: GuildId): boolean {
return this.unreadGuilds.has(guildId);
}
getMentionCount(guildId: GuildId | null): number {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
const state = this.guildStates.get(id);
return state?.mentionCount.get() ?? 0;
}
getTotalMentionCount(excludePrivate = false): number {
let total = 0;
for (const [guildId, state] of this.guildStates.entries()) {
if (excludePrivate && guildId === PRIVATE_CHANNEL_SENTINEL) continue;
total += state.mentionCount.get();
}
return total;
}
getPrivateChannelMentionCount(): number {
const state = this.guildStates.get(PRIVATE_CHANNEL_SENTINEL);
return state?.mentionCount.get() ?? 0;
}
getMentionCountForPrivateChannel(channelId: ChannelId): number {
return ReadStateStore.getMentionCount(channelId);
}
getGuildChangeSentinel(guildId: GuildId | null): number {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
const state = this.guildStates.get(id);
return state?.sentinel.get() ?? 0;
}
getGuildHasUnreadIgnoreMuted(guildId: GuildId): boolean {
const channels = ChannelStore.getGuildChannels(guildId);
for (const channel of channels) {
if (channel.type === ChannelTypes.GUILD_VOICE && ReadStateStore.getMentionCount(channel.id) === 0) {
continue;
}
if (PermissionStore.can(CAN_READ_PERMISSIONS, channel) && ReadStateStore.hasUnreadOrMentions(channel.id)) {
return true;
}
}
return false;
}
handleConnectionOpen(): void {
this.guildStates.clear();
this.unreadGuilds.clear();
this.updateCounter = 0;
this.recomputeAll(null);
for (const guildId of GuildStore.getGuildIds()) {
this.recomputeAll(guildId);
}
this.notifyChange();
}
handleGuildCreate(action: {guild: {id: GuildId}}): void {
this.recomputeAll(action.guild.id);
}
handleGuildDelete(action: {guild: {id: GuildId}}): void {
this.guildStates.delete(action.guild.id);
this.unreadGuilds.delete(action.guild.id);
this.notifyChange();
}
handleChannelUpdate(action: {channel: {id: ChannelId; guildId?: GuildId}}): void {
this.recomputeChannels(action.channel.guildId ?? null, [action.channel.id]);
}
handleGenericUpdate(channelId: ChannelId): void {
const channel = ChannelStore.getChannel(channelId);
if (channel == null) return;
this.recomputeChannels(channel.guildId ?? null, [channelId]);
}
handleBulkChannelUpdate(action: {channels: Array<{id: ChannelId; guildId?: GuildId}>}): void {
const byGuild = new Map<GuildId | null, Array<ChannelId>>();
for (const channel of action.channels) {
const guildId = channel.guildId ?? null;
let channels = byGuild.get(guildId);
if (channels == null) {
channels = [];
byGuild.set(guildId, channels);
}
channels.push(channel.id);
}
for (const [guildId, channelIds] of byGuild.entries()) {
this.recomputeChannels(guildId, channelIds);
}
}
handleGuildSettingsUpdate(action: {guildId: GuildId}): void {
this.recomputeAll(action.guildId);
}
handleRecomputeAll(): void {
this.handleConnectionOpen();
}
handleWindowFocus(): void {
this.notifyChange();
}
handleGuildUpdate(guildId: string): void {
this.recomputeAll(guildId);
}
handleGuildMemberUpdate(_userId: string, guildId: string): void {
this.recomputeAll(guildId);
}
handleChannelDelete(channelId: string): void {
const channel = ChannelStore.getChannel(channelId);
if (channel == null) return;
this.recomputeChannels(channel.guildId ?? null, [channelId]);
}
handleUserGuildSettingsUpdate(): void {
this.processUserGuildSettingsUpdates();
}
subscribe(callback: () => void): () => void {
return reaction(
() => this.version,
() => callback(),
{fireImmediately: true},
);
}
}
export default new GuildReadStateStore();

View File

@@ -0,0 +1,59 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import type {GuildSettingsTabType} from '~/components/modals/utils/guildSettingsConstants';
interface NavigationHandler {
guildId: string;
navigate: (tab: GuildSettingsTabType) => void;
}
class GuildSettingsModalStore {
private activeHandler: NavigationHandler | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
register(handler: NavigationHandler): void {
this.activeHandler = handler;
}
unregister(guildId: string): void {
if (this.activeHandler?.guildId === guildId) {
this.activeHandler = null;
}
}
isOpen(guildId?: string): boolean {
if (!this.activeHandler) return false;
if (guildId) return this.activeHandler.guildId === guildId;
return true;
}
navigateToTab(guildId: string, tab: GuildSettingsTabType): boolean {
if (!this.activeHandler) return false;
if (this.activeHandler.guildId !== guildId) return false;
this.activeHandler.navigate(tab);
return true;
}
}
export default new GuildSettingsModalStore();

View File

@@ -0,0 +1,152 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {Routes} from '~/Routes';
import {type Guild, type GuildReadyData, GuildRecord} from '~/records/GuildRecord';
import {type GuildRole, GuildRoleRecord} from '~/records/GuildRoleRecord';
import * as RouterUtils from '~/utils/RouterUtils';
class GuildStore {
guilds: Record<string, GuildRecord> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
getGuild(guildId: string): GuildRecord | undefined {
return this.guilds[guildId];
}
getGuildIds(): Array<string> {
return Object.keys(this.guilds);
}
getGuildRoles(guildId: string, includeEveryone = false): Array<GuildRoleRecord> {
const guild = this.guilds[guildId];
if (!guild) {
return [];
}
return Object.values(guild.roles).filter((role) => includeEveryone || role.id !== guildId);
}
getGuilds(): Array<GuildRecord> {
return Object.values(this.guilds);
}
getOwnedGuilds(userId: string): Array<GuildRecord> {
return Object.values(this.guilds).filter((guild) => guild.ownerId === userId);
}
handleConnectionOpen({guilds}: {guilds: Array<GuildReadyData>}): void {
const availableGuilds = guilds.filter((guild) => !guild.unavailable);
if (availableGuilds.length === 0) {
this.guilds = {};
return;
}
this.guilds = availableGuilds.reduce<Record<string, GuildRecord>>((acc, guildData) => {
acc[guildData.id] = GuildRecord.fromGuildReadyData(guildData);
return acc;
}, {});
}
handleGuildCreate(guild: GuildReadyData): void {
if (guild.unavailable) {
return;
}
this.guilds[guild.id] = GuildRecord.fromGuildReadyData(guild);
}
handleGuildUpdate(guild: Guild): void {
const existingGuild = this.guilds[guild.id];
if (!existingGuild) {
return;
}
this.guilds[guild.id] = new GuildRecord({
...guild,
roles: existingGuild.roles,
});
}
handleGuildDelete({guildId, unavailable}: {guildId: string; unavailable?: boolean}): void {
delete this.guilds[guildId];
if (!unavailable) {
const history = RouterUtils.getHistory();
const currentPath = history?.location.pathname ?? '';
const guildRoutePrefix = `/channels/${guildId}`;
if (currentPath.startsWith(guildRoutePrefix)) {
RouterUtils.transitionTo(Routes.ME);
}
}
}
private updateGuildWithRoles(
guildId: string,
roleUpdater: (roles: Record<string, GuildRoleRecord>) => Record<string, GuildRoleRecord>,
): void {
const guild = this.guilds[guildId];
if (!guild) {
return;
}
const updatedRoles = roleUpdater({...guild.roles});
this.guilds[guildId] = new GuildRecord({
...guild.toJSON(),
roles: updatedRoles,
});
}
handleGuildRoleCreate({guildId, role}: {guildId: string; role: GuildRole}): void {
this.updateGuildWithRoles(guildId, (roles) => ({
...roles,
[role.id]: new GuildRoleRecord(guildId, role),
}));
}
handleGuildRoleDelete({guildId, roleId}: {guildId: string; roleId: string}): void {
this.updateGuildWithRoles(guildId, (roles) =>
Object.fromEntries(Object.entries(roles).filter(([id]) => id !== roleId)),
);
}
handleGuildRoleUpdate({guildId, role}: {guildId: string; role: GuildRole}): void {
this.updateGuildWithRoles(guildId, (roles) => ({
...roles,
[role.id]: new GuildRoleRecord(guildId, role),
}));
}
handleGuildRoleUpdateBulk({guildId, roles}: {guildId: string; roles: Array<GuildRole>}): void {
this.updateGuildWithRoles(guildId, (existingRoles) => {
const updatedRoles = {...existingRoles};
for (const role of roles) {
updatedRoles[role.id] = new GuildRoleRecord(guildId, role);
}
return updatedRoles;
});
}
}
export default new GuildStore();

View File

@@ -0,0 +1,235 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {GuildOperations, GuildVerificationLevel} from '~/Constants';
import type {GuildRecord} from '~/records/GuildRecord';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import UserStore from '~/stores/UserStore';
const FIVE_MINUTES_MS = 5 * 60 * 1000;
const TEN_MINUTES_MS = 10 * 60 * 1000;
export const VerificationFailureReason = {
UNCLAIMED_ACCOUNT: 'UNCLAIMED_ACCOUNT',
UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL',
ACCOUNT_TOO_NEW: 'ACCOUNT_TOO_NEW',
NOT_MEMBER_LONG_ENOUGH: 'NOT_MEMBER_LONG_ENOUGH',
NO_PHONE_NUMBER: 'NO_PHONE_NUMBER',
SEND_MESSAGE_DISABLED: 'SEND_MESSAGE_DISABLED',
TIMED_OUT: 'TIMED_OUT',
} as const;
export type VerificationFailureReason = (typeof VerificationFailureReason)[keyof typeof VerificationFailureReason];
interface VerificationStatus {
canAccess: boolean;
reason?: VerificationFailureReason;
timeRemaining?: number;
}
class GuildVerificationStore {
verificationStatus: Record<string, VerificationStatus> = {};
private timers: Record<string, NodeJS.Timeout> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
handleConnectionOpen(): void {
this.recomputeAll();
}
handleGuildCreate(guild: {id: string}): void {
this.recomputeGuild(guild.id);
}
handleGuildUpdate(guild: {id: string}): void {
this.recomputeGuild(guild.id);
}
handleGuildDelete(guildId: string): void {
const newVerificationStatus = {...this.verificationStatus};
delete newVerificationStatus[guildId];
if (this.timers[guildId]) {
clearTimeout(this.timers[guildId]);
delete this.timers[guildId];
}
this.verificationStatus = Object.freeze(newVerificationStatus);
}
handleGuildMemberUpdate(guildId: string): void {
this.recomputeGuild(guildId);
}
handleUserUpdate(): void {
this.recomputeAll();
}
private recomputeAll(): void {
const guilds = GuildStore.getGuilds();
const newVerificationStatus: Record<string, VerificationStatus> = {};
for (const timerId of Object.values(this.timers)) {
clearTimeout(timerId);
}
const newTimers: Record<string, NodeJS.Timeout> = {};
for (const guild of guilds) {
const status = this.computeVerificationStatus(guild);
newVerificationStatus[guild.id] = status;
if (!status.canAccess && status.timeRemaining && status.timeRemaining > 0) {
newTimers[guild.id] = setTimeout(() => {
this.recomputeGuild(guild.id);
}, status.timeRemaining);
}
}
this.verificationStatus = Object.freeze(newVerificationStatus);
this.timers = newTimers;
}
private recomputeGuild(guildId: string): void {
const guild = GuildStore.getGuild(guildId);
if (!guild) {
return;
}
const status = this.computeVerificationStatus(guild);
const newVerificationStatus = {
...this.verificationStatus,
[guildId]: status,
};
if (this.timers[guildId]) {
clearTimeout(this.timers[guildId]);
}
const newTimers = {...this.timers};
delete newTimers[guildId];
if (!status.canAccess && status.timeRemaining && status.timeRemaining > 0) {
newTimers[guildId] = setTimeout(() => {
this.recomputeGuild(guildId);
}, status.timeRemaining);
}
this.verificationStatus = Object.freeze(newVerificationStatus);
this.timers = newTimers;
}
private computeVerificationStatus(guild: GuildRecord): VerificationStatus {
const user = UserStore.getCurrentUser();
if (!user) {
return {canAccess: false, reason: VerificationFailureReason.UNCLAIMED_ACCOUNT};
}
const member = GuildMemberStore.getMember(guild.id, user.id);
const now = Date.now();
if (member?.communicationDisabledUntil) {
const timeoutUntil = member.communicationDisabledUntil;
const timeRemaining = timeoutUntil.getTime() - now;
if (timeRemaining > 0) {
return {
canAccess: false,
reason: VerificationFailureReason.TIMED_OUT,
timeRemaining,
};
}
}
if ((guild.disabledOperations & GuildOperations.SEND_MESSAGE) !== 0) {
return {canAccess: false, reason: VerificationFailureReason.SEND_MESSAGE_DISABLED};
}
if (user.id === guild.ownerId) {
return {canAccess: true};
}
const verificationLevel = guild.verificationLevel ?? GuildVerificationLevel.NONE;
if (verificationLevel === GuildVerificationLevel.NONE) {
return {canAccess: true};
}
if (member && member.roles.size > 0) {
return {canAccess: true};
}
if (!user.isClaimed()) {
return {canAccess: false, reason: VerificationFailureReason.UNCLAIMED_ACCOUNT};
}
if (verificationLevel >= GuildVerificationLevel.LOW) {
if (!user.verified) {
return {canAccess: false, reason: VerificationFailureReason.UNVERIFIED_EMAIL};
}
}
if (verificationLevel >= GuildVerificationLevel.MEDIUM) {
const accountAge = Date.now() - user.createdAt.getTime();
if (accountAge < FIVE_MINUTES_MS) {
const timeRemaining = FIVE_MINUTES_MS - accountAge;
return {canAccess: false, reason: VerificationFailureReason.ACCOUNT_TOO_NEW, timeRemaining};
}
}
if (verificationLevel >= GuildVerificationLevel.HIGH) {
if (member?.joinedAt) {
const membershipDuration = Date.now() - member.joinedAt.getTime();
if (membershipDuration < TEN_MINUTES_MS) {
const timeRemaining = TEN_MINUTES_MS - membershipDuration;
return {canAccess: false, reason: VerificationFailureReason.NOT_MEMBER_LONG_ENOUGH, timeRemaining};
}
}
}
if (verificationLevel >= GuildVerificationLevel.VERY_HIGH) {
if (!user.phone) {
return {canAccess: false, reason: VerificationFailureReason.NO_PHONE_NUMBER};
}
}
return {canAccess: true};
}
canAccessGuild(guildId: string): boolean {
const status = this.verificationStatus[guildId];
return status?.canAccess ?? true;
}
getVerificationStatus(guildId: string): VerificationStatus | null {
return this.verificationStatus[guildId] ?? null;
}
getFailureReason(guildId: string): VerificationFailureReason | null {
return this.verificationStatus[guildId]?.reason ?? null;
}
getTimeRemaining(guildId: string): number | null {
return this.verificationStatus[guildId]?.timeRemaining ?? null;
}
}
export default new GuildVerificationStore();

View File

@@ -0,0 +1,87 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {IS_DEV} from '~/lib/env';
import LocalPresenceStore from '~/stores/LocalPresenceStore';
const IDLE_DURATION_MS = 1000 * (IS_DEV ? 10 : 60 * 10);
const IDLE_CHECK_INTERVAL_MS = Math.floor(IDLE_DURATION_MS * 0.25);
class IdleStore {
idle = false;
private lastActivityTime = Date.now();
private checkInterval: ReturnType<typeof setInterval> | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.startIdleCheck();
}
private startIdleCheck(): void {
if (typeof setInterval !== 'function') return;
this.checkInterval = setInterval(() => {
this.updateIdleState();
}, IDLE_CHECK_INTERVAL_MS);
}
destroy(): void {
if (this.checkInterval !== null) {
clearInterval(this.checkInterval);
this.checkInterval = null;
}
}
recordActivity(): void {
this.lastActivityTime = Date.now();
if (this.idle) {
this.updateIdleState();
}
}
markBackground(): void {
this.lastActivityTime = 0;
this.updateIdleState();
}
isIdle(): boolean {
return this.idle;
}
getIdleSince(): number {
return this.idle ? this.lastActivityTime : 0;
}
private updateIdleState(): void {
const now = Date.now();
const timeSinceActivity = now - this.lastActivityTime;
const shouldBeIdle = timeSinceActivity >= IDLE_DURATION_MS;
if (shouldBeIdle !== this.idle) {
this.idle = shouldBeIdle;
LocalPresenceStore.updatePresence();
}
}
}
export default new IdleStore();

View File

@@ -0,0 +1,52 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {Logger} from '~/lib/Logger';
import {makePersistent} from '~/lib/MobXPersistence';
const logger = new Logger('InboxStore');
export type InboxTab = 'bookmarks' | 'mentions' | 'scheduled';
class InboxStore {
selectedTab: InboxTab = 'bookmarks';
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'InboxStore', ['selectedTab']);
}
setTab(tab: InboxTab): void {
if (this.selectedTab !== tab) {
this.selectedTab = tab;
logger.debug(`Set inbox tab to: ${tab}`);
}
}
getSelectedTab(): InboxTab {
return this.selectedTab;
}
}
export default new InboxStore();

View File

@@ -0,0 +1,96 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {action, makeAutoObservable} from 'mobx';
const InitializationState = {
LOADING: 'LOADING',
CONNECTING: 'CONNECTING',
READY: 'READY',
ERROR: 'ERROR',
} as const;
type InitializationState = (typeof InitializationState)[keyof typeof InitializationState];
class InitializationStore {
state: InitializationState = InitializationState.LOADING;
error: string | null = null;
readyPayload: unknown = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
get isLoading(): boolean {
return this.state === InitializationState.LOADING;
}
get isConnecting(): boolean {
return this.state === InitializationState.CONNECTING;
}
get isReady(): boolean {
return this.state === InitializationState.READY;
}
get hasError(): boolean {
return this.state === InitializationState.ERROR;
}
get canNavigateToProtectedRoutes(): boolean {
return this.state === InitializationState.READY;
}
@action
setLoading(): void {
this.state = InitializationState.LOADING;
this.error = null;
this.readyPayload = null;
}
@action
setConnecting(): void {
this.state = InitializationState.CONNECTING;
this.error = null;
this.readyPayload = null;
}
@action
setReady(payload: unknown): void {
this.state = InitializationState.READY;
this.error = null;
this.readyPayload = payload;
}
@action
setError(error: string): void {
this.state = InitializationState.ERROR;
this.error = error;
this.readyPayload = null;
}
@action
reset(): void {
this.state = InitializationState.LOADING;
this.error = null;
this.readyPayload = null;
}
}
export default new InitializationStore();

View File

@@ -0,0 +1,56 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
class InputMonitoringPromptsStore {
hasSeenInputMonitoringCTA = false;
private shownThisSession = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'InputMonitoringPromptsStore', ['hasSeenInputMonitoringCTA']);
}
shouldShowInputMonitoringCTA(): boolean {
return !this.hasSeenInputMonitoringCTA && !this.shownThisSession;
}
markShownThisSession(): void {
this.shownThisSession = true;
}
dismissInputMonitoringCTA(): void {
this.hasSeenInputMonitoringCTA = true;
this.shownThisSession = true;
}
resetInputMonitoringCTA(): void {
this.hasSeenInputMonitoringCTA = false;
this.shownThisSession = false;
}
}
export default new InputMonitoringPromptsStore();

View File

@@ -0,0 +1,233 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {action, makeAutoObservable, runInAction} from 'mobx';
import * as InviteActionCreators from '~/actions/InviteActionCreators';
import type {Invite} from '~/records/MessageRecord';
import {isGuildInvite, isPackInvite} from '~/types/InviteTypes';
type FetchStatus = 'idle' | 'pending' | 'success' | 'error';
interface InviteState {
loading: boolean;
error: Error | null;
data: Invite | null;
}
function upsertInviteByCode(list: Array<Invite>, invite: Invite): Array<Invite> {
const idx = list.findIndex((i) => i.code === invite.code);
if (idx === -1) return [...list, invite];
const next = list.slice();
next[idx] = invite;
return next;
}
function mergeInvitesByCode(existing: Array<Invite>, incoming: Array<Invite>): Array<Invite> {
const map = new Map<string, Invite>();
for (const i of existing) map.set(i.code, i);
for (const i of incoming) map.set(i.code, i);
return Array.from(map.values());
}
class InviteStore {
invites: Map<string, InviteState> = new Map();
pendingRequests: Map<string, Promise<Invite>> = new Map();
channelInvites: Map<string, Array<Invite>> = new Map();
channelInvitesFetchStatus: Map<string, FetchStatus> = new Map();
guildInvites: Map<string, Array<Invite>> = new Map();
guildInvitesFetchStatus: Map<string, FetchStatus> = new Map();
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
getInvite(code: string): InviteState | null {
return this.invites.get(code) || null;
}
getInvites(): Map<string, InviteState> {
return this.invites;
}
getChannelInvites(channelId: string): Array<Invite> | null {
return this.channelInvites.get(channelId) || null;
}
getChannelInvitesFetchStatus(channelId: string): FetchStatus {
return this.channelInvitesFetchStatus.get(channelId) || 'idle';
}
getGuildInvites(guildId: string): Array<Invite> | null {
return this.guildInvites.get(guildId) || null;
}
getGuildInvitesFetchStatus(guildId: string): FetchStatus {
return this.guildInvitesFetchStatus.get(guildId) || 'idle';
}
fetchInvite = action(async (code: string): Promise<Invite> => {
const existingRequest = this.pendingRequests.get(code);
if (existingRequest) {
return existingRequest;
}
const existingInvite = this.invites.get(code);
if (existingInvite?.data) {
return existingInvite.data;
}
runInAction(() => {
this.invites = new Map(this.invites).set(code, {loading: true, error: null, data: null});
});
const promise = InviteActionCreators.fetch(code);
runInAction(() => {
this.pendingRequests = new Map(this.pendingRequests).set(code, promise);
});
try {
const invite = await promise;
runInAction(() => {
const newPendingRequests = new Map(this.pendingRequests);
newPendingRequests.delete(code);
this.invites = new Map(this.invites).set(code, {loading: false, error: null, data: invite});
this.pendingRequests = newPendingRequests;
});
return invite;
} catch (error) {
runInAction(() => {
const newPendingRequests = new Map(this.pendingRequests);
newPendingRequests.delete(code);
this.invites = new Map(this.invites).set(code, {loading: false, error: error as Error, data: null});
this.pendingRequests = newPendingRequests;
});
throw error;
}
});
handleChannelInvitesFetchPending = action((channelId: string): void => {
this.channelInvitesFetchStatus = new Map(this.channelInvitesFetchStatus).set(channelId, 'pending');
});
handleChannelInvitesFetchSuccess = action((channelId: string, invites: Array<Invite>): void => {
const existing = this.channelInvites.get(channelId) ?? [];
const merged = mergeInvitesByCode(existing, invites);
this.channelInvites = new Map(this.channelInvites).set(channelId, merged);
this.channelInvitesFetchStatus = new Map(this.channelInvitesFetchStatus).set(channelId, 'success');
});
handleChannelInvitesFetchError = action((channelId: string): void => {
this.channelInvitesFetchStatus = new Map(this.channelInvitesFetchStatus).set(channelId, 'error');
});
handleGuildInvitesFetchPending = action((guildId: string): void => {
this.guildInvitesFetchStatus = new Map(this.guildInvitesFetchStatus).set(guildId, 'pending');
});
handleGuildInvitesFetchSuccess = action((guildId: string, invites: Array<Invite>): void => {
const existing = this.guildInvites.get(guildId) ?? [];
const merged = mergeInvitesByCode(existing, invites);
this.guildInvites = new Map(this.guildInvites).set(guildId, merged);
this.guildInvitesFetchStatus = new Map(this.guildInvitesFetchStatus).set(guildId, 'success');
});
handleGuildInvitesFetchError = action((guildId: string): void => {
this.guildInvitesFetchStatus = new Map(this.guildInvitesFetchStatus).set(guildId, 'error');
});
handleInviteCreate = action((invite: Invite): void => {
if (!isPackInvite(invite)) {
const newChannelInvites = new Map(this.channelInvites);
const channelId = invite.channel.id;
const existingChannelInvites = newChannelInvites.get(channelId) ?? [];
newChannelInvites.set(channelId, upsertInviteByCode(existingChannelInvites, invite));
const newChannelStatus = new Map(this.channelInvitesFetchStatus);
newChannelStatus.set(channelId, 'success');
this.channelInvites = newChannelInvites;
this.channelInvitesFetchStatus = newChannelStatus;
}
if (isGuildInvite(invite)) {
const newGuildInvites = new Map(this.guildInvites);
const existingGuildInvites = newGuildInvites.get(invite.guild.id) ?? [];
newGuildInvites.set(invite.guild.id, upsertInviteByCode(existingGuildInvites, invite));
const newGuildStatus = new Map(this.guildInvitesFetchStatus);
newGuildStatus.set(invite.guild.id, 'success');
this.guildInvites = newGuildInvites;
this.guildInvitesFetchStatus = newGuildStatus;
}
const newInvites = new Map(this.invites);
newInvites.set(invite.code, {loading: false, error: null, data: invite});
this.invites = newInvites;
});
handleInviteDelete = action((inviteCode: string): void => {
const newChannelInvites = new Map(this.channelInvites);
for (const [channelId, invites] of newChannelInvites) {
newChannelInvites.set(
channelId,
invites.filter((invite) => invite.code !== inviteCode),
);
}
const newGuildInvites = new Map(this.guildInvites);
for (const [guildId, invites] of newGuildInvites) {
newGuildInvites.set(
guildId,
invites.filter((invite) => invite.code !== inviteCode),
);
}
const newInvites = new Map(this.invites);
newInvites.delete(inviteCode);
this.invites = newInvites;
this.channelInvites = newChannelInvites;
this.guildInvites = newGuildInvites;
});
handleChannelDelete = action((channelId: string): void => {
const newChannelInvites = new Map(this.channelInvites);
newChannelInvites.delete(channelId);
const newChannelInvitesFetchStatus = new Map(this.channelInvitesFetchStatus);
newChannelInvitesFetchStatus.delete(channelId);
this.channelInvites = newChannelInvites;
this.channelInvitesFetchStatus = newChannelInvitesFetchStatus;
});
handleGuildDelete = action((guildId: string): void => {
const newGuildInvites = new Map(this.guildInvites);
newGuildInvites.delete(guildId);
const newGuildInvitesFetchStatus = new Map(this.guildInvitesFetchStatus);
newGuildInvitesFetchStatus.delete(guildId);
this.guildInvites = newGuildInvites;
this.guildInvitesFetchStatus = newGuildInvitesFetchStatus;
});
}
export default new InviteStore();

View File

@@ -0,0 +1,648 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {makeAutoObservable, runInAction} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
export type KeybindAction =
| 'quick_switcher'
| 'navigate_history_back'
| 'navigate_history_forward'
| 'navigate_server_previous'
| 'navigate_server_next'
| 'navigate_channel_previous'
| 'navigate_channel_next'
| 'navigate_unread_channel_previous'
| 'navigate_unread_channel_next'
| 'navigate_unread_mentions_previous'
| 'navigate_unread_mentions_next'
| 'navigate_to_current_call'
| 'navigate_last_server_or_dm'
| 'mark_channel_read'
| 'mark_server_read'
| 'mark_top_inbox_read'
| 'toggle_hotkeys'
| 'return_previous_text_channel'
| 'return_previous_text_channel_alt'
| 'return_connected_audio_channel'
| 'return_connected_audio_channel_alt'
| 'toggle_pins_popout'
| 'toggle_mentions_popout'
| 'toggle_channel_member_list'
| 'toggle_emoji_picker'
| 'toggle_gif_picker'
| 'toggle_sticker_picker'
| 'toggle_memes_picker'
| 'scroll_chat_up'
| 'scroll_chat_down'
| 'jump_to_oldest_unread'
| 'create_or_join_server'
| 'answer_incoming_call'
| 'decline_incoming_call'
| 'create_private_group'
| 'start_pm_call'
| 'focus_text_area'
| 'toggle_mute'
| 'toggle_deafen'
| 'toggle_video'
| 'toggle_screen_share'
| 'toggle_settings'
| 'get_help'
| 'search'
| 'upload_file'
| 'push_to_talk'
| 'toggle_push_to_talk_mode'
| 'toggle_soundboard'
| 'zoom_in'
| 'zoom_out'
| 'zoom_reset';
export interface KeyCombo {
key: string;
code?: string;
ctrlOrMeta?: boolean;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
global?: boolean;
enabled?: boolean;
}
export interface KeybindConfig {
action: KeybindAction;
label: string;
description?: string;
combo: KeyCombo;
allowGlobal?: boolean;
category: 'navigation' | 'voice' | 'messaging' | 'popouts' | 'calls' | 'system';
}
const TRANSMIT_MODES = ['voice_activity', 'push_to_talk'] as const;
type TransmitMode = (typeof TRANSMIT_MODES)[number];
const DEFAULT_RELEASE_DELAY_MS = 20;
const MIN_RELEASE_DELAY_MS = 20;
const MAX_RELEASE_DELAY_MS = 2000;
const LATCH_TAP_THRESHOLD_MS = 200;
const getDefaultKeybinds = (i18n: I18n): ReadonlyArray<KeybindConfig> =>
[
{
action: 'quick_switcher',
label: i18n._(msg`Find or Start a Direct Message`),
description: i18n._(msg`Open the quick switcher overlay`),
combo: {key: 'k', ctrlOrMeta: true},
category: 'navigation',
},
{
action: 'navigate_server_previous',
label: i18n._(msg`Previous Community`),
description: i18n._(msg`Navigate to the previous community`),
combo: {key: 'ArrowUp', ctrlOrMeta: true, alt: true},
category: 'navigation',
},
{
action: 'navigate_server_next',
label: i18n._(msg`Next Community`),
description: i18n._(msg`Navigate to the next community`),
combo: {key: 'ArrowDown', ctrlOrMeta: true, alt: true},
category: 'navigation',
},
{
action: 'navigate_channel_previous',
label: i18n._(msg`Previous Channel`),
description: i18n._(msg`Navigate to the previous channel in the community`),
combo: {key: 'ArrowUp', alt: true},
category: 'navigation',
},
{
action: 'navigate_channel_next',
label: i18n._(msg`Next Channel`),
description: i18n._(msg`Navigate to the next channel in the community`),
combo: {key: 'ArrowDown', alt: true},
category: 'navigation',
},
{
action: 'navigate_unread_channel_previous',
label: i18n._(msg`Previous Unread Channel`),
description: i18n._(msg`Jump to the previous unread channel`),
combo: {key: 'ArrowUp', alt: true, shift: true},
category: 'navigation',
},
{
action: 'navigate_unread_channel_next',
label: i18n._(msg`Next Unread Channel`),
description: i18n._(msg`Jump to the next unread channel`),
combo: {key: 'ArrowDown', alt: true, shift: true},
category: 'navigation',
},
{
action: 'navigate_unread_mentions_previous',
label: i18n._(msg`Previous Unread Mention`),
description: i18n._(msg`Jump to the previous unread channel with mentions`),
combo: {key: 'ArrowUp', alt: true, shift: true, ctrlOrMeta: true},
category: 'navigation',
},
{
action: 'navigate_unread_mentions_next',
label: i18n._(msg`Next Unread Mention`),
description: i18n._(msg`Jump to the next unread channel with mentions`),
combo: {key: 'ArrowDown', alt: true, shift: true, ctrlOrMeta: true},
category: 'navigation',
},
{
action: 'navigate_history_back',
label: i18n._(msg`Navigate Back`),
description: i18n._(msg`Go back in navigation history`),
combo: {key: '[', ctrlOrMeta: true},
category: 'navigation',
},
{
action: 'navigate_history_forward',
label: i18n._(msg`Navigate Forward`),
description: i18n._(msg`Go forward in navigation history`),
combo: {key: ']', ctrlOrMeta: true},
category: 'navigation',
},
{
action: 'navigate_to_current_call',
label: i18n._(msg`Go to Current Call`),
description: i18n._(msg`Jump to the channel of the active call`),
combo: {key: 'v', alt: true, shift: true, ctrlOrMeta: true},
category: 'navigation',
},
{
action: 'navigate_last_server_or_dm',
label: i18n._(msg`Toggle Last Community / DMs`),
description: i18n._(msg`Switch between the last community and direct messages`),
combo: {key: 'ArrowRight', alt: true, ctrlOrMeta: true},
category: 'navigation',
},
{
action: 'return_previous_text_channel',
label: i18n._(msg`Return to Previous Text Channel`),
description: i18n._(msg`Go back to the previously focused text channel`),
combo: {key: 'b', ctrlOrMeta: true},
category: 'navigation',
},
{
action: 'return_previous_text_channel_alt',
label: i18n._(msg`Return to Previous Text Channel (Alt)`),
description: i18n._(msg`Alternate binding to jump back to the previously focused text channel`),
combo: {key: 'ArrowRight', alt: true},
category: 'navigation',
},
{
action: 'return_connected_audio_channel',
label: i18n._(msg`Return to Active Audio Channel`),
description: i18n._(msg`Focus the audio channel you are currently connected to`),
combo: {key: 'a', alt: true, ctrlOrMeta: true},
category: 'voice',
},
{
action: 'return_connected_audio_channel_alt',
label: i18n._(msg`Return to Connected Audio Channel`),
description: i18n._(msg`Alternate binding to focus the audio channel you are connected to`),
combo: {key: 'ArrowLeft', alt: true},
category: 'voice',
},
{
action: 'toggle_settings',
label: i18n._(msg`Open User Settings`),
description: i18n._(msg`Open user settings modal`),
combo: {key: ',', ctrlOrMeta: true},
category: 'navigation',
},
{
action: 'toggle_hotkeys',
label: i18n._(msg`Toggle Hotkeys`),
description: i18n._(msg`Show or hide keyboard shortcut help`),
combo: {key: '/', ctrlOrMeta: true},
category: 'system',
},
{
action: 'toggle_pins_popout',
label: i18n._(msg`Toggle Pins Popout`),
description: i18n._(msg`Open or close pinned messages`),
combo: {key: 'p', ctrlOrMeta: true},
category: 'popouts',
},
{
action: 'toggle_mentions_popout',
label: i18n._(msg`Toggle Mentions Popout`),
description: i18n._(msg`Open or close recent mentions`),
combo: {key: 'i', ctrlOrMeta: true},
category: 'popouts',
},
{
action: 'toggle_channel_member_list',
label: i18n._(msg`Toggle Channel Member List`),
description: i18n._(msg`Show or hide the member list for the current channel`),
combo: {key: 'u', ctrlOrMeta: true},
category: 'popouts',
},
{
action: 'toggle_emoji_picker',
label: i18n._(msg`Toggle Emoji Picker`),
description: i18n._(msg`Open or close the emoji picker`),
combo: {key: 'e', ctrlOrMeta: true},
category: 'popouts',
},
{
action: 'toggle_gif_picker',
label: i18n._(msg`Toggle GIF Picker`),
description: i18n._(msg`Open or close the GIF picker`),
combo: {key: 'g', ctrlOrMeta: true},
category: 'popouts',
},
{
action: 'toggle_sticker_picker',
label: i18n._(msg`Toggle Sticker Picker`),
description: i18n._(msg`Open or close the sticker picker`),
combo: {key: 's', ctrlOrMeta: true},
category: 'popouts',
},
{
action: 'toggle_memes_picker',
label: i18n._(msg`Toggle Memes Picker`),
description: i18n._(msg`Open or close the memes picker`),
combo: {key: 'm', ctrlOrMeta: true},
category: 'popouts',
},
{
action: 'scroll_chat_up',
label: i18n._(msg`Scroll Chat Up`),
description: i18n._(msg`Scroll the chat history up`),
combo: {key: 'PageUp'},
category: 'messaging',
},
{
action: 'scroll_chat_down',
label: i18n._(msg`Scroll Chat Down`),
description: i18n._(msg`Scroll the chat history down`),
combo: {key: 'PageDown'},
category: 'messaging',
},
{
action: 'jump_to_oldest_unread',
label: i18n._(msg`Jump to Oldest Unread Message`),
description: i18n._(msg`Jump to the oldest unread message in the channel`),
combo: {key: 'PageUp', shift: true},
category: 'messaging',
},
{
action: 'mark_channel_read',
label: i18n._(msg`Mark Channel as Read`),
description: i18n._(msg`Mark the current channel as read`),
combo: {key: 'Escape'},
category: 'messaging',
},
{
action: 'mark_server_read',
label: i18n._(msg`Mark Community as Read`),
description: i18n._(msg`Mark the current community as read`),
combo: {key: 'Escape', shift: true},
category: 'messaging',
},
{
action: 'mark_top_inbox_read',
label: i18n._(msg`Mark Top Inbox Channel as Read`),
description: i18n._(msg`Mark the first unread channel in your inbox as read`),
combo: {key: 'e', ctrlOrMeta: true, shift: true},
category: 'messaging',
},
{
action: 'create_or_join_server',
label: i18n._(msg`Create or Join a Community`),
description: i18n._(msg`Open the create or join community flow`),
combo: {key: 'n', ctrlOrMeta: true, shift: true},
category: 'system',
},
{
action: 'create_private_group',
label: i18n._(msg`Create a Private Group`),
description: i18n._(msg`Start a new private group`),
combo: {key: 't', ctrlOrMeta: true, shift: true},
category: 'system',
},
{
action: 'start_pm_call',
label: i18n._(msg`Start Call in Private Message or Group`),
description: i18n._(msg`Begin a call in the current private conversation`),
combo: {key: "'", ctrl: true},
category: 'calls',
},
{
action: 'answer_incoming_call',
label: i18n._(msg`Answer Incoming Call`),
description: i18n._(msg`Accept the incoming call`),
combo: {key: 'Enter', ctrlOrMeta: true},
category: 'calls',
},
{
action: 'decline_incoming_call',
label: i18n._(msg`Decline Incoming Call`),
description: i18n._(msg`Decline or dismiss the incoming call`),
combo: {key: 'Escape'},
category: 'calls',
},
{
action: 'focus_text_area',
label: i18n._(msg`Focus Text Area`),
description: i18n._(msg`Move focus to the message composer`),
combo: {key: 'Tab'},
category: 'messaging',
},
{
action: 'toggle_mute',
label: i18n._(msg`Toggle Mute`),
description: i18n._(msg`Mute / unmute microphone`),
combo: {key: 'm', ctrlOrMeta: true, shift: true, global: true, enabled: true},
allowGlobal: true,
category: 'voice',
},
{
action: 'toggle_deafen',
label: i18n._(msg`Toggle Deaf`),
description: i18n._(msg`Deafen / undeafen`),
combo: {key: 'd', ctrlOrMeta: true, shift: true, global: true, enabled: true},
allowGlobal: true,
category: 'voice',
},
{
action: 'toggle_video',
label: i18n._(msg`Toggle Camera`),
description: i18n._(msg`Turn camera on or off`),
combo: {key: 'v', ctrlOrMeta: true, shift: true},
category: 'voice',
},
{
action: 'toggle_screen_share',
label: i18n._(msg`Toggle Screen Share`),
description: i18n._(msg`Start / stop screen sharing`),
combo: {key: 's', ctrlOrMeta: true, shift: true},
category: 'voice',
},
{
action: 'get_help',
label: i18n._(msg`Get Help`),
description: i18n._(msg`Open the help center`),
combo: {key: 'h', ctrlOrMeta: true, shift: true},
category: 'system',
},
{
action: 'search',
label: i18n._(msg`Search`),
description: i18n._(msg`Search within the current view`),
combo: {key: 'f', ctrlOrMeta: true},
category: 'system',
},
{
action: 'upload_file',
label: i18n._(msg`Upload a File`),
description: i18n._(msg`Open the upload file dialog`),
combo: {key: 'u', ctrlOrMeta: true, shift: true},
category: 'messaging',
},
{
action: 'push_to_talk',
label: i18n._(msg`Push-To-Talk (hold)`),
description: i18n._(msg`Hold to temporarily unmute when push-to-talk is enabled`),
combo: {key: '', enabled: false, global: false},
allowGlobal: true,
category: 'voice',
},
{
action: 'toggle_push_to_talk_mode',
label: i18n._(msg`Toggle Push-To-Talk Mode`),
description: i18n._(msg`Enable or disable push-to-talk`),
combo: {key: 'p', ctrlOrMeta: true, shift: true},
category: 'voice',
},
{
action: 'zoom_in',
label: i18n._(msg`Zoom In`),
description: i18n._(msg`Increase app zoom level`),
combo: {key: '=', ctrlOrMeta: true},
category: 'system',
},
{
action: 'zoom_out',
label: i18n._(msg`Zoom Out`),
description: i18n._(msg`Decrease app zoom level`),
combo: {key: '-', ctrlOrMeta: true},
category: 'system',
},
{
action: 'zoom_reset',
label: i18n._(msg`Reset Zoom`),
description: i18n._(msg`Reset zoom to 100%`),
combo: {key: '0', ctrlOrMeta: true},
category: 'system',
},
] as const;
type KeybindState = Record<KeybindAction, KeyCombo>;
class KeybindStore {
keybinds: KeybindState = {} as KeybindState;
transmitMode: TransmitMode = 'voice_activity';
pushToTalkHeld = false;
pushToTalkReleaseDelay = DEFAULT_RELEASE_DELAY_MS;
pushToTalkLatching = false;
private pushToTalkLatched = false;
private pushToTalkPressTime = 0;
private i18n: I18n | null = null;
private initialized = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void makePersistent(
this,
'KeybindStore',
['keybinds', 'transmitMode', 'pushToTalkReleaseDelay', 'pushToTalkLatching'],
{version: 3},
);
}
setI18n(i18n: I18n): void {
this.i18n = i18n;
if (!this.initialized) {
this.resetToDefaults();
this.initialized = true;
}
}
getAll(): Array<KeybindConfig & {combo: KeyCombo}> {
if (!this.i18n) {
throw new Error('KeybindStore: i18n not initialized');
}
const defaultKeybinds = getDefaultKeybinds(this.i18n);
return defaultKeybinds.map((entry) => ({
...entry,
combo: this.keybinds[entry.action] ?? entry.combo,
}));
}
getByAction(action: KeybindAction): KeybindConfig & {combo: KeyCombo} {
if (!this.i18n) {
throw new Error('KeybindStore: i18n not initialized');
}
const defaultKeybinds = getDefaultKeybinds(this.i18n);
const base = defaultKeybinds.find((k) => k.action === action);
if (!base) throw new Error(`Unknown keybind action: ${action}`);
return {
...base,
combo: this.keybinds[action] ?? base.combo,
};
}
setKeybind(action: KeybindAction, combo: KeyCombo): void {
runInAction(() => {
this.keybinds[action] = combo;
});
}
toggleGlobal(action: KeybindAction, enabled: boolean): void {
const config = this.getByAction(action);
if (!config.allowGlobal) return;
this.setKeybind(action, {...config.combo, global: enabled});
}
resetToDefaults(): void {
if (!this.i18n) {
throw new Error('KeybindStore: i18n not initialized');
}
const defaultKeybinds = getDefaultKeybinds(this.i18n);
runInAction(() => {
this.keybinds = defaultKeybinds.reduce<KeybindState>((acc, entry) => {
acc[entry.action] = {...entry.combo};
return acc;
}, {} as KeybindState);
});
}
setTransmitMode(mode: TransmitMode): void {
runInAction(() => {
this.transmitMode = mode;
});
}
isPushToTalkEnabled(): boolean {
return this.transmitMode === 'push_to_talk';
}
setPushToTalkHeld(held: boolean): void {
runInAction(() => {
this.pushToTalkHeld = held;
});
}
isPushToTalkMuted(userMuted: boolean): boolean {
if (!this.isPushToTalkEnabled()) return false;
if (userMuted) return false;
if (this.pushToTalkLatched) return false;
return !this.pushToTalkHeld;
}
hasPushToTalkKeybind(): boolean {
const {combo} = this.getByAction('push_to_talk');
return Boolean(combo.key || combo.code);
}
isPushToTalkEffective(): boolean {
return this.isPushToTalkEnabled() && this.hasPushToTalkKeybind();
}
setPushToTalkReleaseDelay(delayMs: number): void {
const clamped = Math.max(MIN_RELEASE_DELAY_MS, Math.min(MAX_RELEASE_DELAY_MS, delayMs));
runInAction(() => {
this.pushToTalkReleaseDelay = clamped;
});
}
setPushToTalkLatching(enabled: boolean): void {
runInAction(() => {
this.pushToTalkLatching = enabled;
if (!enabled) this.pushToTalkLatched = false;
});
}
handlePushToTalkPress(nowMs: number = Date.now()): boolean {
this.pushToTalkPressTime = nowMs;
if (this.pushToTalkLatching && this.pushToTalkLatched) {
runInAction(() => {
this.pushToTalkLatched = false;
this.pushToTalkHeld = false;
});
return false;
}
runInAction(() => {
this.pushToTalkHeld = true;
});
return true;
}
handlePushToTalkRelease(nowMs: number = Date.now()): boolean {
const pressDuration = nowMs - this.pushToTalkPressTime;
if (this.pushToTalkLatching && pressDuration < LATCH_TAP_THRESHOLD_MS && !this.pushToTalkLatched) {
runInAction(() => {
this.pushToTalkLatched = true;
});
return false;
}
runInAction(() => {
this.pushToTalkHeld = false;
});
return true;
}
isPushToTalkLatched(): boolean {
return this.pushToTalkLatched;
}
resetPushToTalkState(): void {
runInAction(() => {
this.pushToTalkHeld = false;
this.pushToTalkLatched = false;
this.pushToTalkPressTime = 0;
});
}
}
export default new KeybindStore();
export const getDefaultKeybind = (action: KeybindAction, i18n: I18n): KeyCombo | null => {
const defaultKeybinds = getDefaultKeybinds(i18n);
const found = defaultKeybinds.find((k) => k.action === action);
return found ? {...found.combo} : null;
};

View File

@@ -0,0 +1,68 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, runInAction} from 'mobx';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {KeyboardModeIntroModal} from '~/components/modals/KeyboardModeIntroModal';
import {Logger} from '~/lib/Logger';
import {makePersistent} from '~/lib/MobXPersistence';
const logger = new Logger('KeyboardModeStore');
class KeyboardModeStore {
keyboardModeEnabled = false;
introSeen = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void makePersistent(this, 'KeyboardModeStore', ['introSeen']);
}
enterKeyboardMode(showIntro = true): void {
logger.debug(
`Entering keyboard mode (showIntro=${showIntro}) previous=${this.keyboardModeEnabled ? 'true' : 'false'}`,
);
runInAction(() => {
this.keyboardModeEnabled = true;
});
if (showIntro && !this.introSeen) {
this.introSeen = true;
ModalActionCreators.push(modal(() => <KeyboardModeIntroModal />));
}
}
exitKeyboardMode(): void {
if (!this.keyboardModeEnabled) {
logger.debug('exitKeyboardMode ignored (already false)');
return;
}
logger.debug('Exiting keyboard mode');
runInAction(() => {
this.keyboardModeEnabled = false;
});
}
dismissIntro(): void {
this.introSeen = true;
}
}
export default new KeyboardModeStore();

View File

@@ -0,0 +1,120 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as PopoutActionCreators from '~/actions/PopoutActionCreators';
import type {PopoutKey} from '~/components/uikit/Popout';
type LayerType = 'modal' | 'popout' | 'contextmenu';
export interface Layer {
type: LayerType;
key: string | PopoutKey;
timestamp: number;
onClose?: () => void;
}
class LayerManager {
private layers: Array<Layer> = [];
private isInitialized = false;
init() {
if (this.isInitialized) return;
this.isInitialized = true;
document.addEventListener('keydown', this.handleGlobalEscape, {capture: true});
}
destroy() {
if (!this.isInitialized) return;
this.isInitialized = false;
document.removeEventListener('keydown', this.handleGlobalEscape, {capture: true});
this.layers = [];
}
private handleGlobalEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
const topLayer = this.getTopLayer();
if (!topLayer) return;
event.preventDefault();
event.stopPropagation();
if (topLayer.type === 'modal') {
if (topLayer.onClose) {
topLayer.onClose();
} else {
ModalActionCreators.pop();
}
} else if (topLayer.type === 'popout') {
topLayer.onClose?.();
this.removeLayer('popout', topLayer.key);
PopoutActionCreators.close(topLayer.key);
} else if (topLayer.type === 'contextmenu') {
topLayer.onClose?.();
}
};
addLayer(type: LayerType, key: string | PopoutKey, onClose?: () => void) {
this.removeLayer(type, key);
this.layers.push({
type,
key,
timestamp: Date.now(),
onClose,
});
}
removeLayer(type: LayerType, key: string | PopoutKey) {
this.layers = this.layers.filter((layer) => !(layer.type === type && layer.key === key));
}
private getTopLayer(): Layer | undefined {
return this.layers.length > 0 ? this.layers[this.layers.length - 1] : undefined;
}
hasLayers(): boolean {
return this.layers.length > 0;
}
isTopLayer(type: LayerType, key: string | PopoutKey): boolean {
const topLayer = this.getTopLayer();
return topLayer?.type === type && topLayer?.key === key;
}
hasType(type: LayerType): boolean {
return this.layers.some((l) => l.type === type);
}
isTopType(type: LayerType): boolean {
const top = this.getTopLayer();
return top?.type === type;
}
closeAll(): void {
ModalActionCreators.popAll();
PopoutActionCreators.closeAll();
this.layers = [];
}
}
export default new LayerManager();

View File

@@ -0,0 +1,91 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import type {StatusType} from '~/Constants';
import {StatusTypes} from '~/Constants';
import {
type CustomStatus,
customStatusToKey,
type GatewayCustomStatusPayload,
normalizeCustomStatus,
toGatewayCustomStatus,
} from '~/lib/customStatus';
import IdleStore from '~/stores/IdleStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
type Presence = Readonly<{
status: StatusType;
since: number;
afk: boolean;
mobile: boolean;
custom_status: GatewayCustomStatusPayload | null;
}>;
class LocalPresenceStore {
status: StatusType = StatusTypes.ONLINE;
since: number = 0;
customStatus: CustomStatus | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
updatePresence(): void {
const userStatus = UserSettingsStore.status;
const idleSince = IdleStore.getIdleSince();
const effectiveStatus = userStatus === StatusTypes.ONLINE && idleSince > 0 ? StatusTypes.IDLE : userStatus;
const normalizedCustomStatus = normalizeCustomStatus(UserSettingsStore.getCustomStatus());
this.customStatus = normalizedCustomStatus ? {...normalizedCustomStatus} : null;
this.status = effectiveStatus;
this.since = idleSince;
}
getStatus(): StatusType {
return this.status;
}
getPresence(): Presence {
const isMobile = MobileLayoutStore.isMobileLayout();
const idleSince = IdleStore.getIdleSince();
const afkTimeout = UserSettingsStore.getAfkTimeout();
const timeSinceLastActivity = idleSince > 0 ? Date.now() - idleSince : 0;
const afk = !isMobile && timeSinceLastActivity > afkTimeout * 1000;
return {
status: this.status,
since: this.since,
afk,
mobile: isMobile,
custom_status: toGatewayCustomStatus(this.customStatus),
};
}
get presenceFingerprint(): string {
return `${this.status}|${customStatusToKey(this.customStatus)}`;
}
}
export default new LocalPresenceStore();

View File

@@ -0,0 +1,426 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, runInAction} from 'mobx';
import {Logger} from '~/lib/Logger';
import {makePersistent} from '~/lib/MobXPersistence';
import MediaPermissionStore from '~/stores/MediaPermissionStore';
import VoiceDevicePermissionStore, {type VoiceDeviceState} from '~/stores/voice/VoiceDevicePermissionStore';
const logger = new Logger('LocalVoiceStateStore');
class LocalVoiceStateStore {
selfMute = !MediaPermissionStore.isMicrophoneGranted();
selfDeaf = false;
selfVideo = false;
selfStream = false;
selfStreamAudio = false;
selfStreamAudioMute = false;
noiseSuppressionEnabled = true;
viewerStreamKey: string | null = null;
hasUserSetMute = false;
hasUserSetDeaf = false;
private microphonePermissionGranted: boolean | null = MediaPermissionStore.isMicrophoneGranted();
private mutedByPermission = !MediaPermissionStore.isMicrophoneGranted();
private _disposers: Array<() => void> = [];
private lastDevicePermissionStatus: VoiceDeviceState['permissionStatus'] | null =
VoiceDevicePermissionStore.getState().permissionStatus;
private isNotifyingServerOfPermissionMute = false;
constructor() {
makeAutoObservable<
this,
'microphonePermissionGranted' | 'mutedByPermission' | '_disposers' | 'isNotifyingServerOfPermissionMute'
>(
this,
{
microphonePermissionGranted: false,
mutedByPermission: false,
_disposers: false,
isNotifyingServerOfPermissionMute: false,
},
{autoBind: true},
);
this._disposers = [];
void this.initPersistence().then(() => {
this.enforcePermissionMuteIfNeeded();
});
this.initializePermissionSync();
this.initializeDevicePermissionSync();
this.enforcePermissionMuteIfNeeded();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'LocalVoiceStateStore', [
'selfMute',
'selfDeaf',
'noiseSuppressionEnabled',
'hasUserSetMute',
'hasUserSetDeaf',
]);
logger.debug('LocalVoiceStateStore hydrated from localStorage on reload');
}
dispose(): void {
this._disposers.forEach((disposer) => disposer());
this._disposers = [];
}
private async initializePermissionSync(): Promise<void> {
try {
let defaultMuteInitialized = false;
const syncWithPermission = (source: 'init' | 'change') => {
if (!MediaPermissionStore.isInitialized()) {
return;
}
const isMicGranted = MediaPermissionStore.isMicrophoneGranted();
const permissionState = MediaPermissionStore.getMicrophonePermissionState();
this.microphonePermissionGranted = isMicGranted;
logger.debug(source === 'init' ? 'Checking microphone permission for sync' : 'Microphone permission changed', {
isMicGranted,
permissionState,
currentMute: this.selfMute,
hasUserSetMute: this.hasUserSetMute,
mutedByPermission: this.mutedByPermission,
});
if (!isMicGranted) {
this.applyPermissionMute();
return;
}
const shouldAutoUnmute = this.mutedByPermission && this.selfMute;
const shouldApplyDefaultUnmute = !defaultMuteInitialized && !this.hasUserSetMute && this.selfMute;
if (shouldAutoUnmute || shouldApplyDefaultUnmute) {
logger.info(
shouldAutoUnmute
? 'Microphone permission granted, auto-unmuting after forced mute'
: 'Microphone permission granted, defaulting to unmuted state',
{permissionState},
);
runInAction(() => {
this.selfMute = false;
});
}
this.mutedByPermission = false;
defaultMuteInitialized = true;
};
syncWithPermission('init');
const disposer = MediaPermissionStore.addChangeListener(() => {
syncWithPermission('change');
});
this._disposers.push(disposer);
} catch (err) {
logger.error('Failed to initialize permission sync', err);
}
}
private initializeDevicePermissionSync(): void {
const disposer = VoiceDevicePermissionStore.subscribe((state) => {
this.handleDevicePermissionStatus(state.permissionStatus);
});
this._disposers.push(disposer);
}
private handleDevicePermissionStatus(status: VoiceDeviceState['permissionStatus']): void {
if (status === this.lastDevicePermissionStatus) {
return;
}
this.lastDevicePermissionStatus = status;
if (status === 'granted') {
this.applyPermissionGrant();
} else if (status === 'denied') {
this.applyPermissionMute();
}
}
private enforcePermissionMuteIfNeeded(): void {
const devicePermission = VoiceDevicePermissionStore.getState().permissionStatus;
const granted = MediaPermissionStore.isMicrophoneGranted() || devicePermission === 'granted';
if (granted) {
this.microphonePermissionGranted = true;
return;
}
this.microphonePermissionGranted = false;
this.applyPermissionMute();
}
private applyPermissionMute(): void {
const shouldNotify = !this.isNotifyingServerOfPermissionMute;
runInAction(() => {
this.microphonePermissionGranted = false;
this.mutedByPermission = true;
if (!this.selfMute) {
this.selfMute = true;
}
});
if (shouldNotify) {
void this.notifyServerOfPermissionMute();
}
}
private applyPermissionGrant(): void {
runInAction(() => {
this.microphonePermissionGranted = true;
if (this.mutedByPermission && this.selfMute && !this.hasUserSetMute) {
this.selfMute = false;
}
this.mutedByPermission = false;
});
}
private notifyServerOfPermissionMute(): void {
if (this.isNotifyingServerOfPermissionMute) {
logger.debug('Skipping recursive notifyServerOfPermissionMute call');
return;
}
try {
this.isNotifyingServerOfPermissionMute = true;
const store = (
window as {_mediaEngineStore?: {syncLocalVoiceStateWithServer?: (p: {self_mute: boolean}) => void}}
)._mediaEngineStore;
if (store?.syncLocalVoiceStateWithServer) {
store.syncLocalVoiceStateWithServer({self_mute: true});
}
} catch (error) {
logger.debug('Failed to sync permission-mute to server', {error});
} finally {
this.isNotifyingServerOfPermissionMute = false;
}
}
getSelfMute(): boolean {
return this.selfMute;
}
ensurePermissionMute(): void {
this.enforcePermissionMuteIfNeeded();
}
getSelfDeaf(): boolean {
return this.selfDeaf;
}
getSelfVideo(): boolean {
return this.selfVideo;
}
getSelfStream(): boolean {
return this.selfStream;
}
getSelfStreamAudio(): boolean {
return this.selfStreamAudio;
}
getSelfStreamAudioMute(): boolean {
return this.selfStreamAudioMute;
}
getViewerStreamKey(): string | null {
return this.viewerStreamKey;
}
updateViewerStreamKey(value: string | null): void {
runInAction(() => {
this.viewerStreamKey = value;
});
}
getNoiseSuppressionEnabled(): boolean {
return this.noiseSuppressionEnabled;
}
getHasUserSetMute(): boolean {
return this.hasUserSetMute;
}
getHasUserSetDeaf(): boolean {
return this.hasUserSetDeaf;
}
toggleSelfMute(): void {
runInAction(() => {
const newSelfMute = !this.selfMute;
const micDenied = this.microphonePermissionGranted === false;
if (micDenied && !newSelfMute) {
this.hasUserSetMute = true;
this.mutedByPermission = true;
logger.debug('Microphone permission denied, keeping self mute enabled despite toggle');
return;
}
this.hasUserSetMute = true;
if (this.selfDeaf && !newSelfMute) {
this.selfMute = false;
this.selfDeaf = false;
this.hasUserSetDeaf = true;
} else {
this.selfMute = newSelfMute;
}
logger.debug('User toggled self mute', {newSelfMute, hasUserSetMute: true});
});
}
toggleSelfDeaf(): void {
runInAction(() => {
const newSelfDeaf = !this.selfDeaf;
this.hasUserSetDeaf = true;
if (newSelfDeaf) {
this.selfMute = true;
this.selfDeaf = true;
} else {
this.selfDeaf = false;
}
logger.debug('User toggled self deaf', {newSelfDeaf, hasUserSetDeaf: true});
});
}
toggleSelfVideo(): void {
runInAction(() => {
this.selfVideo = !this.selfVideo;
logger.debug('User toggled self video', {selfVideo: this.selfVideo});
});
}
toggleSelfStream(): void {
runInAction(() => {
this.selfStream = !this.selfStream;
logger.debug('User toggled self stream', {selfStream: this.selfStream});
});
}
toggleSelfStreamAudio(): void {
runInAction(() => {
this.selfStreamAudio = !this.selfStreamAudio;
logger.debug('User toggled self stream audio', {selfStreamAudio: this.selfStreamAudio});
});
}
toggleSelfStreamAudioMute(): void {
runInAction(() => {
this.selfStreamAudioMute = !this.selfStreamAudioMute;
logger.debug('User toggled self stream audio mute', {selfStreamAudioMute: this.selfStreamAudioMute});
});
}
toggleNoiseSuppression(): void {
runInAction(() => {
this.noiseSuppressionEnabled = !this.noiseSuppressionEnabled;
logger.debug('User toggled noise suppression', {enabled: this.noiseSuppressionEnabled});
});
}
updateSelfMute(muted: boolean): void {
runInAction(() => {
if (this.microphonePermissionGranted === false && !muted) {
this.mutedByPermission = true;
if (!this.selfMute) {
this.selfMute = true;
logger.debug('Microphone permission denied, overriding requested unmute');
}
return;
}
this.selfMute = muted;
logger.debug('Self mute updated', {muted});
});
}
updateSelfDeaf(deafened: boolean): void {
runInAction(() => {
this.selfDeaf = deafened;
logger.debug('Self deaf updated', {deafened});
});
}
updateSelfVideo(video: boolean): void {
runInAction(() => {
this.selfVideo = video;
logger.debug('Self video updated', {video});
});
}
updateSelfStream(streaming: boolean): void {
runInAction(() => {
this.selfStream = streaming;
logger.debug('Self stream updated', {streaming});
});
}
updateSelfStreamAudio(enabled: boolean): void {
runInAction(() => {
this.selfStreamAudio = enabled;
logger.debug('Self stream audio updated', {enabled});
});
}
updateSelfStreamAudioMute(muted: boolean): void {
runInAction(() => {
this.selfStreamAudioMute = muted;
logger.debug('Self stream audio mute updated', {muted});
});
}
resetUserPreferences(): void {
runInAction(() => {
this.hasUserSetMute = false;
this.hasUserSetDeaf = false;
this.selfMute = false;
this.selfDeaf = false;
this.selfVideo = false;
this.selfStream = false;
this.selfStreamAudio = false;
this.selfStreamAudioMute = false;
this.noiseSuppressionEnabled = true;
this.mutedByPermission = false;
});
if (this.microphonePermissionGranted === false) {
logger.debug('Resetting preferences while microphone permission denied, keeping user muted');
runInAction(() => {
this.selfMute = true;
this.mutedByPermission = true;
});
}
logger.info('Reset user voice preferences');
}
}
export default new LocalVoiceStateStore();

View File

@@ -0,0 +1,72 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
interface MobileLayoutState {
navExpanded: boolean;
chatExpanded: boolean;
}
class LocationStore {
lastLocation: string | null = null;
lastMobileLayoutState: MobileLayoutState | null = null;
isHydrated = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'LocationStore', ['lastLocation', 'lastMobileLayoutState']);
this.isHydrated = true;
}
getLastLocation(): string | null {
return this.lastLocation;
}
getLastMobileLayoutState(): MobileLayoutState | null {
return this.lastMobileLayoutState;
}
saveLocation(location: string): void {
if (location && location !== this.lastLocation) {
this.lastLocation = location;
}
}
saveMobileLayoutState(mobileLayoutState: MobileLayoutState): void {
this.lastMobileLayoutState = mobileLayoutState;
}
saveLocationAndMobileState(location: string, mobileLayoutState: MobileLayoutState): void {
this.lastLocation = location;
this.lastMobileLayoutState = mobileLayoutState;
}
clearLastLocation(): void {
this.lastLocation = null;
this.lastMobileLayoutState = null;
}
}
export default new LocationStore();

View File

@@ -0,0 +1,250 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, reaction, runInAction} from 'mobx';
import {Logger} from '~/lib/Logger';
import {MediaDeviceRefreshType, refreshMediaDeviceLists} from '~/utils/MediaDeviceRefresh';
import {checkNativePermission} from '~/utils/NativePermissions';
const logger = new Logger('MediaPermissionStore');
class MediaPermissionStore {
microphoneExplicitlyDenied = false;
cameraExplicitlyDenied = false;
screenRecordingExplicitlyDenied = false;
microphonePermissionState: PermissionState | null = null;
cameraPermissionState: PermissionState | null = null;
screenRecordingPermissionState: PermissionState | null = null;
initialized = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initializePermissionState();
}
private async initializePermissionState(): Promise<void> {
if (await this.tryInitializeNativePermissions()) {
return;
}
if (!navigator.permissions) {
logger.debug('Permissions API not available');
runInAction(() => {
this.initialized = true;
});
return;
}
try {
const micPermission = await navigator.permissions.query({name: 'microphone' as PermissionName});
const micDenied = micPermission.state === 'denied';
const cameraPermission = await navigator.permissions.query({name: 'camera' as PermissionName});
const cameraDenied = cameraPermission.state === 'denied';
logger.debug('Initial permission state', {
microphone: micPermission.state,
camera: cameraPermission.state,
micDenied,
cameraDenied,
});
runInAction(() => {
this.microphoneExplicitlyDenied = micDenied;
this.cameraExplicitlyDenied = cameraDenied;
this.microphonePermissionState = micPermission.state;
this.cameraPermissionState = cameraPermission.state;
this.initialized = true;
});
micPermission.onchange = () => {
const isDenied = micPermission.state === 'denied';
logger.debug('Microphone permission changed', {state: micPermission.state, isDenied});
runInAction(() => {
this.microphoneExplicitlyDenied = isDenied;
this.microphonePermissionState = micPermission.state;
});
};
cameraPermission.onchange = () => {
const isDenied = cameraPermission.state === 'denied';
logger.debug('Camera permission changed', {state: cameraPermission.state, isDenied});
runInAction(() => {
this.cameraExplicitlyDenied = isDenied;
this.cameraPermissionState = cameraPermission.state;
});
};
} catch (error) {
logger.debug('Failed to query permissions', error);
runInAction(() => {
this.initialized = true;
});
}
}
private async tryInitializeNativePermissions(): Promise<boolean> {
const [micState, cameraState, screenState] = await Promise.all([
checkNativePermission('microphone'),
checkNativePermission('camera'),
checkNativePermission('screen'),
]);
const handled = micState !== 'unsupported' || cameraState !== 'unsupported';
if (!handled) return false;
runInAction(() => {
if (micState !== 'unsupported') {
this.microphoneExplicitlyDenied = micState === 'denied';
this.microphonePermissionState = micState === 'granted' ? 'granted' : 'denied';
}
if (cameraState !== 'unsupported') {
this.cameraExplicitlyDenied = cameraState === 'denied';
this.cameraPermissionState = cameraState === 'granted' ? 'granted' : 'denied';
}
if (screenState !== 'unsupported') {
this.screenRecordingExplicitlyDenied = screenState === 'denied';
this.screenRecordingPermissionState = screenState === 'granted' ? 'granted' : 'denied';
}
this.initialized = true;
});
return true;
}
markMicrophoneExplicitlyDenied(): void {
this.microphoneExplicitlyDenied = true;
this.microphonePermissionState = 'denied';
logger.debug('Marked microphone as explicitly denied');
}
markCameraExplicitlyDenied(): void {
this.cameraExplicitlyDenied = true;
this.cameraPermissionState = 'denied';
logger.debug('Marked camera as explicitly denied');
}
markScreenRecordingExplicitlyDenied(): void {
this.screenRecordingExplicitlyDenied = true;
this.screenRecordingPermissionState = 'denied';
logger.debug('Marked screen recording as explicitly denied');
}
clearMicrophoneDenial(): void {
this.microphoneExplicitlyDenied = false;
logger.debug('Cleared microphone denial');
}
clearCameraDenial(): void {
this.cameraExplicitlyDenied = false;
logger.debug('Cleared camera denial');
}
clearScreenRecordingDenial(): void {
this.screenRecordingExplicitlyDenied = false;
logger.debug('Cleared screen recording denial');
}
updateMicrophonePermissionGranted(): void {
this.microphoneExplicitlyDenied = false;
this.microphonePermissionState = 'granted';
logger.debug('Updated microphone permission to granted');
void refreshMediaDeviceLists({type: MediaDeviceRefreshType.audio});
}
updateCameraPermissionGranted(): void {
this.cameraExplicitlyDenied = false;
this.cameraPermissionState = 'granted';
logger.debug('Updated camera permission to granted');
void refreshMediaDeviceLists({type: MediaDeviceRefreshType.video});
}
updateScreenRecordingPermissionGranted(): void {
this.screenRecordingExplicitlyDenied = false;
this.screenRecordingPermissionState = 'granted';
logger.debug('Updated screen recording permission to granted');
}
reset(): void {
this.microphoneExplicitlyDenied = false;
this.cameraExplicitlyDenied = false;
this.screenRecordingExplicitlyDenied = false;
this.microphonePermissionState = null;
this.cameraPermissionState = null;
this.screenRecordingPermissionState = null;
this.initialized = false;
logger.debug('Reset all permissions');
}
isInitialized(): boolean {
return this.initialized;
}
isMicrophoneExplicitlyDenied(): boolean {
return this.microphoneExplicitlyDenied;
}
isCameraExplicitlyDenied(): boolean {
return this.cameraExplicitlyDenied;
}
isScreenRecordingExplicitlyDenied(): boolean {
return this.screenRecordingExplicitlyDenied;
}
isMicrophoneGranted(): boolean {
return this.microphonePermissionState === 'granted';
}
isCameraGranted(): boolean {
return this.cameraPermissionState === 'granted';
}
isScreenRecordingGranted(): boolean {
return this.screenRecordingPermissionState === 'granted';
}
getMicrophonePermissionState(): PermissionState | null {
return this.microphonePermissionState;
}
getCameraPermissionState(): PermissionState | null {
return this.cameraPermissionState;
}
getScreenRecordingPermissionState(): PermissionState | null {
return this.screenRecordingPermissionState;
}
addChangeListener(callback: () => void): () => void {
const dispose = reaction(
() => ({
mic: this.microphonePermissionState,
camera: this.cameraPermissionState,
screen: this.screenRecordingPermissionState,
}),
() => {
callback();
},
{fireImmediately: true},
);
return dispose;
}
}
export default new MediaPermissionStore();

View File

@@ -0,0 +1,99 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import type {MessageRecord} from '~/records/MessageRecord';
export type MediaViewerItem = Readonly<{
src: string;
originalSrc: string;
naturalWidth: number;
naturalHeight: number;
type: 'image' | 'gif' | 'gifv' | 'video' | 'audio';
contentHash?: string | null;
attachmentId?: string;
embedIndex?: number;
filename?: string;
fileSize?: number;
duration?: number;
expiresAt?: string | null;
expired?: boolean;
}>;
class MediaViewerStore {
isOpen: boolean = false;
items: ReadonlyArray<MediaViewerItem> = [];
currentIndex: number = 0;
channelId?: string = undefined;
messageId?: string = undefined;
message?: MessageRecord = undefined;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
open(
items: ReadonlyArray<MediaViewerItem>,
currentIndex: number,
channelId?: string,
messageId?: string,
message?: MessageRecord,
): void {
this.isOpen = true;
this.items = items;
this.currentIndex = currentIndex;
this.channelId = channelId;
this.messageId = messageId;
this.message = message;
}
close(): void {
this.isOpen = false;
this.items = [];
this.currentIndex = 0;
this.channelId = undefined;
this.messageId = undefined;
this.message = undefined;
}
navigate(index: number): void {
if (index < 0 || index >= this.items.length) {
return;
}
this.currentIndex = index;
}
getCurrentItem(): MediaViewerItem | undefined {
if (!this.isOpen || this.items.length === 0) {
return;
}
return this.items[this.currentIndex];
}
canNavigatePrevious(): boolean {
return this.isOpen && this.currentIndex > 0;
}
canNavigateNext(): boolean {
return this.isOpen && this.currentIndex < this.items.length - 1;
}
}
export default new MediaViewerStore();

View File

@@ -0,0 +1,54 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, reaction} from 'mobx';
import {Logger} from '~/lib/Logger';
import {makePersistent} from '~/lib/MobXPersistence';
const logger = new Logger('MemberListStore');
const getInitialWidth = (): number => window.innerWidth;
class MemberListStore {
isMembersOpen = getInitialWidth() >= 1024;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'MemberListStore', ['isMembersOpen']);
}
toggleMembers(): void {
this.isMembersOpen = !this.isMembersOpen;
logger.debug(`Toggled members list: ${this.isMembersOpen}`);
}
subscribe(callback: () => void): () => void {
return reaction(
() => this.isMembersOpen,
() => callback(),
{fireImmediately: true},
);
}
}
export default new MemberListStore();

View File

@@ -0,0 +1,283 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, runInAction} from 'mobx';
import ConnectionStore from '~/stores/ConnectionStore';
const MEMBER_SUBSCRIPTION_MAX_SIZE = 100;
const MEMBER_SUBSCRIPTION_TTL_MS = 5 * 60 * 1000;
const MEMBER_SUBSCRIPTION_SYNC_DEBOUNCE_MS = 500;
const MEMBER_SUBSCRIPTION_PRUNE_INTERVAL_MS = 30 * 1000;
interface LRUEntry {
userId: string;
lastAccessed: number;
}
class MemberPresenceSubscriptionStore {
private subscriptions: Map<string, Map<string, number>> = new Map();
private activeGuildId: string | null = null;
private syncTimeoutId: number | null = null;
private pruneIntervalId: number | null = null;
private pendingSyncGuilds: Set<string> = new Set();
subscriptionVersion = 0;
constructor() {
makeAutoObservable<this, 'subscriptions' | 'syncTimeoutId' | 'pruneIntervalId' | 'pendingSyncGuilds'>(
this,
{
subscriptions: false,
syncTimeoutId: false,
pruneIntervalId: false,
pendingSyncGuilds: false,
},
{autoBind: true},
);
this.startPruneInterval();
}
private startPruneInterval(): void {
if (this.pruneIntervalId != null) {
return;
}
this.pruneIntervalId = window.setInterval(() => {
this.pruneExpiredEntries();
}, MEMBER_SUBSCRIPTION_PRUNE_INTERVAL_MS);
}
private getGuildSubscriptions(guildId: string): Map<string, number> {
let guildSubs = this.subscriptions.get(guildId);
if (!guildSubs) {
guildSubs = new Map();
this.subscriptions.set(guildId, guildSubs);
}
return guildSubs;
}
touchMember(guildId: string, userId: string): void {
const guildSubs = this.getGuildSubscriptions(guildId);
const now = Date.now();
if (guildSubs.has(userId)) {
guildSubs.delete(userId);
}
guildSubs.set(userId, now);
this.enforceMaxSize(guildId);
this.scheduleSyncToGateway(guildId);
this.bumpVersion();
}
unsubscribe(guildId: string, userId: string): void {
const guildMembers = this.subscriptions.get(guildId);
if (!guildMembers) return;
guildMembers.delete(userId);
if (guildMembers.size === 0) {
this.subscriptions.delete(guildId);
}
this.bumpVersion();
this.syncToGatewayImmediate(guildId);
}
setActiveGuild(guildId: string): void {
if (this.activeGuildId === guildId) {
return;
}
const previous = this.activeGuildId;
this.activeGuildId = guildId;
if (previous) {
this.syncActiveFlagImmediate(previous, false);
}
this.syncActiveFlagImmediate(guildId, true);
this.scheduleSyncToGateway(guildId);
this.bumpVersion();
}
getSubscribedMembers(guildId: string): Array<string> {
const guildSubs = this.subscriptions.get(guildId);
if (!guildSubs) {
return [];
}
return Array.from(guildSubs.keys());
}
clearGuild(guildId: string): void {
const hadSubscriptions = this.subscriptions.has(guildId);
this.subscriptions.delete(guildId);
if (hadSubscriptions) {
this.syncToGatewayImmediate(guildId);
this.bumpVersion();
}
}
clearAll(): void {
const socket = ConnectionStore.socket;
const guildIds = Array.from(this.subscriptions.keys());
const previousActive = this.activeGuildId;
if (socket) {
for (const guildId of guildIds) {
socket.updateGuildSubscriptions({
subscriptions: {
[guildId]: {members: []},
},
});
}
if (previousActive) {
socket.updateGuildSubscriptions({
subscriptions: {
[previousActive]: {active: false},
},
});
}
}
this.subscriptions.clear();
this.activeGuildId = null;
if (this.syncTimeoutId != null) {
clearTimeout(this.syncTimeoutId);
this.syncTimeoutId = null;
}
this.pendingSyncGuilds.clear();
this.bumpVersion();
}
private bumpVersion(): void {
runInAction(() => {
this.subscriptionVersion++;
});
}
private enforceMaxSize(guildId: string): void {
const guildSubs = this.subscriptions.get(guildId);
if (!guildSubs || guildSubs.size <= MEMBER_SUBSCRIPTION_MAX_SIZE) {
return;
}
const entries: Array<LRUEntry> = [];
for (const [userId, lastAccessed] of guildSubs) {
entries.push({userId, lastAccessed});
}
entries.sort((a, b) => a.lastAccessed - b.lastAccessed);
const toRemove = entries.slice(0, entries.length - MEMBER_SUBSCRIPTION_MAX_SIZE);
for (const entry of toRemove) {
guildSubs.delete(entry.userId);
}
}
private pruneExpiredEntries(): void {
const now = Date.now();
const cutoff = now - MEMBER_SUBSCRIPTION_TTL_MS;
const guildsToSync = new Set<string>();
for (const [guildId, guildSubs] of this.subscriptions) {
const toRemove: Array<string> = [];
for (const [userId, lastAccessed] of guildSubs) {
if (lastAccessed < cutoff) {
toRemove.push(userId);
}
}
for (const userId of toRemove) {
guildSubs.delete(userId);
guildsToSync.add(guildId);
}
if (guildSubs.size === 0) {
this.subscriptions.delete(guildId);
}
}
if (guildsToSync.size > 0) {
for (const guildId of guildsToSync) {
this.scheduleSyncToGateway(guildId);
}
this.bumpVersion();
}
}
private scheduleSyncToGateway(guildId: string): void {
this.pendingSyncGuilds.add(guildId);
if (this.syncTimeoutId != null) {
return;
}
this.syncTimeoutId = window.setTimeout(() => {
this.syncTimeoutId = null;
this.flushPendingSyncs();
}, MEMBER_SUBSCRIPTION_SYNC_DEBOUNCE_MS);
}
private flushPendingSyncs(): void {
const guildsToSync = Array.from(this.pendingSyncGuilds);
this.pendingSyncGuilds.clear();
for (const guildId of guildsToSync) {
this.syncToGatewayImmediate(guildId);
}
}
private syncActiveFlagImmediate(guildId: string, active: boolean): void {
const socket = ConnectionStore.socket;
if (!socket) return;
socket.updateGuildSubscriptions({
subscriptions: {
[guildId]: {active, sync: active ? true : undefined},
},
});
}
private syncToGatewayImmediate(guildId: string): void {
const socket = ConnectionStore.socket;
if (!socket) {
return;
}
const guildSubs = this.subscriptions.get(guildId);
const members = guildSubs ? Array.from(guildSubs.keys()) : [];
const payload: {members: Array<string>; active?: boolean} = {members};
if (this.activeGuildId === guildId) {
payload.active = true;
}
socket.updateGuildSubscriptions({
subscriptions: {
[guildId]: payload,
},
});
}
}
export default new MemberPresenceSubscriptionStore();

View File

@@ -0,0 +1,486 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {RelationshipTypes} from '~/Constants';
import type {GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import RelationshipStore from '~/stores/RelationshipStore';
import UserStore from '~/stores/UserStore';
export enum MemberSearchWorkerMessageTypes {
UPDATE_USERS = 'UPDATE_USERS',
USER_RESULTS = 'USER_RESULTS',
QUERY_SET = 'QUERY_SET',
QUERY_CLEAR = 'QUERY_CLEAR',
}
export interface MemberSearchFilters {
friends?: boolean;
guild?: string;
}
export interface TransformedMember {
id: string;
username: string;
isBot?: boolean;
isFriend?: boolean;
guildIds?: Array<string>;
_delete?: boolean;
_removeGuild?: string;
[key: string]: string | boolean | undefined | Array<string>;
}
export type QueryBlacklist = Set<string>;
export type QueryWhitelist = Set<string>;
export type QueryBoosters = Record<string, number>;
interface QueryData {
query: string;
filters?: MemberSearchFilters;
blacklist: Array<string>;
whitelist: Array<string>;
boosters: QueryBoosters;
limit: number;
}
interface WorkerMessage {
type: MemberSearchWorkerMessageTypes;
payload?: unknown;
uuid?: string;
}
interface MemberResultsMessage extends WorkerMessage {
type: MemberSearchWorkerMessageTypes.USER_RESULTS;
uuid: string;
payload: Array<TransformedMember>;
}
interface UpdateMembersMessage extends WorkerMessage {
type: MemberSearchWorkerMessageTypes.UPDATE_USERS;
payload: {users: Array<TransformedMember>};
}
interface QuerySetMessage extends WorkerMessage {
type: MemberSearchWorkerMessageTypes.QUERY_SET;
uuid: string;
payload: QueryData;
}
interface QueryClearMessage extends WorkerMessage {
type: MemberSearchWorkerMessageTypes.QUERY_CLEAR;
uuid: string;
}
const DEFAULT_LIMIT = 10;
let worker: Worker | null = null;
function updateMembers(members: Array<TransformedMember>): void {
if (!worker) {
return;
}
const filtered = members.filter((member) => member != null);
if (filtered.length === 0) {
return;
}
worker.postMessage({
type: MemberSearchWorkerMessageTypes.UPDATE_USERS,
payload: {users: filtered},
} as UpdateMembersMessage);
}
function isFriendRelationship(userId: string): boolean {
const relationship = RelationshipStore.getRelationship(userId);
return relationship?.type === RelationshipTypes.FRIEND;
}
function applyFriendFlag(member: TransformedMember): void {
member.isFriend = isFriendRelationship(member.id);
}
function getTransformedMember(memberRecord: GuildMemberRecord, guildId?: string): TransformedMember | null {
const user = memberRecord.user;
const member: TransformedMember = {
id: user.id,
username: user.discriminator === '0' ? user.username : `${user.username}#${user.discriminator}`,
guildIds: [],
};
if (user.bot) {
member.isBot = true;
}
if (guildId) {
member[guildId] = true;
member.guildIds = [guildId];
}
applyFriendFlag(member);
return member;
}
function updateMembersList(members: Array<GuildMemberRecord>, guildId?: string): Array<TransformedMember> {
const transformedMembers: Array<TransformedMember> = [];
for (const memberRecord of members) {
const member = getTransformedMember(memberRecord, guildId);
if (member) {
transformedMembers.push(member);
}
}
return transformedMembers;
}
export class SearchContext {
private readonly _uuid: string;
private readonly _callback: (results: Array<TransformedMember>) => void;
private readonly _limit: number;
private _currentQuery: QueryData | false | null;
private _nextQuery: QueryData | null;
private readonly _handleMessages: (event: MessageEvent<WorkerMessage>) => void;
constructor(callback: (results: Array<TransformedMember>) => void, limit: number = DEFAULT_LIMIT) {
this._uuid = crypto.randomUUID();
this._callback = callback;
this._limit = limit;
this._currentQuery = null;
this._nextQuery = null;
this._handleMessages = (event: MessageEvent<WorkerMessage>) => {
const data = event.data;
if (!data || data.type !== MemberSearchWorkerMessageTypes.USER_RESULTS) {
return;
}
const resultsMessage = data as MemberResultsMessage;
if (resultsMessage.uuid !== this._uuid) {
return;
}
if (this._currentQuery !== false) {
this._callback(resultsMessage.payload);
}
if (this._currentQuery != null) {
this._currentQuery = null;
}
this._setNextQuery();
};
if (worker) {
worker.addEventListener('message', this._handleMessages);
}
}
destroy(): void {
if (worker) {
worker.removeEventListener('message', this._handleMessages);
}
this.clearQuery();
}
clearQuery(): void {
this._currentQuery = false;
this._nextQuery = null;
if (worker) {
worker.postMessage({
uuid: this._uuid,
type: MemberSearchWorkerMessageTypes.QUERY_CLEAR,
} as QueryClearMessage);
}
}
setQuery(
query: string,
filters: MemberSearchFilters = {},
blacklist: QueryBlacklist = new Set(),
whitelist: QueryWhitelist = new Set(),
boosters: QueryBoosters = {},
): void {
if (query == null) {
return;
}
this._nextQuery = {
query,
filters,
blacklist: Array.from(blacklist),
whitelist: Array.from(whitelist),
boosters,
limit: this._limit,
};
this._setNextQuery();
}
private _setNextQuery(): void {
if (this._currentQuery || !this._nextQuery) {
return;
}
this._currentQuery = this._nextQuery;
this._nextQuery = null;
if (worker) {
worker.postMessage({
uuid: this._uuid,
type: MemberSearchWorkerMessageTypes.QUERY_SET,
payload: this._currentQuery,
} as QuerySetMessage);
}
}
}
class MemberSearchStore {
private initialized: boolean = false;
private readonly inFlightFetches = new Map<string, Promise<void>>();
constructor() {
makeAutoObservable(this);
}
initialize(): void {
if (this.initialized || worker) {
return;
}
this.initialized = true;
try {
worker = new Worker(new URL('../workers/MemberSearch.worker.ts', import.meta.url), {
type: 'module',
});
this.sendInitialMembers();
} catch (err) {
console.error('[MemberSearchStore] Failed to initialize worker:', err);
}
}
private sendInitialMembers(): void {
if (!worker) {
return;
}
const allMembers: Array<TransformedMember> = [];
const guilds = GuildStore.getGuilds();
for (const guild of guilds) {
const members = GuildMemberStore.getMembers(guild.id);
const transformedMembers = updateMembersList(members, guild.id);
allMembers.push(...transformedMembers);
}
updateMembers(allMembers);
}
handleConnectionOpen(): void {
if (worker) {
this.terminate();
}
this.initialize();
}
handleLogout(): void {
this.terminate();
this.initialized = false;
}
handleGuildCreate(guildId: string): void {
if (!worker) return;
const members = GuildMemberStore.getMembers(guildId);
const transformedMembers = updateMembersList(members, guildId);
updateMembers(transformedMembers);
}
handleGuildDelete(guildId: string): void {
if (!worker) return;
const members = GuildMemberStore.getMembers(guildId);
const transformedMembers = updateMembersList(members, guildId);
updateMembers(
transformedMembers.map((m) => ({
id: m.id,
username: m.username,
isBot: m.isBot,
_removeGuild: guildId,
})),
);
}
handleMemberAdd(guildId: string, memberId: string): void {
if (!worker) return;
const member = GuildMemberStore.getMember(guildId, memberId);
if (!member) return;
const transformedMember = getTransformedMember(member, guildId);
if (transformedMember) {
updateMembers([transformedMember]);
}
}
handleMemberUpdate(guildId: string, memberId: string): void {
if (!worker) return;
const member = GuildMemberStore.getMember(guildId, memberId);
if (!member) return;
const transformedMember = getTransformedMember(member, guildId);
if (transformedMember) {
updateMembers([transformedMember]);
}
}
handleMembersChunk(guildId: string, members: Array<GuildMemberRecord>): void {
if (!worker) return;
const transformedMembers = updateMembersList(members, guildId);
updateMembers(transformedMembers);
}
handleUserUpdate(userId: string): void {
if (!worker) return;
const guilds = GuildStore.getGuilds();
const allMembers: Array<TransformedMember> = [];
for (const guild of guilds) {
const member = GuildMemberStore.getMember(guild.id, userId);
if (member) {
const transformedMember = getTransformedMember(member, guild.id);
if (transformedMember) {
allMembers.push(transformedMember);
}
}
}
if (allMembers.length > 0) {
updateMembers(allMembers);
}
}
handleFriendshipChange(userId: string, isFriend: boolean): void {
if (!worker) return;
const user = UserStore.getUser(userId);
if (!user) return;
const username = user.discriminator === '0' ? user.username : `${user.username}#${user.discriminator}`;
updateMembers([{id: userId, username, isFriend}]);
}
getSearchContext(
callback: (results: Array<TransformedMember>) => void,
limit: number = DEFAULT_LIMIT,
): SearchContext {
if (!worker) {
this.initialize();
}
return new SearchContext(callback, limit);
}
private terminate(): void {
if (worker) {
worker.terminate();
worker = null;
}
}
cleanup(): void {
this.terminate();
this.initialized = false;
this.inFlightFetches.clear();
}
async fetchMembersInBackground(query: string, guildIds: Array<string>, priorityGuildId?: string): Promise<void> {
const trimmed = query.trim();
if (!trimmed) {
return;
}
if (!guildIds || guildIds.length === 0) {
return;
}
const sortedGuildIds = priorityGuildId
? [...guildIds].sort((a, b) => (a === priorityGuildId ? -1 : b === priorityGuildId ? 1 : 0))
: guildIds;
const promises = sortedGuildIds.map(async (guildId) => {
if (!guildId) {
return;
}
const guild = GuildStore.getGuild(guildId);
if (!guild) {
return;
}
if (GuildMemberStore.isGuildFullyLoaded(guildId)) {
return;
}
const key = `${guildId}:${trimmed.toLowerCase()}`;
const existing = this.inFlightFetches.get(key);
if (existing) {
await existing;
return;
}
const promise = this.fetchFromGuild(guild, trimmed).finally(() => {
this.inFlightFetches.delete(key);
});
this.inFlightFetches.set(key, promise);
await promise;
});
await Promise.all(promises);
}
private async fetchFromGuild(guild: GuildRecord, query: string): Promise<void> {
if (GuildMemberStore.isGuildFullyLoaded(guild.id)) {
return;
}
try {
const members = (await GuildMemberStore.fetchMembers(guild.id, {query, limit: 25})) as Array<GuildMemberRecord>;
if (members.length > 0) {
const transformedMembers = updateMembersList(members, guild.id);
updateMembers(transformedMembers);
}
} catch (error) {
console.error('[MemberSearchStore] fetchFromGuild failed:', error);
}
}
}
export default new MemberSearchStore();

View File

@@ -0,0 +1,650 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import type {StatusType} from '~/Constants';
import {StatusTypes} from '~/Constants';
import type {CustomStatus, GatewayCustomStatusPayload} from '~/lib/customStatus';
import {fromGatewayCustomStatus} from '~/lib/customStatus';
import type {GuildMemberRecord} from '~/records/GuildMemberRecord';
import ConnectionStore from './ConnectionStore';
import GuildMemberStore from './GuildMemberStore';
interface MemberListGroup {
id: string;
count: number;
}
interface MemberListItem {
type: 'member' | 'group';
data: GuildMemberRecord | MemberListGroup;
}
interface MemberListState {
memberCount: number;
onlineCount: number;
groups: Array<MemberListGroup>;
items: Map<number, MemberListItem>;
subscribedRanges: Array<[number, number]>;
presences: Map<string, StatusType>;
customStatuses: Map<string, CustomStatus | null>;
}
interface MemberListOperation {
op: 'SYNC' | 'INSERT' | 'UPDATE' | 'DELETE' | 'INVALIDATE';
range?: [number, number];
items?: Array<{
member?: {
user: {id: string};
presence?: {status?: string; custom_status?: GatewayCustomStatusPayload | null} | null;
};
group?: MemberListGroup;
}>;
index?: number;
item?: {
member?: {
user: {id: string};
presence?: {status?: string; custom_status?: GatewayCustomStatusPayload | null} | null;
};
group?: MemberListGroup;
};
}
const MEMBER_LIST_TTL_MS = 5 * 60 * 1000;
const MEMBER_LIST_PRUNE_INTERVAL_MS = 30 * 1000;
function areRangesEqual(left?: Array<[number, number]>, right?: Array<[number, number]>): boolean {
const leftRanges = left ?? [];
const rightRanges = right ?? [];
if (leftRanges.length !== rightRanges.length) {
return false;
}
for (let i = 0; i < leftRanges.length; i++) {
const [leftStart, leftEnd] = leftRanges[i];
const [rightStart, rightEnd] = rightRanges[i];
if (leftStart !== rightStart || leftEnd !== rightEnd) {
return false;
}
}
return true;
}
function removeMemberFromItems(items: Map<number, MemberListItem>, memberId: string): Map<number, MemberListItem> {
let foundIndex: number | null = null;
for (const [index, item] of items) {
if (item.type === 'member') {
const member = item.data as GuildMemberRecord;
if (member.user.id === memberId) {
foundIndex = index;
break;
}
}
}
if (foundIndex === null) {
return items;
}
const result = new Map<number, MemberListItem>();
for (const [index, existingItem] of items) {
if (index < foundIndex) {
result.set(index, existingItem);
} else if (index > foundIndex) {
result.set(index - 1, existingItem);
}
}
return result;
}
function removePresenceForItem(item: MemberListItem | undefined, presences: Map<string, StatusType>): void {
if (!item) {
return;
}
if (item.type === 'member') {
const member = item.data as GuildMemberRecord;
presences.delete(member.user.id);
}
}
function removeCustomStatusForItem(
item: MemberListItem | undefined,
customStatuses: Map<string, CustomStatus | null>,
): void {
if (!item || item.type !== 'member') {
return;
}
const member = item.data as GuildMemberRecord;
customStatuses.delete(member.user.id);
}
class MemberSidebarStore {
lists: Record<string, Record<string, MemberListState>> = {};
channelListIds: Record<string, Record<string, string>> = {};
lastAccess: Record<string, Record<string, number>> = {};
pruneIntervalId: number | null = null;
sessionVersion = 0;
constructor() {
makeAutoObservable(this, {lastAccess: false, pruneIntervalId: false}, {autoBind: true});
this.startPruneInterval();
}
handleSessionInvalidated(): void {
this.lists = {};
this.channelListIds = {};
this.lastAccess = {};
this.sessionVersion += 1;
}
handleGuildDelete(guildId: string): void {
if (this.lists[guildId]) {
const {[guildId]: _, ...remainingLists} = this.lists;
this.lists = remainingLists;
}
if (this.channelListIds[guildId]) {
const {[guildId]: _, ...remainingMappings} = this.channelListIds;
this.channelListIds = remainingMappings;
}
if (this.lastAccess[guildId]) {
const {[guildId]: _, ...remainingAccess} = this.lastAccess;
this.lastAccess = remainingAccess;
}
}
handleGuildCreate(guildId: string): void {
if (this.lists[guildId]) {
const {[guildId]: _, ...remainingLists} = this.lists;
this.lists = remainingLists;
}
if (this.channelListIds[guildId]) {
const {[guildId]: _, ...remainingMappings} = this.channelListIds;
this.channelListIds = remainingMappings;
}
if (this.lastAccess[guildId]) {
const {[guildId]: _, ...remainingAccess} = this.lastAccess;
this.lastAccess = remainingAccess;
}
}
handleListUpdate(params: {
guildId: string;
listId: string;
channelId?: string;
memberCount: number;
onlineCount: number;
groups: Array<MemberListGroup>;
ops: Array<MemberListOperation>;
}): void {
const {guildId, listId, channelId, memberCount, onlineCount, groups, ops} = params;
const storageKey = listId;
const existingGuildLists = this.lists[guildId] ?? {};
const guildLists: Record<string, MemberListState> = {...existingGuildLists};
if (channelId) {
this.registerChannelListId(guildId, channelId, listId);
if (guildLists[channelId] && !guildLists[storageKey]) {
guildLists[storageKey] = guildLists[channelId];
delete guildLists[channelId];
}
}
if (!guildLists[storageKey]) {
guildLists[storageKey] = {
memberCount: 0,
onlineCount: 0,
groups: [],
items: new Map(),
subscribedRanges: [],
presences: new Map(),
customStatuses: new Map(),
};
}
const listState = guildLists[storageKey];
const newItems = new Map(listState.items);
const newPresences = new Map(listState.presences);
const newCustomStatuses = new Map(listState.customStatuses);
this.touchList(guildId, storageKey);
for (const op of ops) {
switch (op.op) {
case 'SYNC': {
if (op.range && op.items) {
const [start, end] = op.range;
for (let i = start; i <= end; i++) {
removePresenceForItem(newItems.get(i), newPresences);
removeCustomStatusForItem(newItems.get(i), newCustomStatuses);
newItems.delete(i);
}
for (let i = 0; i < op.items.length; i++) {
const rawItem = op.items[i];
const item = this.convertItem(guildId, rawItem);
if (item) {
newItems.set(start + i, item);
this.extractPresence(rawItem, newPresences);
this.extractCustomStatus(rawItem, newCustomStatuses);
}
}
}
break;
}
case 'INSERT': {
if (op.index !== undefined && op.item) {
const item = this.convertItem(guildId, op.item);
if (item) {
if (item.type === 'member') {
const member = item.data as GuildMemberRecord;
newPresences.delete(member.user.id);
newCustomStatuses.delete(member.user.id);
const deduped = removeMemberFromItems(newItems, member.user.id);
if (deduped !== newItems) {
newItems.clear();
for (const [k, v] of deduped) {
newItems.set(k, v);
}
}
}
this.extractPresence(op.item, newPresences);
this.extractCustomStatus(op.item, newCustomStatuses);
const shiftedItems = new Map<number, MemberListItem>();
for (const [index, existingItem] of newItems) {
if (index >= op.index) {
shiftedItems.set(index + 1, existingItem);
} else {
shiftedItems.set(index, existingItem);
}
}
shiftedItems.set(op.index, item);
newItems.clear();
for (const [k, v] of shiftedItems) {
newItems.set(k, v);
}
}
}
break;
}
case 'UPDATE': {
if (op.index !== undefined && op.item) {
removePresenceForItem(newItems.get(op.index), newPresences);
removeCustomStatusForItem(newItems.get(op.index), newCustomStatuses);
const item = this.convertItem(guildId, op.item);
if (item) {
newItems.set(op.index, item);
this.extractPresence(op.item, newPresences);
this.extractCustomStatus(op.item, newCustomStatuses);
}
}
break;
}
case 'DELETE': {
if (op.index !== undefined) {
removePresenceForItem(newItems.get(op.index), newPresences);
removeCustomStatusForItem(newItems.get(op.index), newCustomStatuses);
const shiftedItems = new Map<number, MemberListItem>();
for (const [index, existingItem] of newItems) {
if (index > op.index) {
shiftedItems.set(index - 1, existingItem);
} else if (index !== op.index) {
shiftedItems.set(index, existingItem);
}
}
newItems.clear();
for (const [k, v] of shiftedItems) {
newItems.set(k, v);
}
}
break;
}
case 'INVALIDATE': {
if (op.range) {
const [start, end] = op.range;
for (let i = start; i <= end; i++) {
removePresenceForItem(newItems.get(i), newPresences);
removeCustomStatusForItem(newItems.get(i), newCustomStatuses);
newItems.delete(i);
}
}
break;
}
}
}
listState.memberCount = memberCount;
listState.onlineCount = onlineCount;
listState.groups = groups;
listState.items = newItems;
listState.presences = newPresences;
listState.customStatuses = newCustomStatuses;
this.lists = {...this.lists, [guildId]: {...guildLists, [storageKey]: listState}};
}
private convertItem(
guildId: string,
rawItem: {
member?: {
user: {id: string};
presence?: {status?: string; custom_status?: GatewayCustomStatusPayload | null} | null;
};
group?: MemberListGroup;
},
): MemberListItem | null {
if (rawItem.group) {
return {
type: 'group',
data: rawItem.group,
};
}
if (rawItem.member) {
const userId = rawItem.member.user.id;
const member = GuildMemberStore.getMember(guildId, userId);
if (member) {
return {
type: 'member',
data: member,
};
} else {
console.warn('[MemberSidebarStore] Member not found in store:', {guildId, userId});
}
}
return null;
}
private extractPresence(
rawItem: {
member?: {
user: {id: string};
presence?: {status?: string; custom_status?: GatewayCustomStatusPayload | null} | null;
};
group?: MemberListGroup;
},
presences: Map<string, StatusType>,
): void {
if (!rawItem.member) {
return;
}
const userId = rawItem.member.user.id;
const rawPresence = rawItem.member.presence;
if (rawPresence?.status) {
presences.set(userId, this.normalizeStatus(rawPresence.status));
} else {
presences.delete(userId);
}
}
private extractCustomStatus(
rawItem: {
member?: {
user: {id: string};
presence?: {status?: string; custom_status?: GatewayCustomStatusPayload | null} | null;
};
group?: MemberListGroup;
},
customStatuses: Map<string, CustomStatus | null>,
): void {
if (!rawItem.member || !rawItem.member.presence) {
return;
}
const userId = rawItem.member.user.id;
const presence = rawItem.member.presence;
if (!Object.hasOwn(presence, 'custom_status')) {
customStatuses.delete(userId);
return;
}
const customStatus = fromGatewayCustomStatus(presence.custom_status ?? null);
customStatuses.set(userId, customStatus);
}
private normalizeStatus(status: string): StatusType {
switch (status.toLowerCase()) {
case 'online':
return StatusTypes.ONLINE;
case 'idle':
return StatusTypes.IDLE;
case 'dnd':
return StatusTypes.DND;
default:
return StatusTypes.OFFLINE;
}
}
subscribeToChannel(guildId: string, channelId: string, ranges: Array<[number, number]>): void {
const storageKey = this.resolveListKey(guildId, channelId);
const socket = ConnectionStore.socket;
const existingGuildLists = this.lists[guildId] ?? {};
const guildLists: Record<string, MemberListState> = {...existingGuildLists};
const existingList = guildLists[storageKey];
const shouldSendUpdate = !areRangesEqual(existingList?.subscribedRanges, ranges);
if (shouldSendUpdate) {
socket?.updateGuildSubscriptions({
subscriptions: {
[guildId]: {
member_list_channels: {[channelId]: ranges},
},
},
});
}
if (!existingList) {
guildLists[storageKey] = {
memberCount: 0,
onlineCount: 0,
groups: [],
items: new Map(),
subscribedRanges: ranges,
presences: new Map(),
customStatuses: new Map(),
};
} else {
guildLists[storageKey] = {...existingList, subscribedRanges: ranges};
}
this.touchList(guildId, storageKey);
this.lists = {...this.lists, [guildId]: guildLists};
}
unsubscribeFromChannel(guildId: string, channelId: string): void {
const socket = ConnectionStore.socket;
socket?.updateGuildSubscriptions({
subscriptions: {
[guildId]: {
member_list_channels: {[channelId]: []},
},
},
});
const storageKey = this.resolveListKey(guildId, channelId);
const existingGuildLists = this.lists[guildId] ?? {};
const existingList = existingGuildLists[storageKey];
if (existingList) {
const guildLists = {...existingGuildLists};
guildLists[storageKey] = {...existingList, subscribedRanges: []};
this.lists = {...this.lists, [guildId]: guildLists};
}
}
getSubscribedRanges(guildId: string, channelId: string): Array<[number, number]> {
const storageKey = this.resolveListKey(guildId, channelId);
return this.lists[guildId]?.[storageKey]?.subscribedRanges ?? [];
}
getVisibleItems(guildId: string, listId: string, range: [number, number]): Array<MemberListItem> {
const storageKey = this.resolveListKey(guildId, listId);
const listState = this.lists[guildId]?.[storageKey];
if (!listState) {
return [];
}
this.touchList(guildId, storageKey);
const [start, end] = range;
const items: Array<MemberListItem> = [];
for (let i = start; i <= end; i++) {
const item = listState.items.get(i);
if (item) {
items.push(item);
}
}
return items;
}
getList(guildId: string, listId: string): MemberListState | undefined {
const storageKey = this.resolveListKey(guildId, listId);
const list = this.lists[guildId]?.[storageKey];
if (list) {
this.touchList(guildId, storageKey);
}
return list;
}
getMemberCount(guildId: string, listId: string): number {
const storageKey = this.resolveListKey(guildId, listId);
return this.lists[guildId]?.[storageKey]?.memberCount ?? 0;
}
getOnlineCount(guildId: string, listId: string): number {
const storageKey = this.resolveListKey(guildId, listId);
return this.lists[guildId]?.[storageKey]?.onlineCount ?? 0;
}
getPresence(guildId: string, listId: string, userId: string): StatusType | null {
const storageKey = this.resolveListKey(guildId, listId);
const listState = this.lists[guildId]?.[storageKey];
if (!listState) {
return null;
}
this.touchList(guildId, storageKey);
return listState.presences.get(userId) ?? null;
}
getCustomStatus(guildId: string, listId: string, userId: string): CustomStatus | null | undefined {
const storageKey = this.resolveListKey(guildId, listId);
const listState = this.lists[guildId]?.[storageKey];
if (!listState) {
return undefined;
}
this.touchList(guildId, storageKey);
if (!listState.customStatuses.has(userId)) {
return undefined;
}
return listState.customStatuses.get(userId) ?? null;
}
private touchList(guildId: string, listId: string): void {
const now = Date.now();
if (!this.lastAccess[guildId]) {
this.lastAccess[guildId] = {};
}
this.lastAccess[guildId][listId] = now;
}
private resolveListKey(guildId: string, listIdOrChannelId: string): string {
const guildMappings = this.channelListIds[guildId];
return guildMappings?.[listIdOrChannelId] ?? listIdOrChannelId;
}
private registerChannelListId(guildId: string, channelId: string, listId: string): void {
const guildMappings = this.channelListIds[guildId] ?? {};
if (guildMappings[channelId] === listId) {
if (!this.channelListIds[guildId]) {
this.channelListIds = {...this.channelListIds, [guildId]: guildMappings};
}
return;
}
this.channelListIds = {
...this.channelListIds,
[guildId]: {...guildMappings, [channelId]: listId},
};
}
private startPruneInterval(): void {
if (this.pruneIntervalId != null) {
return;
}
this.pruneIntervalId = window.setInterval(() => this.pruneExpiredLists(), MEMBER_LIST_PRUNE_INTERVAL_MS);
}
private pruneExpiredLists(): void {
const now = Date.now();
const ttlCutoff = now - MEMBER_LIST_TTL_MS;
const updatedLists: Record<string, Record<string, MemberListState>> = {...this.lists};
const updatedAccess: Record<string, Record<string, number>> = {...this.lastAccess};
const updatedMappings: Record<string, Record<string, string>> = {...this.channelListIds};
Object.entries(this.lastAccess).forEach(([guildId, accessMap]) => {
const guildLists = {...(updatedLists[guildId] ?? {})};
const guildAccess = {...accessMap};
const guildMappings = {...(updatedMappings[guildId] ?? {})};
Object.entries(accessMap).forEach(([listId, lastSeen]) => {
if (lastSeen < ttlCutoff) {
delete guildLists[listId];
delete guildAccess[listId];
Object.entries(guildMappings).forEach(([channelId, mappedListId]) => {
if (mappedListId === listId) {
delete guildMappings[channelId];
const socket = ConnectionStore.socket;
socket?.updateGuildSubscriptions({
subscriptions: {
[guildId]: {
member_list_channels: {[channelId]: []},
},
},
});
}
});
}
});
if (Object.keys(guildLists).length === 0) {
delete updatedLists[guildId];
} else {
updatedLists[guildId] = guildLists;
}
if (Object.keys(guildAccess).length === 0) {
delete updatedAccess[guildId];
} else {
updatedAccess[guildId] = guildAccess;
}
if (Object.keys(guildMappings).length === 0) {
delete updatedMappings[guildId];
} else {
updatedMappings[guildId] = guildMappings;
}
});
this.lists = updatedLists;
this.lastAccess = updatedAccess;
this.channelListIds = updatedMappings;
}
}
export default new MemberSidebarStore();

View File

@@ -0,0 +1,146 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {ComponentDispatch} from '~/lib/ComponentDispatch';
import {Logger} from '~/lib/Logger';
import {makePersistent} from '~/lib/MobXPersistence';
import type {FavoriteMemeRecord} from '~/records/FavoriteMemeRecord';
type MemeUsageEntry = Readonly<{
count: number;
lastUsed: number;
}>;
const MAX_FRECENT_MEMES = 21;
const FRECENCY_TIME_DECAY_HOURS = 24 * 7;
const logger = new Logger('MemesPickerStore');
class MemesPickerStore {
memeUsage: Record<string, MemeUsageEntry> = {};
favoriteMemes: Array<string> = [];
collapsedCategories: Array<string> = [];
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'MemesPickerStore', ['memeUsage', 'favoriteMemes', 'collapsedCategories']);
}
trackMemeUsage(memeKey: string): void {
const now = Date.now();
const currentUsage = this.memeUsage[memeKey];
const newCount = (currentUsage?.count ?? 0) + 1;
this.memeUsage[memeKey] = {
count: newCount,
lastUsed: now,
};
}
toggleFavorite(memeKey: string): void {
if (this.favoriteMemes.includes(memeKey)) {
const index = this.favoriteMemes.indexOf(memeKey);
if (index > -1) {
this.favoriteMemes.splice(index, 1);
}
} else {
this.favoriteMemes.push(memeKey);
}
ComponentDispatch.dispatch('MEMES_PICKER_RERENDER');
logger.debug(`Toggled favorite meme: ${memeKey}`);
}
toggleCategory(category: string): void {
if (this.collapsedCategories.includes(category)) {
const index = this.collapsedCategories.indexOf(category);
if (index > -1) {
this.collapsedCategories.splice(index, 1);
}
} else {
this.collapsedCategories.push(category);
}
ComponentDispatch.dispatch('MEMES_PICKER_RERENDER');
logger.debug(`Toggled category: ${category}`);
}
isFavorite(meme: FavoriteMemeRecord): boolean {
return this.favoriteMemes.includes(this.getMemeKey(meme));
}
isCategoryCollapsed(categoryId: string): boolean {
return this.collapsedCategories.includes(categoryId);
}
private getFrecencyScore(entry: MemeUsageEntry): number {
const now = Date.now();
const hoursSinceLastUse = (now - entry.lastUsed) / (1000 * 60 * 60);
const timeDecay = Math.max(0, 1 - hoursSinceLastUse / FRECENCY_TIME_DECAY_HOURS);
return entry.count * (1 + timeDecay);
}
getFrecentMemes(
allMemes: ReadonlyArray<FavoriteMemeRecord>,
limit: number = MAX_FRECENT_MEMES,
): Array<FavoriteMemeRecord> {
const memeScores: Array<{meme: FavoriteMemeRecord; score: number}> = [];
for (const meme of allMemes) {
const memeKey = this.getMemeKey(meme);
const usage = this.memeUsage[memeKey];
if (usage) {
const score = this.getFrecencyScore(usage);
memeScores.push({meme, score});
}
}
memeScores.sort((a, b) => b.score - a.score);
return memeScores.slice(0, limit).map((item) => item.meme);
}
getFavoriteMemes(allMemes: ReadonlyArray<FavoriteMemeRecord>): Array<FavoriteMemeRecord> {
const favorites: Array<FavoriteMemeRecord> = [];
for (const meme of allMemes) {
if (this.isFavorite(meme)) {
favorites.push(meme);
}
}
return favorites;
}
getFrecencyScoreForMeme(meme: FavoriteMemeRecord): number {
const usage = this.memeUsage[this.getMemeKey(meme)];
return usage ? this.getFrecencyScore(usage) : 0;
}
private getMemeKey(meme: FavoriteMemeRecord): string {
return meme.id;
}
}
export default new MemesPickerStore();

View File

@@ -0,0 +1,50 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
class MessageEditMobileStore {
editingMessageIds: Record<string, string> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
startEditingMobile(channelId: string, messageId: string): void {
this.editingMessageIds = {
...this.editingMessageIds,
[channelId]: messageId,
};
}
stopEditingMobile(channelId: string): void {
const {[channelId]: _, ...remainingEdits} = this.editingMessageIds;
this.editingMessageIds = remainingEdits;
}
isEditingMobile(channelId: string, messageId: string): boolean {
return this.editingMessageIds[channelId] === messageId;
}
getEditingMobileMessageId(channelId: string): string | null {
return this.editingMessageIds[channelId] ?? null;
}
}
export default new MessageEditMobileStore();

View File

@@ -0,0 +1,96 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, reaction} from 'mobx';
interface EditingState {
messageId: string;
content: string;
}
class MessageEditStore {
private editingStates: Record<string, EditingState> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
startEditing(channelId: string, messageId: string, initialContent: string): void {
const currentState = this.editingStates[channelId];
if (currentState?.messageId === messageId && currentState.content === initialContent) {
return;
}
this.editingStates = {
...this.editingStates,
[channelId]: {
messageId,
content: initialContent,
},
};
}
stopEditing(channelId: string): void {
const {[channelId]: _, ...remainingEdits} = this.editingStates;
this.editingStates = remainingEdits;
}
isEditing(channelId: string, messageId: string): boolean {
const state = this.editingStates[channelId];
return state?.messageId === messageId;
}
getEditingMessageId(channelId: string): string | null {
return this.editingStates[channelId]?.messageId ?? null;
}
setEditingContent(channelId: string, messageId: string, content: string): void {
const state = this.editingStates[channelId];
if (!state || state.messageId !== messageId || state.content === content) {
return;
}
this.editingStates = {
...this.editingStates,
[channelId]: {
...state,
content,
},
};
}
getEditingContent(channelId: string, messageId: string): string | null {
const state = this.editingStates[channelId];
if (!state || state.messageId !== messageId) {
return null;
}
return state.content;
}
subscribe(callback: () => void): () => void {
return reaction(
() => this.editingStates,
() => callback(),
{fireImmediately: true},
);
}
}
export default new MessageEditStore();

View File

@@ -0,0 +1,152 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {type UserPartial, UserRecord} from '~/records/UserRecord';
import UserStore from '~/stores/UserStore';
import {getReactionKey, type ReactionEmoji} from '~/utils/ReactionUtils';
type ReactionUsers = Record<string, UserRecord>;
type FetchStatus = 'idle' | 'pending' | 'success' | 'error';
interface Reaction {
users: ReactionUsers;
fetchStatus: FetchStatus;
}
type ReactionMap = Record<string, Reaction>;
const createEmptyReaction = (): Reaction => ({
users: {},
fetchStatus: 'idle',
});
class MessageReactionsStore {
reactions: ReactionMap = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
getReactionEntry(messageId: string, emoji: ReactionEmoji): Reaction | undefined {
const reactionKey = getReactionKey(messageId, emoji);
return this.reactions[reactionKey];
}
getReactions(messageId: string, emoji: ReactionEmoji): ReadonlyArray<UserRecord> {
const entry = this.getReactionEntry(messageId, emoji);
return entry ? Object.values(entry.users) : [];
}
getFetchStatus(messageId: string, emoji: ReactionEmoji): FetchStatus {
const entry = this.getReactionEntry(messageId, emoji);
return entry?.fetchStatus ?? 'idle';
}
private getOrCreateReactionEntry(messageId: string, emoji: ReactionEmoji): Reaction {
const key = getReactionKey(messageId, emoji);
let entry = this.reactions[key];
if (!entry) {
entry = createEmptyReaction();
this.reactions[key] = entry;
}
return entry;
}
handleConnectionOpen(): void {
this.reactions = {};
}
handleReactionAdd(messageId: string, userId: string, emoji: ReactionEmoji): void {
const entry = this.getOrCreateReactionEntry(messageId, emoji);
const user = UserStore.getUser(userId);
if (!user) return;
entry.users[userId] = user;
}
handleReactionRemove(messageId: string, userId: string, emoji: ReactionEmoji): void {
const entry = this.getReactionEntry(messageId, emoji);
if (!entry) return;
delete entry.users[userId];
}
handleReactionRemoveAll(messageId: string): void {
const keysToDelete: Array<string> = [];
for (const key of Object.keys(this.reactions)) {
if (key.startsWith(messageId)) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
delete this.reactions[key];
}
}
handleReactionRemoveEmoji(messageId: string, emoji: ReactionEmoji): void {
const entry = this.getOrCreateReactionEntry(messageId, emoji);
entry.users = {};
entry.fetchStatus = 'idle';
}
handleFetchPending(messageId: string, emoji: ReactionEmoji): void {
const entry = this.getOrCreateReactionEntry(messageId, emoji);
entry.fetchStatus = 'pending';
}
handleFetchSuccess(messageId: string, users: ReadonlyArray<UserPartial>, emoji: ReactionEmoji): void {
const entry = this.getOrCreateReactionEntry(messageId, emoji);
UserStore.cacheUsers(users.slice());
entry.users = {};
for (const userPartial of users) {
entry.users[userPartial.id] = new UserRecord(userPartial);
}
entry.fetchStatus = 'success';
}
handleFetchAppend(messageId: string, users: ReadonlyArray<UserPartial>, emoji: ReactionEmoji): void {
const entry = this.getReactionEntry(messageId, emoji);
if (!entry) {
this.handleFetchSuccess(messageId, users, emoji);
return;
}
UserStore.cacheUsers(users.slice());
for (const userPartial of users) {
entry.users[userPartial.id] = new UserRecord(userPartial);
}
entry.fetchStatus = 'success';
}
handleFetchError(messageId: string, emoji: ReactionEmoji): void {
const entry = this.getOrCreateReactionEntry(messageId, emoji);
entry.fetchStatus = 'error';
}
}
export default new MessageReactionsStore();

View File

@@ -0,0 +1,232 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {Endpoints} from '~/Endpoints';
import http, {HttpError} from '~/lib/HttpClient';
import {Logger} from '~/lib/Logger';
import {type Message, MessageRecord} from '~/records/MessageRecord';
import MessageStore from '~/stores/MessageStore';
const logger = new Logger('MessageReferenceStore');
export const MessageReferenceState = {
LOADED: 'LOADED',
NOT_LOADED: 'NOT_LOADED',
DELETED: 'DELETED',
} as const;
export type MessageReferenceState = (typeof MessageReferenceState)[keyof typeof MessageReferenceState];
class MessageReferenceStore {
deletedMessageIds = new Set<string>();
cachedMessages = new Map<string, MessageRecord>();
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
private getKey(channelId: string, messageId: string): string {
return `${channelId}:${messageId}`;
}
handleMessageCreate(message: Message, _optimistic: boolean): void {
if (message.referenced_message) {
const refChannelId = message.message_reference?.channel_id ?? message.channel_id;
const key = this.getKey(refChannelId, message.referenced_message.id);
if (!this.cachedMessages.has(key) && !MessageStore.getMessage(refChannelId, message.referenced_message.id)) {
const referencedMessageRecord = new MessageRecord(message.referenced_message);
this.cachedMessages.set(key, referencedMessageRecord);
}
}
}
handleMessageDelete(channelId: string, messageId: string): void {
const key = this.getKey(channelId, messageId);
this.deletedMessageIds.add(key);
this.cachedMessages.delete(key);
}
handleMessageDeleteBulk(channelId: string, messageIds: Array<string>): void {
for (const messageId of messageIds) {
const key = this.getKey(channelId, messageId);
this.deletedMessageIds.add(key);
this.cachedMessages.delete(key);
}
}
handleMessagesFetchSuccess(channelId: string, messages: Array<Message>): void {
for (const message of messages) {
if (message.referenced_message) {
const refChannelId = message.message_reference?.channel_id ?? channelId;
const key = this.getKey(refChannelId, message.referenced_message.id);
if (!this.cachedMessages.has(key) && !MessageStore.getMessage(refChannelId, message.referenced_message.id)) {
const referencedMessageRecord = new MessageRecord(message.referenced_message);
this.cachedMessages.set(key, referencedMessageRecord);
}
}
}
const potentiallyMissingMessageIds = messages
.filter((message) => message.message_reference && !message.referenced_message)
.map((message) => ({
channelId: message.message_reference!.channel_id ?? channelId,
messageId: message.message_reference!.message_id,
}))
.filter(
({channelId: refChannelId, messageId}) =>
!MessageStore.getMessage(refChannelId, messageId) &&
!this.deletedMessageIds.has(this.getKey(refChannelId, messageId)) &&
!this.cachedMessages.has(this.getKey(refChannelId, messageId)),
);
if (potentiallyMissingMessageIds.length > 0) {
this.fetchMissingMessages(potentiallyMissingMessageIds);
}
this.cleanupCachedMessages(channelId, messages);
}
handleChannelDelete(channelId: string): void {
this.cleanupChannelMessages(channelId);
}
handleConnectionOpen(): void {
this.deletedMessageIds.clear();
this.cachedMessages.clear();
}
private fetchMissingMessages(refs: Array<{channelId: string; messageId: string}>): void {
Promise.allSettled(
refs.map(({channelId, messageId}) =>
http
.get<Message>({
url: Endpoints.CHANNEL_MESSAGE(channelId, messageId),
})
.then((response) => {
if (response.body) {
this.handleMessageFetchSuccess(channelId, messageId, response.body);
}
})
.catch((error) => this.handleMessageFetchError(channelId, messageId, error)),
),
);
}
private handleMessageFetchSuccess(channelId: string, messageId: string, message: Message): void {
const messageRecord = new MessageRecord(message);
const key = this.getKey(channelId, messageId);
this.cachedMessages.set(key, messageRecord);
if (MessageStore.getMessage(channelId, messageId)) {
this.cachedMessages.delete(key);
this.deletedMessageIds.delete(key);
}
}
private handleMessageFetchError(channelId: string, messageId: string, error: unknown): void {
const key = this.getKey(channelId, messageId);
if (error instanceof HttpError && error.status === 404) {
this.deletedMessageIds.add(key);
this.cachedMessages.delete(key);
} else {
logger.error(`Failed to fetch message ${messageId}`, error);
}
}
private cleanupCachedMessages(channelId: string, messages: Array<Message>): void {
for (const message of messages) {
const messageId = message.message_reference?.message_id;
if (!messageId) continue;
const key = this.getKey(channelId, messageId);
if (MessageStore.getMessage(channelId, messageId)) {
this.cachedMessages.delete(key);
this.deletedMessageIds.delete(key);
}
}
}
private cleanupChannelMessages(channelId: string): void {
const channelPrefix = `${channelId}:`;
for (const key of Array.from(this.deletedMessageIds)) {
if (key.startsWith(channelPrefix)) {
this.deletedMessageIds.delete(key);
}
}
for (const key of Array.from(this.cachedMessages.keys())) {
if (key.startsWith(channelPrefix)) {
this.cachedMessages.delete(key);
}
}
}
getMessage(channelId: string, messageId: string): MessageRecord | null {
const key = this.getKey(channelId, messageId);
if (this.deletedMessageIds.has(key)) {
return null;
}
return MessageStore.getMessage(channelId, messageId) || this.cachedMessages.get(key) || null;
}
getMessageReference(
channelId: string,
messageId: string,
): {
message: MessageRecord | null;
state: MessageReferenceState;
} {
const key = this.getKey(channelId, messageId);
if (this.deletedMessageIds.has(key)) {
return {
message: null,
state: MessageReferenceState.DELETED,
};
}
const message = MessageStore.getMessage(channelId, messageId);
if (message) {
return {
message,
state: MessageReferenceState.LOADED,
};
}
const cachedMessage = this.cachedMessages.get(key);
if (cachedMessage) {
return {
message: cachedMessage,
state: MessageReferenceState.LOADED,
};
}
return {
message: null,
state: MessageReferenceState.NOT_LOADED,
};
}
}
export default new MessageReferenceStore();

View File

@@ -0,0 +1,93 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import AuthenticationStore from '~/stores/AuthenticationStore';
import MessageStore from '~/stores/MessageStore';
type MessageReplyState = Readonly<{
messageId: string;
mentioning: boolean;
}>;
class MessageReplyStore {
replyingMessageIds: Record<string, MessageReplyState> = {};
highlightMessageId: string | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
isReplying(channelId: string, messageId: string): boolean {
return this.replyingMessageIds[channelId]?.messageId === messageId;
}
isHighlight(messageId: string): boolean {
return this.highlightMessageId === messageId;
}
startReply(channelId: string, messageId: string, mentioning: boolean): void {
const message = MessageStore.getMessage(channelId, messageId);
if (!message) {
return;
}
const shouldMention =
message.author.id === AuthenticationStore.currentUserId || message.webhookId ? false : mentioning;
this.replyingMessageIds = {
...this.replyingMessageIds,
[channelId]: {messageId, mentioning: shouldMention},
};
}
setMentioning(channelId: string, mentioning: boolean): void {
const currentReply = this.replyingMessageIds[channelId];
if (!currentReply) {
return;
}
this.replyingMessageIds = {
...this.replyingMessageIds,
[channelId]: {
...currentReply,
mentioning,
},
};
}
stopReply(channelId: string): void {
const {[channelId]: _, ...remainingReplies} = this.replyingMessageIds;
this.replyingMessageIds = remainingReplies;
}
highlightMessage(messageId: string): void {
this.highlightMessageId = messageId;
}
clearHighlight(): void {
this.highlightMessageId = null;
}
getReplyingMessage(channelId: string): MessageReplyState | null {
return this.replyingMessageIds[channelId] ?? null;
}
}
export default new MessageReplyStore();

View File

@@ -0,0 +1,754 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {action, makeAutoObservable, reaction} from 'mobx';
import * as MessageActionCreators from '~/actions/MessageActionCreators';
import {FAVORITES_GUILD_ID, MAX_MESSAGES_PER_CHANNEL, ME, MessageStates} from '~/Constants';
import type {JumpOptions} from '~/lib/ChannelMessages';
import {ChannelMessages} from '~/lib/ChannelMessages';
import type {GuildMember} from '~/records/GuildMemberRecord';
import type {Message, MessageRecord} from '~/records/MessageRecord';
import type {Presence} from '~/stores/PresenceStore';
import type {ReactionEmoji} from '~/utils/ReactionUtils';
import ChannelStore from './ChannelStore';
import ConnectionStore from './ConnectionStore';
import DimensionStore from './DimensionStore';
import GuildStore from './GuildStore';
import RelationshipStore from './RelationshipStore';
import SelectedChannelStore from './SelectedChannelStore';
import SelectedGuildStore from './SelectedGuildStore';
import UserStore from './UserStore';
type ChannelId = string;
type MessageId = string;
type GuildId = string;
interface GuildMemberUpdateAction {
type: 'GUILD_MEMBER_UPDATE';
guildId: string;
member: GuildMember;
}
interface PresenceUpdateAction {
type: 'PRESENCE_UPDATE';
presence: Presence;
}
interface PendingMessageJump {
channelId: ChannelId;
messageId: MessageId;
}
class MessageStore {
pendingMessageJump: PendingMessageJump | null = null;
updateCounter = 0;
private pendingFullHydration = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
get version(): number {
return this.updateCounter;
}
@action
private notifyChange(): void {
this.updateCounter += 1;
}
getMessages(channelId: ChannelId): ChannelMessages {
return ChannelMessages.getOrCreate(channelId);
}
getMessage(channelId: ChannelId, messageId: MessageId): MessageRecord | undefined {
return ChannelMessages.getOrCreate(channelId).get(messageId);
}
getLastEditableMessage(channelId: ChannelId): MessageRecord | undefined {
const messages = this.getMessages(channelId).toArray();
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.isCurrentUserAuthor() && message.state === MessageStates.SENT && message.isUserMessage()) {
return message;
}
}
return;
}
jumpedMessageId(channelId: ChannelId): MessageId | null | undefined {
const channel = ChannelMessages.get(channelId);
return channel?.jumpTargetId;
}
hasPresent(channelId: ChannelId): boolean {
const channel = ChannelMessages.get(channelId);
return channel?.hasPresent() ?? false;
}
@action
handleConnectionClosed(): boolean {
let didUpdate = false;
ChannelMessages.forEach((messages) => {
if (messages.loadingMore) {
ChannelMessages.commit(messages.mutate({loadingMore: false}));
didUpdate = true;
}
});
if (didUpdate) {
this.notifyChange();
}
return false;
}
@action
handleSessionInvalidated(): boolean {
const channelIds: Array<string> = [];
ChannelMessages.forEach((messages) => channelIds.push(messages.channelId));
for (const channelId of channelIds) {
ChannelMessages.clear(channelId);
DimensionStore.clearChannelDimensions(channelId);
}
this.pendingMessageJump = null;
this.pendingFullHydration = true;
this.notifyChange();
return true;
}
@action
handleResumed(): boolean {
ChannelMessages.forEach((messages) => {
ChannelMessages.commit(messages.mutate({ready: true}));
});
this.notifyChange();
return true;
}
@action
handleConnectionOpen(): boolean {
const selectedChannelId = SelectedChannelStore.currentChannelId;
let didHydrateSelectedChannel = false;
if (this.pendingMessageJump && this.pendingMessageJump.channelId === selectedChannelId) {
MessageActionCreators.jumpToMessage(this.pendingMessageJump.channelId, this.pendingMessageJump.messageId, true);
this.pendingMessageJump = null;
this.pendingFullHydration = false;
return true;
}
ChannelMessages.forEach((messages) => {
if (messages.channelId === selectedChannelId && ChannelStore.getChannel(messages.channelId) != null) {
this.startChannelHydration(messages.channelId, {forceScrollToBottom: this.pendingFullHydration});
didHydrateSelectedChannel = true;
} else {
ChannelMessages.clear(messages.channelId);
DimensionStore.clearChannelDimensions(messages.channelId);
}
});
if (this.pendingFullHydration && !didHydrateSelectedChannel && selectedChannelId) {
this.startChannelHydration(selectedChannelId, {forceScrollToBottom: true});
didHydrateSelectedChannel = true;
}
this.pendingFullHydration = false;
if (!didHydrateSelectedChannel && selectedChannelId && ChannelStore.getChannel(selectedChannelId)) {
const messages = ChannelMessages.getOrCreate(selectedChannelId);
if (!messages.ready && !messages.loadingMore && messages.length === 0) {
ChannelMessages.commit(messages.mutate({loadingMore: true}));
MessageActionCreators.fetchMessages(selectedChannelId, null, null, MAX_MESSAGES_PER_CHANNEL);
didHydrateSelectedChannel = true;
}
}
this.notifyChange();
return didHydrateSelectedChannel;
}
private startChannelHydration(channelId: ChannelId, options: {forceScrollToBottom?: boolean} = {}): void {
if (!ChannelStore.getChannel(channelId)) return;
const {forceScrollToBottom = false} = options;
const messages = ChannelMessages.getOrCreate(channelId);
ChannelMessages.commit(messages.mutate({loadingMore: true, ready: false, error: false}));
if (forceScrollToBottom) {
DimensionStore.updateChannelDimensions(channelId, 1, 1, 0);
}
MessageActionCreators.fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL);
}
@action
handleChannelSelect(action: {guildId?: GuildId; channelId?: ChannelId | null; messageId?: MessageId}): boolean {
const channelId = action.channelId ?? action.guildId;
if (channelId == null || channelId === ME) {
return false;
}
const currentChannel = ChannelStore.getChannel(channelId);
let messages = ChannelMessages.getOrCreate(channelId);
if (messages.jumpTargetId) {
messages = messages.mutate({jumpTargetId: null, jumped: false});
ChannelMessages.commit(messages);
this.notifyChange();
}
if (action.messageId && !ConnectionStore.isConnected) {
this.pendingMessageJump = {channelId, messageId: action.messageId};
return true;
}
if (ConnectionStore.isConnected && action.messageId) {
MessageActionCreators.jumpToMessage(channelId, action.messageId, true);
return false;
}
if (!ConnectionStore.isConnected || messages.loadingMore || messages.ready) {
if (messages.ready && DimensionStore.isAtBottom(channelId)) {
ChannelMessages.commit(messages.truncateTop(MAX_MESSAGES_PER_CHANNEL));
this.notifyChange();
}
return false;
}
const isPrivateChannel = currentChannel?.isPrivate() ?? false;
const isNonGuildChannel = action.guildId == null || action.guildId === ME || isPrivateChannel;
const isFavoritesGuild = action.guildId === FAVORITES_GUILD_ID;
let guildExists = false;
if (isFavoritesGuild && !isPrivateChannel) {
const channelGuildId = currentChannel?.guildId;
guildExists = channelGuildId ? !!GuildStore.getGuild(channelGuildId) : false;
} else if (action.guildId && !isPrivateChannel) {
guildExists = !!GuildStore.getGuild(action.guildId);
}
if (!isNonGuildChannel && !guildExists) {
return false;
}
ChannelMessages.commit(messages.mutate({loadingMore: true}));
this.notifyChange();
MessageActionCreators.fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL);
return false;
}
@action
handleGuildUnavailable(guildId: GuildId, unavailable: boolean): boolean {
if (!unavailable) {
return false;
}
let didUpdate = false;
const selectedChannelId = SelectedChannelStore.currentChannelId;
let selectedChannelAffected = false;
ChannelMessages.forEach(({channelId}) => {
const channel = ChannelStore.getChannel(channelId);
if (channel && channel.guildId === guildId) {
ChannelMessages.clear(channelId);
DimensionStore.clearChannelDimensions(channelId);
didUpdate = true;
if (channelId === selectedChannelId) {
selectedChannelAffected = true;
}
}
});
if (selectedChannelAffected) {
this.pendingFullHydration = true;
}
if (didUpdate) {
this.notifyChange();
}
return didUpdate;
}
@action
handleGuildCreate(action: {guild: {id: GuildId}}): boolean {
if (SelectedGuildStore.selectedGuildId !== action.guild.id) {
return false;
}
const selectedChannelId = SelectedChannelStore.selectedChannelIds.get(action.guild.id);
if (!selectedChannelId) {
return false;
}
const currentMessages = ChannelMessages.get(selectedChannelId);
const didChannelSelect = this.handleChannelSelect({
guildId: action.guild.id,
channelId: selectedChannelId,
messageId: undefined,
});
if (!didChannelSelect && currentMessages && currentMessages.length === 0 && !currentMessages.ready) {
ChannelMessages.commit(currentMessages.mutate({loadingMore: true}));
MessageActionCreators.fetchMessages(selectedChannelId, null, null, MAX_MESSAGES_PER_CHANNEL);
this.notifyChange();
return true;
}
return didChannelSelect;
}
@action
handleLoadMessages(action: {channelId: ChannelId; jump?: JumpOptions}): boolean {
const messages = ChannelMessages.getOrCreate(action.channelId);
ChannelMessages.commit(messages.loadStart(action.jump));
this.notifyChange();
return false;
}
@action
handleTruncateMessages(action: {channelId: ChannelId; truncateBottom?: boolean; truncateTop?: boolean}): boolean {
const messages = ChannelMessages.getOrCreate(action.channelId).truncate(
action.truncateBottom ?? false,
action.truncateTop ?? false,
);
ChannelMessages.commit(messages);
this.notifyChange();
return false;
}
@action
handleLoadMessagesSuccessCached(action: {
channelId: ChannelId;
jump?: JumpOptions;
before?: string;
after?: string;
limit: number;
}): boolean {
let messages = ChannelMessages.getOrCreate(action.channelId);
if (action.jump?.present) {
messages = messages.jumpToPresent(action.limit);
} else if (action.jump?.messageId) {
messages = messages.jumpToMessage(
action.jump.messageId,
action.jump.flash,
action.jump.offset,
action.jump.returnMessageId,
action.jump.jumpType,
);
} else if (action.before || action.after) {
messages = messages.loadFromCache(action.before != null, action.limit);
}
ChannelMessages.commit(messages);
this.notifyChange();
return false;
}
@action
handleLoadMessagesSuccess(action: {
channelId: ChannelId;
isBefore?: boolean;
isAfter?: boolean;
jump?: JumpOptions;
hasMoreBefore?: boolean;
hasMoreAfter?: boolean;
cached?: boolean;
messages: Array<Message>;
}): boolean {
const messages = ChannelMessages.getOrCreate(action.channelId).loadComplete({
newMessages: action.messages,
isBefore: action.isBefore,
isAfter: action.isAfter,
jump: action.jump,
hasMoreBefore: action.hasMoreBefore,
hasMoreAfter: action.hasMoreAfter,
cached: action.cached,
});
ChannelMessages.commit(messages);
this.notifyChange();
return false;
}
@action
handleLoadMessagesFailure(action: {channelId: ChannelId}): boolean {
const messages = ChannelMessages.getOrCreate(action.channelId);
ChannelMessages.commit(messages.mutate({loadingMore: false, error: true}));
this.notifyChange();
return false;
}
@action
handleIncomingMessage(action: {channelId: ChannelId; message: Message}): boolean {
ChannelStore.handleMessageCreate({message: action.message});
const existing = ChannelMessages.get(action.channelId);
if (!existing?.ready) {
return false;
}
const updated = existing.receiveMessage(action.message, DimensionStore.isAtBottom(action.channelId));
ChannelMessages.commit(updated);
this.notifyChange();
return false;
}
@action
handleSendFailed(action: {channelId: ChannelId; nonce: string}): boolean {
const existing = ChannelMessages.get(action.channelId);
if (!existing || !existing.has(action.nonce)) return false;
const updated = existing.update(action.nonce, (message) => message.withUpdates({state: MessageStates.FAILED}));
ChannelMessages.commit(updated);
this.notifyChange();
return true;
}
@action
handleMessageDelete(action: {id: MessageId; channelId: ChannelId}): boolean {
const existing = ChannelMessages.get(action.channelId);
if (!existing || !existing.has(action.id)) {
return false;
}
let messages = existing;
if (messages.revealedMessageId === action.id) {
const messageAfter = messages.getAfter(action.id);
messages = messages.mutate({revealedMessageId: messageAfter?.id ?? null});
}
messages = messages.remove(action.id);
ChannelMessages.commit(messages);
this.notifyChange();
return true;
}
@action
handleMessageDeleteBulk(action: {ids: Array<MessageId>; channelId: ChannelId}): boolean {
const existing = ChannelMessages.get(action.channelId);
if (!existing) return false;
let messages = existing.removeMany(action.ids);
if (messages === existing) return false;
if (messages.revealedMessageId != null && action.ids.includes(messages.revealedMessageId)) {
const after = messages.getAfter(messages.revealedMessageId);
messages = messages.mutate({revealedMessageId: after?.id ?? null});
}
ChannelMessages.commit(messages);
this.notifyChange();
return true;
}
@action
handleMessageUpdate(action: {message: Message}): boolean {
const messageId = action.message.id;
const channelId = action.message.channel_id;
const existing = ChannelMessages.get(channelId);
if (!existing || !existing.has(messageId)) return false;
const updated = existing.update(messageId, (message) => {
if (message.isEditing && action.message.state === undefined) {
return message.withUpdates({...action.message, state: MessageStates.SENT});
}
return message.withUpdates(action.message);
});
ChannelMessages.commit(updated);
this.notifyChange();
return true;
}
@action
handleUserUpdate(action: {user: {id: string}}): boolean {
let hasChanges = false;
ChannelMessages.forEach((messages) => {
let changedInChannel = false;
const updatedMessages = messages.map((message) => {
if (message.author.id !== action.user.id) return message;
const updatedAuthor = UserStore.getUser(action.user.id);
const updated = message.withUpdates({author: updatedAuthor?.toJSON()});
if (updated !== message) {
changedInChannel = true;
hasChanges = true;
}
return updated;
});
if (changedInChannel) {
ChannelMessages.commit(messages.reset(updatedMessages));
}
});
if (hasChanges) {
this.notifyChange();
}
return hasChanges;
}
@action
handleGuildMemberUpdate(action: GuildMemberUpdateAction): boolean {
let hasChanges = false;
ChannelMessages.forEach((messages) => {
const channel = ChannelStore.getChannel(messages.channelId);
if (channel == null || channel.guildId !== action.guildId) return;
let changedInChannel = false;
const updatedMessages = messages.map((message) => {
if (message.author.id !== action.member.user.id) return message;
const updatedAuthor = UserStore.getUser(action.member.user.id);
const updated = message.withUpdates({author: updatedAuthor?.toJSON()});
if (updated !== message) {
changedInChannel = true;
hasChanges = true;
}
return updated;
});
if (changedInChannel) {
ChannelMessages.commit(messages.reset(updatedMessages));
}
});
if (hasChanges) {
this.notifyChange();
}
return hasChanges;
}
@action
handlePresenceUpdate(action: PresenceUpdateAction): boolean {
if (!action.presence.user.username && !action.presence.user.avatar && !action.presence.user.discriminator) {
return false;
}
let hasChanges = false;
ChannelMessages.forEach((messages) => {
const channel = ChannelStore.getChannel(messages.channelId);
if (action.presence.guild_id && channel && channel.guildId !== action.presence.guild_id) return;
let changedInChannel = false;
const updatedMessages = messages.map((message) => {
if (message.author.id !== action.presence.user.id) return message;
const updatedAuthor = UserStore.getUser(action.presence.user.id);
const updated = message.withUpdates({author: updatedAuthor?.toJSON()});
if (updated !== message) {
changedInChannel = true;
hasChanges = true;
}
return updated;
});
if (changedInChannel) {
ChannelMessages.commit(messages.reset(updatedMessages));
}
});
if (hasChanges) {
this.notifyChange();
}
return hasChanges;
}
@action
handleCleanup(): boolean {
ChannelMessages.forEach(({channelId}) => {
if (ChannelStore.getChannel(channelId) == null) {
ChannelMessages.clear(channelId);
DimensionStore.clearChannelDimensions(channelId);
}
});
this.notifyChange();
return false;
}
@action
handleRelationshipUpdate(): boolean {
ChannelMessages.forEach((messages) => {
const updatedMessages = messages.map((message) =>
message.withUpdates({
blocked: RelationshipStore.isBlocked(message.author.id),
}),
);
ChannelMessages.commit(messages.reset(updatedMessages));
});
this.notifyChange();
return false;
}
@action
handleMessageReveal(action: {channelId: ChannelId; messageId: MessageId | null}): boolean {
const messages = ChannelMessages.getOrCreate(action.channelId);
ChannelMessages.commit(messages.mutate({revealedMessageId: action.messageId}));
this.notifyChange();
return true;
}
@action
handleClearJumpTarget(action: {channelId: ChannelId}): boolean {
const messages = ChannelMessages.get(action.channelId);
if (messages?.jumpTargetId != null) {
ChannelMessages.commit(messages.mutate({jumpTargetId: null, jumped: false}));
this.notifyChange();
return true;
}
return false;
}
@action
handleReaction(action: {
type: 'MESSAGE_REACTION_ADD' | 'MESSAGE_REACTION_REMOVE';
channelId: ChannelId;
messageId: MessageId;
userId: string;
emoji: ReactionEmoji;
optimistic?: boolean;
}): boolean {
const existing = ChannelMessages.get(action.channelId);
if (!existing) return false;
const currentUser = UserStore.getCurrentUser();
const isCurrentUser = currentUser?.id === action.userId;
if (action.optimistic && !isCurrentUser) return false;
const updated = existing.update(action.messageId, (message) => {
return action.type === 'MESSAGE_REACTION_ADD'
? message.withReaction(action.emoji, true, isCurrentUser)
: message.withReaction(action.emoji, false, isCurrentUser);
});
ChannelMessages.commit(updated);
this.notifyChange();
return true;
}
@action
handleRemoveAllReactions(action: {channelId: ChannelId; messageId: MessageId}): boolean {
const existing = ChannelMessages.get(action.channelId);
if (!existing) return false;
const updated = existing.update(action.messageId, (message) => message.withUpdates({reactions: []}));
ChannelMessages.commit(updated);
this.notifyChange();
return true;
}
@action
handleMessagePreload(action: {messages: Record<ChannelId, Message>}): boolean {
let hasChanges = false;
for (const [channelId, messageData] of Object.entries(action.messages)) {
if (!messageData?.id || !messageData.author) continue;
ChannelStore.handleMessageCreate({message: messageData});
const channelMessages = ChannelMessages.getOrCreate(channelId);
if (!channelMessages.has(messageData.id)) {
ChannelMessages.commit(channelMessages.receiveMessage(messageData, false));
hasChanges = true;
}
}
if (hasChanges) {
this.notifyChange();
}
return hasChanges;
}
@action
handleOptimisticEdit(action: {
channelId: ChannelId;
messageId: MessageId;
content: string;
}): {originalContent: string; originalEditedTimestamp: string | null} | null {
const {channelId, messageId, content} = action;
const existing = ChannelMessages.get(channelId);
if (!existing) return null;
const originalMessage = existing.get(messageId);
if (!originalMessage) return null;
const rollbackData = {
originalContent: originalMessage.content,
originalEditedTimestamp: originalMessage.editedTimestamp?.toISOString() ?? null,
};
const updated = existing.update(messageId, (msg) =>
msg.withUpdates({
content,
state: MessageStates.EDITING,
}),
);
ChannelMessages.commit(updated);
this.notifyChange();
return rollbackData;
}
@action
handleEditRollback(action: {
channelId: ChannelId;
messageId: MessageId;
originalContent: string;
originalEditedTimestamp: string | null;
}): void {
const {channelId, messageId, originalContent, originalEditedTimestamp} = action;
const existing = ChannelMessages.get(channelId);
if (!existing || !existing.has(messageId)) return;
const updated = existing.update(messageId, (msg) =>
msg.withUpdates({
content: originalContent,
edited_timestamp: originalEditedTimestamp ?? undefined,
state: MessageStates.SENT,
}),
);
ChannelMessages.commit(updated);
this.notifyChange();
}
subscribe(callback: () => void): () => void {
return reaction(
() => this.version,
() => callback(),
{fireImmediately: true},
);
}
}
export default new MessageStore();

View File

@@ -0,0 +1,124 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, reaction} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
import {Platform} from '~/lib/Platform';
import WindowStore from '~/stores/WindowStore';
const MOBILE_ENABLE_BREAKPOINT = 640;
const MOBILE_DISABLE_BREAKPOINT = 768;
const shouldForceMobileLayout = (): boolean => Platform.isMobileBrowser;
const getInitialMobileEnabled = (): boolean => {
if (shouldForceMobileLayout()) {
return true;
}
return window.innerWidth < MOBILE_ENABLE_BREAKPOINT;
};
class MobileLayoutStore {
navExpanded = true;
chatExpanded = false;
enabled = getInitialMobileEnabled();
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
this.initWindowSync();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'MobileLayoutStore', ['navExpanded', 'chatExpanded']);
}
private initWindowSync(): void {
this.handleWindowSizeChange();
reaction(
() => WindowStore.windowSize,
() => this.handleWindowSizeChange(),
{fireImmediately: false},
);
}
isEnabled() {
return this.enabled;
}
private handleWindowSizeChange(): void {
const windowSize = WindowStore.windowSize;
const forceMobile = shouldForceMobileLayout();
const threshold = this.enabled ? MOBILE_DISABLE_BREAKPOINT : MOBILE_ENABLE_BREAKPOINT;
const widthBased = windowSize.width < threshold;
const newEnabled = forceMobile || widthBased;
if (newEnabled === this.enabled) {
return;
}
this.enabled = newEnabled;
if (newEnabled) {
this.navExpanded = this.navExpanded && !this.chatExpanded;
}
}
updateState(data: {navExpanded?: boolean; chatExpanded?: boolean}): void {
const hasChanges =
(data.navExpanded !== undefined && data.navExpanded !== this.navExpanded) ||
(data.chatExpanded !== undefined && data.chatExpanded !== this.chatExpanded);
if (!hasChanges) {
return;
}
if (data.navExpanded !== undefined) {
this.navExpanded = data.navExpanded;
if (data.navExpanded && this.enabled && this.chatExpanded) {
this.chatExpanded = false;
}
}
if (data.chatExpanded !== undefined) {
this.chatExpanded = data.chatExpanded;
if (data.chatExpanded && this.enabled && this.navExpanded) {
this.navExpanded = false;
}
}
}
isMobileLayout(): boolean {
return this.enabled;
}
get platformMobileDetected(): boolean {
return shouldForceMobileLayout();
}
isNavExpanded(): boolean {
return this.navExpanded;
}
isChatExpanded(): boolean {
return this.chatExpanded;
}
}
export default new MobileLayoutStore();

View File

@@ -0,0 +1,67 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, observable} from 'mobx';
import type {MessageRecord} from '~/records/MessageRecord';
const MAX_QUEUE_LENGTH = 5;
class MobileMentionToastStore {
queue: Array<MessageRecord> = [];
constructor() {
makeAutoObservable(this, {queue: observable.shallow}, {autoBind: true});
}
enqueue(message: MessageRecord): void {
if (this.queue.some((entry) => entry.id === message.id)) {
return;
}
this.queue.push(message);
if (this.queue.length > MAX_QUEUE_LENGTH) {
this.queue.shift();
}
}
dequeue(targetId?: string): void {
if (!targetId) {
this.queue.shift();
return;
}
if (this.queue[0]?.id === targetId) {
this.queue.shift();
return;
}
this.queue = this.queue.filter((entry) => entry.id !== targetId);
}
get current(): MessageRecord | undefined {
return this.queue[0];
}
get count(): number {
return this.queue.length;
}
}
export default new MobileMentionToastStore();

View File

@@ -0,0 +1,49 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {UserRecord} from '~/records/UserRecord';
interface MockIncomingCallData {
channel: ChannelRecord;
initiator: UserRecord;
}
class MockIncomingCallStore {
mockCall: MockIncomingCallData | null = null;
constructor() {
makeAutoObservable(this);
}
setMockCall(data: MockIncomingCallData): void {
this.mockCall = data;
}
clearMockCall(): void {
this.mockCall = null;
}
isMockCall(channelId: string): boolean {
return this.mockCall?.channel.id === channelId;
}
}
export default new MockIncomingCallStore();

View File

@@ -0,0 +1,215 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import type React from 'react';
import type {ModalRender} from '~/actions/ModalActionCreators';
import {Logger} from '~/lib/Logger';
import KeyboardModeStore from './KeyboardModeStore';
import ToastStore from './ToastStore';
const logger = new Logger('ModalStore');
const BASE_Z_INDEX = 10000;
const Z_INDEX_INCREMENT = 2;
export function getZIndexForStack(stackIndex: number): number {
return BASE_Z_INDEX + stackIndex * Z_INDEX_INCREMENT;
}
export function getBackdropZIndexForStack(stackIndex: number): number {
return BASE_Z_INDEX + stackIndex * Z_INDEX_INCREMENT - 1;
}
interface Modal {
modal: ModalRender;
key: string;
focusReturnTarget: HTMLElement | null;
keyboardModeEnabled: boolean;
isBackground: boolean;
}
interface ModalWithStackInfo extends Modal {
stackIndex: number;
isVisible: boolean;
needsBackdrop: boolean;
}
interface PushOptions {
isBackground?: boolean;
}
class ModalStore {
modals: Array<Modal> = [];
private hasShownStackingToast = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
push(modal: ModalRender, key: string | number, options: PushOptions = {}): void {
const isBackground = options.isBackground ?? false;
this.modals.push({
modal,
key: key.toString(),
focusReturnTarget: this.getActiveElement(),
keyboardModeEnabled: KeyboardModeStore.keyboardModeEnabled,
isBackground,
});
this.checkAlternatingStackPattern();
}
private getModalSignature(modal: Modal): string {
const element = modal.modal();
const typeName = typeof element.type === 'function' ? element.type.name : String(element.type);
try {
return `${typeName}:${JSON.stringify(element.props)}`;
} catch {
return `${typeName}:${modal.key}`;
}
}
private checkAlternatingStackPattern(): void {
if (this.hasShownStackingToast) return;
if (this.modals.length < 5) return;
const lastFive = this.modals.slice(-5);
const signatures = lastFive.map((m) => this.getModalSignature(m));
const signatureA = signatures[0];
const signatureB = signatures[1];
if (signatureA === signatureB) return;
const isAlternating = signatures[2] === signatureA && signatures[3] === signatureB && signatures[4] === signatureA;
if (isAlternating) {
this.hasShownStackingToast = true;
ToastStore.createToast({type: 'info', children: 'Having fun?', timeout: 3000});
}
}
update(key: string | number, updater: (currentModal: ModalRender) => ModalRender, options?: PushOptions): void {
const modalIndex = this.modals.findIndex((modal) => modal.key === key.toString());
if (modalIndex === -1) return;
const existingModal = this.modals[modalIndex];
this.modals[modalIndex] = {
...existingModal,
modal: updater(existingModal.modal),
isBackground: options?.isBackground ?? existingModal.isBackground,
};
}
pop(key?: string | number): void {
let removed: Modal | undefined;
let wasTopmost = false;
if (key) {
const keyStr = key.toString();
const idx = this.modals.findIndex((modal) => modal.key === keyStr);
if (idx !== -1) {
wasTopmost = idx === this.modals.length - 1;
[removed] = this.modals.splice(idx, 1);
}
} else {
removed = this.modals.pop();
wasTopmost = true;
}
if (removed && wasTopmost) {
logger.debug(`ModalStore.pop restoring focus topmost=${wasTopmost} keyboardMode=${removed.keyboardModeEnabled}`);
this.scheduleFocus(removed.focusReturnTarget, removed.keyboardModeEnabled);
}
}
popAll(): void {
const lastModal = this.modals.at(-1);
this.modals = [];
if (lastModal) {
this.scheduleFocus(lastModal.focusReturnTarget, lastModal.keyboardModeEnabled);
}
}
get orderedModals(): Array<ModalWithStackInfo> {
const topmostRegularIndex = this.modals.findLastIndex((m) => !m.isBackground);
return this.modals.map((modal, index) => {
const isVisible = modal.isBackground || index === topmostRegularIndex;
const needsBackdrop = modal.isBackground || index === 0 || (index > 0 && this.modals[index - 1].isBackground);
return {
...modal,
stackIndex: index,
isVisible,
needsBackdrop,
};
});
}
getModal(): Modal | undefined {
return this.modals.at(-1);
}
hasModalOpen(): boolean {
return this.modals.length > 0;
}
hasModal(key: string): boolean {
return this.modals.some((modal) => modal.key === key);
}
hasModalOfType<T>(component: React.ComponentType<T>): boolean {
return this.modals.some((modal) => modal.modal().type === component);
}
private getActiveElement(): HTMLElement | null {
const active = document.activeElement;
return active instanceof HTMLElement ? active : null;
}
private scheduleFocus(target: HTMLElement | null, keyboardModeEnabled: boolean): void {
logger.debug(
`ModalStore.scheduleFocus target=${target ? target.tagName : 'null'} keyboardMode=${keyboardModeEnabled}`,
);
if (!target) return;
queueMicrotask(() => {
if (!target.isConnected) {
logger.debug('ModalStore.scheduleFocus aborted: target disconnected');
return;
}
try {
target.focus({preventScroll: true});
logger.debug('ModalStore.scheduleFocus applied focus to target');
} catch (error) {
logger.error('ModalStore.scheduleFocus failed to focus target', error as Error);
return;
}
if (keyboardModeEnabled) {
logger.debug('ModalStore.scheduleFocus re-entering keyboard mode');
KeyboardModeStore.enterKeyboardMode(false);
}
});
}
}
export type {PushOptions};
export default new ModalStore();

View File

@@ -0,0 +1,356 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
export interface NagbarSettings {
iosInstallDismissed: boolean;
pwaInstallDismissed: boolean;
pushNotificationDismissed: boolean;
desktopNotificationDismissed: boolean;
premiumGracePeriodDismissed: boolean;
premiumExpiredDismissed: boolean;
premiumOnboardingDismissed: boolean;
giftInventoryDismissed: boolean;
desktopDownloadDismissed: boolean;
mobileDownloadDismissed: boolean;
pendingBulkDeletionDismissed: Record<string, boolean>;
invitesDisabledDismissed: Record<string, boolean>;
claimAccountModalShownThisSession: boolean;
forceOffline: boolean;
forceEmailVerification: boolean;
forceIOSInstall: boolean;
forcePWAInstall: boolean;
forcePushNotification: boolean;
forceUnclaimedAccount: boolean;
forceDesktopNotification: boolean;
forceInvitesDisabled: boolean;
forcePremiumGracePeriod: boolean;
forcePremiumExpired: boolean;
forcePremiumOnboarding: boolean;
forceGiftInventory: boolean;
forceUpdateAvailable: boolean;
forceDesktopDownload: boolean;
forceMobileDownload: boolean;
forceHideOffline: boolean;
forceHideEmailVerification: boolean;
forceHideIOSInstall: boolean;
forceHidePWAInstall: boolean;
forceHidePushNotification: boolean;
forceHideUnclaimedAccount: boolean;
forceHideDesktopNotification: boolean;
forceHideInvitesDisabled: boolean;
forceHidePremiumGracePeriod: boolean;
forceHidePremiumExpired: boolean;
forceHidePremiumOnboarding: boolean;
forceHideGiftInventory: boolean;
forceHideUpdateAvailable: boolean;
forceHideDesktopDownload: boolean;
forceHideMobileDownload: boolean;
}
export type NagbarToggleKey = Exclude<
keyof NagbarSettings,
'invitesDisabledDismissed' | 'claimAccountModalShownThisSession' | 'pendingBulkDeletionDismissed'
>;
export class NagbarStore implements NagbarSettings {
iosInstallDismissed = false;
pwaInstallDismissed = false;
pushNotificationDismissed = false;
desktopNotificationDismissed = false;
premiumGracePeriodDismissed = false;
premiumExpiredDismissed = false;
premiumOnboardingDismissed = false;
giftInventoryDismissed = false;
desktopDownloadDismissed = false;
mobileDownloadDismissed = false;
pendingBulkDeletionDismissed: Record<string, boolean> = {};
invitesDisabledDismissed: Record<string, boolean> = {};
claimAccountModalShownThisSession = false;
forceOffline = false;
forceEmailVerification = false;
forceIOSInstall = false;
forcePWAInstall = false;
forcePushNotification = false;
forceUnclaimedAccount = false;
forceDesktopNotification = false;
forceInvitesDisabled = false;
forcePremiumGracePeriod = false;
forcePremiumExpired = false;
forcePremiumOnboarding = false;
forceGiftInventory = false;
forceUpdateAvailable = false;
forceDesktopDownload = false;
forceMobileDownload = false;
forceHideOffline = false;
forceHideEmailVerification = false;
forceHideIOSInstall = false;
forceHidePWAInstall = false;
forceHidePushNotification = false;
forceHideUnclaimedAccount = false;
forceHideDesktopNotification = false;
forceHideInvitesDisabled = false;
forceHidePremiumGracePeriod = false;
forceHidePremiumExpired = false;
forceHidePremiumOnboarding = false;
forceHideGiftInventory = false;
forceHideUpdateAvailable = false;
forceHideDesktopDownload = false;
forceHideMobileDownload = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'NagbarStore', [
'iosInstallDismissed',
'pwaInstallDismissed',
'pushNotificationDismissed',
'desktopNotificationDismissed',
'premiumGracePeriodDismissed',
'premiumExpiredDismissed',
'premiumOnboardingDismissed',
'giftInventoryDismissed',
'desktopDownloadDismissed',
'mobileDownloadDismissed',
'pendingBulkDeletionDismissed',
'invitesDisabledDismissed',
]);
}
getIosInstallDismissed(): boolean {
return this.iosInstallDismissed;
}
getPwaInstallDismissed(): boolean {
return this.pwaInstallDismissed;
}
getPushNotificationDismissed(): boolean {
return this.pushNotificationDismissed;
}
getForceOffline(): boolean {
return this.forceOffline;
}
getForceEmailVerification(): boolean {
return this.forceEmailVerification;
}
getForceIOSInstall(): boolean {
return this.forceIOSInstall;
}
getForcePWAInstall(): boolean {
return this.forcePWAInstall;
}
getForcePushNotification(): boolean {
return this.forcePushNotification;
}
getForceUnclaimedAccount(): boolean {
return this.forceUnclaimedAccount;
}
getInvitesDisabledDismissed(guildId: string): boolean {
return this.invitesDisabledDismissed[guildId] ?? false;
}
getForceInvitesDisabled(): boolean {
return this.forceInvitesDisabled;
}
getForceHideOffline(): boolean {
return this.forceHideOffline;
}
getForceHideEmailVerification(): boolean {
return this.forceHideEmailVerification;
}
getForceHideIOSInstall(): boolean {
return this.forceHideIOSInstall;
}
getForceHidePWAInstall(): boolean {
return this.forceHidePWAInstall;
}
getForceHidePushNotification(): boolean {
return this.forceHidePushNotification;
}
getForceHideUnclaimedAccount(): boolean {
return this.forceHideUnclaimedAccount;
}
getForceHideDesktopNotification(): boolean {
return this.forceHideDesktopNotification;
}
getForceHideInvitesDisabled(): boolean {
return this.forceHideInvitesDisabled;
}
getForceHidePremiumGracePeriod(): boolean {
return this.forceHidePremiumGracePeriod;
}
getForceHidePremiumExpired(): boolean {
return this.forceHidePremiumExpired;
}
getForceHidePremiumOnboarding(): boolean {
return this.forceHidePremiumOnboarding;
}
getForceHideGiftInventory(): boolean {
return this.forceHideGiftInventory;
}
getForceHideUpdateAvailable(): boolean {
return this.forceHideUpdateAvailable;
}
hasPendingBulkDeletionDismissed(scheduleKey: string | null): boolean {
if (!scheduleKey) {
return false;
}
return Boolean(this.pendingBulkDeletionDismissed[scheduleKey]);
}
markClaimAccountModalShown(): void {
this.claimAccountModalShownThisSession = true;
}
resetClaimAccountModalShown(): void {
this.claimAccountModalShownThisSession = false;
}
dismiss(nagbarType: NagbarToggleKey): void {
this[nagbarType] = true;
}
dismissPendingBulkDeletion(scheduleKey: string): void {
this.pendingBulkDeletionDismissed = {
...this.pendingBulkDeletionDismissed,
[scheduleKey]: true,
};
}
dismissInvitesDisabled(guildId: string): void {
this.invitesDisabledDismissed = {
...this.invitesDisabledDismissed,
[guildId]: true,
};
}
clearPendingBulkDeletionDismissed(scheduleKey: string): void {
const {[scheduleKey]: _, ...rest} = this.pendingBulkDeletionDismissed;
this.pendingBulkDeletionDismissed = rest;
}
reset(nagbarType: NagbarToggleKey): void {
this[nagbarType] = false;
}
setFlag(key: NagbarToggleKey, value: boolean): void {
this[key] = value;
}
resetInvitesDisabled(guildId: string): void {
const {[guildId]: _, ...rest} = this.invitesDisabledDismissed;
this.invitesDisabledDismissed = rest;
}
resetAll(): void {
this.iosInstallDismissed = false;
this.pwaInstallDismissed = false;
this.pushNotificationDismissed = false;
this.desktopNotificationDismissed = false;
this.premiumGracePeriodDismissed = false;
this.premiumExpiredDismissed = false;
this.premiumOnboardingDismissed = false;
this.giftInventoryDismissed = false;
this.desktopDownloadDismissed = false;
this.mobileDownloadDismissed = false;
this.pendingBulkDeletionDismissed = {};
this.invitesDisabledDismissed = {};
this.claimAccountModalShownThisSession = false;
this.forceOffline = false;
this.forceEmailVerification = false;
this.forceIOSInstall = false;
this.forcePWAInstall = false;
this.forcePushNotification = false;
this.forceUnclaimedAccount = false;
this.forceDesktopNotification = false;
this.forceInvitesDisabled = false;
this.forcePremiumGracePeriod = false;
this.forcePremiumExpired = false;
this.forcePremiumOnboarding = false;
this.forceGiftInventory = false;
this.forceUpdateAvailable = false;
this.forceDesktopDownload = false;
this.forceMobileDownload = false;
this.forceHideOffline = false;
this.forceHideEmailVerification = false;
this.forceHideIOSInstall = false;
this.forceHidePWAInstall = false;
this.forceHidePushNotification = false;
this.forceHideUnclaimedAccount = false;
this.forceHideDesktopNotification = false;
this.forceHideInvitesDisabled = false;
this.forceHidePremiumGracePeriod = false;
this.forceHidePremiumExpired = false;
this.forceHidePremiumOnboarding = false;
this.forceHideGiftInventory = false;
this.forceHideUpdateAvailable = false;
this.forceHideDesktopDownload = false;
this.forceHideMobileDownload = false;
}
handleGuildUpdate(action: {
guild: {
id: string;
features?: ReadonlyArray<string>;
properties?: {features: ReadonlyArray<string>};
};
}): void {
const guildId = action.guild.id;
const features: ReadonlyArray<string> = action.guild.features ?? action.guild.properties?.features ?? [];
const hasInvitesDisabled = features.includes('INVITES_DISABLED');
if (!hasInvitesDisabled && this.invitesDisabledDismissed[guildId]) {
const {[guildId]: _, ...rest} = this.invitesDisabledDismissed;
this.invitesDisabledDismissed = rest;
}
}
}
export default new NagbarStore();

View File

@@ -0,0 +1,114 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, runInAction} from 'mobx';
import {Logger} from '~/lib/Logger';
import {checkNativePermission, type NativePermissionResult} from '~/utils/NativePermissions';
import {getNativePlatform, isDesktop, type NativePlatform} from '~/utils/NativeUtils';
const logger = new Logger('NativePermissionStore');
class NativePermissionStore {
private _initialized = false;
private _isDesktop = false;
private _platform: NativePlatform = 'unknown';
private _inputMonitoringStatus: NativePermissionResult = 'granted';
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initialize();
}
private async initialize(): Promise<void> {
const desktop = isDesktop();
const platform = await getNativePlatform();
let inputMonitoringStatus: NativePermissionResult = 'granted';
if (desktop && platform === 'macos') {
inputMonitoringStatus = await checkNativePermission('input-monitoring');
}
logger.debug('Initialized', {
desktop,
platform,
inputMonitoringStatus,
});
runInAction(() => {
this._isDesktop = desktop;
this._platform = platform;
this._inputMonitoringStatus = inputMonitoringStatus;
this._initialized = true;
});
}
get initialized(): boolean {
return this._initialized;
}
get isDesktop(): boolean {
return this._isDesktop;
}
get isMacOS(): boolean {
return this._platform === 'macos';
}
get isNativeMacDesktop(): boolean {
return this._isDesktop && this._platform === 'macos';
}
get platform(): NativePlatform {
return this._platform;
}
get inputMonitoringStatus(): NativePermissionResult {
return this._inputMonitoringStatus;
}
get isInputMonitoringGranted(): boolean {
return this._inputMonitoringStatus === 'granted';
}
get shouldShowInputMonitoringBanner(): boolean {
return this._isDesktop && this._platform === 'macos' && this._inputMonitoringStatus !== 'granted';
}
async recheckInputMonitoring(): Promise<NativePermissionResult> {
if (!this._isDesktop || this._platform !== 'macos') {
return 'granted';
}
const status = await checkNativePermission('input-monitoring');
runInAction(() => {
this._inputMonitoringStatus = status;
});
logger.debug('Rechecked input monitoring', {status});
return status;
}
setInputMonitoringStatus(status: NativePermissionResult): void {
this._inputMonitoringStatus = status;
}
}
export default new NativePermissionStore();

View File

@@ -0,0 +1,63 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
import {buildStateFlags, saveCurrentWindowState} from '~/utils/WindowStateUtils';
class NativeWindowStateStore {
rememberSizeAndPosition = true;
rememberMaximized = true;
rememberFullscreen = true;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void makePersistent(this, 'NativeWindowStateStore', [
'rememberSizeAndPosition',
'rememberMaximized',
'rememberFullscreen',
]);
}
setRememberSizeAndPosition(enabled: boolean): void {
this.rememberSizeAndPosition = enabled;
void this.saveWithCurrentFlags();
}
setRememberMaximized(enabled: boolean): void {
this.rememberMaximized = enabled;
void this.saveWithCurrentFlags();
}
setRememberFullscreen(enabled: boolean): void {
this.rememberFullscreen = enabled;
void this.saveWithCurrentFlags();
}
private async saveWithCurrentFlags(): Promise<void> {
const flags = buildStateFlags({
rememberSizeAndPosition: this.rememberSizeAndPosition,
rememberMaximized: this.rememberMaximized,
rememberFullscreen: this.rememberFullscreen,
});
await saveCurrentWindowState(flags);
}
}
export default new NativeWindowStateStore();

View File

@@ -0,0 +1,117 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {action, makeAutoObservable} from 'mobx';
import {ME} from '~/Constants';
import type {Router} from '~/lib/router';
import NavigationCoordinator from '~/navigation/NavigationCoordinator';
import type {NavState} from '~/navigation/routeParser';
type ChannelId = string;
type GuildId = string;
class NavigationStore {
guildId: GuildId | null = null;
channelId: ChannelId | null = null;
messageId: string | null = null;
private router: Router | null = null;
private unsubscribe: (() => void) | null = null;
currentLocation: URL | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
@action
initialize(router: Router): void {
this.router = router;
this.updateFromRouter();
this.unsubscribe = this.router.subscribe(() => {
this.updateFromRouter();
});
}
@action
private updateFromRouter(): void {
if (!this.router) return;
const state = this.router.getState();
this.currentLocation = state.location;
const match = state.matches[state.matches.length - 1];
if (!match) {
this.guildId = null;
this.channelId = null;
this.messageId = null;
this.updateCoordinator();
return;
}
const params = match.params;
this.guildId = (params.guildId as GuildId) ?? null;
this.channelId = (params.channelId as ChannelId) ?? null;
this.messageId = (params.messageId as string) ?? null;
this.updateCoordinator();
}
get pathname(): string {
return this.currentLocation?.pathname ?? '';
}
get search(): string {
return this.currentLocation?.search ?? '';
}
get hash(): string {
return this.currentLocation?.hash ?? '';
}
@action
private updateCoordinator(): void {
const guildId = this.guildId;
let context: NavState['context'];
if (guildId === ME || !guildId) {
context = {kind: 'dm'};
} else if (guildId === '@favorites') {
context = {kind: 'favorites'};
} else {
context = {kind: 'guild', guildId};
}
const navState: NavState = {
context,
channelId: this.channelId,
messageId: this.messageId,
};
NavigationCoordinator.applyRoute(navState);
}
destroy(): void {
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
this.router = null;
}
}
export default new NavigationStore();

View File

@@ -0,0 +1,293 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {Trans} from '@lingui/react/macro';
import {makeAutoObservable, runInAction} from 'mobx';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import {Logger} from '~/lib/Logger';
import {makePersistent} from '~/lib/MobXPersistence';
import VoiceSettingsStore from '~/stores/VoiceSettingsStore';
import VoiceDevicePermissionStore, {type VoiceDeviceState} from '~/stores/voice/VoiceDevicePermissionStore';
const logger = new Logger('NewDeviceMonitoringStore');
type DeviceType = 'input' | 'output';
interface PendingDevicePrompt {
deviceId: string;
deviceName: string;
deviceType: DeviceType;
}
class NewDeviceMonitoringStore {
knownDeviceIds: Array<string> = [];
ignoredDeviceIds: Array<string> = [];
suppressAlerts = false;
private isInitialized = false;
private isStarted = false;
private startPromise: Promise<void> | null = null;
private startEpoch = 0;
private pendingPrompts: Array<PendingDevicePrompt> = [];
private isShowingPrompt = false;
private unsubscribe: (() => void) | null = null;
private i18n: I18n | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
setI18n(i18n: I18n): void {
this.i18n = i18n;
}
private startMonitoring(): void {
if (this.unsubscribe) return;
this.unsubscribe = VoiceDevicePermissionStore.subscribe(this.handleDeviceStateChange);
}
async start(): Promise<void> {
if (this.startPromise) return this.startPromise;
this.isStarted = true;
const epoch = ++this.startEpoch;
this.startPromise = (async () => {
await makePersistent(this, 'NewDeviceMonitoringStore', ['knownDeviceIds', 'ignoredDeviceIds', 'suppressAlerts']);
if (!this.isStarted || epoch !== this.startEpoch) return;
this.startMonitoring();
})();
return this.startPromise;
}
private handleDeviceStateChange(state: VoiceDeviceState): void {
if (!this.isStarted) return;
if (state.permissionStatus !== 'granted') {
return;
}
if (this.suppressAlerts) {
return;
}
const currentInputIds = state.inputDevices.map((d) => d.deviceId);
const currentOutputIds = state.outputDevices.map((d) => d.deviceId);
const allCurrentIds = [...currentInputIds, ...currentOutputIds];
if (!this.isInitialized) {
runInAction(() => {
this.knownDeviceIds = [...new Set([...this.knownDeviceIds, ...allCurrentIds])];
this.isInitialized = true;
});
logger.debug('Initialized with known devices', {count: this.knownDeviceIds.length});
return;
}
const newInputDevices = state.inputDevices.filter(
(device) =>
device.deviceId !== 'default' &&
!this.knownDeviceIds.includes(device.deviceId) &&
!this.ignoredDeviceIds.includes(device.deviceId) &&
device.label,
);
const newOutputDevices = state.outputDevices.filter(
(device) =>
device.deviceId !== 'default' &&
!this.knownDeviceIds.includes(device.deviceId) &&
!this.ignoredDeviceIds.includes(device.deviceId) &&
device.label,
);
if (newInputDevices.length > 0 || newOutputDevices.length > 0) {
logger.debug('New devices detected', {
inputs: newInputDevices.map((d) => d.label),
outputs: newOutputDevices.map((d) => d.label),
});
runInAction(() => {
for (const device of newInputDevices) {
this.pendingPrompts.push({
deviceId: device.deviceId,
deviceName: device.label,
deviceType: 'input',
});
this.knownDeviceIds.push(device.deviceId);
}
for (const device of newOutputDevices) {
this.pendingPrompts.push({
deviceId: device.deviceId,
deviceName: device.label,
deviceType: 'output',
});
this.knownDeviceIds.push(device.deviceId);
}
});
this.processNextPrompt();
}
}
private processNextPrompt(): void {
if (!this.isStarted) return;
if (this.isShowingPrompt || this.pendingPrompts.length === 0) {
return;
}
const prompt = this.pendingPrompts.shift();
if (!prompt) {
return;
}
this.isShowingPrompt = true;
this.showNewDeviceModal(prompt);
}
private showNewDeviceModal(prompt: PendingDevicePrompt): void {
if (!this.i18n) {
throw new Error('NewDeviceMonitoringStore: i18n not initialized');
}
const i18n = this.i18n;
const {deviceId, deviceName, deviceType} = prompt;
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={i18n._(msg`New audio device detected!`)}
description={
deviceType === 'input' ? (
<Trans>
Fluxer has found a new audio input device named <strong>{deviceName}</strong>. Do you want to switch to
it?
</Trans>
) : (
<Trans>
Fluxer has found a new audio output device named <strong>{deviceName}</strong>. Do you want to switch to
it?
</Trans>
)
}
primaryText={i18n._(msg`Switch Device`)}
primaryVariant="primary"
secondaryText={i18n._(msg`Not Now`)}
checkboxContent={
<Checkbox>
<Trans>
Don't ask me this again for <strong>{deviceName}</strong>
</Trans>
</Checkbox>
}
onPrimary={(dontAskAgain) => {
if (deviceType === 'input') {
VoiceSettingsStore.updateSettings({inputDeviceId: deviceId});
} else {
VoiceSettingsStore.updateSettings({outputDeviceId: deviceId});
}
if (dontAskAgain) {
this.addToIgnored(deviceId);
}
queueMicrotask(() => this.onModalClosed());
}}
onSecondary={(dontAskAgain) => {
if (dontAskAgain) {
this.addToIgnored(deviceId);
}
queueMicrotask(() => this.onModalClosed());
}}
/>
)),
);
}
private onModalClosed(): void {
this.isShowingPrompt = false;
this.processNextPrompt();
}
private addToIgnored(deviceId: string): void {
if (!this.ignoredDeviceIds.includes(deviceId)) {
runInAction(() => {
this.ignoredDeviceIds.push(deviceId);
});
logger.debug('Added device to ignore list', {deviceId});
}
}
clearIgnoredDevices(): void {
this.ignoredDeviceIds = [];
logger.debug('Cleared all ignored devices');
}
removeFromIgnored(deviceId: string): void {
const index = this.ignoredDeviceIds.indexOf(deviceId);
if (index !== -1) {
this.ignoredDeviceIds.splice(index, 1);
logger.debug('Removed device from ignore list', {deviceId});
}
}
getIgnoredDeviceIds(): ReadonlyArray<string> {
return this.ignoredDeviceIds;
}
setSuppressAlerts(suppress: boolean): void {
this.suppressAlerts = suppress;
logger.debug('Suppress alerts setting changed', {suppress});
}
showTestModal(): void {
this.showNewDeviceModal({
deviceId: 'test-device-id',
deviceName: 'Test Audio Device',
deviceType: 'input',
});
}
dispose(): void {
this.isStarted = false;
this.startPromise = null;
this.startEpoch++;
this.pendingPrompts = [];
this.isShowingPrompt = false;
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = null;
}
this.isInitialized = false;
}
}
export default new NewDeviceMonitoringStore();

View File

@@ -0,0 +1,522 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {LRUCache} from 'lru-cache';
import {makeAutoObservable, reaction, runInAction} from 'mobx';
import {
ChannelTypes,
ME,
MessageFlags,
MessageNotifications,
MessageTypes,
RelationshipTypes,
StatusTypes,
} from '~/Constants';
import {IS_DEV} from '~/lib/env';
import {Logger} from '~/lib/Logger';
import {makePersistent, stopPersistent} from '~/lib/MobXPersistence';
import {parseAndRenderToPlaintext} from '~/lib/markdown/plaintext';
import {getParserFlagsForContext, MarkdownContext} from '~/lib/markdown/renderers';
import {Routes} from '~/Routes';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {Message} from '~/records/MessageRecord';
import {MessageRecord} from '~/records/MessageRecord';
import type {Relationship} from '~/records/RelationshipRecord';
import type {UserRecord} from '~/records/UserRecord';
import * as PushSubscriptionService from '~/services/push/PushSubscriptionService';
import AccountManager from '~/stores/AccountManager';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ChannelStore from '~/stores/ChannelStore';
import GuildStore from '~/stores/GuildStore';
import LocalPresenceStore from '~/stores/LocalPresenceStore';
import RelationshipStore from '~/stores/RelationshipStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import UserStore from '~/stores/UserStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import * as MessageUtils from '~/utils/MessageUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
import * as NotificationUtils from '~/utils/NotificationUtils';
import {isInstalledPwa} from '~/utils/PwaUtils';
import {SystemMessageUtils} from '~/utils/SystemMessageUtils';
import FriendsTabStore from './FriendsTabStore';
import GuildNSFWAgreeStore from './GuildNSFWAgreeStore';
const logger = new Logger('NotificationStore');
const shouldManagePushSubscriptions = (): boolean => isInstalledPwa();
export enum TTSNotificationMode {
FOR_ALL_CHANNELS = 0,
FOR_CURRENT_CHANNEL = 1,
NEVER = 2,
}
const MAX_PER_CHANNEL = 5;
const CACHE_SIZE = 500;
interface TrackedNotification {
browserNotification: Notification | null;
nativeId: string | null;
}
const notificationTracker = new (class {
private channels: Record<string, Array<TrackedNotification>> = {};
track(channelId: string, notification: TrackedNotification): void {
let notifications = this.channels[channelId];
if (notifications == null) {
notifications = [];
this.channels[channelId] = notifications;
}
notifications.push(notification);
while (notifications.length > MAX_PER_CHANNEL) {
const old = notifications.shift();
if (old) {
old.browserNotification?.close();
if (old.nativeId) {
NotificationUtils.closeNativeNotification(old.nativeId);
}
}
}
}
clearChannel(channelId: string): void {
const notifications = this.channels[channelId];
if (notifications == null) return;
delete this.channels[channelId];
const browserNotifications = notifications
.map((n) => n.browserNotification)
.filter((n): n is Notification => n != null);
browserNotifications.forEach((notification) => notification.close());
const nativeIds = notifications.map((n) => n.nativeId).filter((id): id is string => id != null);
if (nativeIds.length > 0) {
NotificationUtils.closeNativeNotifications(nativeIds);
}
}
})();
type NotificationData = Readonly<{
message: Message;
user: UserRecord;
channel: ChannelRecord;
}>;
class NotificationStore {
browserNotificationsEnabled = false;
unreadMessageBadgeEnabled = true;
ttsNotificationMode: TTSNotificationMode = TTSNotificationMode.NEVER;
focused = true;
notifiedMessageIds = new LRUCache<string, boolean>({max: CACHE_SIZE});
private isPersisting = false;
private accountReactionDisposer: (() => void) | null = null;
private i18n: I18n | null = null;
constructor() {
makeAutoObservable(
this,
{
notifiedMessageIds: false,
},
{autoBind: true},
);
this.initPersistence();
queueMicrotask(() => {
this.refreshPermission();
});
queueMicrotask(() => {
NotificationUtils.ensureDesktopNotificationClickHandler();
});
queueMicrotask(() => {
this.accountReactionDisposer = reaction(
() => {
try {
return AccountManager?.currentUserId;
} catch {
return undefined;
}
},
() => {
if (!shouldManagePushSubscriptions()) return;
if (!this.browserNotificationsEnabled) return;
void PushSubscriptionService.registerPushSubscription();
},
);
});
if (IS_DEV) {
window.__notificationStoreCleanup = () => this.cleanup();
}
}
setI18n(i18n: I18n): void {
this.i18n = i18n;
}
private async initPersistence(): Promise<void> {
if (this.isPersisting) return;
this.isPersisting = true;
await makePersistent(this, 'NotificationStore', [
'browserNotificationsEnabled',
'unreadMessageBadgeEnabled',
'ttsNotificationMode',
]);
}
private cleanup(): void {
if (!this.isPersisting) return;
stopPersistent('NotificationStore', this);
this.isPersisting = false;
this.accountReactionDisposer?.();
this.accountReactionDisposer = null;
}
getUnreadMessageBadgeEnabled(): boolean {
return this.unreadMessageBadgeEnabled;
}
getBrowserNotificationsEnabled(): boolean {
return this.browserNotificationsEnabled;
}
getTTSNotificationMode(): TTSNotificationMode {
return this.ttsNotificationMode;
}
setTTSNotificationMode(mode: TTSNotificationMode): void {
this.ttsNotificationMode = mode;
}
isFocused(): boolean {
return this.focused;
}
private isMessageMentionLike(channel: ChannelRecord, message: MessageRecord, currentUser: UserRecord): boolean {
if (MessageUtils.isMentioned(currentUser, message)) {
return true;
}
if (channel.isPrivate()) {
return !UserGuildSettingsStore.isGuildOrChannelMuted(null, channel.id);
}
return false;
}
private shouldNotifyBasedOnSettings(
channel: ChannelRecord,
messageRecord: MessageRecord,
currentUser: UserRecord,
): boolean {
const level = UserGuildSettingsStore.resolvedMessageNotifications({
id: channel.id,
guildId: channel.guildId,
parentId: channel.parentId ?? undefined,
type: channel.type,
});
if (level === MessageNotifications.NO_MESSAGES) {
return false;
}
if (level === MessageNotifications.ALL_MESSAGES) {
return true;
}
return this.isMessageMentionLike(channel, messageRecord, currentUser);
}
private validateNotificationData(message: Message): NotificationData | null {
const channel = ChannelStore.getChannel(message.channel_id);
if (!channel) return null;
const user = UserStore.getUser(message.author.id);
if (!user) return null;
if (message.author.id === AuthenticationStore.currentUserId) return null;
if (RelationshipStore.isBlocked(user.id)) return null;
if (LocalPresenceStore.getStatus() === StatusTypes.DND) return null;
if ((message.flags & MessageFlags.SUPPRESS_NOTIFICATIONS) === MessageFlags.SUPPRESS_NOTIFICATIONS) {
return null;
}
if (
UserGuildSettingsStore.allowNoMessages({
id: channel.id,
guildId: channel.guildId,
parentId: channel.parentId ?? undefined,
type: channel.type,
})
) {
return null;
}
if (this.notifiedMessageIds.has(message.id)) {
return null;
}
if (channel.isNSFW() && GuildNSFWAgreeStore.shouldShowGate(channel.id)) {
return null;
}
const currentUser = UserStore.getCurrentUser();
if (!currentUser) return null;
const messageRecord = new MessageRecord(message, {skipUserCache: false});
if (!this.shouldNotifyBasedOnSettings(channel, messageRecord, currentUser)) {
return null;
}
return {message, user, channel};
}
private markNotified(key: string): void {
const newCache = new LRUCache<string, boolean>({max: CACHE_SIZE});
this.notifiedMessageIds.forEach((value, k) => newCache.set(k, value));
newCache.set(key, true);
this.notifiedMessageIds = newCache;
}
private async showNotification(data: NotificationData): Promise<void> {
if (!this.i18n) {
throw new Error('NotificationStore: i18n not initialized');
}
const {message, user, channel} = data;
const shouldPlaySound = !this.focused || channel.id !== SelectedChannelStore.currentChannelId;
if (shouldPlaySound) {
NotificationUtils.playNotificationSoundIfEnabled();
}
if (!this.browserNotificationsEnabled) {
this.markNotified(message.id);
return;
}
if (this.focused && channel.id === SelectedChannelStore.currentChannelId) {
this.markNotified(message.id);
return;
}
let title = NicknameUtils.getNickname(user, channel.guildId);
switch (channel.type) {
case ChannelTypes.GUILD_TEXT:
if (message.type === MessageTypes.DEFAULT) {
title = `${title} (#${channel.name})`;
} else {
const guild = channel.guildId ? GuildStore.getGuild(channel.guildId) : null;
if (guild) {
title = `${guild.name} (#${channel.name})`;
}
}
break;
case ChannelTypes.GROUP_DM:
title = `${title} (${channel.name || 'Group DM'})`;
break;
}
let body = '';
const isUserMessage =
message.type === MessageTypes.DEFAULT ||
message.type === MessageTypes.REPLY ||
message.type === MessageTypes.CLIENT_SYSTEM;
if (!isUserMessage) {
body = SystemMessageUtils.stringify(message, this.i18n) || '';
} else {
body = parseAndRenderToPlaintext(
message.content,
getParserFlagsForContext(MarkdownContext.STANDARD_WITHOUT_JUMBO),
{
channelId: channel.id,
preserveMarkdown: true,
includeEmojiNames: true,
},
);
}
if (!body && message.attachments?.length) {
body = this.i18n._(msg`Attachment: ${message.attachments[0].filename}`);
}
if (!body && message.embeds?.length) {
const embed = message.embeds[0];
if (embed.description) {
body = embed.title ? `${embed.title}: ${embed.description}` : embed.description;
} else if (embed.title) {
body = embed.title;
} else if (embed.fields?.length) {
const field = embed.fields[0];
body = `${field.name}: ${field.value}`;
}
}
const notificationUrl =
channel.guildId && channel.guildId !== ME
? Routes.channelMessage(channel.guildId, channel.id, message.id)
: Routes.dmChannelMessage(channel.id, message.id);
try {
const result = await NotificationUtils.showNotification({
title,
body,
icon: AvatarUtils.getUserAvatarURL(user),
url: notificationUrl,
playSound: false,
});
notificationTracker.track(channel.id, {
browserNotification: result.browserNotification,
nativeId: result.nativeNotificationId,
});
this.markNotified(message.id);
} catch (error) {
logger.error('Failed to show notification', {messageId: message.id, channelId: channel.id}, error);
this.markNotified(message.id);
}
}
handleMessageCreate({message}: {message: Message}): boolean {
const notificationData = this.validateNotificationData(message);
if (!notificationData) {
return false;
}
void this.showNotification(notificationData);
return true;
}
handleNotificationPermissionGranted(): void {
this.browserNotificationsEnabled = true;
if (shouldManagePushSubscriptions()) {
void PushSubscriptionService.registerPushSubscription();
}
}
handleNotificationPermissionDenied(): void {
this.browserNotificationsEnabled = false;
if (shouldManagePushSubscriptions()) {
void PushSubscriptionService.unregisterAllPushSubscriptions();
}
}
async refreshPermission(): Promise<void> {
try {
const granted = await NotificationUtils.isGranted();
runInAction(() => {
this.browserNotificationsEnabled = granted;
});
if (granted) {
if (shouldManagePushSubscriptions()) {
void PushSubscriptionService.registerPushSubscription();
}
}
} catch (error) {
logger.error('Failed to refresh notification permission', error);
}
}
handleNotificationSoundToggle(enabled: boolean): void {
this.unreadMessageBadgeEnabled = enabled;
}
handleWindowFocus({focused}: {focused: boolean}): void {
this.focused = focused;
if (focused) {
const channelId = SelectedChannelStore.currentChannelId;
if (channelId) {
notificationTracker.clearChannel(channelId);
}
}
}
handleChannelSelect({channelId}: {channelId?: string | null}): void {
if (channelId) {
notificationTracker.clearChannel(channelId);
}
}
handleMessageAck({channelId}: {channelId: string}): void {
notificationTracker.clearChannel(channelId);
}
handleMessageDelete({channelId}: {channelId: string}): void {
notificationTracker.clearChannel(channelId);
}
handleRelationshipNotification(relationship: Relationship): void {
if (!this.i18n) {
throw new Error('NotificationStore: i18n not initialized');
}
if (!this.browserNotificationsEnabled) {
return;
}
if (LocalPresenceStore.getStatus() === StatusTypes.DND) {
return;
}
const user = UserStore.getUser(relationship.user?.id ?? relationship.id);
if (!user) {
return;
}
const cacheKey = `relationship_${relationship.type}_${user.id}`;
if (this.notifiedMessageIds.has(cacheKey)) {
return;
}
let title: string;
let body: string;
if (relationship.type === RelationshipTypes.INCOMING_REQUEST) {
title = this.i18n._(msg`Friend Request`);
body = this.i18n._(msg`${user.displayName} sent you a friend request`);
FriendsTabStore.setTab('pending');
} else if (relationship.type === RelationshipTypes.FRIEND) {
title = this.i18n._(msg`Friend Added`);
body = this.i18n._(msg`${user.displayName} is now your friend!`);
} else {
return;
}
void NotificationUtils.showNotification({
title,
body,
icon: AvatarUtils.getUserAvatarURL(user),
url: Routes.ME,
}).catch((error) => {
logger.error('Failed to show relationship notification', {cacheKey}, error);
});
this.markNotified(cacheKey);
}
}
export default new NotificationStore();

View File

@@ -0,0 +1,53 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
const BASE_Z_INDEX = 10000;
const Z_INDEX_INCREMENT = 10;
class OverlayStackStore {
private counter = 0;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
acquire(): number {
const zIndex = BASE_Z_INDEX + this.counter * Z_INDEX_INCREMENT;
this.counter++;
return zIndex;
}
release(): void {
if (this.counter > 0) {
this.counter--;
}
}
peek(): number {
return BASE_Z_INDEX + this.counter * Z_INDEX_INCREMENT;
}
reset(): void {
this.counter = 0;
}
}
export default new OverlayStackStore();

View File

@@ -0,0 +1,88 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, runInAction} from 'mobx';
import * as PackActionCreators from '~/actions/PackActionCreators';
import type {PackDashboardResponse} from '~/types/PackTypes';
type FetchStatus = 'idle' | 'pending' | 'success' | 'error';
class PackStore {
dashboard: PackDashboardResponse | null = null;
fetchStatus: FetchStatus = 'idle';
error: Error | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
async fetch(): Promise<PackDashboardResponse> {
if (this.fetchStatus === 'pending') {
throw new Error('Pack fetch already in progress');
}
this.fetchStatus = 'pending';
this.error = null;
try {
const dashboard = await PackActionCreators.list();
runInAction(() => {
this.dashboard = dashboard;
this.fetchStatus = 'success';
});
return dashboard;
} catch (err) {
runInAction(() => {
this.fetchStatus = 'error';
this.error = err instanceof Error ? err : new Error('Failed to load packs');
});
throw err;
}
}
async refresh(): Promise<void> {
await this.fetch();
}
async createPack(type: 'emoji' | 'sticker', name: string, description?: string | null): Promise<void> {
await PackActionCreators.create(type, name, description);
await this.refresh();
}
async updatePack(packId: string, data: {name?: string; description?: string | null}): Promise<void> {
await PackActionCreators.update(packId, data);
await this.refresh();
}
async deletePack(packId: string): Promise<void> {
await PackActionCreators.remove(packId);
await this.refresh();
}
async installPack(packId: string): Promise<void> {
await PackActionCreators.install(packId);
await this.refresh();
}
async uninstallPack(packId: string): Promise<void> {
await PackActionCreators.uninstall(packId);
await this.refresh();
}
}
export default new PackStore();

View File

@@ -0,0 +1,115 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {RemoteAudioTrack, RemoteParticipant, Room} from 'livekit-client';
import {Track} from 'livekit-client';
import {makeAutoObservable} from 'mobx';
import {Logger} from '~/lib/Logger';
import {makePersistent} from '~/lib/MobXPersistence';
const logger = new Logger('ParticipantVolumeStore');
const isRemoteAudioTrack = (track: Track | null | undefined): track is RemoteAudioTrack =>
track?.kind === Track.Kind.Audio;
const idUser = (identity: string): string | null => {
const m = identity.match(/^user_(\d+)(?:_(.+))?$/);
return m ? m[1] : null;
};
class ParticipantVolumeStore {
volumes: Record<string, number> = {};
localMutes: Record<string, boolean> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'ParticipantVolumeStore', ['volumes', 'localMutes']);
}
setVolume(userId: string, volume: number): void {
const clamped = Math.max(0, Math.min(200, volume));
this.volumes = {
...this.volumes,
[userId]: clamped,
};
logger.debug(`Set volume for ${userId}: ${clamped}`);
}
setLocalMute(userId: string, muted: boolean): void {
this.localMutes = {
...this.localMutes,
[userId]: muted,
};
logger.debug(`Set local mute for ${userId}: ${muted}`);
}
getVolume(userId: string): number {
return this.volumes[userId] ?? 100;
}
isLocalMuted(userId: string): boolean {
return this.localMutes[userId] ?? false;
}
resetUserSettings(userId: string): void {
const newVolumes = {...this.volumes};
const newLocalMutes = {...this.localMutes};
delete newVolumes[userId];
delete newLocalMutes[userId];
this.volumes = newVolumes;
this.localMutes = newLocalMutes;
logger.debug(`Reset settings for ${userId}`);
}
applySettingsToRoom(room: Room | null, selfDeaf: boolean): void {
if (!room) return;
room.remoteParticipants.forEach((participant) => {
this.applySettingsToParticipant(participant, selfDeaf);
});
}
applySettingsToParticipant(participant: RemoteParticipant, selfDeaf: boolean): void {
const userId = idUser(participant.identity);
if (!userId) return;
const volume = this.getVolume(userId);
const locallyMuted = this.isLocalMuted(userId);
participant.audioTrackPublications.forEach((pub) => {
try {
const track = pub.track;
if (isRemoteAudioTrack(track)) {
track.setVolume(volume / 100);
}
const shouldDisable = locallyMuted || selfDeaf;
pub.setEnabled(!shouldDisable);
} catch (error) {
logger.warn(`Failed to apply settings to participant ${userId}`, {error});
}
});
}
}
export default new ParticipantVolumeStore();

View File

@@ -0,0 +1,75 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
export enum PermissionLayoutMode {
COMFY = 'comfy',
DENSE = 'dense',
}
export enum PermissionGridMode {
SINGLE = 'single',
GRID = 'grid',
}
class PermissionLayoutStore {
layoutMode: PermissionLayoutMode = PermissionLayoutMode.COMFY;
gridMode: PermissionGridMode = PermissionGridMode.SINGLE;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'PermissionLayoutStore', ['layoutMode', 'gridMode']);
}
get isComfy(): boolean {
return this.layoutMode === PermissionLayoutMode.COMFY;
}
get isDense(): boolean {
return this.layoutMode === PermissionLayoutMode.DENSE;
}
get isGrid(): boolean {
return this.gridMode === PermissionGridMode.GRID;
}
setLayoutMode(mode: PermissionLayoutMode): void {
this.layoutMode = mode;
}
setGridMode(mode: PermissionGridMode): void {
this.gridMode = mode;
}
toggleLayoutMode(): void {
this.layoutMode = this.isComfy ? PermissionLayoutMode.DENSE : PermissionLayoutMode.COMFY;
}
toggleGridMode(): void {
this.gridMode = this.isGrid ? PermissionGridMode.SINGLE : PermissionGridMode.GRID;
}
}
export default new PermissionLayoutStore();

View File

@@ -0,0 +1,224 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, observable, reaction} from 'mobx';
import type {Channel} from '~/records/ChannelRecord';
import type {Guild, GuildRecord} from '~/records/GuildRecord';
import type {UserRecord} from '~/records/UserRecord';
import ChannelStore from '~/stores/ChannelStore';
import GuildStore from '~/stores/GuildStore';
import UserStore from '~/stores/UserStore';
import * as PermissionUtils from '~/utils/PermissionUtils';
type ChannelId = string;
type GuildId = string;
type UserId = string;
const isChannelLike = (value: unknown): value is Channel => {
return Boolean(value && typeof value === 'object' && 'type' in value && 'id' in value);
};
const isGuildLike = (value: unknown): value is Guild | GuildRecord => {
return Boolean(value && typeof value === 'object' && ('owner_id' in value || 'ownerId' in value));
};
class PermissionStore {
private readonly guildPermissions = observable.map<GuildId, bigint>();
private readonly channelPermissions = observable.map<ChannelId, bigint>();
private readonly guildVersions = observable.map<GuildId, number>();
private globalVersion = 0;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
getChannelPermissions(channelId: ChannelId): bigint | undefined {
return this.channelPermissions.get(channelId);
}
getGuildPermissions(guildId: GuildId): bigint | undefined {
return this.guildPermissions.get(guildId);
}
getGuildVersion(guildId: GuildId): number | undefined {
return this.guildVersions.get(guildId);
}
get version(): number {
return this.globalVersion;
}
can(
permission: bigint,
context: Channel | Guild | GuildRecord | {channelId?: ChannelId; guildId?: GuildId},
): boolean {
let permissions = PermissionUtils.NONE;
if (isChannelLike(context)) {
permissions = this.channelPermissions.get(context.id) ?? PermissionUtils.NONE;
} else if (isGuildLike(context)) {
permissions = this.guildPermissions.get(context.id) ?? PermissionUtils.NONE;
} else if (context.channelId) {
permissions = this.channelPermissions.get(context.channelId) ?? PermissionUtils.NONE;
} else if (context.guildId) {
permissions = this.guildPermissions.get(context.guildId) ?? PermissionUtils.NONE;
}
return (permissions & permission) === permission;
}
canManageUser(permission: bigint, otherUser: UserRecord | UserId, guild: Guild): boolean {
const otherUserId = typeof otherUser === 'string' ? otherUser : otherUser.id;
if (guild.owner_id === otherUserId) {
return false;
}
const me = UserStore.currentUser;
if (!me) return false;
if (!this.can(permission, guild)) {
return false;
}
const myHighestRole = PermissionUtils.getHighestRole(guild, me.id);
const otherHighestRole = PermissionUtils.getHighestRole(guild, otherUserId);
return PermissionUtils.isRoleHigher(guild, me.id, myHighestRole, otherHighestRole);
}
handleConnectionOpen(): void {
this.rebuildPermissions();
}
handleConnectionClose(): void {
this.guildPermissions.clear();
this.channelPermissions.clear();
this.guildVersions.clear();
this.bumpGlobalVersion();
}
handleGuild(): void {
this.rebuildPermissions();
}
handleGuildMemberUpdate(userId: string): void {
const currentUser = UserStore.currentUser;
if (!currentUser) return;
if (userId !== currentUser.id) return;
this.rebuildPermissions();
}
handleUserUpdate(userId: string): void {
this.handleGuildMemberUpdate(userId);
}
handleChannelUpdate(channelId: ChannelId): void {
const channel = ChannelStore.getChannel(channelId);
if (!channel) {
return;
}
const currentUser = UserStore.currentUser;
if (!currentUser) return;
this.channelPermissions.set(channel.id, PermissionUtils.computePermissions(currentUser, channel.toJSON()));
this.bumpGuildVersion(channel.guildId);
}
handleChannelDelete(channelId: ChannelId, guildId?: GuildId): void {
this.channelPermissions.delete(channelId);
this.bumpGuildVersion(guildId);
}
handleGuildRole(guildId: GuildId): void {
const currentUser = UserStore.currentUser;
if (!currentUser) return;
const guild = GuildStore.getGuild(guildId);
if (!guild) return;
this.guildPermissions.set(guildId, PermissionUtils.computePermissions(currentUser, guild.toJSON()));
for (const channel of ChannelStore.channels) {
if (channel.guildId === guildId) {
this.channelPermissions.set(channel.id, PermissionUtils.computePermissions(currentUser, channel.toJSON()));
}
}
this.bumpGuildVersion(guildId);
}
private rebuildPermissions(): void {
const user = UserStore.currentUser;
if (!user) {
this.guildPermissions.clear();
this.channelPermissions.clear();
this.guildVersions.clear();
this.bumpGlobalVersion();
return;
}
this.guildPermissions.clear();
this.channelPermissions.clear();
for (const guild of GuildStore.getGuilds()) {
this.guildPermissions.set(guild.id, PermissionUtils.computePermissions(user, guild.toJSON()));
this.bumpGuildVersion(guild.id);
}
for (const channel of ChannelStore.channels) {
if (Object.keys(channel.permissionOverwrites).length === 0) {
if (channel.guildId != null) {
const guildPerms = this.guildPermissions.get(channel.guildId) ?? PermissionUtils.NONE;
this.channelPermissions.set(channel.id, guildPerms);
} else {
this.channelPermissions.set(channel.id, PermissionUtils.NONE);
}
} else {
this.channelPermissions.set(channel.id, PermissionUtils.computePermissions(user, channel.toJSON()));
}
this.bumpGuildVersion(channel.guildId);
}
this.bumpGlobalVersion();
}
private bumpGlobalVersion(): void {
this.globalVersion += 1;
}
private bumpGuildVersion(guildId?: GuildId | null): void {
if (!guildId) return;
const current = this.guildVersions.get(guildId) ?? 0;
this.guildVersions.set(guildId, current + 1);
this.bumpGlobalVersion();
}
subscribe(callback: () => void): () => void {
return reaction(
() => this.version,
() => callback(),
{fireImmediately: true},
);
}
}
export default new PermissionStore();

View File

@@ -0,0 +1,223 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, runInAction} from 'mobx';
import type {Popout, PopoutKey} from '~/components/uikit/Popout';
import {Logger} from '~/lib/Logger';
import KeyboardModeStore from './KeyboardModeStore';
const logger = new Logger('PopoutStore');
interface FocusRestoreMeta {
target: HTMLElement | null;
keyboardModeEnabled: boolean;
}
class PopoutStore {
popouts: Record<string, Popout> = {};
private focusReturnMeta = new Map<string, FocusRestoreMeta>();
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
open(popout: Popout): void {
logger.debug(`Opening popout: ${popout.key || 'unknown'}`);
const key = this.normalizeKey(popout.key);
const focusTarget = popout.returnFocusRef?.current ?? popout.target ?? null;
this.focusReturnMeta.set(key, {
target: focusTarget,
keyboardModeEnabled: KeyboardModeStore.keyboardModeEnabled,
});
runInAction(() => {
const normalizedDependsOn = popout.dependsOn != null ? this.normalizeKey(popout.dependsOn) : undefined;
const popoutWithNormalizedDependency = normalizedDependsOn ? {...popout, dependsOn: normalizedDependsOn} : popout;
if (!popout.dependsOn) {
this.popouts = {[key]: popoutWithNormalizedDependency};
} else {
const parentChain = this.getParentPopoutChain(normalizedDependsOn!);
this.popouts = {
...parentChain,
[key]: popoutWithNormalizedDependency,
};
}
});
popout.onOpen?.();
}
close(key?: string | number): void {
logger.debug(`Closing popout${key ? `: ${key}` : ''}`);
if (key == null) {
runInAction(() => {
this.popouts = {};
});
this.focusReturnMeta.clear();
return;
}
let closingPopout: Popout | undefined;
let focusMeta: FocusRestoreMeta | null = null;
const keyStr = this.normalizeKey(key);
runInAction(() => {
const targetPopout = this.popouts[keyStr];
closingPopout = targetPopout;
if (!targetPopout) return;
focusMeta = this.focusReturnMeta.get(keyStr) ?? {
target: targetPopout.returnFocusRef?.current ?? targetPopout.target ?? null,
keyboardModeEnabled: KeyboardModeStore.keyboardModeEnabled,
};
const newPopouts = {...this.popouts};
const parentChain = targetPopout.dependsOn
? this.getParentPopoutChain(this.normalizeKey(targetPopout.dependsOn))
: {};
this.removePopoutAndDependents(keyStr, newPopouts);
Object.assign(newPopouts, parentChain);
this.popouts = newPopouts;
});
closingPopout?.onClose?.();
this.focusReturnMeta.delete(keyStr);
this.scheduleFocus(focusMeta);
}
closeAll(): void {
logger.debug('Closing all popouts');
const currentPopouts = Object.values(this.popouts);
currentPopouts.forEach((popout) => {
popout.onClose?.();
});
runInAction(() => {
this.popouts = {};
});
this.focusReturnMeta.clear();
}
reposition(key: PopoutKey): void {
const normalizedKey = this.normalizeKey(key);
const existingPopout = this.popouts[normalizedKey];
if (!existingPopout) return;
runInAction(() => {
this.popouts = {
...this.popouts,
[normalizedKey]: {
...existingPopout,
shouldReposition: true,
},
};
});
}
isOpen(key: PopoutKey): boolean {
return this.normalizeKey(key) in this.popouts;
}
hasDependents(key: PopoutKey): boolean {
const normalizedKey = this.normalizeKey(key);
return Object.values(this.popouts).some((popout) =>
popout.dependsOn ? this.normalizeKey(popout.dependsOn) === normalizedKey : false,
);
}
getPopouts(): Array<Popout> {
return Object.values(this.popouts);
}
private getParentPopoutChain(dependsOnKey: string): Record<string, Popout> {
const result: Record<string, Popout> = {};
let currentKey: string | undefined = dependsOnKey;
while (currentKey != null) {
const popout: Popout = this.popouts[currentKey];
if (!popout) break;
result[currentKey] = popout;
currentKey = popout.dependsOn ? this.normalizeKey(popout.dependsOn) : undefined;
}
return result;
}
private removePopoutAndDependents(key: string, popouts: Record<string, Popout>): void {
const dependentKeys = Object.entries(popouts)
.filter(([_, popout]) => (popout.dependsOn ? this.normalizeKey(popout.dependsOn) === key : false))
.map(([k]) => k);
dependentKeys.forEach((depKey) => {
this.removePopoutAndDependents(depKey, popouts);
this.focusReturnMeta.delete(depKey);
});
delete popouts[key];
this.focusReturnMeta.delete(key);
}
private scheduleFocus(meta: FocusRestoreMeta | null): void {
const retries = 5;
logger.debug(
`PopoutStore.scheduleFocus target=${meta?.target ? meta.target.tagName : 'null'} keyboardMode=${meta?.keyboardModeEnabled ?? false}`,
);
if (!meta || !meta.target) return;
const {target, keyboardModeEnabled} = meta;
queueMicrotask(() => {
const hasHiddenAncestor = (element: HTMLElement): boolean =>
Boolean(element.closest('[aria-hidden="true"], [data-floating-ui-inert]'));
const attemptFocus = (remainingRetries: number): void => {
if (!target.isConnected) {
logger.debug('PopoutStore.scheduleFocus aborted: target disconnected');
return;
}
if (hasHiddenAncestor(target) && remainingRetries > 0) {
requestAnimationFrame(() => attemptFocus(remainingRetries - 1));
return;
}
try {
target.focus({preventScroll: true});
logger.debug('PopoutStore.scheduleFocus applied focus to target');
} catch (error) {
logger.error('PopoutStore.scheduleFocus failed to focus target', error as Error);
return;
}
if (keyboardModeEnabled) {
logger.debug('PopoutStore.scheduleFocus re-entering keyboard mode');
KeyboardModeStore.enterKeyboardMode(false);
}
};
attemptFocus(retries);
});
}
private normalizeKey(key: PopoutKey | string): string {
return typeof key === 'string' ? key : key.toString();
}
}
export default new PopoutStore();

View File

@@ -0,0 +1,518 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, reaction} from 'mobx';
import type {StatusType} from '~/Constants';
import {ChannelTypes, ME, normalizeStatus, RelationshipTypes, StatusTypes} from '~/Constants';
import {type CustomStatus, fromGatewayCustomStatus, type GatewayCustomStatusPayload} from '~/lib/customStatus';
import type {GuildReadyData} from '~/records/GuildRecord';
import type {UserPartial, UserPrivate} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ChannelStore from '~/stores/ChannelStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import LocalPresenceStore from '~/stores/LocalPresenceStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import RelationshipStore from '~/stores/RelationshipStore';
export interface Presence {
readonly guild_id?: string | null;
readonly user: UserPartial;
readonly status?: string | null;
readonly afk?: boolean;
readonly mobile?: boolean;
readonly custom_status?: GatewayCustomStatusPayload | null;
}
interface FlattenedPresence {
status: StatusType;
timestamp: number;
afk?: boolean;
mobile?: boolean;
guildIds: Set<string>;
customStatus: CustomStatus | null;
}
type StatusListener = (userId: string, status: StatusType, isMobile: boolean) => void;
class PresenceStore {
private presences = new Map<string, FlattenedPresence>();
private customStatuses = new Map<string, CustomStatus | null>();
statuses = new Map<string, StatusType>();
presenceVersion = 0;
private statusListeners: Map<string, Set<StatusListener>> = new Map();
constructor() {
makeAutoObservable<this, 'statusListeners' | 'presences'>(
this,
{
statusListeners: false,
presences: false,
},
{autoBind: true},
);
reaction(
() => ({status: LocalPresenceStore.status, customStatus: LocalPresenceStore.customStatus}),
() => this.syncLocalPresence(),
);
}
private bumpPresenceVersion(): void {
this.presenceVersion++;
}
getStatus(userId: string): StatusType {
return this.statuses.get(userId) ?? StatusTypes.OFFLINE;
}
isMobile(userId: string): boolean {
if (userId === AuthenticationStore.currentUserId) {
return MobileLayoutStore.isMobileLayout();
}
return this.presences.get(userId)?.mobile ?? false;
}
getCustomStatus(userId: string): CustomStatus | null {
return this.customStatuses.get(userId) ?? null;
}
getPresenceCount(guildId: string): number {
void this.presenceVersion;
const currentUserId = AuthenticationStore.currentUserId;
const localStatus = LocalPresenceStore.getStatus();
const localPresence =
currentUserId &&
GuildMemberStore.getMember(guildId, currentUserId) != null &&
localStatus !== StatusTypes.OFFLINE &&
localStatus !== StatusTypes.INVISIBLE
? 1
: 0;
let remotePresences = 0;
for (const presence of this.presences.values()) {
if (
presence.guildIds.has(guildId) &&
presence.status !== StatusTypes.OFFLINE &&
presence.status !== StatusTypes.INVISIBLE
) {
remotePresences++;
}
}
return localPresence + remotePresences;
}
subscribeToUserStatus(userId: string, listener: StatusListener): () => void {
let listeners = this.statusListeners.get(userId);
if (!listeners) {
listeners = new Set();
this.statusListeners.set(userId, listeners);
}
listeners.add(listener);
listener(userId, this.getStatus(userId), this.isMobile(userId));
return () => {
const currentListeners = this.statusListeners.get(userId);
if (!currentListeners) {
return;
}
currentListeners.delete(listener);
if (currentListeners.size === 0) {
this.statusListeners.delete(userId);
}
};
}
handleGuildMemberAdd(guildId: string, userId: string): void {
if (userId === AuthenticationStore.currentUserId) {
return;
}
const presence = this.presences.get(userId);
if (!presence) {
return;
}
presence.guildIds.add(guildId);
this.bumpPresenceVersion();
}
handleGuildMemberRemove(guildId: string, userId: string): void {
if (userId === AuthenticationStore.currentUserId) {
return;
}
const presence = this.presences.get(userId);
if (!presence) {
return;
}
if (presence.guildIds.delete(guildId) && presence.guildIds.size === 0) {
this.evictPresence(userId);
return;
}
this.bumpPresenceVersion();
}
handleGuildMemberUpdate(guildId: string, userId: string): void {
if (userId === AuthenticationStore.currentUserId) {
return;
}
const guild = GuildStore.getGuild(guildId);
if (!guild) {
return;
}
const presence = this.presences.get(userId);
if (!presence) {
return;
}
presence.guildIds.add(guildId);
presence.timestamp = Date.now();
this.bumpPresenceVersion();
}
handleConnectionOpen(user: UserPrivate, guilds: Array<GuildReadyData>, presences?: ReadonlyArray<Presence>): void {
const localStatus = LocalPresenceStore.getStatus();
const localCustomStatus = LocalPresenceStore.customStatus;
this.presences.clear();
this.statuses.clear();
this.customStatuses.clear();
this.bumpPresenceVersion();
this.statuses.set(user.id, localStatus);
this.customStatuses.set(user.id, localCustomStatus);
const userGuildIds = new Map<string, Set<string>>();
const meContextUserIds = this.buildMeContextUserIds(user.id);
for (const guild of guilds) {
if (guild.unavailable) {
continue;
}
this.indexGuildMembers(guild, user.id, userGuildIds);
}
if (presences?.length) {
for (const presence of presences) {
const presenceUserId = presence.user.id;
this.handleReadyPresence(presence, userGuildIds.get(presenceUserId), meContextUserIds.has(presenceUserId));
}
}
this.resyncExternalStatusListeners();
}
handleGuildCreate(guild: GuildReadyData): void {
if (guild.unavailable) {
return;
}
const currentUserId = AuthenticationStore.currentUserId;
if (!currentUserId) {
return;
}
const members = guild.members;
if (!members?.length) {
return;
}
let updated = false;
for (const member of members) {
const userId = member.user.id;
if (!userId || userId === currentUserId) {
continue;
}
const presence = this.presences.get(userId);
if (presence) {
presence.guildIds.add(guild.id);
updated = true;
}
}
if (updated) {
this.bumpPresenceVersion();
}
}
handleGuildDelete(guildId: string): void {
const usersToEvict: Array<string> = [];
let changed = false;
for (const [userId, presence] of this.presences) {
if (!presence.guildIds.has(guildId)) {
continue;
}
presence.guildIds.delete(guildId);
changed = true;
if (presence.guildIds.size === 0) {
usersToEvict.push(userId);
}
}
for (const userId of usersToEvict) {
this.evictPresence(userId);
}
if (changed && usersToEvict.length === 0) {
this.bumpPresenceVersion();
}
}
handlePresenceUpdate(presence: Presence): void {
const {guild_id: guildIdRaw, user, status, afk, mobile, custom_status: customStatusPayload} = presence;
const normalizedStatus = normalizeStatus(status);
const userId = user.id;
const customStatus = fromGatewayCustomStatus(customStatusPayload);
if (userId === AuthenticationStore.currentUserId) {
return;
}
const guildId = guildIdRaw ?? ME;
const existing = this.presences.get(userId);
const now = Date.now();
if (!existing) {
const guildIds = new Set<string>();
guildIds.add(guildId);
const flattened: FlattenedPresence = {
status: normalizedStatus,
timestamp: now,
afk,
mobile,
guildIds,
customStatus,
};
this.presences.set(userId, flattened);
this.customStatuses.set(userId, customStatus);
this.updateStatusFromPresence(userId, flattened);
this.bumpPresenceVersion();
return;
}
existing.guildIds.add(guildId);
existing.status = normalizedStatus;
existing.timestamp = now;
if (afk !== undefined) {
existing.afk = afk;
}
if (mobile !== undefined) {
existing.mobile = mobile;
}
existing.customStatus = customStatus;
this.customStatuses.set(userId, customStatus);
if (normalizedStatus === StatusTypes.OFFLINE && guildIdRaw == null) {
existing.guildIds.delete(ME);
if (existing.guildIds.size === 0) {
this.evictPresence(userId);
return;
}
}
this.updateStatusFromPresence(userId, existing);
this.bumpPresenceVersion();
}
private handleReadyPresence(presence: Presence, initialGuildIds?: Set<string>, hasMeContext = false): void {
const {user, status, afk, mobile, custom_status: customStatusPayload} = presence;
const normalizedStatus = normalizeStatus(status);
const customStatus = fromGatewayCustomStatus(customStatusPayload);
const userId = user.id;
if (userId === AuthenticationStore.currentUserId) {
return;
}
const now = Date.now();
const guildIds = initialGuildIds && initialGuildIds.size > 0 ? new Set<string>(initialGuildIds) : new Set<string>();
if (hasMeContext || guildIds.size === 0) {
guildIds.add(ME);
}
const flattened: FlattenedPresence = {
status: normalizedStatus,
timestamp: now,
afk,
mobile,
guildIds,
customStatus,
};
this.presences.set(userId, flattened);
this.customStatuses.set(userId, customStatus);
this.updateStatusFromPresence(userId, flattened);
this.bumpPresenceVersion();
}
private indexGuildMembers(
guild: GuildReadyData,
currentUserId: string,
userGuildIds: Map<string, Set<string>>,
): void {
const members = guild.members;
if (!members?.length) {
return;
}
for (const member of members) {
const userId = member.user.id;
if (!userId || userId === currentUserId) {
continue;
}
let guildIds = userGuildIds.get(userId);
if (!guildIds) {
guildIds = new Set<string>();
userGuildIds.set(userId, guildIds);
}
guildIds.add(guild.id);
}
}
private syncLocalPresence(): void {
if (!AuthenticationStore) return;
const userId = AuthenticationStore.currentUserId;
if (!userId) {
return;
}
const localStatus = LocalPresenceStore.getStatus();
const localCustomStatus = LocalPresenceStore.customStatus;
const oldStatus = this.statuses.get(userId);
let changed = false;
if (oldStatus !== localStatus) {
this.statuses.set(userId, localStatus);
this.notifyStatusListeners(userId, localStatus, this.isMobile(userId));
changed = true;
}
this.customStatuses.set(userId, localCustomStatus);
changed = true;
if (changed) {
this.bumpPresenceVersion();
}
}
private buildMeContextUserIds(currentUserId: string): Set<string> {
const userIds = new Set<string>();
for (const relationship of RelationshipStore.getRelationships()) {
if (relationship.type === RelationshipTypes.FRIEND || relationship.type === RelationshipTypes.INCOMING_REQUEST) {
userIds.add(relationship.id);
}
}
for (const channel of ChannelStore.getPrivateChannels()) {
if (channel.type !== ChannelTypes.GROUP_DM) {
continue;
}
for (const userId of channel.recipientIds) {
if (userId !== currentUserId) {
userIds.add(userId);
}
}
}
return userIds;
}
private resyncExternalStatusListeners(): void {
for (const userId of Array.from(this.statusListeners.keys())) {
this.notifyStatusListeners(userId, this.getStatus(userId), this.isMobile(userId));
}
}
private notifyStatusListeners(userId: string, status: StatusType, isMobile: boolean): void {
const listeners = this.statusListeners.get(userId);
if (!listeners || listeners.size === 0) {
return;
}
for (const listener of listeners) {
try {
listener(userId, status, isMobile);
} catch (error) {
console.error(`Error in status listener for user ${userId}:`, error);
}
}
}
private updateStatusFromPresence(userId: string, presence: FlattenedPresence): void {
const oldStatus = this.statuses.get(userId) ?? StatusTypes.OFFLINE;
const newStatus = presence.status ?? StatusTypes.OFFLINE;
const newMobile = presence.mobile ?? false;
const statusChanged = oldStatus !== newStatus;
if (statusChanged) {
this.statuses.set(userId, newStatus);
}
this.notifyStatusListeners(userId, newStatus, newMobile);
}
private evictPresence(userId: string): void {
this.presences.delete(userId);
this.customStatuses.delete(userId);
this.bumpPresenceVersion();
const oldStatus = this.statuses.get(userId);
if (oldStatus === undefined) {
return;
}
this.statuses.delete(userId);
if (oldStatus !== StatusTypes.OFFLINE) {
this.notifyStatusListeners(userId, StatusTypes.OFFLINE, false);
}
}
}
export default new PresenceStore();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,214 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {ChannelTypes} from '~/Constants';
import {makePersistent} from '~/lib/MobXPersistence';
import type {Channel} from '~/records/ChannelRecord';
import {type Message, MessageRecord, messageMentionsCurrentUser} from '~/records/MessageRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ChannelStore from '~/stores/ChannelStore';
import GuildNSFWAgreeStore from '~/stores/GuildNSFWAgreeStore';
import GuildStore from '~/stores/GuildStore';
import MobileMentionToastStore from '~/stores/MobileMentionToastStore';
import type {ReactionEmoji} from '~/utils/ReactionUtils';
export interface MentionFilters {
includeEveryone: boolean;
includeRoles: boolean;
includeGuilds: boolean;
}
class RecentMentionsStore {
recentMentions: Array<MessageRecord> = [];
fetched = false;
hasMore = true;
isLoadingMore = false;
filters: MentionFilters = {
includeEveryone: true,
includeRoles: true,
includeGuilds: true,
};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'RecentMentionsStore', ['filters']);
}
getFilters(): MentionFilters {
return this.filters;
}
getHasMore(): boolean {
return this.hasMore;
}
getIsLoadingMore(): boolean {
return this.isLoadingMore;
}
getAccessibleMentions(): ReadonlyArray<MessageRecord> {
return this.recentMentions.filter((message) => this.isMessageAccessible(message));
}
private isMessageAccessible(message: MessageRecord): boolean {
const channel = ChannelStore.getChannel(message.channelId);
if (!channel) {
return false;
}
switch (channel.type) {
case ChannelTypes.DM:
case ChannelTypes.DM_PERSONAL_NOTES:
return true;
case ChannelTypes.GROUP_DM:
return channel.recipientIds.length > 0;
case ChannelTypes.GUILD_TEXT:
case ChannelTypes.GUILD_VOICE: {
if (!channel.guildId) return false;
const guild = GuildStore.getGuild(channel.guildId);
return guild != null;
}
default:
return false;
}
}
handleConnectionOpen(): void {
this.recentMentions = this.recentMentions.filter((message) => this.isMessageAccessible(message));
}
handleFetchPending(): void {
this.isLoadingMore = true;
}
handleRecentMentionsFetchSuccess(messages: ReadonlyArray<Message>): void {
const filteredMessages = this.filterMessages(messages);
const isLoadMore = this.isLoadingMore && this.fetched;
if (isLoadMore) {
this.recentMentions.push(...filteredMessages.map((m) => new MessageRecord(m)));
} else {
this.recentMentions = filteredMessages.map((message) => new MessageRecord(message));
}
this.fetched = true;
this.hasMore = messages.length === 25;
this.isLoadingMore = false;
}
handleRecentMentionsFetchError(): void {
this.isLoadingMore = false;
}
updateFilters(filters: Partial<MentionFilters>): void {
Object.assign(this.filters, filters);
this.fetched = false;
}
private filterMessages(messages: ReadonlyArray<Message>): ReadonlyArray<Message> {
return messages.filter((message) => {
const channel = ChannelStore.getChannel(message.channel_id);
if (!channel) return false;
if (channel.isNSFW()) {
return !GuildNSFWAgreeStore.shouldShowGate(channel.id);
}
return true;
});
}
handleChannelDelete(channel: Channel): void {
this.recentMentions = this.recentMentions.filter((message) => message.channelId !== channel.id);
}
handleGuildDelete(guildId: string): void {
this.recentMentions = this.recentMentions.filter((message) => {
const channel = ChannelStore.getChannel(message.channelId);
return !channel || channel.guildId !== guildId;
});
}
handleMessageUpdate(message: Message): void {
const index = this.recentMentions.findIndex((m) => m.id === message.id);
if (index === -1) return;
this.recentMentions[index] = this.recentMentions[index].withUpdates(message);
}
handleMessageDelete(messageId: string): void {
this.recentMentions = this.recentMentions.filter((message) => message.id !== messageId);
MobileMentionToastStore.dequeue(messageId);
}
handleMessageCreate(message: Message): void {
if (!messageMentionsCurrentUser(message)) {
return;
}
const channel = ChannelStore.getChannel(message.channel_id);
if (!channel) return;
if (channel.isNSFW()) {
if (GuildNSFWAgreeStore.shouldShowGate(channel.id)) {
return;
}
}
const messageRecord = new MessageRecord(message);
this.recentMentions.unshift(messageRecord);
MobileMentionToastStore.enqueue(messageRecord);
}
private updateMessageWithReaction(messageId: string, updater: (message: MessageRecord) => MessageRecord): void {
const index = this.recentMentions.findIndex((m) => m.id === messageId);
if (index === -1) return;
this.recentMentions[index] = updater(this.recentMentions[index]);
}
handleMessageReactionAdd(messageId: string, userId: string, emoji: ReactionEmoji): void {
this.updateMessageWithReaction(messageId, (message) =>
message.withReaction(emoji, true, userId === AuthenticationStore.currentUserId),
);
}
handleMessageReactionRemove(messageId: string, userId: string, emoji: ReactionEmoji): void {
this.updateMessageWithReaction(messageId, (message) =>
message.withReaction(emoji, false, userId === AuthenticationStore.currentUserId),
);
}
handleMessageReactionRemoveAll(messageId: string): void {
this.updateMessageWithReaction(messageId, (message) => message.withUpdates({reactions: []}));
}
handleMessageReactionRemoveEmoji(messageId: string, emoji: ReactionEmoji): void {
this.updateMessageWithReaction(messageId, (message) => message.withoutReactionEmoji(emoji));
}
}
export default new RecentMentionsStore();

View File

@@ -0,0 +1,76 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {RelationshipTypes} from '~/Constants';
import {type Relationship, RelationshipRecord} from '~/records/RelationshipRecord';
class RelationshipStore {
relationships: Record<string, RelationshipRecord> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
loadRelationships(relationships: ReadonlyArray<Relationship>): void {
const newRelationships: Record<string, RelationshipRecord> = {};
for (const relationship of relationships) {
newRelationships[relationship.id] = new RelationshipRecord(relationship);
}
this.relationships = newRelationships;
}
updateRelationship(relationship: Relationship): void {
const existingRelationship = this.relationships[relationship.id];
if (existingRelationship) {
this.relationships = {
...this.relationships,
[relationship.id]: existingRelationship.withUpdates(relationship),
};
} else {
this.relationships = {
...this.relationships,
[relationship.id]: new RelationshipRecord(relationship),
};
}
}
removeRelationship(relationshipId: string): void {
const {[relationshipId]: _, ...remainingRelationships} = this.relationships;
this.relationships = remainingRelationships;
}
getRelationship(relationshipId: string): RelationshipRecord | undefined {
return this.relationships[relationshipId];
}
getRelationships(): ReadonlyArray<RelationshipRecord> {
return Object.values(this.relationships);
}
isBlocked(userId: string): boolean {
const relationship = this.relationships[userId];
return relationship?.type === RelationshipTypes.BLOCKED;
}
}
export default new RelationshipStore();

View File

@@ -0,0 +1,435 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, reaction, runInAction} from 'mobx';
import Config from '~/Config';
import {API_CODE_VERSION} from '~/Constants';
import type {HttpRequestConfig} from '~/lib/HttpClient';
import HttpClient from '~/lib/HttpClient';
import {makePersistent} from '~/lib/MobXPersistence';
import {wrapUrlWithElectronApiProxy} from '~/utils/ApiProxyUtils';
import DeveloperOptionsStore from './DeveloperOptionsStore';
const DEFAULT_FLUXER_GATEWAY_HOSTS = new Set(['gateway.fluxer.app', 'gateway.canary.fluxer.app']);
export interface InstanceFeatures {
sms_mfa_enabled: boolean;
voice_enabled: boolean;
stripe_enabled: boolean;
self_hosted: boolean;
}
export interface InstanceEndpoints {
api: string;
api_client?: string;
api_public?: string;
gateway: string;
media: string;
cdn: string;
marketing: string;
admin: string;
invite: string;
gift: string;
webapp: string;
}
export interface InstanceCaptcha {
provider: 'hcaptcha' | 'turnstile' | 'none';
hcaptcha_site_key: string | null;
turnstile_site_key: string | null;
}
export interface InstancePush {
public_vapid_key: string | null;
}
export interface InstanceDiscoveryResponse {
api_code_version: number;
endpoints: InstanceEndpoints;
captcha: InstanceCaptcha;
features: InstanceFeatures;
push?: InstancePush;
}
export interface RuntimeConfigSnapshot {
apiEndpoint: string;
apiPublicEndpoint: string;
gatewayEndpoint: string;
mediaEndpoint: string;
cdnEndpoint: string;
marketingEndpoint: string;
adminEndpoint: string;
inviteEndpoint: string;
giftEndpoint: string;
webAppEndpoint: string;
captchaProvider: 'hcaptcha' | 'turnstile' | 'none';
hcaptchaSiteKey: string | null;
turnstileSiteKey: string | null;
apiCodeVersion: number;
features: InstanceFeatures;
publicPushVapidKey: string | null;
}
type InitState = 'initializing' | 'ready' | 'error';
class RuntimeConfigStore {
private _initState: InitState = 'initializing';
private _initError: Error | null = null;
private _initPromise: Promise<void>;
private _resolveInit!: () => void;
private _rejectInit!: (err: Error) => void;
private _connectSeq = 0;
apiEndpoint: string = '';
apiPublicEndpoint: string = '';
gatewayEndpoint: string = '';
mediaEndpoint: string = '';
cdnEndpoint: string = '';
marketingEndpoint: string = '';
adminEndpoint: string = '';
inviteEndpoint: string = '';
giftEndpoint: string = '';
webAppEndpoint: string = '';
captchaProvider: 'hcaptcha' | 'turnstile' | 'none' = 'none';
hcaptchaSiteKey: string | null = null;
turnstileSiteKey: string | null = null;
apiCodeVersion: number = API_CODE_VERSION;
features: InstanceFeatures = {
sms_mfa_enabled: false,
voice_enabled: false,
stripe_enabled: false,
self_hosted: false,
};
publicPushVapidKey: string | null = null;
constructor() {
this._initPromise = new Promise<void>((resolve, reject) => {
this._resolveInit = resolve;
this._rejectInit = reject;
});
makeAutoObservable(this, {}, {autoBind: true});
this.initialize().catch(() => {});
reaction(
() => this.apiEndpoint,
(endpoint) => {
if (endpoint) {
HttpClient.setBaseUrl(endpoint, Config.PUBLIC_API_VERSION);
}
},
{fireImmediately: true},
);
}
private async initialize(): Promise<void> {
try {
await makePersistent(this, 'runtimeConfig', [
'apiEndpoint',
'apiPublicEndpoint',
'gatewayEndpoint',
'mediaEndpoint',
'cdnEndpoint',
'marketingEndpoint',
'adminEndpoint',
'inviteEndpoint',
'giftEndpoint',
'webAppEndpoint',
'captchaProvider',
'hcaptchaSiteKey',
'turnstileSiteKey',
'apiCodeVersion',
'features',
'publicPushVapidKey',
]);
const bootstrapEndpoint = this.apiEndpoint || Config.PUBLIC_BOOTSTRAP_API_ENDPOINT;
await this.connectToEndpoint(bootstrapEndpoint);
runInAction(() => {
this._initState = 'ready';
this._initError = null;
});
this._resolveInit();
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
runInAction(() => {
this._initState = 'error';
this._initError = err;
});
this._rejectInit(err);
}
}
waitForInit(): Promise<void> {
return this._initPromise;
}
get initialized(): boolean {
return this._initState === 'ready';
}
get initError(): Error | null {
return this._initError;
}
applySnapshot(snapshot: RuntimeConfigSnapshot): void {
this.apiEndpoint = snapshot.apiEndpoint;
this.apiPublicEndpoint = snapshot.apiPublicEndpoint;
this.gatewayEndpoint = snapshot.gatewayEndpoint;
this.mediaEndpoint = snapshot.mediaEndpoint;
this.cdnEndpoint = snapshot.cdnEndpoint;
this.marketingEndpoint = snapshot.marketingEndpoint;
this.adminEndpoint = snapshot.adminEndpoint;
this.inviteEndpoint = snapshot.inviteEndpoint;
this.giftEndpoint = snapshot.giftEndpoint;
this.webAppEndpoint = snapshot.webAppEndpoint;
this.captchaProvider = snapshot.captchaProvider;
this.hcaptchaSiteKey = snapshot.hcaptchaSiteKey;
this.turnstileSiteKey = snapshot.turnstileSiteKey;
this.apiCodeVersion = snapshot.apiCodeVersion;
this.features = snapshot.features;
this.publicPushVapidKey = snapshot.publicPushVapidKey;
}
getSnapshot(): RuntimeConfigSnapshot {
return {
apiEndpoint: this.apiEndpoint,
apiPublicEndpoint: this.apiPublicEndpoint,
gatewayEndpoint: this.gatewayEndpoint,
mediaEndpoint: this.mediaEndpoint,
cdnEndpoint: this.cdnEndpoint,
marketingEndpoint: this.marketingEndpoint,
adminEndpoint: this.adminEndpoint,
inviteEndpoint: this.inviteEndpoint,
giftEndpoint: this.giftEndpoint,
webAppEndpoint: this.webAppEndpoint,
captchaProvider: this.captchaProvider,
hcaptchaSiteKey: this.hcaptchaSiteKey,
turnstileSiteKey: this.turnstileSiteKey,
apiCodeVersion: this.apiCodeVersion,
features: {...this.features},
publicPushVapidKey: this.publicPushVapidKey,
};
}
async withSnapshot<T>(snapshot: RuntimeConfigSnapshot, fn: () => Promise<T>): Promise<T> {
const before = this.getSnapshot();
this.applySnapshot(snapshot);
try {
return await fn();
} finally {
this.applySnapshot(before);
}
}
async resetToDefaults(): Promise<void> {
await this.connectToEndpoint(Config.PUBLIC_BOOTSTRAP_API_ENDPOINT);
}
async connectToEndpoint(input: string): Promise<void> {
const connectId = ++this._connectSeq;
const apiEndpoint = this.normalizeEndpoint(input);
const instanceUrl = `${apiEndpoint}/instance`;
const requestUrl = wrapUrlWithElectronApiProxy(instanceUrl);
const request: HttpRequestConfig = {url: requestUrl};
const response = await HttpClient.get<InstanceDiscoveryResponse>(request);
if (connectId !== this._connectSeq) {
return;
}
if (!response.ok) {
throw new Error(`Failed to reach ${instanceUrl} (${response.status})`);
}
this.updateFromInstance(response.body);
}
private normalizeEndpoint(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
throw new Error('API endpoint is required');
}
let candidate = trimmed;
if (candidate.startsWith('/')) {
candidate = `${window.location.origin}${candidate}`;
} else if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(candidate)) {
candidate = `https://${candidate}`;
}
const url = new URL(candidate);
if (url.pathname === '' || url.pathname === '/') {
url.pathname = '/api';
}
url.pathname = url.pathname.replace(/\/+$/, '');
return url.toString();
}
private updateFromInstance(instance: InstanceDiscoveryResponse): void {
this.assertCodeVersion(instance.api_code_version);
const apiEndpoint = instance.endpoints.api_client ?? instance.endpoints.api;
const apiPublicEndpoint = instance.endpoints.api_public ?? apiEndpoint;
runInAction(() => {
this.apiEndpoint = apiEndpoint;
this.apiPublicEndpoint = apiPublicEndpoint;
this.gatewayEndpoint = instance.endpoints.gateway;
this.mediaEndpoint = instance.endpoints.media;
this.cdnEndpoint = instance.endpoints.cdn;
this.marketingEndpoint = instance.endpoints.marketing;
this.adminEndpoint = instance.endpoints.admin;
this.inviteEndpoint = instance.endpoints.invite;
this.giftEndpoint = instance.endpoints.gift;
this.webAppEndpoint = instance.endpoints.webapp;
this.captchaProvider = instance.captcha.provider;
this.hcaptchaSiteKey = instance.captcha.hcaptcha_site_key;
this.turnstileSiteKey = instance.captcha.turnstile_site_key;
this.apiCodeVersion = instance.api_code_version;
this.features = instance.features;
this.publicPushVapidKey = instance.push?.public_vapid_key ?? null;
});
}
private assertCodeVersion(instanceVersion: number): void {
if (instanceVersion < API_CODE_VERSION) {
throw new Error(
`Incompatible server (code version ${instanceVersion}); this client requires ${API_CODE_VERSION}.`,
);
}
}
get webAppBaseUrl(): string {
if (this.webAppEndpoint) {
return this.webAppEndpoint.replace(/\/$/, '');
}
try {
const url = new URL(this.apiEndpoint);
if (url.pathname.endsWith('/api')) {
url.pathname = url.pathname.slice(0, -4) || '/';
}
return url.toString().replace(/\/$/, '');
} catch {
return this.apiEndpoint.replace(/\/api$/, '');
}
}
isSelfHosted(): boolean {
return DeveloperOptionsStore.selfHostedModeOverride || this.features.self_hosted;
}
isThirdPartyGateway(): boolean {
if (!this.gatewayEndpoint) return false;
try {
const url = new URL(this.gatewayEndpoint);
return !DEFAULT_FLUXER_GATEWAY_HOSTS.has(url.hostname);
} catch {
return false;
}
}
hasElectronWsProxy(): boolean {
return typeof window.electron?.getWsProxyUrl === 'function';
}
private getWsProxyBaseUrl(): URL | null {
if (!this.hasElectronWsProxy()) {
return null;
}
const raw = window.electron?.getWsProxyUrl();
if (!raw) return null;
try {
return new URL(raw);
} catch {
return null;
}
}
wrapGatewayUrlWithProxy(url: string): string {
if (!this.isThirdPartyGateway()) {
return url;
}
const proxy = this.getWsProxyBaseUrl();
if (!proxy) {
return url;
}
proxy.searchParams.set('target', url);
return proxy.toString();
}
get marketingHost(): string {
try {
return new URL(this.marketingEndpoint).host;
} catch {
return '';
}
}
get inviteHost(): string {
try {
return new URL(this.inviteEndpoint).host;
} catch {
return '';
}
}
get giftHost(): string {
try {
return new URL(this.giftEndpoint).host;
} catch {
return '';
}
}
}
export function describeApiEndpoint(endpoint: string): string {
try {
const url = new URL(endpoint);
const path = url.pathname === '/api' ? '' : url.pathname;
return `${url.host}${path}`;
} catch {
return endpoint;
}
}
export default new RuntimeConfigStore();

View File

@@ -0,0 +1,122 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import type {Channel} from '~/records/ChannelRecord';
import {type Message, MessageRecord} from '~/records/MessageRecord';
import type {SavedMessageEntryRecord, SavedMessageMissingEntry} from '~/records/SavedMessageEntryRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import type {ReactionEmoji} from '~/utils/ReactionUtils';
class SavedMessagesStore {
savedMessages: Array<MessageRecord> = [];
missingSavedMessages: Array<SavedMessageMissingEntry> = [];
fetched = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
isSaved(messageId: string): boolean {
return (
this.savedMessages.some((message) => message.id === messageId) ||
this.missingSavedMessages.some((entry) => entry.id === messageId)
);
}
getMissingEntries(): Array<SavedMessageMissingEntry> {
return this.missingSavedMessages.slice();
}
fetchSuccess(entries: ReadonlyArray<SavedMessageEntryRecord>): void {
this.savedMessages = entries
.filter((entry) => entry.status === 'available' && entry.message)
.map((entry) => entry.message!)
.sort((a, b) => (b.id > a.id ? 1 : a.id > b.id ? -1 : 0));
this.missingSavedMessages = entries
.filter((entry) => entry.status === 'missing_permissions' || entry.message === null)
.map((entry) => entry.toMissingEntry());
this.fetched = true;
}
fetchError(): void {
this.savedMessages = [];
this.missingSavedMessages = [];
this.fetched = false;
}
handleChannelDelete(channel: Channel): void {
this.savedMessages = this.savedMessages.filter((message) => message.channelId !== channel.id);
this.missingSavedMessages = this.missingSavedMessages.filter((entry) => entry.channelId !== channel.id);
}
handleMessageUpdate(message: Message): void {
const index = this.savedMessages.findIndex((m) => m.id === message.id);
if (index === -1) return;
this.savedMessages = [
...this.savedMessages.slice(0, index),
this.savedMessages[index].withUpdates(message),
...this.savedMessages.slice(index + 1),
];
}
handleMessageDelete(messageId: string): void {
this.savedMessages = this.savedMessages.filter((message) => message.id !== messageId);
this.missingSavedMessages = this.missingSavedMessages.filter((entry) => entry.id !== messageId);
}
handleMessageCreate(message: Message): void {
this.missingSavedMessages = this.missingSavedMessages.filter((entry) => entry.id !== message.id);
this.savedMessages = [new MessageRecord(message), ...this.savedMessages];
}
private updateMessageWithReaction(messageId: string, updater: (message: MessageRecord) => MessageRecord): void {
const index = this.savedMessages.findIndex((m) => m.id === messageId);
if (index === -1) return;
this.savedMessages = [
...this.savedMessages.slice(0, index),
updater(this.savedMessages[index]),
...this.savedMessages.slice(index + 1),
];
}
handleMessageReactionAdd(messageId: string, userId: string, emoji: ReactionEmoji): void {
this.updateMessageWithReaction(messageId, (message) =>
message.withReaction(emoji, true, userId === AuthenticationStore.currentUserId),
);
}
handleMessageReactionRemove(messageId: string, userId: string, emoji: ReactionEmoji): void {
this.updateMessageWithReaction(messageId, (message) =>
message.withReaction(emoji, false, userId === AuthenticationStore.currentUserId),
);
}
handleMessageReactionRemoveAll(messageId: string): void {
this.updateMessageWithReaction(messageId, (message) => message.withUpdates({reactions: []}));
}
handleMessageReactionRemoveEmoji(messageId: string, emoji: ReactionEmoji): void {
this.updateMessageWithReaction(messageId, (message) => message.withoutReactionEmoji(emoji));
}
}
export default new SavedMessagesStore();

View File

@@ -0,0 +1,61 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import type {ScheduledMessagePayload, ScheduledMessageRecord} from '~/records/ScheduledMessageRecord';
interface ScheduledMessageEditState {
scheduledMessageId: string;
channelId: string;
payload: ScheduledMessagePayload;
scheduledLocalAt: string;
timezone: string;
}
class ScheduledMessageEditorStore {
private state: ScheduledMessageEditState | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
startEditing(record: ScheduledMessageRecord): void {
this.state = {
scheduledMessageId: record.id,
channelId: record.channelId,
payload: record.payload,
scheduledLocalAt: record.scheduledLocalAt,
timezone: record.timezone,
};
}
stopEditing(): void {
this.state = null;
}
isEditingChannel(channelId: string): boolean {
return this.state?.channelId === channelId;
}
getEditingState(): ScheduledMessageEditState | null {
return this.state;
}
}
export default new ScheduledMessageEditorStore();

View File

@@ -0,0 +1,72 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import type {ScheduledMessageRecord} from '~/records/ScheduledMessageRecord';
class ScheduledMessagesStore {
scheduledMessages: Array<ScheduledMessageRecord> = [];
fetched = false;
fetching = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
get hasScheduledMessages(): boolean {
return this.scheduledMessages.length > 0;
}
fetchStart(): void {
this.fetching = true;
}
fetchSuccess(messages: Array<ScheduledMessageRecord>): void {
this.scheduledMessages = sortScheduledMessages(messages);
this.fetching = false;
this.fetched = true;
}
fetchError(): void {
this.fetching = false;
this.fetched = false;
this.scheduledMessages = [];
}
upsert(message: ScheduledMessageRecord): void {
const existingIndex = this.scheduledMessages.findIndex((entry) => entry.id === message.id);
const next = [...this.scheduledMessages];
if (existingIndex === -1) {
next.push(message);
} else {
next[existingIndex] = message;
}
this.scheduledMessages = sortScheduledMessages(next);
}
remove(messageId: string): void {
this.scheduledMessages = this.scheduledMessages.filter((message) => message.id !== messageId);
}
}
function sortScheduledMessages(messages: Array<ScheduledMessageRecord>): Array<ScheduledMessageRecord> {
return [...messages].sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
}
export default new ScheduledMessagesStore();

View File

@@ -0,0 +1,90 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {action, makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
import type {SearchHints} from '~/utils/SearchQueryParser';
export interface SearchHistoryEntry {
query: string;
hints?: SearchHints;
ts: number;
}
class SearchHistoryStoreImpl {
entriesByChannel: Record<string, Array<SearchHistoryEntry>> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void makePersistent(this, 'SearchHistoryStore', ['entriesByChannel']);
}
private getEntries(channelId?: string): Array<SearchHistoryEntry> {
if (!channelId) return [];
return this.entriesByChannel[channelId] ?? [];
}
recent(channelId?: string): ReadonlyArray<SearchHistoryEntry> {
return this.getEntries(channelId);
}
search(term: string, channelId?: string): ReadonlyArray<SearchHistoryEntry> {
const entries = this.getEntries(channelId);
const t = term.trim().toLowerCase();
if (!t) return entries;
return entries.filter((e) => e.query.toLowerCase().includes(t));
}
@action
add(query: string, channelId?: string, hints?: SearchHints): void {
if (!channelId) return;
const q = query.trim();
if (!q) return;
if (!this.entriesByChannel[channelId]) {
this.entriesByChannel[channelId] = [];
}
const entries = this.entriesByChannel[channelId];
const ts = Date.now();
const existingIdx = entries.findIndex((e) => e.query === q);
const entry: SearchHistoryEntry = {query: q, hints, ts};
if (existingIdx !== -1) {
entries.splice(existingIdx, 1);
}
entries.unshift(entry);
if (entries.length > 10) {
this.entriesByChannel[channelId] = entries.slice(0, 10);
}
}
@action
clear(channelId?: string): void {
if (!channelId) return;
delete this.entriesByChannel[channelId];
}
@action
clearAll(): void {
this.entriesByChannel = {};
}
}
export default new SearchHistoryStoreImpl();

View File

@@ -0,0 +1,200 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {action, makeAutoObservable, reaction} from 'mobx';
import {FAVORITES_GUILD_ID, ME} from '~/Constants';
import {makePersistent} from '~/lib/MobXPersistence';
import type {Channel} from '~/records/ChannelRecord';
import NavigationStore from '~/stores/NavigationStore';
import FavoritesStore from './FavoritesStore';
interface ChannelVisit {
channelId: string;
guildId: string;
timestamp: number;
}
const MAX_RECENTLY_VISITED_CHANNELS = 20;
const RECENT_CHANNEL_HISTORY_LIMIT = 12;
class SelectedChannelStore {
selectedChannelIds = new Map<string, string>();
recentlyVisitedChannels: Array<ChannelVisit> = [];
private navigationDisposer: (() => void) | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
@action
private async initPersistence(): Promise<void> {
await makePersistent(this, 'SelectedChannelStore', ['selectedChannelIds', 'recentlyVisitedChannels']);
this.migrateRecentVisits();
this.setupNavigationReaction();
}
private setupNavigationReaction(): void {
this.navigationDisposer?.();
this.navigationDisposer = reaction(
() => [NavigationStore.guildId, NavigationStore.channelId],
([guildId, channelId]) => {
if (!guildId || !channelId) {
return;
}
this.selectChannel(guildId, channelId);
},
{
fireImmediately: true,
},
);
}
private normalizeGuildId(guildId: string | null | undefined): string | null {
if (!guildId) return null;
if (guildId === '@favorites') return FAVORITES_GUILD_ID;
return guildId;
}
private getCurrentGuildId(): string | null {
return this.normalizeGuildId(NavigationStore.guildId);
}
get currentChannelId(): string | null {
const guildId = this.getCurrentGuildId();
if (guildId == null) return null;
return this.selectedChannelIds.get(guildId) ?? null;
}
@action
private migrateRecentVisits(): void {
const needsMigration = this.recentlyVisitedChannels.some((visit) => !visit.guildId);
if (needsMigration) {
this.recentlyVisitedChannels = [];
}
}
private getSortedRecentVisits(): Array<ChannelVisit> {
return [...this.recentlyVisitedChannels].sort((a, b) => b.timestamp - a.timestamp);
}
get recentChannels(): ReadonlyArray<string> {
return this.getSortedRecentVisits()
.slice(0, RECENT_CHANNEL_HISTORY_LIMIT)
.map((visit) => visit.channelId);
}
get recentChannelVisits(): ReadonlyArray<{channelId: string; guildId: string}> {
return this.getSortedRecentVisits()
.slice(0, RECENT_CHANNEL_HISTORY_LIMIT)
.map((visit) => ({channelId: visit.channelId, guildId: visit.guildId}));
}
@action
selectChannel(guildId?: string, channelId?: string | null): void {
const normalizedGuildId = this.normalizeGuildId(guildId ?? null);
if (!normalizedGuildId) return;
if (channelId == null) {
this.removeGuildSelection(normalizedGuildId);
return;
}
this.updateRecentVisit(normalizedGuildId, channelId);
this.selectedChannelIds.set(normalizedGuildId, channelId);
}
private updateRecentVisit(guildId: string, channelId: string): void {
const now = Date.now();
const existingIndex = this.recentlyVisitedChannels.findIndex(
(visit) => visit.channelId === channelId && visit.guildId === guildId,
);
if (existingIndex >= 0) {
const updated = [...this.recentlyVisitedChannels];
updated[existingIndex] = {...updated[existingIndex], timestamp: now};
this.recentlyVisitedChannels = updated;
} else {
this.recentlyVisitedChannels = [...this.recentlyVisitedChannels, {channelId, guildId, timestamp: now}];
}
this.pruneRecentVisits();
}
private pruneRecentVisits(): void {
if (this.recentlyVisitedChannels.length <= MAX_RECENTLY_VISITED_CHANNELS) {
return;
}
this.recentlyVisitedChannels = [...this.recentlyVisitedChannels]
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, MAX_RECENTLY_VISITED_CHANNELS);
}
@action
deselectChannel(): void {
const guildId = this.getCurrentGuildId();
if (guildId != null) {
this.removeGuildSelection(guildId);
}
}
@action
clearGuildSelection(guildId: string): void {
const normalizedGuildId = this.normalizeGuildId(guildId);
if (!normalizedGuildId) return;
this.removeGuildSelection(normalizedGuildId);
}
@action
handleChannelDelete(channel: Channel): void {
const guildId = channel.guild_id ?? ME;
const normalizedGuildId = this.normalizeGuildId(guildId) ?? guildId;
const selectedChannelId = this.selectedChannelIds.get(normalizedGuildId);
if (selectedChannelId === channel.id) {
this.removeGuildSelection(normalizedGuildId);
}
}
@action
private removeGuildSelection(guildId: string): void {
this.selectedChannelIds.delete(guildId);
}
@action
getValidatedFavoritesChannel(): string | null {
const selectedChannelId = this.selectedChannelIds.get(FAVORITES_GUILD_ID);
if (selectedChannelId && FavoritesStore.isChannelAccessible(selectedChannelId)) {
return selectedChannelId;
}
const firstAccessible = FavoritesStore.getFirstAccessibleChannel();
if (firstAccessible) {
this.selectChannel(FAVORITES_GUILD_ID, firstAccessible.channelId);
return firstAccessible.channelId;
}
this.removeGuildSelection(FAVORITES_GUILD_ID);
return null;
}
}
export default new SelectedChannelStore();

View File

@@ -0,0 +1,123 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {action, makeAutoObservable, reaction} from 'mobx';
import {ME} from '~/Constants';
import {makePersistent} from '~/lib/MobXPersistence';
import NavigationStore from '~/stores/NavigationStore';
const FAVORITES_ROUTE_ID = '@favorites';
class SelectedGuildStore {
lastSelectedGuildId: string | null = null;
selectedGuildId: string | null = null;
selectionNonce: number = 0;
private navigationDisposer: (() => void) | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
@action
private async initPersistence(): Promise<void> {
await makePersistent(this, 'SelectedGuildStore', ['lastSelectedGuildId']);
this.setupNavigationReaction();
}
private setupNavigationReaction(): void {
this.navigationDisposer?.();
this.navigationDisposer = reaction(
() => NavigationStore.guildId,
(guildId) => {
const normalized = this.normalizeGuildFromNavigation(guildId);
if (normalized) {
this.applyNavigationGuild(normalized);
} else {
this.clearSelection();
}
},
{
fireImmediately: true,
},
);
}
private normalizeGuildFromNavigation(guildId: string | null): string | null {
if (!guildId || guildId === ME || guildId === FAVORITES_ROUTE_ID) {
return null;
}
return guildId;
}
@action
selectGuild(guildId: string, _forceSync = false): void {
if (!guildId) {
return;
}
this.setGuild(guildId, {forceNonce: true});
}
@action
syncCurrentGuild(): void {
this.bumpNonce();
}
@action
deselectGuild(): void {
this.clearSelection();
}
private applyNavigationGuild(guildId: string): void {
this.setGuild(guildId);
}
private setGuild(guildId: string, options?: {forceNonce?: boolean}): void {
const hasChanged = guildId !== this.selectedGuildId;
if (hasChanged) {
this.lastSelectedGuildId = this.selectedGuildId;
this.selectedGuildId = guildId;
this.bumpNonce();
return;
}
if (options?.forceNonce) {
this.bumpNonce();
}
}
private clearSelection(): void {
if (this.selectedGuildId == null) {
return;
}
this.lastSelectedGuildId = this.selectedGuildId;
this.selectedGuildId = null;
this.bumpNonce();
}
private bumpNonce(): void {
this.selectionNonce++;
}
}
export default new SelectedGuildStore();

View File

@@ -0,0 +1,63 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, observable} from 'mobx';
import type React from 'react';
class SettingsSidebarStore {
ownerId: string | null = null;
overrideContent: React.ReactNode | null = null;
useOverride = false;
constructor() {
makeAutoObservable(this, {overrideContent: observable.ref}, {autoBind: true});
}
get hasOverride(): boolean {
return this.overrideContent != null;
}
setOverride(ownerId: string, content: React.ReactNode, options?: {defaultOn?: boolean}): void {
this.ownerId = ownerId;
this.overrideContent = content;
if (options?.defaultOn) this.useOverride = true;
}
updateOverride(ownerId: string, content: React.ReactNode): void {
if (this.ownerId && this.ownerId !== ownerId) return;
this.overrideContent = content;
}
clearOverride(ownerId?: string): void {
if (ownerId && this.ownerId && this.ownerId !== ownerId) return;
this.ownerId = null;
this.overrideContent = null;
this.useOverride = false;
}
setUseOverride(value: boolean): void {
if (!this.hasOverride) {
this.useOverride = false;
return;
}
this.useOverride = value;
}
}
export default new SettingsSidebarStore();

Some files were not shown because too many files have changed in this diff Show More