initial commit
This commit is contained in:
191
fluxer_app/src/stores/AccessibilityOverrideStore.tsx
Normal file
191
fluxer_app/src/stores/AccessibilityOverrideStore.tsx
Normal 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();
|
||||
468
fluxer_app/src/stores/AccessibilityStore.tsx
Normal file
468
fluxer_app/src/stores/AccessibilityStore.tsx
Normal 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();
|
||||
161
fluxer_app/src/stores/AccountManager.tsx
Normal file
161
fluxer_app/src/stores/AccountManager.tsx
Normal 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();
|
||||
76
fluxer_app/src/stores/AudioVolumeStore.ts
Normal file
76
fluxer_app/src/stores/AudioVolumeStore.ts
Normal 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();
|
||||
69
fluxer_app/src/stores/AuthSessionStore.tsx
Normal file
69
fluxer_app/src/stores/AuthSessionStore.tsx
Normal 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();
|
||||
147
fluxer_app/src/stores/AuthenticationStore.tsx
Normal file
147
fluxer_app/src/stores/AuthenticationStore.tsx
Normal 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();
|
||||
171
fluxer_app/src/stores/AutoAckStore.tsx
Normal file
171
fluxer_app/src/stores/AutoAckStore.tsx
Normal 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();
|
||||
51
fluxer_app/src/stores/AutocompleteStore.tsx
Normal file
51
fluxer_app/src/stores/AutocompleteStore.tsx
Normal 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();
|
||||
82
fluxer_app/src/stores/BetaCodeStore.tsx
Normal file
82
fluxer_app/src/stores/BetaCodeStore.tsx
Normal 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();
|
||||
64
fluxer_app/src/stores/CallAvailabilityStore.tsx
Normal file
64
fluxer_app/src/stores/CallAvailabilityStore.tsx
Normal 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();
|
||||
49
fluxer_app/src/stores/CallInitiatorStore.tsx
Normal file
49
fluxer_app/src/stores/CallInitiatorStore.tsx
Normal 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();
|
||||
55
fluxer_app/src/stores/CallMediaPrefsStore.ts
Normal file
55
fluxer_app/src/stores/CallMediaPrefsStore.ts
Normal 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};
|
||||
265
fluxer_app/src/stores/CallStateStore.tsx
Normal file
265
fluxer_app/src/stores/CallStateStore.tsx
Normal 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();
|
||||
176
fluxer_app/src/stores/ChannelDisplayNameStore.tsx
Normal file
176
fluxer_app/src/stores/ChannelDisplayNameStore.tsx
Normal 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();
|
||||
253
fluxer_app/src/stores/ChannelPinsStore.tsx
Normal file
253
fluxer_app/src/stores/ChannelPinsStore.tsx
Normal 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();
|
||||
141
fluxer_app/src/stores/ChannelSearchStore.tsx
Normal file
141
fluxer_app/src/stores/ChannelSearchStore.tsx
Normal 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();
|
||||
55
fluxer_app/src/stores/ChannelStickerStore.tsx
Normal file
55
fluxer_app/src/stores/ChannelStickerStore.tsx
Normal 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();
|
||||
322
fluxer_app/src/stores/ChannelStore.tsx
Normal file
322
fluxer_app/src/stores/ChannelStore.tsx
Normal 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();
|
||||
20
fluxer_app/src/stores/ConnectionStore.tsx
Normal file
20
fluxer_app/src/stores/ConnectionStore.tsx
Normal 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';
|
||||
130
fluxer_app/src/stores/ContextMenuStore.tsx
Normal file
130
fluxer_app/src/stores/ContextMenuStore.tsx
Normal 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();
|
||||
38
fluxer_app/src/stores/CountryCodeStore.tsx
Normal file
38
fluxer_app/src/stores/CountryCodeStore.tsx
Normal 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();
|
||||
80
fluxer_app/src/stores/DeveloperModeStore.ts
Normal file
80
fluxer_app/src/stores/DeveloperModeStore.ts
Normal 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();
|
||||
258
fluxer_app/src/stores/DeveloperOptionsStore.tsx
Normal file
258
fluxer_app/src/stores/DeveloperOptionsStore.tsx
Normal 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();
|
||||
174
fluxer_app/src/stores/DimensionStore.tsx
Normal file
174
fluxer_app/src/stores/DimensionStore.tsx
Normal 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();
|
||||
40
fluxer_app/src/stores/DismissedUpsellStore.ts
Normal file
40
fluxer_app/src/stores/DismissedUpsellStore.ts
Normal 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();
|
||||
84
fluxer_app/src/stores/DraftStore.tsx
Normal file
84
fluxer_app/src/stores/DraftStore.tsx
Normal 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();
|
||||
179
fluxer_app/src/stores/EmojiPickerStore.tsx
Normal file
179
fluxer_app/src/stores/EmojiPickerStore.tsx
Normal 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();
|
||||
56
fluxer_app/src/stores/EmojiStickerLayoutStore.ts
Normal file
56
fluxer_app/src/stores/EmojiStickerLayoutStore.ts
Normal 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();
|
||||
391
fluxer_app/src/stores/EmojiStore.tsx
Normal file
391
fluxer_app/src/stores/EmojiStore.tsx
Normal 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();
|
||||
71
fluxer_app/src/stores/ExpressionPickerStore.tsx
Normal file
71
fluxer_app/src/stores/ExpressionPickerStore.tsx
Normal 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();
|
||||
69
fluxer_app/src/stores/FavoriteMemeStore.tsx
Normal file
69
fluxer_app/src/stores/FavoriteMemeStore.tsx
Normal 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();
|
||||
250
fluxer_app/src/stores/FavoritesStore.tsx
Normal file
250
fluxer_app/src/stores/FavoritesStore.tsx
Normal 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();
|
||||
58
fluxer_app/src/stores/FeatureFlagOverridesStore.ts
Normal file
58
fluxer_app/src/stores/FeatureFlagOverridesStore.ts
Normal 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();
|
||||
93
fluxer_app/src/stores/FeatureFlagStore.tsx
Normal file
93
fluxer_app/src/stores/FeatureFlagStore.tsx
Normal 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();
|
||||
42
fluxer_app/src/stores/FriendsTabStore.tsx
Normal file
42
fluxer_app/src/stores/FriendsTabStore.tsx
Normal 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();
|
||||
91
fluxer_app/src/stores/GeoIPStore.tsx
Normal file
91
fluxer_app/src/stores/GeoIPStore.tsx
Normal 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();
|
||||
111
fluxer_app/src/stores/GiftStore.tsx
Normal file
111
fluxer_app/src/stores/GiftStore.tsx
Normal 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();
|
||||
67
fluxer_app/src/stores/GuildAvailabilityStore.tsx
Normal file
67
fluxer_app/src/stores/GuildAvailabilityStore.tsx
Normal 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();
|
||||
121
fluxer_app/src/stores/GuildExpressionTabCache.ts
Normal file
121
fluxer_app/src/stores/GuildExpressionTabCache.ts
Normal 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);
|
||||
};
|
||||
112
fluxer_app/src/stores/GuildListStore.tsx
Normal file
112
fluxer_app/src/stores/GuildListStore.tsx
Normal 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();
|
||||
46
fluxer_app/src/stores/GuildMemberLayoutStore.ts
Normal file
46
fluxer_app/src/stores/GuildMemberLayoutStore.ts
Normal 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();
|
||||
282
fluxer_app/src/stores/GuildMemberStore.tsx
Normal file
282
fluxer_app/src/stores/GuildMemberStore.tsx
Normal 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();
|
||||
105
fluxer_app/src/stores/GuildNSFWAgreeStore.tsx
Normal file
105
fluxer_app/src/stores/GuildNSFWAgreeStore.tsx
Normal 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();
|
||||
516
fluxer_app/src/stores/GuildReadStateStore.tsx
Normal file
516
fluxer_app/src/stores/GuildReadStateStore.tsx
Normal 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();
|
||||
59
fluxer_app/src/stores/GuildSettingsModalStore.tsx
Normal file
59
fluxer_app/src/stores/GuildSettingsModalStore.tsx
Normal 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();
|
||||
152
fluxer_app/src/stores/GuildStore.tsx
Normal file
152
fluxer_app/src/stores/GuildStore.tsx
Normal 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();
|
||||
235
fluxer_app/src/stores/GuildVerificationStore.tsx
Normal file
235
fluxer_app/src/stores/GuildVerificationStore.tsx
Normal 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();
|
||||
87
fluxer_app/src/stores/IdleStore.tsx
Normal file
87
fluxer_app/src/stores/IdleStore.tsx
Normal 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();
|
||||
52
fluxer_app/src/stores/InboxStore.tsx
Normal file
52
fluxer_app/src/stores/InboxStore.tsx
Normal 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();
|
||||
96
fluxer_app/src/stores/InitializationStore.tsx
Normal file
96
fluxer_app/src/stores/InitializationStore.tsx
Normal 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();
|
||||
56
fluxer_app/src/stores/InputMonitoringPromptsStore.tsx
Normal file
56
fluxer_app/src/stores/InputMonitoringPromptsStore.tsx
Normal 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();
|
||||
233
fluxer_app/src/stores/InviteStore.tsx
Normal file
233
fluxer_app/src/stores/InviteStore.tsx
Normal 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();
|
||||
648
fluxer_app/src/stores/KeybindStore.ts
Normal file
648
fluxer_app/src/stores/KeybindStore.ts
Normal 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;
|
||||
};
|
||||
68
fluxer_app/src/stores/KeyboardModeStore.tsx
Normal file
68
fluxer_app/src/stores/KeyboardModeStore.tsx
Normal 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();
|
||||
120
fluxer_app/src/stores/LayerManager.tsx
Normal file
120
fluxer_app/src/stores/LayerManager.tsx
Normal 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();
|
||||
91
fluxer_app/src/stores/LocalPresenceStore.tsx
Normal file
91
fluxer_app/src/stores/LocalPresenceStore.tsx
Normal 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();
|
||||
426
fluxer_app/src/stores/LocalVoiceStateStore.tsx
Normal file
426
fluxer_app/src/stores/LocalVoiceStateStore.tsx
Normal 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();
|
||||
72
fluxer_app/src/stores/LocationStore.tsx
Normal file
72
fluxer_app/src/stores/LocationStore.tsx
Normal 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();
|
||||
250
fluxer_app/src/stores/MediaPermissionStore.tsx
Normal file
250
fluxer_app/src/stores/MediaPermissionStore.tsx
Normal 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();
|
||||
99
fluxer_app/src/stores/MediaViewerStore.tsx
Normal file
99
fluxer_app/src/stores/MediaViewerStore.tsx
Normal 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();
|
||||
54
fluxer_app/src/stores/MemberListStore.tsx
Normal file
54
fluxer_app/src/stores/MemberListStore.tsx
Normal 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();
|
||||
283
fluxer_app/src/stores/MemberPresenceSubscriptionStore.tsx
Normal file
283
fluxer_app/src/stores/MemberPresenceSubscriptionStore.tsx
Normal 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();
|
||||
486
fluxer_app/src/stores/MemberSearchStore.tsx
Normal file
486
fluxer_app/src/stores/MemberSearchStore.tsx
Normal 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();
|
||||
650
fluxer_app/src/stores/MemberSidebarStore.tsx
Normal file
650
fluxer_app/src/stores/MemberSidebarStore.tsx
Normal 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();
|
||||
146
fluxer_app/src/stores/MemesPickerStore.tsx
Normal file
146
fluxer_app/src/stores/MemesPickerStore.tsx
Normal 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();
|
||||
50
fluxer_app/src/stores/MessageEditMobileStore.tsx
Normal file
50
fluxer_app/src/stores/MessageEditMobileStore.tsx
Normal 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();
|
||||
96
fluxer_app/src/stores/MessageEditStore.tsx
Normal file
96
fluxer_app/src/stores/MessageEditStore.tsx
Normal 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();
|
||||
152
fluxer_app/src/stores/MessageReactionsStore.tsx
Normal file
152
fluxer_app/src/stores/MessageReactionsStore.tsx
Normal 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();
|
||||
232
fluxer_app/src/stores/MessageReferenceStore.tsx
Normal file
232
fluxer_app/src/stores/MessageReferenceStore.tsx
Normal 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();
|
||||
93
fluxer_app/src/stores/MessageReplyStore.tsx
Normal file
93
fluxer_app/src/stores/MessageReplyStore.tsx
Normal 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();
|
||||
754
fluxer_app/src/stores/MessageStore.tsx
Normal file
754
fluxer_app/src/stores/MessageStore.tsx
Normal 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();
|
||||
124
fluxer_app/src/stores/MobileLayoutStore.tsx
Normal file
124
fluxer_app/src/stores/MobileLayoutStore.tsx
Normal 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();
|
||||
67
fluxer_app/src/stores/MobileMentionToastStore.tsx
Normal file
67
fluxer_app/src/stores/MobileMentionToastStore.tsx
Normal 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();
|
||||
49
fluxer_app/src/stores/MockIncomingCallStore.tsx
Normal file
49
fluxer_app/src/stores/MockIncomingCallStore.tsx
Normal 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();
|
||||
215
fluxer_app/src/stores/ModalStore.tsx
Normal file
215
fluxer_app/src/stores/ModalStore.tsx
Normal 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();
|
||||
356
fluxer_app/src/stores/NagbarStore.tsx
Normal file
356
fluxer_app/src/stores/NagbarStore.tsx
Normal 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();
|
||||
114
fluxer_app/src/stores/NativePermissionStore.ts
Normal file
114
fluxer_app/src/stores/NativePermissionStore.ts
Normal 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();
|
||||
63
fluxer_app/src/stores/NativeWindowStateStore.ts
Normal file
63
fluxer_app/src/stores/NativeWindowStateStore.ts
Normal 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();
|
||||
117
fluxer_app/src/stores/NavigationStore.tsx
Normal file
117
fluxer_app/src/stores/NavigationStore.tsx
Normal 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();
|
||||
293
fluxer_app/src/stores/NewDeviceMonitoringStore.tsx
Normal file
293
fluxer_app/src/stores/NewDeviceMonitoringStore.tsx
Normal 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();
|
||||
522
fluxer_app/src/stores/NotificationStore.tsx
Normal file
522
fluxer_app/src/stores/NotificationStore.tsx
Normal 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();
|
||||
53
fluxer_app/src/stores/OverlayStackStore.tsx
Normal file
53
fluxer_app/src/stores/OverlayStackStore.tsx
Normal 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();
|
||||
88
fluxer_app/src/stores/PackStore.tsx
Normal file
88
fluxer_app/src/stores/PackStore.tsx
Normal 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();
|
||||
115
fluxer_app/src/stores/ParticipantVolumeStore.tsx
Normal file
115
fluxer_app/src/stores/ParticipantVolumeStore.tsx
Normal 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();
|
||||
75
fluxer_app/src/stores/PermissionLayoutStore.tsx
Normal file
75
fluxer_app/src/stores/PermissionLayoutStore.tsx
Normal 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();
|
||||
224
fluxer_app/src/stores/PermissionStore.tsx
Normal file
224
fluxer_app/src/stores/PermissionStore.tsx
Normal 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();
|
||||
223
fluxer_app/src/stores/PopoutStore.tsx
Normal file
223
fluxer_app/src/stores/PopoutStore.tsx
Normal 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();
|
||||
518
fluxer_app/src/stores/PresenceStore.tsx
Normal file
518
fluxer_app/src/stores/PresenceStore.tsx
Normal 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();
|
||||
1354
fluxer_app/src/stores/QuickSwitcherStore.tsx
Normal file
1354
fluxer_app/src/stores/QuickSwitcherStore.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1019
fluxer_app/src/stores/ReadStateStore.tsx
Normal file
1019
fluxer_app/src/stores/ReadStateStore.tsx
Normal file
File diff suppressed because it is too large
Load Diff
214
fluxer_app/src/stores/RecentMentionsStore.tsx
Normal file
214
fluxer_app/src/stores/RecentMentionsStore.tsx
Normal 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();
|
||||
76
fluxer_app/src/stores/RelationshipStore.tsx
Normal file
76
fluxer_app/src/stores/RelationshipStore.tsx
Normal 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();
|
||||
435
fluxer_app/src/stores/RuntimeConfigStore.ts
Normal file
435
fluxer_app/src/stores/RuntimeConfigStore.ts
Normal 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();
|
||||
122
fluxer_app/src/stores/SavedMessagesStore.tsx
Normal file
122
fluxer_app/src/stores/SavedMessagesStore.tsx
Normal 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();
|
||||
61
fluxer_app/src/stores/ScheduledMessageEditorStore.tsx
Normal file
61
fluxer_app/src/stores/ScheduledMessageEditorStore.tsx
Normal 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();
|
||||
72
fluxer_app/src/stores/ScheduledMessagesStore.tsx
Normal file
72
fluxer_app/src/stores/ScheduledMessagesStore.tsx
Normal 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();
|
||||
90
fluxer_app/src/stores/SearchHistoryStore.tsx
Normal file
90
fluxer_app/src/stores/SearchHistoryStore.tsx
Normal 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();
|
||||
200
fluxer_app/src/stores/SelectedChannelStore.tsx
Normal file
200
fluxer_app/src/stores/SelectedChannelStore.tsx
Normal 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();
|
||||
123
fluxer_app/src/stores/SelectedGuildStore.tsx
Normal file
123
fluxer_app/src/stores/SelectedGuildStore.tsx
Normal 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();
|
||||
63
fluxer_app/src/stores/SettingsSidebarStore.tsx
Normal file
63
fluxer_app/src/stores/SettingsSidebarStore.tsx
Normal 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
Reference in New Issue
Block a user