refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -17,12 +17,12 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@app/lib/Logger';
import {makePersistent} from '@app/lib/MobXPersistence';
import AccessibilityStore from '@app/stores/AccessibilityStore';
import UserSettingsStore, {type UserSettings} from '@app/stores/UserSettingsStore';
import {StickerAnimationOptions} from '@fluxer/constants/src/UserConstants';
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');
@@ -184,6 +184,29 @@ class AccessibilityOverrideStore {
return false;
}
}
get effectiveGifAutoPlay(): boolean {
if (this.isOverriddenByReducedMotion('gif_auto_play')) {
return false;
}
return UserSettingsStore.getGifAutoPlay();
}
get effectiveAnimateEmoji(): boolean {
if (this.isOverriddenByReducedMotion('animate_emoji')) {
return false;
}
return UserSettingsStore.getAnimateEmoji();
}
get effectiveAnimateStickers(): number {
if (this.isOverriddenByReducedMotion('animate_stickers')) {
if (UserSettingsStore.getAnimateStickers() === StickerAnimationOptions.ALWAYS_ANIMATE) {
return StickerAnimationOptions.ANIMATE_ON_INTERACTION;
}
}
return UserSettingsStore.getAnimateStickers();
}
}
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;

View File

@@ -17,13 +17,12 @@
* 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';
import {makePersistent} from '@app/lib/MobXPersistence';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import {StickerAnimationOptions} from '@fluxer/constants/src/UserConstants';
import {makeAutoObservable, reaction, runInAction} from 'mobx';
export enum ChannelTypingIndicatorMode {
export enum GuildChannelPresenceIndicatorMode {
AVATARS = 0,
INDICATOR_ONLY = 1,
HIDDEN = 2,
@@ -40,6 +39,17 @@ export enum DMMessagePreviewMode {
NONE = 2,
}
export enum ChannelTypingIndicatorMode {
AVATARS = 0,
INDICATOR_ONLY = 1,
HIDDEN = 2,
}
export enum HdrDisplayMode {
FULL = 'full',
STANDARD = 'standard',
}
export interface AccessibilitySettings {
saturationFactor: number;
alwaysUnderlineLinks: boolean;
@@ -60,16 +70,11 @@ export interface AccessibilitySettings {
mobileStickerAnimationValue: number;
mobileGifAutoPlayValue: boolean;
mobileAnimateEmojiValue: boolean;
syncThemeAcrossDevices: boolean;
localThemeOverride: string | null;
showMessageDividers: boolean;
autoSendTenorGifs: boolean;
showGiftButton: boolean;
autoSendKlipyGifs: boolean;
showGifButton: boolean;
showMemesButton: boolean;
showStickersButton: boolean;
showEmojiButton: boolean;
showUploadButton: boolean;
showMediaFavoriteButton: boolean;
showMediaDownloadButton: boolean;
showMediaDeleteButton: boolean;
@@ -96,6 +101,9 @@ export interface AccessibilitySettings {
dmMessagePreviewMode: DMMessagePreviewMode;
enableTTSCommand: boolean;
ttsRate: number;
showFadedUnreadOnMutedChannels: boolean;
showContextMenuShortcuts: boolean;
hdrDisplayMode: HdrDisplayMode;
}
const getDefaultDmMessagePreviewMode = (): DMMessagePreviewMode =>
@@ -105,7 +113,7 @@ class AccessibilityStore {
saturationFactor = 1;
alwaysUnderlineLinks = false;
enableTextSelection = false;
showMessageSendButton = true;
showMessageSendButton = false;
showTextareaFocusRing = true;
hideKeyboardHints = false;
escapeExitsKeyboardMode = false;
@@ -121,16 +129,11 @@ class AccessibilityStore {
mobileStickerAnimationValue: number = StickerAnimationOptions.ANIMATE_ON_INTERACTION;
mobileGifAutoPlayValue = false;
mobileAnimateEmojiValue = true;
syncThemeAcrossDevices = true;
localThemeOverride: string | null = null;
showMessageDividers = false;
autoSendTenorGifs = true;
showGiftButton = true;
autoSendKlipyGifs = true;
showGifButton = true;
showMemesButton = true;
showStickersButton = true;
showEmojiButton = true;
showUploadButton = true;
showMediaFavoriteButton = true;
showMediaDownloadButton = true;
showMediaDeleteButton = true;
@@ -158,22 +161,20 @@ class AccessibilityStore {
dmMessagePreviewMode: DMMessagePreviewMode = getDefaultDmMessagePreviewMode();
enableTTSCommand = true;
ttsRate = 1.0;
showFadedUnreadOnMutedChannels = false;
showContextMenuShortcuts = false;
hdrDisplayMode = HdrDisplayMode.FULL;
mediaQuery: MediaQueryList | null = null;
private _hydrated = false;
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;
}
});
get isHydrated(): boolean {
return this._hydrated;
}
private async initPersistence(): Promise<void> {
@@ -197,16 +198,11 @@ class AccessibilityStore {
'mobileStickerAnimationValue',
'mobileGifAutoPlayValue',
'mobileAnimateEmojiValue',
'syncThemeAcrossDevices',
'localThemeOverride',
'showMessageDividers',
'autoSendTenorGifs',
'showGiftButton',
'autoSendKlipyGifs',
'showGifButton',
'showMemesButton',
'showStickersButton',
'showEmojiButton',
'showUploadButton',
'showMediaFavoriteButton',
'showMediaDownloadButton',
'showMediaDeleteButton',
@@ -233,7 +229,14 @@ class AccessibilityStore {
'dmMessagePreviewMode',
'enableTTSCommand',
'ttsRate',
'showFadedUnreadOnMutedChannels',
'showContextMenuShortcuts',
'hdrDisplayMode',
]);
runInAction(() => {
this._hydrated = true;
});
}
private initializeMotionDetection() {
@@ -301,19 +304,11 @@ class AccessibilityStore {
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.autoSendKlipyGifs !== undefined) this.autoSendKlipyGifs = validated.autoSendKlipyGifs;
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)
@@ -360,6 +355,11 @@ class AccessibilityStore {
if (validated.dmMessagePreviewMode !== undefined) this.dmMessagePreviewMode = validated.dmMessagePreviewMode;
if (validated.enableTTSCommand !== undefined) this.enableTTSCommand = validated.enableTTSCommand;
if (validated.ttsRate !== undefined) this.ttsRate = validated.ttsRate;
if (validated.showFadedUnreadOnMutedChannels !== undefined)
this.showFadedUnreadOnMutedChannels = validated.showFadedUnreadOnMutedChannels;
if (validated.showContextMenuShortcuts !== undefined)
this.showContextMenuShortcuts = validated.showContextMenuShortcuts;
if (validated.hdrDisplayMode !== undefined) this.hdrDisplayMode = validated.hdrDisplayMode;
}
private validateSettings(data: Readonly<Partial<AccessibilitySettings>>): Partial<AccessibilitySettings> {
@@ -383,16 +383,11 @@ class AccessibilityStore {
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,
autoSendKlipyGifs: data.autoSendKlipyGifs ?? this.autoSendKlipyGifs,
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,
@@ -430,6 +425,9 @@ class AccessibilityStore {
dmMessagePreviewMode: data.dmMessagePreviewMode ?? this.dmMessagePreviewMode,
enableTTSCommand: data.enableTTSCommand ?? this.enableTTSCommand,
ttsRate: Math.max(0.1, Math.min(2.0, data.ttsRate ?? this.ttsRate)),
showFadedUnreadOnMutedChannels: data.showFadedUnreadOnMutedChannels ?? this.showFadedUnreadOnMutedChannels,
showContextMenuShortcuts: data.showContextMenuShortcuts ?? this.showContextMenuShortcuts,
hdrDisplayMode: data.hdrDisplayMode ?? this.hdrDisplayMode,
};
}
@@ -437,7 +435,6 @@ class AccessibilityStore {
return reaction(
() => ({
messageGroupSpacing: this.messageGroupSpacing,
showMessageDividers: this.showMessageDividers,
}),
() => callback(),
{fireImmediately: true},

View File

@@ -17,17 +17,15 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserData} from '@app/lib/AccountStorage';
import SessionManager, {type Account, SessionExpiredError} from '@app/lib/SessionManager';
import {Routes} from '@app/Routes';
import * as PushSubscriptionService from '@app/services/push/PushSubscriptionService';
import GatewayConnectionStore from '@app/stores/gateway/GatewayConnectionStore';
import * as NotificationUtils from '@app/utils/NotificationUtils';
import {isInstalledPwa} from '@app/utils/PwaUtils';
import * as RouterUtils from '@app/utils/RouterUtils';
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() {
@@ -53,7 +51,7 @@ class AccountManager {
return SessionManager.userId;
}
get accounts(): Map<string, AccountSummary> {
get accounts(): Map<string, Account> {
return new Map(SessionManager.accounts.map((a) => [a.userId, a]));
}
@@ -65,11 +63,11 @@ class AccountManager {
return SessionManager.isLoggingOut || SessionManager.isSwitching;
}
get currentAccount(): AccountSummary | null {
get currentAccount(): Account | null {
return SessionManager.currentAccount;
}
get orderedAccounts(): Array<AccountSummary> {
get orderedAccounts(): Array<Account> {
return SessionManager.accounts;
}
@@ -77,7 +75,7 @@ class AccountManager {
return SessionManager.canSwitchAccount();
}
getAllAccounts(): Array<AccountSummary> {
getAllAccounts(): Array<Account> {
return this.orderedAccounts;
}
@@ -116,7 +114,7 @@ class AccountManager {
}
await SessionManager.switchAccount(userId);
ConnectionStore.startSession(SessionManager.token ?? undefined);
GatewayConnectionStore.startSession(SessionManager.token ?? undefined);
RouterUtils.replaceWith(Routes.ME);
if (this.shouldManagePushSubscriptions()) {
@@ -130,7 +128,7 @@ class AccountManager {
async switchToNewAccount(userId: string, token: string, userData?: UserData, skipReload = false): Promise<void> {
await SessionManager.login(token, userId, userData);
ConnectionStore.startSession(token);
GatewayConnectionStore.startSession(token);
if (!skipReload) {
RouterUtils.replaceWith(Routes.ME);
}

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
const DEFAULT_VOLUME = 1;

View File

@@ -17,8 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AuthSessionRecord} from '@app/records/AuthSessionRecord';
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {makeAutoObservable} from 'mobx';
import {type AuthSession, AuthSessionRecord} from '~/records/AuthSessionRecord';
type FetchStatus = 'idle' | 'pending' | 'success' | 'error';
@@ -44,7 +45,7 @@ class AuthSessionStore {
this.fetchStatus = 'pending';
}
fetchSuccess(authSessions: ReadonlyArray<AuthSession>): void {
fetchSuccess(authSessions: ReadonlyArray<AuthSessionResponse>): void {
this.authSessions = authSessions.map((session) => new AuthSessionRecord(session));
this.fetchStatus = 'success';
}

View File

@@ -17,18 +17,23 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import SessionManager from '@app/lib/SessionManager';
import * as RouterUtils from '@app/utils/RouterUtils';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
import type {UserPrivate} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
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 LoginState = ValueOf<typeof LoginState>;
export type MfaMethods = {sms: boolean; totp: boolean; webauthn: boolean};
export interface MfaMethods {
sms: boolean;
totp: boolean;
webauthn: boolean;
}
class AuthenticationStore {
loginState: LoginState = LoginState.Default;

View File

@@ -17,25 +17,23 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@app/lib/Logger';
import ChannelStore from '@app/stores/ChannelStore';
import DimensionStore from '@app/stores/DimensionStore';
import ReadStateStore from '@app/stores/ReadStateStore';
import SelectedChannelStore from '@app/stores/SelectedChannelStore';
import WindowStore from '@app/stores/WindowStore';
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 windowChannels = new Map<string, Set<string>>();
private readonly windowConditions = new Map<
string,
{
channelId: ChannelId | null;
channelId: string | null;
isAtBottom: boolean;
canAutoAck: boolean;
}
@@ -86,7 +84,7 @@ class AutoAckStore {
@action
private updateAutoAckState(conditions: {
windowId: string;
channelId: ChannelId | null;
channelId: string | null;
isAtBottom: boolean;
canAutoAck: boolean;
}): void {
@@ -113,7 +111,7 @@ class AutoAckStore {
}
@action
private enableAutomaticAckInternal(channelId: ChannelId, windowId: string): void {
private enableAutomaticAckInternal(channelId: string, windowId: string): void {
const channel = ChannelStore.getChannel(channelId);
if (channel == null) {
logger.debug(`Ignoring enableAutomaticAck for non-existent channel ${channelId}`);
@@ -133,7 +131,7 @@ class AutoAckStore {
}
@action
private disableAutomaticAckInternal(channelId: ChannelId, windowId: string): void {
private disableAutomaticAckInternal(channelId: string, windowId: string): void {
const channels = this.windowChannels.get(windowId);
if (channels == null) return;
@@ -147,7 +145,7 @@ class AutoAckStore {
}
}
isAutomaticAckEnabled(channelId: ChannelId): boolean {
isAutomaticAckEnabled(channelId: string): boolean {
for (const channels of this.windowChannels.values()) {
if (channels.has(channelId)) return true;
}
@@ -155,7 +153,7 @@ class AutoAckStore {
}
@action
disableForChannel(channelId: ChannelId): void {
disableForChannel(channelId: string): void {
for (const [windowId, channels] of this.windowChannels.entries()) {
if (channels.has(channelId)) {
channels.delete(channelId);

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@app/lib/Logger';
import {makeAutoObservable} from 'mobx';
import {Logger} from '~/lib/Logger';
const logger = new Logger('AutocompleteStore');

View File

@@ -1,82 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {type BetaCode, BetaCodeRecord} from '~/records/BetaCodeRecord';
type FetchStatus = 'idle' | 'pending' | 'success' | 'error';
class BetaCodeStore {
betaCodes: Array<BetaCodeRecord> = [];
fetchStatus: FetchStatus = 'idle';
isCreateError = false;
isDeleteError = false;
allowance = 3;
nextResetAt: Date | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
fetchPending(): void {
this.fetchStatus = 'pending';
}
fetchSuccess(betaCodes: ReadonlyArray<BetaCode>, allowance: number, nextResetAt: string | null): void {
this.betaCodes = betaCodes.map((betaCode) => new BetaCodeRecord(betaCode));
this.fetchStatus = 'success';
this.allowance = allowance;
this.nextResetAt = nextResetAt ? new Date(nextResetAt) : null;
}
fetchError(): void {
this.fetchStatus = 'error';
}
createPending(): void {
this.isCreateError = false;
}
createSuccess(betaCode: BetaCodeRecord): void {
this.betaCodes = [...this.betaCodes, betaCode];
this.allowance = Math.max(0, this.allowance - 1);
}
createError(): void {
this.isCreateError = true;
}
deletePending(): void {
this.isDeleteError = false;
}
deleteSuccess(code: string): void {
const removed = this.betaCodes.find((betaCode) => betaCode.code === code);
this.betaCodes = this.betaCodes.filter((betaCode) => betaCode.code !== code);
if (removed && !removed.redeemer) {
this.allowance = this.allowance + 1;
}
}
deleteError(): void {
this.isDeleteError = true;
}
}
export default new BetaCodeStore();

View File

@@ -41,6 +41,10 @@ class CallInitiatorStore {
return recipients ? Array.from(recipients) : [];
}
hasInitiated(channelId: string): boolean {
return this.initiatedRecipients.has(channelId);
}
clearChannel(channelId: string): void {
this.initiatedRecipients.delete(channelId);
}

View File

@@ -23,11 +23,11 @@ interface CallScopedPrefs {
disabledVideoByIdentity: Record<string, boolean>;
}
class CallMediaPrefsStore {
export class CallMediaPrefsStore {
private byCall: Record<string, CallScopedPrefs> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
makeAutoObservable(this, {isVideoDisabled: false}, {autoBind: true});
}
private ensure(callId: string): CallScopedPrefs {
@@ -52,4 +52,3 @@ class CallMediaPrefsStore {
}
export default new CallMediaPrefsStore();
export {CallMediaPrefsStore};

View File

@@ -17,10 +17,17 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import MediaEngineStore from '@app/stores/voice/MediaEngineFacade';
import VoiceStateManager from '@app/stores/voice/VoiceStateManager';
import type {CallVoiceState} from '@app/types/gateway/GatewayVoiceTypes';
import {ME} from '@fluxer/constants/src/AppConstants';
import {makeAutoObservable, observable} from 'mobx';
import {ME} from '~/Constants';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import VoiceStateManager from '~/stores/voice/VoiceStateManager';
export enum CallMode {
MINIMUM = 'MINIMUM',
NORMAL = 'NORMAL',
FULL_SCREEN = 'FULL_SCREEN',
}
export enum CallLayout {
MINIMUM = 'MINIMUM',
@@ -28,22 +35,12 @@ export enum CallLayout {
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>;
voice_states?: Array<CallVoiceState>;
}
export interface Call {
@@ -60,7 +57,20 @@ class CallStateStore {
private pendingRinging = observable.map<string, Set<string>>();
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
makeAutoObservable(
this,
{
getCall: false,
getActiveCalls: false,
hasActiveCall: false,
isCallActive: false,
getCallLayout: false,
getMessageId: false,
getParticipants: false,
isUserPendingRinging: false,
},
{autoBind: true},
);
}
getCall(channelId: string): Call | undefined {
@@ -150,24 +160,27 @@ class CallStateStore {
handleCallCreate(data: {channelId: string; call?: GatewayCallData}): void {
if (!data.call) return;
const existingCall = this.calls.get(data.channelId);
if (existingCall) {
this.handleCallUpdate(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,
layout: CallLayout.MINIMUM,
participants,
};
this.calls.set(data.channelId, call);
this.recordIncomingRinging(data.channelId, normalizedRinging);
this.setPendingRinging(data.channelId, normalizedRinging);
}
handleCallUpdate(data: GatewayCallData): void {
@@ -180,8 +193,8 @@ class CallStateStore {
return;
}
const normalizedRinging = this.normalizeUserIds(ringing);
const hasRingingPayload = ringing !== undefined;
const normalizedRinging = hasRingingPayload ? this.normalizeUserIds(ringing) : call.ringing;
const hasVoiceStatesPayload = voice_states !== undefined;
const participants = hasVoiceStatesPayload
? this.extractParticipantsFromVoiceStates(voice_states)
@@ -189,45 +202,76 @@ class CallStateStore {
const updatedCall: Call = {
...call,
ringing: hasRingingPayload ? normalizedRinging : call.ringing,
ringing: normalizedRinging,
messageId: message_id !== undefined ? message_id : call.messageId,
region: region !== undefined ? region : call.region,
participants,
};
this.calls.set(channel_id, updatedCall);
if (!this.isCallSnapshotEqual(call, updatedCall)) {
this.calls.set(channel_id, updatedCall);
}
if (hasRingingPayload) {
if (normalizedRinging.length > 0) {
this.recordIncomingRinging(channel_id, normalizedRinging);
} else {
this.clearPendingRinging(channel_id);
}
this.setPendingRinging(channel_id, normalizedRinging);
}
}
private normalizeUserIds(userIds?: Array<string>): Array<string> {
if (!userIds || userIds.length === 0) return [];
return userIds.map(String).filter(Boolean);
const normalized = userIds.map(String).filter(Boolean);
return Array.from(new Set(normalized)).sort();
}
private extractParticipantsFromVoiceStates(voiceStates?: Array<VoiceState>): Array<string> {
private extractParticipantsFromVoiceStates(voiceStates?: Array<CallVoiceState>): Array<string> {
if (!voiceStates || voiceStates.length === 0) return [];
return voiceStates.map((state) => state.user_id).filter((id): id is string => Boolean(id));
const participants = voiceStates.map((state) => state.user_id).filter((id): id is string => Boolean(id));
return Array.from(new Set(participants)).sort();
}
private recordIncomingRinging(channelId: string, userIds: Array<string>): void {
if (userIds.length === 0) return;
private setPendingRinging(channelId: string, userIds: Array<string>): void {
const nextSet = new Set(userIds);
const existing = this.pendingRinging.get(channelId);
const nextSet = existing ? new Set(existing) : new Set<string>();
for (const id of userIds) {
nextSet.add(id);
if (existing && this.areSetsEqual(existing, nextSet)) {
return;
}
if (nextSet.size === 0) {
this.pendingRinging.delete(channelId);
this.syncCallRinging(channelId, new Set());
return;
}
this.pendingRinging.set(channelId, nextSet);
this.syncCallRinging(channelId, nextSet);
}
private isCallSnapshotEqual(a: Call, b: Call): boolean {
return (
a.channelId === b.channelId &&
a.messageId === b.messageId &&
a.region === b.region &&
a.layout === b.layout &&
this.areArraysEqual(a.ringing, b.ringing) &&
this.areArraysEqual(a.participants, b.participants)
);
}
private areArraysEqual(a: Array<string>, b: Array<string>): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
private areSetsEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const value of a) {
if (!b.has(value)) return false;
}
return true;
}
private syncCallRinging(channelId: string, ringSet?: Set<string>): void {
const call = this.calls.get(channelId);
if (!call) return;

View File

@@ -17,12 +17,12 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {UserRecord} from '@app/records/UserRecord';
import UserStore from '@app/stores/UserStore';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
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;

View File

@@ -17,11 +17,12 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {MessageRecord} from '@app/records/MessageRecord';
import AuthenticationStore from '@app/stores/AuthenticationStore';
import type {ReactionEmoji} from '@app/utils/ReactionUtils';
import type {Channel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
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;

View File

@@ -17,14 +17,14 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {SearchMachineState} from '@app/components/channel/SearchResultsUtils';
import {cloneMachineState} from '@app/components/channel/SearchResultsUtils';
import type {ChannelRecord} from '@app/records/ChannelRecord';
import SelectedGuildStore from '@app/stores/SelectedGuildStore';
import type {SearchSegment} from '@app/utils/SearchSegmentManager';
import type {MessageSearchScope} from '@app/utils/SearchUtils';
import {ME} from '@fluxer/constants/src/AppConstants';
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 = '';
@@ -120,10 +120,10 @@ class ChannelSearchStore {
}
}
export const getChannelSearchContextId = (
export function getChannelSearchContextId(
channel?: ChannelRecord | null,
selectedGuildId?: string | null,
): string | null => {
): string | null {
if (!channel) {
return null;
}
@@ -136,6 +136,6 @@ export const getChannelSearchContextId = (
}
return channel.guildId ?? resolvedGuildId ?? channel.id;
};
}
export default new ChannelSearchStore();

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {GuildStickerRecord} from '@app/records/GuildStickerRecord';
import {makeAutoObservable, observable} from 'mobx';
import type {GuildStickerRecord} from '~/records/GuildStickerRecord';
class ChannelStickerStore {
pendingStickers: Map<string, GuildStickerRecord> = observable.map();

View File

@@ -17,19 +17,22 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as NavigationActionCreators from '@app/actions/NavigationActionCreators';
import {Routes} from '@app/Routes';
import {ChannelRecord} from '@app/records/ChannelRecord';
import AuthenticationStore from '@app/stores/AuthenticationStore';
import ChannelDisplayNameStore from '@app/stores/ChannelDisplayNameStore';
import UserStore from '@app/stores/UserStore';
import type {GuildReadyData} from '@app/types/gateway/GatewayGuildTypes';
import * as ChannelUtils from '@app/utils/ChannelUtils';
import * as RouterUtils from '@app/utils/RouterUtils';
import {ME} from '@fluxer/constants/src/AppConstants';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {Channel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
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;
@@ -237,7 +240,7 @@ class ChannelStore {
const expectedPath = Routes.dmChannel(channelId);
if (currentPath.startsWith(expectedPath)) {
RouterUtils.transitionTo(Routes.ME);
NavigationActionCreators.selectChannel(ME);
}
return;
}
@@ -267,14 +270,14 @@ class ChannelStore {
}
if (guildId === ME) {
RouterUtils.transitionTo(Routes.ME);
NavigationActionCreators.selectChannel(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));
NavigationActionCreators.selectChannel(guildId, selectableChannel.id);
} else {
RouterUtils.transitionTo(Routes.ME);
NavigationActionCreators.selectChannel(ME);
}
}
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import AppStorage from '@app/lib/AppStorage';
import {makeAutoObservable} from 'mobx';
interface CompactVoiceCallHeightState {
defaultHeight: number | null;
heightsByKey: Record<string, number>;
}
const COMPACT_VOICE_CALL_HEIGHT_STORAGE_KEY = 'compact_voice_call_heights';
const COMPACT_VOICE_CALL_HEIGHT_MIN = 320;
const COMPACT_VOICE_CALL_HEIGHT_MAX = 1049;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value != null && !Array.isArray(value);
}
function clampCompactVoiceCallHeight(value: number): number {
return Math.max(COMPACT_VOICE_CALL_HEIGHT_MIN, Math.min(Math.round(value), COMPACT_VOICE_CALL_HEIGHT_MAX));
}
function parseCompactVoiceCallHeight(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
return clampCompactVoiceCallHeight(value);
}
function parseHeightMap(value: unknown): Record<string, number> {
if (!isRecord(value)) {
return {};
}
const result: Record<string, number> = {};
for (const [key, rawHeight] of Object.entries(value)) {
const parsed = parseCompactVoiceCallHeight(rawHeight);
if (parsed == null) {
continue;
}
result[key] = parsed;
}
return result;
}
function getInitialState(): CompactVoiceCallHeightState {
const raw = AppStorage.getJSON<unknown>(COMPACT_VOICE_CALL_HEIGHT_STORAGE_KEY);
if (!isRecord(raw)) {
return {
defaultHeight: null,
heightsByKey: {},
};
}
return {
defaultHeight: parseCompactVoiceCallHeight(raw.defaultHeight),
heightsByKey: parseHeightMap(raw.heightsByKey),
};
}
class CompactVoiceCallHeightStore {
defaultHeight: number | null = null;
heightsByKey: Record<string, number> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
const initialState = getInitialState();
this.defaultHeight = initialState.defaultHeight;
this.heightsByKey = initialState.heightsByKey;
}
getStartingHeight(heightKey: string): number | null {
return this.heightsByKey[heightKey] ?? this.defaultHeight;
}
setHeightForKey(heightKey: string, height: number): number {
const normalizedHeight = clampCompactVoiceCallHeight(height);
this.defaultHeight = normalizedHeight;
this.heightsByKey = {
...this.heightsByKey,
[heightKey]: normalizedHeight,
};
this.persist();
return normalizedHeight;
}
private persist(): void {
const state: CompactVoiceCallHeightState = {
defaultHeight: this.defaultHeight,
heightsByKey: this.heightsByKey,
};
AppStorage.setJSON(COMPACT_VOICE_CALL_HEIGHT_STORAGE_KEY, state);
}
}
export {COMPACT_VOICE_CALL_HEIGHT_MAX, COMPACT_VOICE_CALL_HEIGHT_MIN};
export default new CompactVoiceCallHeightStore();

View File

@@ -0,0 +1,82 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import AppStorage from '@app/lib/AppStorage';
import {makeAutoObservable} from 'mobx';
interface CompactVoiceCallPiPPositionState {
x: number;
y: number;
}
const COMPACT_VOICE_CALL_PIP_POSITION_KEY = 'compact_voice_call_pip_position';
const DEFAULT_COMPACT_PIP_POSITION: CompactVoiceCallPiPPositionState = {
x: 1,
y: 0,
};
function clampNormalizedValue(value: unknown, fallback: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return fallback;
}
return Math.max(0, Math.min(1, value));
}
function parseCompactVoiceCallPiPPosition(value: unknown): CompactVoiceCallPiPPositionState {
if (typeof value !== 'object' || value == null || Array.isArray(value)) {
return DEFAULT_COMPACT_PIP_POSITION;
}
const state = value as Record<string, unknown>;
return {
x: clampNormalizedValue(state.x, DEFAULT_COMPACT_PIP_POSITION.x),
y: clampNormalizedValue(state.y, DEFAULT_COMPACT_PIP_POSITION.y),
};
}
class CompactVoiceCallPiPPositionStore {
x = DEFAULT_COMPACT_PIP_POSITION.x;
y = DEFAULT_COMPACT_PIP_POSITION.y;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
const initialState = parseCompactVoiceCallPiPPosition(
AppStorage.getJSON<unknown>(COMPACT_VOICE_CALL_PIP_POSITION_KEY),
);
this.x = initialState.x;
this.y = initialState.y;
}
getPosition(): CompactVoiceCallPiPPositionState {
return {
x: this.x,
y: this.y,
};
}
setPosition(position: CompactVoiceCallPiPPositionState): void {
this.x = clampNormalizedValue(position.x, DEFAULT_COMPACT_PIP_POSITION.x);
this.y = clampNormalizedValue(position.y, DEFAULT_COMPACT_PIP_POSITION.y);
AppStorage.setJSON(COMPACT_VOICE_CALL_PIP_POSITION_KEY, {
x: this.x,
y: this.y,
});
}
}
export default new CompactVoiceCallPiPPositionStore();

View File

@@ -1,20 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
export {default} from './gateway/ConnectionStore';

View File

@@ -17,9 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@app/lib/Logger';
import KeyboardModeStore from '@app/stores/KeyboardModeStore';
import {makeAutoObservable} from 'mobx';
import {Logger} from '~/lib/Logger';
import KeyboardModeStore from './KeyboardModeStore';
const logger = new Logger('ContextMenuStore');
@@ -33,12 +33,12 @@ export interface FocusableContextMenuTarget {
export type ContextMenuTargetElement = HTMLElement | FocusableContextMenuTarget;
export const isContextMenuNodeTarget = (target: ContextMenuTargetElement | null | undefined): target is HTMLElement => {
export function isContextMenuNodeTarget(target: ContextMenuTargetElement | null | undefined): target is HTMLElement {
if (!target || typeof Node === 'undefined') {
return false;
}
return target instanceof HTMLElement;
};
}
export interface ContextMenuTarget {
x: number;
@@ -51,7 +51,7 @@ export interface ContextMenuConfig {
noBlurEvent?: boolean;
returnFocus?: boolean;
returnFocusTarget?: ContextMenuTargetElement | null;
align?: 'top-left' | 'top-right';
align?: 'top-left' | 'top-right' | 'bottom-left';
}
export interface ContextMenu {

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@app/lib/Logger';
import {makeAutoObservable} from 'mobx';
import {Logger} from '~/lib/Logger';
const logger = new Logger('CountryCodeStore');

View File

@@ -17,10 +17,10 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {IS_DEV} from '@app/lib/Env';
import {makePersistent} from '@app/lib/MobXPersistence';
import UserStore from '@app/stores/UserStore';
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;

View File

@@ -17,13 +17,12 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import AppStorage from '@app/lib/AppStorage';
import {makePersistent} from '@app/lib/MobXPersistence';
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;
@@ -80,8 +79,6 @@ export type DeveloperOptionsState = Readonly<{
forceNoAttachFiles: boolean;
mockSlowmodeActive: boolean;
mockSlowmodeRemaining: number;
mockVisionarySoldOut: boolean;
mockVisionaryRemaining: number | null;
mockGiftInventory: boolean | null;
mockGiftDurationMonths: number | null;
mockGiftRedeemed: boolean | null;
@@ -101,7 +98,6 @@ type MutableDeveloperOptionsState = {
class DeveloperOptionsStore implements DeveloperOptionsState {
bypassSplashScreen = false;
forceFailUploads = false;
forceFailMessageSends = false;
forceRenderPlaceholders = false;
forceEmbedSkeletons = false;
@@ -167,9 +163,6 @@ class DeveloperOptionsStore implements DeveloperOptionsState {
}
> = {};
mockVisionarySoldOut = false;
mockVisionaryRemaining: number | null = null;
mockGiftInventory: boolean | null = null;
mockGiftDurationMonths: number | null = 12;
mockGiftRedeemed: boolean | null = null;
@@ -183,7 +176,6 @@ class DeveloperOptionsStore implements DeveloperOptionsState {
private async initPersistence(): Promise<void> {
await makePersistent(this, 'DeveloperOptionsStore', [
'bypassSplashScreen',
'forceFailUploads',
'forceFailMessageSends',
'forceRenderPlaceholders',
'forceEmbedSkeletons',
@@ -233,8 +225,6 @@ class DeveloperOptionsStore implements DeveloperOptionsState {
'forceNoAttachFiles',
'mockSlowmodeActive',
'mockSlowmodeRemaining',
'mockVisionarySoldOut',
'mockVisionaryRemaining',
'mockGiftInventory',
'mockGiftDurationMonths',
'mockGiftRedeemed',

View File

@@ -0,0 +1,98 @@
/*
* 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 {DiscoveryGuild} from '@app/actions/DiscoveryActionCreators';
import * as DiscoveryActionCreators from '@app/actions/DiscoveryActionCreators';
import {makeAutoObservable, runInAction} from 'mobx';
class DiscoveryStore {
guilds: Array<DiscoveryGuild> = [];
total = 0;
loading = false;
query = '';
category: number | null = null;
sortBy = 'member_count';
categories: Array<{id: number; name: string}> = [];
categoriesLoaded = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
async search(params: {query?: string; category?: number | null; sortBy?: string; offset?: number}): Promise<void> {
const query = params.query ?? this.query;
const category = params.category !== undefined ? params.category : this.category;
const sortBy = params.sortBy ?? this.sortBy;
const offset = params.offset ?? 0;
runInAction(() => {
this.loading = true;
this.query = query;
this.category = category;
this.sortBy = sortBy;
});
try {
const result = await DiscoveryActionCreators.searchGuilds({
query: query || undefined,
category: category ?? undefined,
sort_by: sortBy,
limit: 24,
offset,
});
runInAction(() => {
if (offset === 0) {
this.guilds = result.guilds;
} else {
this.guilds = [...this.guilds, ...result.guilds];
}
this.total = result.total;
this.loading = false;
});
} catch {
runInAction(() => {
this.loading = false;
});
}
}
async loadCategories(): Promise<void> {
if (this.categoriesLoaded) return;
try {
const categories = await DiscoveryActionCreators.getCategories();
runInAction(() => {
this.categories = categories;
this.categoriesLoaded = true;
});
} catch {
// Fail silently - categories are optional UI enhancement
}
}
reset(): void {
this.guilds = [];
this.total = 0;
this.loading = false;
this.query = '';
this.category = null;
this.sortBy = 'member_count';
}
}
export default new DiscoveryStore();

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
class DismissedUpsellStore {
pickerPremiumUpsellDismissed = false;

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {action, makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
class DraftStore {
drafts: Record<string, string> = {};

View File

@@ -17,11 +17,11 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ComponentDispatch} from '@app/lib/ComponentDispatch';
import {Logger} from '@app/lib/Logger';
import {makePersistent} from '@app/lib/MobXPersistence';
import type {FlatEmoji} from '@app/types/EmojiTypes';
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');
@@ -37,6 +37,7 @@ const DEFAULT_QUICK_EMOJIS = [
{name: 'thumbsup', uniqueName: 'thumbsup'},
{name: 'ok_hand', uniqueName: 'ok_hand'},
{name: 'tada', uniqueName: 'tada'},
{name: 'heart', uniqueName: 'heart'},
];
class EmojiPickerStore {
@@ -94,7 +95,7 @@ class EmojiPickerStore {
logger.debug(`Toggled category: ${category}`);
}
isFavorite(emoji: Emoji): boolean {
isFavorite(emoji: FlatEmoji): boolean {
return this.favoriteEmojis.includes(this.getEmojiKey(emoji));
}
@@ -109,8 +110,8 @@ class EmojiPickerStore {
return entry.count * (1 + timeDecay);
}
getFrecentEmojis(allEmojis: ReadonlyArray<Emoji>, limit: number = MAX_FRECENT_EMOJIS): Array<Emoji> {
const emojiScores: Array<{emoji: Emoji; score: number}> = [];
getFrecentEmojis(allEmojis: ReadonlyArray<FlatEmoji>, limit: number = MAX_FRECENT_EMOJIS): Array<FlatEmoji> {
const emojiScores: Array<{emoji: FlatEmoji; score: number}> = [];
for (const emoji of allEmojis) {
const emojiKey = this.getEmojiKey(emoji);
@@ -126,8 +127,8 @@ class EmojiPickerStore {
return emojiScores.slice(0, limit).map((item) => item.emoji);
}
getFavoriteEmojis(allEmojis: ReadonlyArray<Emoji>): Array<Emoji> {
const favorites: Array<Emoji> = [];
getFavoriteEmojis(allEmojis: ReadonlyArray<FlatEmoji>): Array<FlatEmoji> {
const favorites: Array<FlatEmoji> = [];
for (const emoji of allEmojis) {
if (this.isFavorite(emoji)) {
@@ -138,12 +139,12 @@ class EmojiPickerStore {
return favorites;
}
getFrecencyScoreForEmoji(emoji: Emoji): number {
getFrecencyScoreForEmoji(emoji: FlatEmoji): number {
const usage = this.emojiUsage[this.getEmojiKey(emoji)];
return usage ? this.getFrecencyScore(usage) : 0;
}
getQuickReactionEmojis(allEmojis: ReadonlyArray<Emoji>, count: number): Array<Emoji> {
getQuickReactionEmojis(allEmojis: ReadonlyArray<FlatEmoji>, count: number): Array<FlatEmoji> {
const frecent = this.getFrecentEmojis(allEmojis, count);
if (frecent.length >= count) {
@@ -164,14 +165,14 @@ class EmojiPickerStore {
return result.slice(0, count);
}
private getEmojiKey(emoji: Emoji): string {
private getEmojiKey(emoji: FlatEmoji): string {
if (emoji.id) {
return `custom:${emoji.guildId}:${emoji.id}`;
}
return `unicode:${emoji.uniqueName}`;
}
trackEmoji(emoji: Emoji): void {
trackEmoji(emoji: FlatEmoji): void {
this.trackEmojiUsage(this.getEmojiKey(emoji));
}
}

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
export type EmojiLayout = 'list' | 'grid';
export type StickerViewMode = 'cozy' | 'compact';

View File

@@ -0,0 +1,117 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import EmojiStore from '@app/stores/EmojiStore';
import type {GuildReadyData} from '@app/types/gateway/GatewayGuildTypes';
import type {GuildEmoji} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {Guild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {beforeEach, describe, expect, test} from 'vitest';
function createGuildBase(guildId: string): Guild {
return {
id: guildId,
name: `Guild ${guildId}`,
icon: null,
vanity_url_code: null,
owner_id: '1000',
system_channel_id: null,
features: [],
unavailable: false,
};
}
function createGuildReadyData(guildId: string, emojis: ReadonlyArray<GuildEmoji>): GuildReadyData {
return {
id: guildId,
properties: createGuildBase(guildId),
channels: [],
emojis,
stickers: [],
members: [],
member_count: 0,
presences: [],
voice_states: [],
roles: [],
joined_at: '2026-01-01T00:00:00.000Z',
unavailable: false,
};
}
describe('EmojiStore', () => {
beforeEach(() => {
EmojiStore.handleConnectionOpen({guilds: []});
});
test('keeps guild emojis on metadata-only guild update payloads', () => {
const guildId = '1';
EmojiStore.handleConnectionOpen({
guilds: [
createGuildReadyData(guildId, [
{
id: '11',
name: 'party',
animated: false,
},
]),
],
});
expect(EmojiStore.getGuildEmoji(guildId)).toHaveLength(1);
EmojiStore.handleGuildUpdate({
guild: {
...createGuildBase(guildId),
name: 'Guild renamed',
},
});
const emojisAfterGuildUpdate = EmojiStore.getGuildEmoji(guildId);
expect(emojisAfterGuildUpdate).toHaveLength(1);
expect(emojisAfterGuildUpdate[0]?.id).toBe('11');
});
test('replaces guild emojis when a payload includes an explicit emoji list', () => {
const guildId = '2';
EmojiStore.handleConnectionOpen({
guilds: [
createGuildReadyData(guildId, [
{
id: '21',
name: 'old',
animated: false,
},
]),
],
});
EmojiStore.handleGuildUpdate({
guild: createGuildReadyData(guildId, [
{
id: '22',
name: 'new',
animated: false,
},
]),
});
const emojisAfterExplicitUpdate = EmojiStore.getGuildEmoji(guildId);
expect(emojisAfterExplicitUpdate).toHaveLength(1);
expect(emojisAfterExplicitUpdate[0]?.id).toBe('22');
});
});

View File

@@ -17,36 +17,23 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ComponentDispatch} from '@app/lib/ComponentDispatch';
import {makePersistent} from '@app/lib/MobXPersistence';
import UnicodeEmojis from '@app/lib/UnicodeEmojis';
import type {ChannelRecord} from '@app/records/ChannelRecord';
import {GuildEmojiRecord} from '@app/records/GuildEmojiRecord';
import EmojiPickerStore from '@app/stores/EmojiPickerStore';
import {patchGuildEmojiCacheFromGateway} from '@app/stores/GuildExpressionTabCache';
import GuildListStore from '@app/stores/GuildListStore';
import type {FlatEmoji} from '@app/types/EmojiTypes';
import type {GuildReadyData} from '@app/types/gateway/GatewayGuildTypes';
import {filterEmojisForAutocomplete} from '@app/utils/ExpressionPermissionUtils';
import * as RegexUtils from '@app/utils/RegexUtils';
import type {GuildEmoji} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {Guild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {sortBySnowflakeDesc} from '@fluxer/snowflake/src/SnowflakeUtils';
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>;
@@ -54,16 +41,16 @@ type GuildEmojiContext = Readonly<{
}>;
export function normalizeEmojiSearchQuery(query: string): string {
return query.trim().replace(/^:+/, '').replace(/:+$/, '');
return query['trim']().replace(/^:+/, '').replace(/:+$/, '');
}
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 disambiguatedEmoji: ReadonlyArray<FlatEmoji> | null = null;
private customEmojis: ReadonlyMap<string, FlatEmoji> | null = null;
private emojisByName: ReadonlyMap<string, FlatEmoji> | null = null;
private emojisById: ReadonlyMap<string, FlatEmoji> | null = null;
private constructor(guildId?: string | null) {
this.guildId = guildId ?? null;
@@ -86,27 +73,27 @@ class EmojiDisambiguations {
}
}
getDisambiguatedEmoji(): ReadonlyArray<Emoji> {
getDisambiguatedEmoji(): ReadonlyArray<FlatEmoji> {
this.ensureDisambiguated();
return this.disambiguatedEmoji ?? [];
}
getCustomEmoji(): ReadonlyMap<string, Emoji> {
getCustomEmoji(): ReadonlyMap<string, FlatEmoji> {
this.ensureDisambiguated();
return this.customEmojis ?? new Map();
}
getByName(disambiguatedEmojiName: string): Emoji | undefined {
getByName(disambiguatedEmojiName: string): FlatEmoji | undefined {
this.ensureDisambiguated();
return this.emojisByName?.get(disambiguatedEmojiName);
}
getById(emojiId: string): Emoji | undefined {
getById(emojiId: string): FlatEmoji | undefined {
this.ensureDisambiguated();
return this.emojisById?.get(emojiId);
}
nameMatchesChain(testName: (name: string) => boolean): ReadonlyArray<Emoji> {
nameMatchesChain(testName: (name: string) => boolean): ReadonlyArray<FlatEmoji> {
return this.getDisambiguatedEmoji().filter(({names, name}) => (names ? names.some(testName) : testName(name)));
}
@@ -122,12 +109,12 @@ class EmojiDisambiguations {
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 disambiguatedEmoji: Array<FlatEmoji> = [];
const customEmojis = new Map<string, FlatEmoji>();
const emojisByName = new Map<string, FlatEmoji>();
const emojisById = new Map<string, FlatEmoji>();
const disambiguateEmoji = (emoji: Emoji): void => {
const disambiguateEmoji = (emoji: FlatEmoji): void => {
const uniqueName = emoji.name;
const existingCount = emojiCountByName.get(uniqueName) ?? 0;
emojiCountByName.set(uniqueName, existingCount + 1);
@@ -151,7 +138,7 @@ class EmojiDisambiguations {
};
UnicodeEmojis.forEachEmoji((unicodeEmoji) => {
const compatibleEmoji: Emoji = {
const compatibleEmoji: FlatEmoji = {
...unicodeEmoji,
name: unicodeEmoji.uniqueName,
url: unicodeEmoji.url || undefined,
@@ -167,7 +154,7 @@ class EmojiDisambiguations {
const guildEmoji = emojiGuildRegistry.get(guildId);
if (!guildEmoji) return;
guildEmoji.usableEmojis.forEach((emoji) => {
const emojiForDisambiguation: Emoji = {
const emojiForDisambiguation: FlatEmoji = {
...emoji,
name: emoji.name,
uniqueName: emoji.name,
@@ -230,12 +217,6 @@ class EmojiGuildRegistry {
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);
@@ -274,7 +255,7 @@ class EmojiStore {
return emojiGuildRegistry.getGuildEmojis(guildId);
}
getEmojiById(emojiId: string): Emoji | undefined {
getEmojiById(emojiId: string): FlatEmoji | undefined {
return this.getDisambiguatedEmojiContext(null).getById(emojiId);
}
@@ -282,15 +263,24 @@ class EmojiStore {
return EmojiDisambiguations.getInstance(guildId);
}
getEmojiMarkdown(emoji: Emoji): string {
return emoji.id ? `<${emoji.animated ? 'a' : ''}:${emoji.uniqueName}:${emoji.id}>` : `:${emoji.uniqueName}:`;
getEmojiMarkdown(emoji: FlatEmoji): string {
if (emoji.id) {
return `<${emoji.animated ? 'a' : ''}:${emoji.uniqueName}:${emoji.id}>`;
}
if (emoji.hasDiversity && this.skinTone) {
const skinToneName = UnicodeEmojis.convertSurrogateToName(this.skinTone, false);
if (skinToneName) {
return `:${emoji.uniqueName}::${skinToneName}:`;
}
}
return `:${emoji.uniqueName}:`;
}
filterExternal(
channel: ChannelRecord | null,
nameTest: (name: string) => boolean,
count: number,
): ReadonlyArray<Emoji> {
): ReadonlyArray<FlatEmoji> {
const results = EmojiDisambiguations.getInstance(channel?.guildId).nameMatchesChain(nameTest);
const filtered = filterEmojisForAutocomplete(i18n, results, channel);
@@ -298,11 +288,11 @@ class EmojiStore {
return count > 0 ? filtered.slice(0, count) : filtered;
}
getAllEmojis(channel: ChannelRecord | null): ReadonlyArray<Emoji> {
getAllEmojis(channel: ChannelRecord | null): ReadonlyArray<FlatEmoji> {
return this.getDisambiguatedEmojiContext(channel?.guildId).getDisambiguatedEmoji();
}
search(channel: ChannelRecord | null, query: string, count = 0): ReadonlyArray<Emoji> {
search(channel: ChannelRecord | null, query: string, count = 0): ReadonlyArray<FlatEmoji> {
const normalizedQuery = normalizeEmojiSearchQuery(query);
const lowerCasedQuery = normalizedQuery.toLowerCase();
if (!lowerCasedQuery) {
@@ -360,36 +350,31 @@ class EmojiStore {
emojiGuildRegistry.updateGuild(guild.id, guild.emojis);
}
emojiGuildRegistry.rebuildRegistry();
ComponentDispatch.dispatch('EMOJI_PICKER_RERENDER');
}
handleGuildUpdate({guild}: {guild: Guild | GuildReadyData}): void {
emojiGuildRegistry.updateGuild(guild.id, 'emojis' in guild ? guild.emojis : undefined);
if (!('emojis' in guild)) {
ComponentDispatch.dispatch('EMOJI_PICKER_RERENDER');
return;
}
emojiGuildRegistry.updateGuild(guild.id, guild.emojis);
emojiGuildRegistry.rebuildRegistry();
ComponentDispatch.dispatch('EMOJI_PICKER_RERENDER');
}
handleGuildEmojiUpdated({guildId, emojis}: {guildId: string; emojis: ReadonlyArray<GuildEmoji>}): void {
emojiGuildRegistry.updateGuild(guildId, emojis);
emojiGuildRegistry.rebuildRegistry();
patchGuildEmojiCacheFromGateway(guildId, emojis);
ComponentDispatch.dispatch('EMOJI_PICKER_RERENDER');
}
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();
ComponentDispatch.dispatch('EMOJI_PICKER_RERENDER');
}
}

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ExpressionPickerTabType} from '@app/components/popouts/ExpressionPickerPopout';
import {makeAutoObservable, runInAction} from 'mobx';
import type {ExpressionPickerTabType} from '~/components/popouts/ExpressionPickerPopout';
class ExpressionPickerStore {
isOpen = false;

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {type FavoriteMeme, FavoriteMemeRecord} from '@app/records/FavoriteMemeRecord';
import {makeAutoObservable} from 'mobx';
import {type FavoriteMeme, FavoriteMemeRecord} from '~/records/FavoriteMemeRecord';
class FavoriteMemeStore {
memes: ReadonlyArray<FavoriteMemeRecord> = [];

View File

@@ -17,10 +17,10 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import ChannelStore from '@app/stores/ChannelStore';
import UserGuildSettingsStore from '@app/stores/UserGuildSettingsStore';
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
import ChannelStore from './ChannelStore';
import UserGuildSettingsStore from './UserGuildSettingsStore';
export interface FavoriteChannel {
channelId: string;

View File

@@ -1,93 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable} from 'mobx';
import {ALL_FEATURE_FLAGS, type FeatureFlag, FeatureFlags} from '~/Constants';
import FeatureFlagOverridesStore from '~/stores/FeatureFlagOverridesStore';
type FeatureFlagGuildMap = Record<FeatureFlag, Set<string>>;
class FeatureFlagStore {
private featureFlagGuilds: FeatureFlagGuildMap;
constructor() {
this.featureFlagGuilds = FeatureFlagStore.createEmptyMap();
makeAutoObservable(this, {}, {autoBind: true});
}
private static createEmptyMap(): FeatureFlagGuildMap {
const map: FeatureFlagGuildMap = {} as FeatureFlagGuildMap;
for (const flag of ALL_FEATURE_FLAGS) {
map[flag] = new Set();
}
return map;
}
handleConnectionOpen(featureFlags?: Record<FeatureFlag, Array<string>>): void {
this.featureFlagGuilds = FeatureFlagStore.createEmptyMap();
if (!featureFlags) {
return;
}
for (const flag of ALL_FEATURE_FLAGS) {
const guildIds = featureFlags[flag] ?? [];
this.featureFlagGuilds[flag] = new Set(guildIds);
}
}
private getGuildSet(flag: FeatureFlag): Set<string> {
return this.featureFlagGuilds[flag] ?? new Set();
}
isFeatureEnabled(flag: FeatureFlag, guildId?: string): boolean {
const overrideEnabled = FeatureFlagOverridesStore.getOverride(flag);
if (overrideEnabled !== null) {
return overrideEnabled;
}
if (!guildId) {
return false;
}
return this.getGuildSet(flag).has(guildId);
}
isMessageSchedulingEnabled(guildId?: string): boolean {
return this.isFeatureEnabled(FeatureFlags.MESSAGE_SCHEDULING, guildId);
}
isExpressionPacksEnabled(guildId?: string): boolean {
return this.isFeatureEnabled(FeatureFlags.EXPRESSION_PACKS, guildId);
}
getGuildIdsForFlag(flag: FeatureFlag): Array<string> {
return Array.from(this.getGuildSet(flag));
}
hasAccessToAnyEnabledGuild(flag: FeatureFlag, guildIds: Array<string>): boolean {
const guildSet = this.getGuildSet(flag);
if (guildSet.size === 0 || guildIds.length === 0) {
return false;
}
return guildIds.some((guildId) => guildSet.has(guildId));
}
}
export default new FeatureFlagStore();

View File

@@ -17,10 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {FriendsTab} from '@app/components/channel/friends/FriendsTypes';
import {makeAutoObservable} from 'mobx';
export type FriendsTab = 'online' | 'all' | 'pending' | 'add';
class FriendsTabStore {
pendingTab: FriendsTab | null = null;

View File

@@ -44,7 +44,7 @@ class GeoIPStore {
async fetchGeoData(): Promise<void> {
try {
const response = await fetch('https://ip.fluxer.workers.dev/');
const response = await fetch('https://ip.fluxer.workers.dev/', {signal: AbortSignal.timeout(5000)});
if (!response.ok) {
throw new Error(`Failed to fetch geo data: ${response.statusText}`);
}

View File

@@ -17,9 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Gift} from '@app/actions/GiftActionCreators';
import * as GiftActionCreators from '@app/actions/GiftActionCreators';
import {makeAutoObservable, observable, runInAction} from 'mobx';
import type {Gift} from '~/actions/GiftActionCreators';
import * as GiftActionCreators from '~/actions/GiftActionCreators';
interface GiftState {
loading: boolean;

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {GuildReadyData} from '@app/types/gateway/GatewayGuildTypes';
import {makeAutoObservable, observable} from 'mobx';
import type {GuildReadyData} from '~/records/GuildRecord';
class GuildAvailabilityStore {
unavailableGuilds: Set<string> = observable.set();

View File

@@ -17,9 +17,13 @@
* 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';
import type {
GuildEmoji,
GuildEmojiWithUser,
GuildSticker,
GuildStickerWithUser,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import {sortBySnowflakeDesc} from '@fluxer/snowflake/src/SnowflakeUtils';
type EmojiUpdateListener = (emojis: ReadonlyArray<GuildEmojiWithUser>) => void;
type StickerUpdateListener = (stickers: ReadonlyArray<GuildStickerWithUser>) => void;
@@ -30,43 +34,45 @@ 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]);
function freezeList<T>(items: ReadonlyArray<T>): ReadonlyArray<T> {
return Object.freeze([...items]);
}
const notifyListeners = <T>(
function 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}>(
function 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 => {
export function seedGuildEmojiCache(guildId: string, emojis: ReadonlyArray<GuildEmojiWithUser>): void {
setCache(emojiCache, emojiListeners, guildId, emojis, false);
};
}
export const seedGuildStickerCache = (guildId: string, stickers: ReadonlyArray<GuildStickerWithUser>): void => {
export function seedGuildStickerCache(guildId: string, stickers: ReadonlyArray<GuildStickerWithUser>): void {
setCache(stickerCache, stickerListeners, guildId, stickers, false);
};
}
export const subscribeToGuildEmojiUpdates = (guildId: string, listener: EmojiUpdateListener): (() => void) => {
export function subscribeToGuildEmojiUpdates(guildId: string, listener: EmojiUpdateListener): () => void {
let listenersForGuild = emojiListeners.get(guildId);
if (!listenersForGuild) {
listenersForGuild = new Set();
@@ -79,9 +85,9 @@ export const subscribeToGuildEmojiUpdates = (guildId: string, listener: EmojiUpd
emojiListeners.delete(guildId);
}
};
};
}
export const subscribeToGuildStickerUpdates = (guildId: string, listener: StickerUpdateListener): (() => void) => {
export function subscribeToGuildStickerUpdates(guildId: string, listener: StickerUpdateListener): () => void {
let listenersForGuild = stickerListeners.get(guildId);
if (!listenersForGuild) {
listenersForGuild = new Set();
@@ -94,28 +100,44 @@ export const subscribeToGuildStickerUpdates = (guildId: string, listener: Sticke
stickerListeners.delete(guildId);
}
};
};
}
export const patchGuildEmojiCacheFromGateway = (guildId: string, updates: ReadonlyArray<GuildEmoji>) => {
export function 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),
}));
const next = updates
.map((emoji) => {
const user = emoji.user ?? previousUserById.get(emoji.id);
if (!user) {
return null;
}
return {
...emoji,
user,
};
})
.filter((entry): entry is GuildEmojiWithUser => Boolean(entry));
setCache(emojiCache, emojiListeners, guildId, next, true);
};
}
export const patchGuildStickerCacheFromGateway = (guildId: string, updates: ReadonlyArray<GuildSticker>) => {
export function 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),
}));
const next = updates
.map((sticker) => {
const user = sticker.user ?? previousUserById.get(sticker.id);
if (!user) {
return null;
}
return {
...sticker,
user,
};
})
.filter((entry): entry is GuildStickerWithUser => Boolean(entry));
setCache(stickerCache, stickerListeners, guildId, next, true);
};
}

View File

@@ -17,14 +17,11 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {makeAutoObservable} from 'mobx';
import type {FeatureFlag} from '~/Constants';
import {makePersistent} from '~/lib/MobXPersistence';
type FeatureFlagOverrides = Partial<Record<FeatureFlag, boolean>>;
class FeatureFlagOverridesStore {
overrides: FeatureFlagOverrides = {};
class GuildFolderExpandedStore {
expandedFolderIds: Array<number> = [];
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -32,27 +29,23 @@ class FeatureFlagOverridesStore {
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'FeatureFlagOverridesStore', ['overrides']);
await makePersistent(this, 'GuildFolderExpandedStore', ['expandedFolderIds']);
}
getOverride(flag: FeatureFlag): boolean | null {
if (Object.hasOwn(this.overrides, flag)) {
return this.overrides[flag] ?? null;
}
return null;
isExpanded(folderId: number): boolean {
return this.expandedFolderIds.includes(folderId);
}
setOverride(flag: FeatureFlag, value: boolean | null): void {
const next = {...this.overrides};
if (value === null) {
delete next[flag];
toggleExpanded(folderId: number): void {
if (this.expandedFolderIds.includes(folderId)) {
const index = this.expandedFolderIds.indexOf(folderId);
if (index > -1) {
this.expandedFolderIds.splice(index, 1);
}
} else {
next[flag] = value;
this.expandedFolderIds.push(folderId);
}
this.overrides = next;
}
}
export default new FeatureFlagOverridesStore();
export default new GuildFolderExpandedStore();

View File

@@ -17,10 +17,17 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {GuildRecord} from '@app/records/GuildRecord';
import GuildStore from '@app/stores/GuildStore';
import UserSettingsStore, {type GuildFolder} from '@app/stores/UserSettingsStore';
import type {GuildReadyData} from '@app/types/gateway/GatewayGuildTypes';
import {UNCATEGORIZED_FOLDER_ID} from '@fluxer/constants/src/UserConstants';
import type {Guild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {action, makeAutoObservable} from 'mobx';
import {type Guild, type GuildReadyData, GuildRecord} from '~/records/GuildRecord';
import GuildStore from '~/stores/GuildStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
export type OrganizedItem =
| {type: 'folder'; folder: GuildFolder; guilds: Array<GuildRecord>}
| {type: 'guild'; guild: GuildRecord};
class GuildListStore {
guilds: Array<GuildRecord> = [];
@@ -90,12 +97,39 @@ class GuildListStore {
this.guilds = [...this.sortGuildArray([...this.guilds])];
}
getOrganizedGuildList(): Array<OrganizedItem> {
const guildFolders = UserSettingsStore.guildFolders;
const guildMap = new Map(this.guilds.map((guild) => [guild.id, guild]));
const result: Array<OrganizedItem> = [];
for (const folder of guildFolders) {
const folderGuilds = folder.guildIds
.map((guildId) => guildMap.get(guildId))
.filter((guild): guild is GuildRecord => guild !== undefined);
if (folderGuilds.length === 0) {
continue;
}
if (folder.id === UNCATEGORIZED_FOLDER_ID) {
for (const guild of folderGuilds) {
result.push({type: 'guild', guild});
}
} else {
result.push({type: 'folder', folder, guilds: folderGuilds});
}
}
return result;
}
private sortGuildArray(guilds: ReadonlyArray<GuildRecord>): ReadonlyArray<GuildRecord> {
const guildPositions = UserSettingsStore.guildPositions;
const guildFolders = UserSettingsStore.guildFolders;
const guildOrder = guildFolders.flatMap((folder) => folder.guildIds);
return [...guilds].sort((a, b) => {
const aIndex = guildPositions.indexOf(a.id);
const bIndex = guildPositions.indexOf(b.id);
const aIndex = guildOrder.indexOf(a.id);
const bIndex = guildOrder.indexOf(b.id);
if (aIndex === -1 && bIndex === -1) {
return a.name.localeCompare(b.name);

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
export type GuildMemberViewMode = 'table' | 'grid';

View File

@@ -17,11 +17,11 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {GuildMemberRecord} from '@app/records/GuildMemberRecord';
import GatewayConnectionStore from '@app/stores/gateway/GatewayConnectionStore';
import type {GuildReadyData} from '@app/types/gateway/GatewayGuildTypes';
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {makeAutoObservable} from 'mobx';
import {type GuildMember, GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {GuildReadyData} from '~/records/GuildRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ConnectionStore from '~/stores/ConnectionStore';
type Members = Record<string, GuildMemberRecord>;
@@ -91,10 +91,13 @@ class GuildMemberStore {
return;
}
const ownMember = guild.members.find((m) => m.user.id === AuthenticationStore.currentUserId);
const members: Members = {};
for (const member of guild.members) {
members[member.user.id] = new GuildMemberRecord(guild.id, member);
}
this.members = {
...this.members,
[guild.id]: ownMember ? {[ownMember.user.id]: new GuildMemberRecord(guild.id, ownMember)} : {},
[guild.id]: members,
};
}
@@ -102,7 +105,7 @@ class GuildMemberStore {
this.members = Object.fromEntries(Object.entries(this.members).filter(([id]) => id !== guildId));
}
handleMemberAdd(guildId: string, member: GuildMember): void {
handleMemberAdd(guildId: string, member: GuildMemberData): void {
this.members = {
...this.members,
[guildId]: {
@@ -157,7 +160,7 @@ class GuildMemberStore {
handleMembersChunk(params: {
guildId: string;
members: Array<GuildMember>;
members: Array<GuildMemberData>;
chunkIndex: number;
chunkCount: number;
nonce?: string;
@@ -201,6 +204,7 @@ class GuildMemberStore {
query?: string;
limit?: number;
userIds?: Array<string>;
presences?: boolean;
},
): Promise<Array<GuildMemberRecord>> {
const nonce = generateMemberNonce();
@@ -214,16 +218,18 @@ class GuildMemberStore {
expectedChunks: 1,
});
const socket = ConnectionStore.socket;
const socket = GatewayConnectionStore.socket;
const requestOptions: {
guildId: string;
nonce: string;
query?: string;
limit?: number;
userIds?: Array<string>;
presences?: boolean;
} = {
guildId,
nonce,
presences: options?.presences ?? true,
};
if (options?.query) {

View File

@@ -17,11 +17,14 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import ChannelStore from '@app/stores/ChannelStore';
import DeveloperOptionsStore from '@app/stores/DeveloperOptionsStore';
import GeoIPStore from '@app/stores/GeoIPStore';
import GuildStore from '@app/stores/GuildStore';
import UserStore from '@app/stores/UserStore';
import {GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
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,
@@ -30,8 +33,14 @@ export enum NSFWGateReason {
CONSENT_REQUIRED = 3,
}
export interface NSFWGateContext {
channelId?: string | null;
guildId?: string | null;
}
class GuildNSFWAgreeStore {
agreedChannelIds: Array<string> = [];
agreedGuildIds: Array<string> = [];
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -39,7 +48,7 @@ class GuildNSFWAgreeStore {
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'GuildNSFWAgreeStore', ['agreedChannelIds']);
await makePersistent(this, 'GuildNSFWAgreeStore', ['agreedChannelIds', 'agreedGuildIds']);
}
agreeToChannel(channelId: string): void {
@@ -48,15 +57,50 @@ class GuildNSFWAgreeStore {
}
}
agreeToGuild(guildId: string): void {
if (!this.agreedGuildIds.includes(guildId)) {
this.agreedGuildIds.push(guildId);
}
}
reset(): void {
this.agreedChannelIds = [];
this.agreedGuildIds = [];
}
hasAgreedToChannel(channelId: string): boolean {
return this.agreedChannelIds.includes(channelId);
}
getGateReason(channelId: string): NSFWGateReason {
hasAgreedToGuild(guildId: string): boolean {
return this.agreedGuildIds.includes(guildId);
}
private resolveContext(context: NSFWGateContext): {
channelId: string | null;
guildId: string | null;
channelIsNsfw: boolean;
guildIsAgeRestricted: boolean;
} {
const channelId = context.channelId ?? null;
const guildIdFromArg = context.guildId ?? null;
const channel = channelId ? ChannelStore.getChannel(channelId) : null;
const guildId = guildIdFromArg ?? channel?.guildId ?? null;
const guild = guildId ? GuildStore.getGuild(guildId) : null;
const guildIsAgeRestricted = guild?.nsfwLevel === GuildNSFWLevel.AGE_RESTRICTED;
const channelIsNsfw = channel?.isNSFW() ?? false;
return {channelId, guildId, channelIsNsfw, guildIsAgeRestricted};
}
isGatedContent(context: NSFWGateContext): boolean {
const {channelIsNsfw, guildIsAgeRestricted} = this.resolveContext(context);
return channelIsNsfw || guildIsAgeRestricted;
}
getGateReason(context: NSFWGateContext): NSFWGateReason {
const mockReason = DeveloperOptionsStore.mockNSFWGateReason;
if (mockReason !== 'none') {
switch (mockReason) {
@@ -69,6 +113,11 @@ class GuildNSFWAgreeStore {
}
}
const resolved = this.resolveContext(context);
if (!resolved.channelIsNsfw && !resolved.guildIsAgeRestricted) {
return NSFWGateReason.NONE;
}
const countryCode = GeoIPStore.countryCode;
const regionCode = GeoIPStore.regionCode;
const ageRestrictedGeos = GeoIPStore.ageRestrictedGeos;
@@ -90,15 +139,22 @@ class GuildNSFWAgreeStore {
return NSFWGateReason.AGE_RESTRICTED;
}
if (!this.hasAgreedToChannel(channelId)) {
if (resolved.guildIsAgeRestricted) {
if (!resolved.guildId || !this.hasAgreedToGuild(resolved.guildId)) {
return NSFWGateReason.CONSENT_REQUIRED;
}
return NSFWGateReason.NONE;
}
if (!resolved.channelId || !this.hasAgreedToChannel(resolved.channelId)) {
return NSFWGateReason.CONSENT_REQUIRED;
}
return NSFWGateReason.NONE;
}
shouldShowGate(channelId: string): boolean {
return this.getGateReason(channelId) !== NSFWGateReason.NONE;
shouldShowGate(context: NSFWGateContext): boolean {
return this.getGateReason(context) !== NSFWGateReason.NONE;
}
}

View File

@@ -17,19 +17,18 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import ChannelStore from '@app/stores/ChannelStore';
import GuildStore from '@app/stores/GuildStore';
import PermissionStore from '@app/stores/PermissionStore';
import ReadStateStore from '@app/stores/ReadStateStore';
import UserGuildSettingsStore from '@app/stores/UserGuildSettingsStore';
import {ME} from '@fluxer/constants/src/AppConstants';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import type {ChannelId, GuildId} from '@fluxer/schema/src/branded/WireIds';
import {makeAutoObservable, observable, reaction, runInAction} from 'mobx';
import {ChannelTypes, ME, Permissions} from '~/Constants';
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 CAN_READ_PERMISSIONS = Permissions.VIEW_CHANNEL;
class GuildReadState {
unread = observable.box(false);
@@ -138,12 +137,12 @@ class GuildReadStateStore {
const byGuild = new Map<GuildId | null, Array<ChannelId>>();
for (const {channelId, guildId} of changes) {
let list = byGuild.get(guildId);
let list = byGuild.get(guildId as GuildId | null);
if (list == null) {
list = [];
byGuild.set(guildId, list);
byGuild.set(guildId as GuildId | null, list);
}
list.push(channelId);
list.push(channelId as ChannelId);
}
for (const [guildId, ids] of byGuild.entries()) {
@@ -161,12 +160,12 @@ class GuildReadStateStore {
return this.updateCounter;
}
private getOrCreate(guildId: GuildId | null): GuildReadState {
private getOrCreate(guildId: string | null): GuildReadState {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
let state = this.guildStates.get(id);
let state = this.guildStates.get(id as GuildId);
if (state == null) {
state = new GuildReadState();
this.guildStates.set(id, state);
this.guildStates.set(id as GuildId, state);
}
return state;
}
@@ -175,13 +174,13 @@ class GuildReadStateStore {
this.updateCounter++;
}
private incrementSentinel(guildId: GuildId | null): void {
private incrementSentinel(guildId: string | null): void {
const state = this.getOrCreate(guildId);
state.incrementSentinel();
this.notifyChange();
}
private recomputeChannels(guildId: GuildId | null, channelIds: Array<ChannelId>): boolean {
private recomputeChannels(guildId: string | null, channelIds: Array<ChannelId>): boolean {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
const prevState = this.getOrCreate(id);
const newState = prevState.clone();
@@ -245,7 +244,7 @@ class GuildReadStateStore {
if (
oldUnreadChannel != null &&
!channelIds.includes(oldUnreadChannel.id) &&
!channelIds.includes(oldUnreadChannel.id as ChannelId) &&
ReadStateStore.hasUnread(oldUnreadChannel.id) &&
canContributeToGuildUnread(oldUnreadChannel, 0)
) {
@@ -256,7 +255,7 @@ class GuildReadStateStore {
return this.commitState(id, newState, prevState);
}
private recomputeAll(guildId: GuildId | null, skipIfMuted = false): boolean {
private recomputeAll(guildId: string | null, skipIfMuted = false): boolean {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
const newState = new GuildReadState();
@@ -268,12 +267,12 @@ class GuildReadStateStore {
if (mentionCount > 0 && canContribute) {
newState.mentionCount.set(newState.mentionCount.get() + mentionCount);
newState.mentionChannels.add(channel.id);
newState.mentionChannels.add(channel.id as ChannelId);
}
if (!newState.unread.get() && ReadStateStore.hasUnread(channel.id) && canContributeToGuildUnread(channel, 0)) {
newState.unread.set(true);
newState.unreadChannelId.set(channel.id);
newState.unreadChannelId.set(channel.id as ChannelId);
}
}
} else {
@@ -289,8 +288,8 @@ class GuildReadStateStore {
for (const channel of channels) {
const isChannelMuted =
isGuildMuted ||
mutedChannels.has(channel.id) ||
(channel.parentId != null && mutedChannels.has(channel.parentId));
mutedChannels.has(channel.id as ChannelId) ||
(channel.parentId != null && mutedChannels.has(channel.parentId as ChannelId));
const mentionCount = ReadStateStore.getMentionCount(channel.id);
const hasUnread = ReadStateStore.hasUnread(channel.id);
@@ -306,12 +305,12 @@ class GuildReadStateStore {
if ((shouldShowUnread || hasMention) && canContributeToGuildUnread(channel, mentionCount)) {
if (shouldShowUnread) {
newState.unread.set(true);
newState.unreadChannelId.set(channel.id);
newState.unreadChannelId.set(channel.id as ChannelId);
}
if (hasMention) {
newState.mentionCount.set(newState.mentionCount.get() + mentionCount);
newState.mentionChannels.add(channel.id);
newState.mentionChannels.add(channel.id as ChannelId);
}
}
}
@@ -331,13 +330,13 @@ class GuildReadStateStore {
}
runInAction(() => {
this.guildStates.set(guildId, newState);
this.guildStates.set(guildId as GuildId, newState);
if (guildId !== PRIVATE_CHANNEL_SENTINEL) {
if (newState.unread.get()) {
this.unreadGuilds.add(guildId);
this.unreadGuilds.add(guildId as GuildId);
} else {
this.unreadGuilds.delete(guildId);
this.unreadGuilds.delete(guildId as GuildId);
}
}
@@ -369,13 +368,13 @@ class GuildReadStateStore {
return this.unreadGuilds.size > 0;
}
hasUnread(guildId: GuildId): boolean {
return this.unreadGuilds.has(guildId);
hasUnread(guildId: string): boolean {
return this.unreadGuilds.has(guildId as GuildId);
}
getMentionCount(guildId: GuildId | null): number {
getMentionCount(guildId: string | null): number {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
const state = this.guildStates.get(id);
const state = this.guildStates.get(id as GuildId);
return state?.mentionCount.get() ?? 0;
}
@@ -389,21 +388,21 @@ class GuildReadStateStore {
}
getPrivateChannelMentionCount(): number {
const state = this.guildStates.get(PRIVATE_CHANNEL_SENTINEL);
const state = this.guildStates.get(PRIVATE_CHANNEL_SENTINEL as GuildId);
return state?.mentionCount.get() ?? 0;
}
getMentionCountForPrivateChannel(channelId: ChannelId): number {
getMentionCountForPrivateChannel(channelId: string): number {
return ReadStateStore.getMentionCount(channelId);
}
getGuildChangeSentinel(guildId: GuildId | null): number {
getGuildChangeSentinel(guildId: string | null): number {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
const state = this.guildStates.get(id);
const state = this.guildStates.get(id as GuildId);
return state?.sentinel.get() ?? 0;
}
getGuildHasUnreadIgnoreMuted(guildId: GuildId): boolean {
getGuildHasUnreadIgnoreMuted(guildId: string): boolean {
const channels = ChannelStore.getGuildChannels(guildId);
for (const channel of channels) {
@@ -433,37 +432,37 @@ class GuildReadStateStore {
this.notifyChange();
}
handleGuildCreate(action: {guild: {id: GuildId}}): void {
handleGuildCreate(action: {guild: {id: string}}): void {
this.recomputeAll(action.guild.id);
}
handleGuildDelete(action: {guild: {id: GuildId}}): void {
this.guildStates.delete(action.guild.id);
this.unreadGuilds.delete(action.guild.id);
handleGuildDelete(action: {guild: {id: string}}): void {
this.guildStates.delete(action.guild.id as GuildId);
this.unreadGuilds.delete(action.guild.id as GuildId);
this.notifyChange();
}
handleChannelUpdate(action: {channel: {id: ChannelId; guildId?: GuildId}}): void {
this.recomputeChannels(action.channel.guildId ?? null, [action.channel.id]);
handleChannelUpdate(action: {channel: {id: string; guildId?: string}}): void {
this.recomputeChannels(action.channel.guildId ?? null, [action.channel.id as ChannelId]);
}
handleGenericUpdate(channelId: ChannelId): void {
handleGenericUpdate(channelId: string): void {
const channel = ChannelStore.getChannel(channelId);
if (channel == null) return;
this.recomputeChannels(channel.guildId ?? null, [channelId]);
this.recomputeChannels(channel.guildId ?? null, [channelId as ChannelId]);
}
handleBulkChannelUpdate(action: {channels: Array<{id: ChannelId; guildId?: GuildId}>}): void {
handleBulkChannelUpdate(action: {channels: Array<{id: string; guildId?: string}>}): 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);
let channels = byGuild.get(guildId as GuildId | null);
if (channels == null) {
channels = [];
byGuild.set(guildId, channels);
byGuild.set(guildId as GuildId | null, channels);
}
channels.push(channel.id);
channels.push(channel.id as ChannelId);
}
for (const [guildId, channelIds] of byGuild.entries()) {
@@ -471,7 +470,7 @@ class GuildReadStateStore {
}
}
handleGuildSettingsUpdate(action: {guildId: GuildId}): void {
handleGuildSettingsUpdate(action: {guildId: string}): void {
this.recomputeAll(action.guildId);
}
@@ -494,7 +493,7 @@ class GuildReadStateStore {
handleChannelDelete(channelId: string): void {
const channel = ChannelStore.getChannel(channelId);
if (channel == null) return;
this.recomputeChannels(channel.guildId ?? null, [channelId]);
this.recomputeChannels(channel.guildId ?? null, [channelId as ChannelId]);
}
handleUserGuildSettingsUpdate(): void {

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {GuildSettingsTabType} from '@app/components/modals/utils/GuildSettingsConstants';
import {makeAutoObservable} from 'mobx';
import type {GuildSettingsTabType} from '~/components/modals/utils/guildSettingsConstants';
interface NavigationHandler {
guildId: string;

View File

@@ -17,11 +17,14 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Routes} from '@app/Routes';
import {GuildRecord} from '@app/records/GuildRecord';
import {GuildRoleRecord} from '@app/records/GuildRoleRecord';
import type {GuildReadyData} from '@app/types/gateway/GatewayGuildTypes';
import * as RouterUtils from '@app/utils/RouterUtils';
import type {Guild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {GuildRole} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
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> = {};

View File

@@ -17,12 +17,13 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {GuildRecord} from '@app/records/GuildRecord';
import GuildMemberStore from '@app/stores/GuildMemberStore';
import GuildStore from '@app/stores/GuildStore';
import UserStore from '@app/stores/UserStore';
import {GuildOperations, GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
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;
@@ -37,7 +38,7 @@ export const VerificationFailureReason = {
TIMED_OUT: 'TIMED_OUT',
} as const;
export type VerificationFailureReason = (typeof VerificationFailureReason)[keyof typeof VerificationFailureReason];
export type VerificationFailureReason = ValueOf<typeof VerificationFailureReason>;
interface VerificationStatus {
canAccess: boolean;

View File

@@ -17,9 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {IS_DEV} from '@app/lib/Env';
import LocalPresenceStore from '@app/stores/LocalPresenceStore';
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);

View File

@@ -17,13 +17,13 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@app/lib/Logger';
import {makePersistent} from '@app/lib/MobXPersistence';
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';
export type InboxTab = 'bookmarks' | 'mentions' | 'scheduled' | 'unreadChannels';
class InboxStore {
selectedTab: InboxTab = 'bookmarks';

View File

@@ -17,6 +17,7 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
import {action, makeAutoObservable} from 'mobx';
const InitializationState = {
@@ -26,7 +27,7 @@ const InitializationState = {
ERROR: 'ERROR',
} as const;
type InitializationState = (typeof InitializationState)[keyof typeof InitializationState];
type InitializationState = ValueOf<typeof InitializationState>;
class InitializationStore {
state: InitializationState = InitializationState.LOADING;

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
class InputMonitoringPromptsStore {
hasSeenInputMonitoringCTA = false;

View File

@@ -0,0 +1,323 @@
/*
* 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 {Logger} from '@app/lib/Logger';
import type {
GifProvider,
InstanceAppPublic,
InstanceCaptcha,
InstanceEndpoints,
InstanceFeatures,
InstanceSsoConfig,
} from '@app/stores/RuntimeConfigStore';
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
import {MS_PER_HOUR, MS_PER_MINUTE} from '@fluxer/date_utils/src/DateConstants';
import {expandWireFormat} from '@fluxer/limits/src/LimitDiffer';
import type {LimitConfigSnapshot, LimitConfigWireFormat} from '@fluxer/limits/src/LimitTypes';
import {makeAutoObservable, runInAction} from 'mobx';
const logger = new Logger('InstanceConfigStore');
const CONFIG_REFRESH_INTERVAL_MS = 30 * MS_PER_MINUTE;
const CONFIG_STALE_THRESHOLD_MS = MS_PER_HOUR;
export interface FederationConfig {
enabled: boolean;
version: number;
}
export interface InstancePublicKey {
id: string;
algorithm: 'x25519';
public_key_base64: string;
}
export interface OAuth2Config {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
scopes_supported: Array<string>;
}
export interface InstanceConfig {
domain: string;
fetchedAt: number;
apiCodeVersion: number;
endpoints: InstanceEndpoints;
captcha: InstanceCaptcha;
features: InstanceFeatures;
gif: {provider: GifProvider};
sso: InstanceSsoConfig | null;
limits: LimitConfigSnapshot;
push: {public_vapid_key: string | null} | null;
appPublic: InstanceAppPublic;
federation: FederationConfig | null;
publicKey: InstancePublicKey | null;
oauth2: OAuth2Config | null;
}
interface InstanceDiscoveryResponse {
api_code_version: number;
endpoints: InstanceEndpoints;
captcha: InstanceCaptcha;
features: InstanceFeatures;
gif?: {provider: GifProvider};
sso?: InstanceSsoConfig;
limits: LimitConfigSnapshot | LimitConfigWireFormat;
push?: {public_vapid_key: string | null};
app_public: InstanceAppPublic;
federation?: FederationConfig;
public_key?: InstancePublicKey;
oauth2?: OAuth2Config;
}
class InstanceConfigStore {
instanceConfigs: Map<string, InstanceConfig> = new Map();
localInstanceDomain: string | null = null;
private refreshIntervalId: number | null = null;
private pendingFetches: Map<string, Promise<InstanceConfig>> = new Map();
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.startPeriodicRefresh();
}
private startPeriodicRefresh(): void {
if (this.refreshIntervalId !== null) {
return;
}
this.refreshIntervalId = window.setInterval(() => {
this.refreshAllConfigs().catch((err) => {
logger.warn('Periodic config refresh failed:', err);
});
}, CONFIG_REFRESH_INTERVAL_MS);
}
stopPeriodicRefresh(): void {
if (this.refreshIntervalId !== null) {
clearInterval(this.refreshIntervalId);
this.refreshIntervalId = null;
}
}
async fetchInstanceConfig(domain: string, forceRefresh = false): Promise<InstanceConfig> {
const normalizedDomain = domain.toLowerCase();
if (!forceRefresh) {
const cached = this.instanceConfigs.get(normalizedDomain);
if (cached && !this.isConfigStale(cached)) {
logger.debug('Using cached config for:', normalizedDomain);
return cached;
}
}
const existingFetch = this.pendingFetches.get(normalizedDomain);
if (existingFetch) {
logger.debug('Waiting for existing fetch for:', normalizedDomain);
return existingFetch;
}
const fetchPromise = this.doFetchInstanceConfig(normalizedDomain);
this.pendingFetches.set(normalizedDomain, fetchPromise);
try {
return await fetchPromise;
} finally {
this.pendingFetches.delete(normalizedDomain);
}
}
private async doFetchInstanceConfig(domain: string): Promise<InstanceConfig> {
logger.debug('Fetching config for:', domain);
const wellKnownUrl = `https://${domain}/.well-known/fluxer`;
const response = await fetch(wellKnownUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch instance config for ${domain}: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as InstanceDiscoveryResponse;
const limits = this.processLimitsFromApi(data.limits);
const gifProvider: GifProvider = data.gif?.provider === 'tenor' ? 'tenor' : 'klipy';
const config: InstanceConfig = {
domain,
fetchedAt: Date.now(),
apiCodeVersion: data.api_code_version,
endpoints: data.endpoints,
captcha: data.captcha,
features: data.features,
gif: {provider: gifProvider},
sso: data.sso ?? null,
limits,
push: data.push ?? null,
appPublic: data.app_public,
federation: data.federation ?? null,
publicKey: data.public_key ?? null,
oauth2: data.oauth2 ?? null,
};
runInAction(() => {
this.instanceConfigs.set(domain, config);
});
logger.debug('Cached config for:', domain);
return config;
}
getInstanceConfig(domain: string): InstanceConfig | null {
const normalizedDomain = domain.toLowerCase();
const cached = this.instanceConfigs.get(normalizedDomain);
if (cached && this.isConfigStale(cached)) {
this.fetchInstanceConfig(normalizedDomain, true).catch((err) => {
logger.warn('Background config refresh failed for:', normalizedDomain, err);
});
}
return cached ?? null;
}
getLocalInstanceConfig(): InstanceConfig | null {
const domain = RuntimeConfigStore.localInstanceDomain;
if (!domain) {
return null;
}
const existing = this.instanceConfigs.get(domain.toLowerCase());
if (existing) {
return existing;
}
return {
domain,
fetchedAt: Date.now(),
apiCodeVersion: RuntimeConfigStore.apiCodeVersion,
endpoints: {
api: RuntimeConfigStore.apiEndpoint,
api_client: RuntimeConfigStore.apiEndpoint,
api_public: RuntimeConfigStore.apiPublicEndpoint,
gateway: RuntimeConfigStore.gatewayEndpoint,
media: RuntimeConfigStore.mediaEndpoint,
static_cdn: RuntimeConfigStore.staticCdnEndpoint,
marketing: RuntimeConfigStore.marketingEndpoint,
admin: RuntimeConfigStore.adminEndpoint,
invite: RuntimeConfigStore.inviteEndpoint,
gift: RuntimeConfigStore.giftEndpoint,
webapp: RuntimeConfigStore.webAppEndpoint,
},
captcha: {
provider: RuntimeConfigStore.captchaProvider,
hcaptcha_site_key: RuntimeConfigStore.hcaptchaSiteKey,
turnstile_site_key: RuntimeConfigStore.turnstileSiteKey,
},
features: RuntimeConfigStore.features,
gif: {provider: RuntimeConfigStore.gifProvider},
sso: RuntimeConfigStore.sso,
limits: RuntimeConfigStore.limits,
push: {public_vapid_key: RuntimeConfigStore.publicPushVapidKey},
appPublic: {
sentry_dsn: RuntimeConfigStore.sentryDsn,
sentry_proxy_path: RuntimeConfigStore.sentryProxyPath,
sentry_report_host: RuntimeConfigStore.sentryReportHost,
sentry_project_id: RuntimeConfigStore.sentryProjectId,
sentry_public_key: RuntimeConfigStore.sentryPublicKey,
},
federation: null,
publicKey: null,
oauth2: null,
};
}
getLimitsForInstance(domain: string): LimitConfigSnapshot | null {
const config = this.getInstanceConfig(domain);
return config?.limits ?? null;
}
async refreshAllConfigs(): Promise<void> {
const domains = Array.from(this.instanceConfigs.keys());
logger.debug('Refreshing configs for', domains.length, 'instances');
const refreshPromises = domains.map(async (domain) => {
try {
await this.fetchInstanceConfig(domain, true);
} catch (err) {
logger.warn('Failed to refresh config for:', domain, err);
}
});
await Promise.allSettled(refreshPromises);
}
async onGatewayReady(domain: string): Promise<void> {
try {
await this.fetchInstanceConfig(domain, true);
logger.debug('Refreshed config on gateway ready for:', domain);
} catch (err) {
logger.warn('Failed to refresh config on gateway ready for:', domain, err);
}
}
clearInstanceConfig(domain: string): void {
const normalizedDomain = domain.toLowerCase();
this.instanceConfigs.delete(normalizedDomain);
logger.debug('Cleared config for:', normalizedDomain);
}
clearAllConfigs(): void {
this.instanceConfigs.clear();
logger.debug('Cleared all instance configs');
}
private isConfigStale(config: InstanceConfig): boolean {
return Date.now() - config.fetchedAt > CONFIG_STALE_THRESHOLD_MS;
}
private processLimitsFromApi(limits: LimitConfigSnapshot | LimitConfigWireFormat | undefined): LimitConfigSnapshot {
if (!limits) {
return this.createEmptyLimitConfig();
}
if ('defaultsHash' in limits && limits.version === 2) {
return expandWireFormat(limits);
}
return limits as LimitConfigSnapshot;
}
private createEmptyLimitConfig(): LimitConfigSnapshot {
return {
version: 1,
traitDefinitions: [],
rules: [],
};
}
}
export default new InstanceConfigStore();

View File

@@ -17,10 +17,10 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as InviteActionCreators from '@app/actions/InviteActionCreators';
import {isGuildInvite, isPackInvite} from '@app/types/InviteTypes';
import type {Invite} from '@fluxer/schema/src/domains/invite/InviteSchemas';
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';

View File

@@ -17,12 +17,12 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
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 =
export type KeybindCommand =
| 'quick_switcher'
| 'navigate_history_back'
| 'navigate_history_forward'
@@ -39,11 +39,20 @@ export type KeybindAction =
| 'mark_channel_read'
| 'mark_server_read'
| 'mark_top_inbox_read'
| 'edit_message'
| 'delete_message'
| 'pin_message'
| 'add_reaction'
| 'reply_message'
| 'forward_message'
| 'speak_message'
| 'copy_text'
| 'mark_unread'
| 'bookmark_message'
| 'toggle_suppress_embeds'
| 'copy_message_link'
| 'copy_message_id'
| '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'
@@ -62,14 +71,11 @@ export type KeybindAction =
| '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'
@@ -88,11 +94,14 @@ export interface KeyCombo {
}
export interface KeybindConfig {
action: KeybindAction;
action: KeybindCommand;
label: string;
description?: string;
combo: KeyCombo;
ignoreWhileTyping?: boolean;
allowGlobal?: boolean;
requiresKeyboardMode?: boolean;
requiresMessageFocus?: boolean;
category: 'navigation' | 'voice' | 'messaging' | 'popouts' | 'calls' | 'system';
}
@@ -197,34 +206,6 @@ const getDefaultKeybinds = (i18n: I18n): ReadonlyArray<KeybindConfig> =>
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`),
@@ -248,8 +229,8 @@ const getDefaultKeybinds = (i18n: I18n): ReadonlyArray<KeybindConfig> =>
},
{
action: 'toggle_mentions_popout',
label: i18n._(msg`Toggle Mentions Popout`),
description: i18n._(msg`Open or close recent mentions`),
label: i18n._(msg`Toggle Inbox Popout`),
description: i18n._(msg`Open or close the inbox popout`),
combo: {key: 'i', ctrlOrMeta: true},
category: 'popouts',
},
@@ -283,8 +264,8 @@ const getDefaultKeybinds = (i18n: I18n): ReadonlyArray<KeybindConfig> =>
},
{
action: 'toggle_memes_picker',
label: i18n._(msg`Toggle Memes Picker`),
description: i18n._(msg`Open or close the memes picker`),
label: i18n._(msg`Toggle Saved Media`),
description: i18n._(msg`Open or close the saved media picker`),
combo: {key: 'm', ctrlOrMeta: true},
category: 'popouts',
},
@@ -330,6 +311,123 @@ const getDefaultKeybinds = (i18n: I18n): ReadonlyArray<KeybindConfig> =>
combo: {key: 'e', ctrlOrMeta: true, shift: true},
category: 'messaging',
},
{
action: 'edit_message',
label: i18n._(msg`Edit Message`),
description: i18n._(msg`Start editing the focused message`),
combo: {key: 'e'},
requiresKeyboardMode: true,
requiresMessageFocus: true,
category: 'messaging',
},
{
action: 'delete_message',
label: i18n._(msg`Delete Message`),
description: i18n._(msg`Remove the focused message`),
combo: {key: 'd'},
requiresKeyboardMode: true,
requiresMessageFocus: true,
category: 'messaging',
},
{
action: 'pin_message',
label: i18n._(msg`Pin Message`),
description: i18n._(msg`Pin or unpin the focused message`),
combo: {key: 'p'},
requiresKeyboardMode: true,
requiresMessageFocus: true,
category: 'messaging',
},
{
action: 'add_reaction',
label: i18n._(msg`Add Reaction`),
description: i18n._(msg`Open the emoji picker for the focused message`),
combo: {key: 'a'},
requiresKeyboardMode: true,
requiresMessageFocus: true,
category: 'messaging',
},
{
action: 'reply_message',
label: i18n._(msg`Reply`),
description: i18n._(msg`Start a reply to the focused message`),
combo: {key: 'r'},
requiresKeyboardMode: true,
requiresMessageFocus: true,
category: 'messaging',
},
{
action: 'forward_message',
label: i18n._(msg`Forward Message`),
description: i18n._(msg`Forward the focused message to a different channel`),
combo: {key: 'f'},
requiresKeyboardMode: true,
requiresMessageFocus: true,
category: 'messaging',
},
{
action: 'speak_message',
label: i18n._(msg`Speak Message`),
description: i18n._(msg`Read the focused message aloud`),
combo: {key: 't'},
requiresKeyboardMode: true,
requiresMessageFocus: true,
category: 'messaging',
},
{
action: 'copy_text',
label: i18n._(msg`Copy Text`),
description: i18n._(msg`Copy the focused message text to the clipboard`),
combo: {key: 'c'},
requiresKeyboardMode: true,
requiresMessageFocus: true,
category: 'messaging',
},
{
action: 'mark_unread',
label: i18n._(msg`Mark Unread`),
description: i18n._(msg`Mark the focused message as unread`),
combo: {key: 'u'},
requiresKeyboardMode: true,
requiresMessageFocus: true,
category: 'messaging',
},
{
action: 'bookmark_message',
label: i18n._(msg`Bookmark Message`),
description: i18n._(msg`Save or remove the focused message from bookmarks`),
combo: {key: 'b'},
requiresKeyboardMode: true,
requiresMessageFocus: true,
category: 'messaging',
},
{
action: 'toggle_suppress_embeds',
label: i18n._(msg`Toggle Embed Suppression`),
description: i18n._(msg`Suppress or reveal embeds on the focused message`),
combo: {key: 's'},
requiresKeyboardMode: true,
requiresMessageFocus: true,
category: 'messaging',
},
{
action: 'copy_message_link',
label: i18n._(msg`Copy Message Link`),
description: i18n._(msg`Copy a jump link to the focused message`),
combo: {key: 'l'},
requiresKeyboardMode: true,
requiresMessageFocus: true,
category: 'messaging',
},
{
action: 'copy_message_id',
label: i18n._(msg`Copy Message ID`),
description: i18n._(msg`Copy the focused message ID to the clipboard`),
combo: {key: 'i'},
requiresKeyboardMode: true,
requiresMessageFocus: true,
category: 'messaging',
},
{
action: 'create_or_join_server',
label: i18n._(msg`Create or Join a Community`),
@@ -388,20 +486,6 @@ const getDefaultKeybinds = (i18n: I18n): ReadonlyArray<KeybindConfig> =>
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`),
@@ -431,13 +515,6 @@ const getDefaultKeybinds = (i18n: I18n): ReadonlyArray<KeybindConfig> =>
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`),
@@ -461,7 +538,7 @@ const getDefaultKeybinds = (i18n: I18n): ReadonlyArray<KeybindConfig> =>
},
] as const;
type KeybindState = Record<KeybindAction, KeyCombo>;
type KeybindState = Record<KeybindCommand, KeyCombo>;
class KeybindStore {
keybinds: KeybindState = {} as KeybindState;
@@ -506,7 +583,7 @@ class KeybindStore {
}));
}
getByAction(action: KeybindAction): KeybindConfig & {combo: KeyCombo} {
getByAction(action: KeybindCommand): KeybindConfig & {combo: KeyCombo} {
if (!this.i18n) {
throw new Error('KeybindStore: i18n not initialized');
}
@@ -520,13 +597,13 @@ class KeybindStore {
};
}
setKeybind(action: KeybindAction, combo: KeyCombo): void {
setKeybind(action: KeybindCommand, combo: KeyCombo): void {
runInAction(() => {
this.keybinds[action] = combo;
});
}
toggleGlobal(action: KeybindAction, enabled: boolean): void {
toggleGlobal(action: KeybindCommand, enabled: boolean): void {
const config = this.getByAction(action);
if (!config.allowGlobal) return;
@@ -641,8 +718,8 @@ class KeybindStore {
export default new KeybindStore();
export const getDefaultKeybind = (action: KeybindAction, i18n: I18n): KeyCombo | null => {
export function getDefaultKeybind(action: KeybindCommand, i18n: I18n): KeyCombo | null {
const defaultKeybinds = getDefaultKeybinds(i18n);
const found = defaultKeybinds.find((k) => k.action === action);
return found ? {...found.combo} : null;
};
}

View File

@@ -17,12 +17,11 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {KeyboardModeIntroModal} from '@app/components/modals/KeyboardModeIntroModal';
import {Logger} from '@app/lib/Logger';
import {makePersistent} from '@app/lib/MobXPersistence';
import {registerKeyboardModeRestoreCallback, registerKeyboardModeStateResolver} from '@app/stores/ModalStore';
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');
@@ -45,7 +44,9 @@ class KeyboardModeStore {
if (showIntro && !this.introSeen) {
this.introSeen = true;
ModalActionCreators.push(modal(() => <KeyboardModeIntroModal />));
void import('@app/actions/ModalActionCreators').then(({modal, push}) => {
push(modal(() => <KeyboardModeIntroModal />));
});
}
}
@@ -65,4 +66,8 @@ class KeyboardModeStore {
}
}
export default new KeyboardModeStore();
const keyboardModeStore = new KeyboardModeStore();
registerKeyboardModeStateResolver(() => keyboardModeStore.keyboardModeEnabled);
registerKeyboardModeRestoreCallback((showIntro) => keyboardModeStore.enterKeyboardMode(showIntro));
export default keyboardModeStore;

View File

@@ -17,9 +17,9 @@
* 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';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as PopoutActionCreators from '@app/actions/PopoutActionCreators';
import type {PopoutKey} from '@app/components/uikit/popout';
type LayerType = 'modal' | 'popout' | 'contextmenu';

View File

@@ -0,0 +1,70 @@
/*
* 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 {makePersistent} from '@app/lib/MobXPersistence';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {makeAutoObservable} from 'mobx';
class LimitOverrideStore {
overrides: Partial<Record<LimitKey, number>> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
void this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'LimitOverrideStore', ['overrides']);
}
getOverride(key: LimitKey): number | null {
if (Object.hasOwn(this.overrides, key)) {
return this.overrides[key] ?? null;
}
return null;
}
getAllOverrides(): Partial<Record<LimitKey, number>> {
return {...this.overrides};
}
hasOverrides(): boolean {
return Object.keys(this.overrides).length > 0;
}
setOverride(key: LimitKey, value: number | null): void {
const next = {...this.overrides};
if (value === null) {
delete next[key];
} else {
next[key] = value;
}
this.overrides = next;
}
clearOverride(key: LimitKey): void {
this.setOverride(key, null);
}
clearAll(): void {
this.overrides = {};
}
}
export default new LimitOverrideStore();

View File

@@ -17,19 +17,14 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {CustomStatus, GatewayCustomStatusPayload} from '@app/lib/CustomStatus';
import {customStatusToKey, normalizeCustomStatus, toGatewayCustomStatus} from '@app/lib/CustomStatus';
import IdleStore from '@app/stores/IdleStore';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import UserSettingsStore from '@app/stores/UserSettingsStore';
import type {StatusType} from '@fluxer/constants/src/StatusConstants';
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
import {makeAutoObservable, reaction} 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;

View File

@@ -17,11 +17,13 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@app/lib/Logger';
import {makePersistent} from '@app/lib/MobXPersistence';
import MediaPermissionStore from '@app/stores/MediaPermissionStore';
import VoiceDevicePermissionStore from '@app/stores/voice/VoiceDevicePermissionStore';
import {syncLocalVoiceStateWithServer} from '@app/stores/voice/VoiceMediaEngineBridge';
import type {VoiceDeviceState} from '@app/utils/VoiceDeviceManager';
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');
@@ -33,7 +35,7 @@ class LocalVoiceStateStore {
selfStreamAudio = false;
selfStreamAudioMute = false;
noiseSuppressionEnabled = true;
viewerStreamKey: string | null = null;
viewerStreamKeys: Array<string> = [];
hasUserSetMute = false;
hasUserSetDeaf = false;
@@ -64,6 +66,19 @@ class LocalVoiceStateStore {
_disposers: false,
isNotifyingServerOfPermissionMute: false,
shouldUnmuteOnUndeafen: false,
getSelfMute: false,
getSelfDeaf: false,
getSelfVideo: false,
getSelfStream: false,
getSelfStreamAudio: false,
getSelfStreamAudioMute: false,
getViewerStreamKeys: false,
addViewerStreamKey: false,
removeViewerStreamKey: false,
hasViewerStreamKey: false,
getNoiseSuppressionEnabled: false,
getHasUserSetMute: false,
getHasUserSetDeaf: false,
},
{autoBind: true},
);
@@ -215,14 +230,10 @@ class LocalVoiceStateStore {
try {
this.isNotifyingServerOfPermissionMute = true;
const store = (
window as {_mediaEngineStore?: {syncLocalVoiceStateWithServer?: (p: {self_mute: boolean}) => void}}
)._mediaEngineStore;
if (store?.syncLocalVoiceStateWithServer) {
store.syncLocalVoiceStateWithServer({self_mute: true});
}
syncLocalVoiceStateWithServer({self_mute: true});
} catch (error) {
logger.debug('Failed to sync permission-mute to server', {error});
logger.error('Failed to sync permission-mute to server', {error});
throw error;
} finally {
this.isNotifyingServerOfPermissionMute = false;
}
@@ -256,16 +267,33 @@ class LocalVoiceStateStore {
return this.selfStreamAudioMute;
}
getViewerStreamKey(): string | null {
return this.viewerStreamKey;
getViewerStreamKeys(): Array<string> {
return this.viewerStreamKeys;
}
updateViewerStreamKey(value: string | null): void {
updateViewerStreamKeys(keys: Array<string>): void {
runInAction(() => {
this.viewerStreamKey = value;
this.viewerStreamKeys = keys;
});
}
addViewerStreamKey(key: string): void {
if (this.viewerStreamKeys.includes(key)) return;
runInAction(() => {
this.viewerStreamKeys = [...this.viewerStreamKeys, key];
});
}
removeViewerStreamKey(key: string): void {
runInAction(() => {
this.viewerStreamKeys = this.viewerStreamKeys.filter((k) => k !== key);
});
}
hasViewerStreamKey(key: string): boolean {
return this.viewerStreamKeys.includes(key);
}
getNoiseSuppressionEnabled(): boolean {
return this.noiseSuppressionEnabled;
}
@@ -443,6 +471,7 @@ class LocalVoiceStateStore {
this.selfStream = false;
this.selfStreamAudio = false;
this.selfStreamAudioMute = false;
this.viewerStreamKeys = [];
this.noiseSuppressionEnabled = true;
this.mutedByPermission = false;
this.shouldUnmuteOnUndeafen = false;

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
interface MobileLayoutState {
navExpanded: boolean;

View File

@@ -17,10 +17,10 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@app/lib/Logger';
import {MediaDeviceRefreshType, refreshMediaDeviceLists} from '@app/utils/MediaDeviceRefresh';
import {checkNativePermission} from '@app/utils/NativePermissions';
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');

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageRecord} from '@app/records/MessageRecord';
import {makeAutoObservable} from 'mobx';
import type {MessageRecord} from '~/records/MessageRecord';
export type MediaViewerItem = Readonly<{
src: string;
@@ -34,6 +34,9 @@ export type MediaViewerItem = Readonly<{
duration?: number;
expiresAt?: string | null;
expired?: boolean;
animated?: boolean;
providerName?: string;
initialTime?: number;
}>;
class MediaViewerStore {

View File

@@ -17,9 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@app/lib/Logger';
import {makePersistent} from '@app/lib/MobXPersistence';
import {makeAutoObservable, reaction} from 'mobx';
import {Logger} from '~/lib/Logger';
import {makePersistent} from '~/lib/MobXPersistence';
const logger = new Logger('MemberListStore');

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import GatewayConnectionStore from '@app/stores/gateway/GatewayConnectionStore';
import {makeAutoObservable, runInAction} from 'mobx';
import ConnectionStore from '~/stores/ConnectionStore';
const MEMBER_SUBSCRIPTION_MAX_SIZE = 100;
const MEMBER_SUBSCRIPTION_TTL_MS = 5 * 60 * 1000;
@@ -128,16 +128,21 @@ class MemberPresenceSubscriptionStore {
clearGuild(guildId: string): void {
const hadSubscriptions = this.subscriptions.has(guildId);
const wasActiveGuild = this.activeGuildId === guildId;
this.subscriptions.delete(guildId);
if (hadSubscriptions) {
this.syncToGatewayImmediate(guildId);
this.bumpVersion();
}
if (wasActiveGuild) {
this.activeGuildId = null;
this.syncActiveFlagImmediate(guildId, false);
}
}
clearAll(): void {
const socket = ConnectionStore.socket;
const socket = GatewayConnectionStore.socket;
const guildIds = Array.from(this.subscriptions.keys());
const previousActive = this.activeGuildId;
@@ -250,7 +255,7 @@ class MemberPresenceSubscriptionStore {
}
private syncActiveFlagImmediate(guildId: string, active: boolean): void {
const socket = ConnectionStore.socket;
const socket = GatewayConnectionStore.socket;
if (!socket) return;
socket.updateGuildSubscriptions({
subscriptions: {
@@ -260,21 +265,17 @@ class MemberPresenceSubscriptionStore {
}
private syncToGatewayImmediate(guildId: string): void {
const socket = ConnectionStore.socket;
const socket = GatewayConnectionStore.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;
}
const members = guildSubs ? Array.from(guildSubs.keys()).sort() : [];
socket.updateGuildSubscriptions({
subscriptions: {
[guildId]: payload,
[guildId]: {members},
},
});
}

View File

@@ -17,14 +17,22 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@app/lib/Logger';
import type {GuildMemberRecord} from '@app/records/GuildMemberRecord';
import type {GuildRecord} from '@app/records/GuildRecord';
import GuildMemberStore from '@app/stores/GuildMemberStore';
import GuildStore from '@app/stores/GuildStore';
import RelationshipStore from '@app/stores/RelationshipStore';
import UserStore from '@app/stores/UserStore';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
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 MemberSearchActionTypes {
UPDATE_USERS = 'UPDATE_USERS',
USER_RESULTS = 'USER_RESULTS',
QUERY_SET = 'QUERY_SET',
QUERY_CLEAR = 'QUERY_CLEAR',
}
export enum MemberSearchWorkerMessageTypes {
UPDATE_USERS = 'UPDATE_USERS',
@@ -254,6 +262,7 @@ export class SearchContext {
}
class MemberSearchStore {
private logger = new Logger('MemberSearchStore');
private initialized: boolean = false;
private readonly inFlightFetches = new Map<string, Promise<void>>();
@@ -269,13 +278,13 @@ class MemberSearchStore {
this.initialized = true;
try {
worker = new Worker(new URL('../workers/MemberSearch.worker.ts', import.meta.url), {
worker = new Worker(new URL('../workers/MemberSearch.Worker.tsx', import.meta.url), {
type: 'module',
});
this.sendInitialMembers();
} catch (err) {
console.error('[MemberSearchStore] Failed to initialize worker:', err);
this.logger.error('Failed to initialize worker:', err);
}
}
@@ -421,7 +430,7 @@ class MemberSearchStore {
}
async fetchMembersInBackground(query: string, guildIds: Array<string>, priorityGuildId?: string): Promise<void> {
const trimmed = query.trim();
const trimmed = query['trim']();
if (!trimmed) {
return;
}
@@ -478,7 +487,7 @@ class MemberSearchStore {
updateMembers(transformedMembers);
}
} catch (error) {
console.error('[MemberSearchStore] fetchFromGuild failed:', error);
this.logger.error('fetchFromGuild failed:', error);
}
}
}

View File

@@ -0,0 +1,360 @@
/*
* 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 GuildMemberStore from '@app/stores/GuildMemberStore';
import MemberSidebarStore from '@app/stores/MemberSidebarStore';
import {buildMemberListLayout} from '@app/utils/MemberListLayout';
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {beforeEach, describe, expect, test} from 'vitest';
const DEFAULT_JOINED_AT = '2026-02-01T00:00:00.000Z';
function createUser(userId: string, username: string): UserPartialResponse {
return {
id: userId,
username,
discriminator: '0001',
global_name: username,
avatar: null,
avatar_color: null,
flags: 0,
};
}
function createMember(_guildId: string, userId: string, username: string): GuildMemberData {
return {
user: createUser(userId, username),
nick: null,
avatar: null,
banner: null,
accent_color: null,
roles: [],
joined_at: DEFAULT_JOINED_AT,
mute: false,
deaf: false,
communication_disabled_until: null,
profile_flags: 0,
};
}
function seedMembers(guildId: string, members: Array<{id: string; name: string}>): void {
for (const member of members) {
GuildMemberStore.handleMemberAdd(guildId, createMember(guildId, member.id, member.name));
}
}
describe('MemberSidebarStore', () => {
beforeEach(() => {
MemberSidebarStore.handleSessionInvalidated();
GuildMemberStore.handleConnectionOpen([]);
});
test('stores members by member index when sync includes group entries', () => {
const guildId = 'guild-1';
const listId = 'list-1';
seedMembers(guildId, [
{id: 'u-1', name: 'Alpha'},
{id: 'u-2', name: 'Bravo'},
{id: 'u-3', name: 'Charlie'},
]);
MemberSidebarStore.handleListUpdate({
guildId,
listId,
memberCount: 3,
onlineCount: 2,
groups: [
{id: 'online', count: 2},
{id: 'offline', count: 1},
],
ops: [
{
op: 'SYNC',
range: [0, 4],
items: [
{group: {id: 'online', count: 2}},
{member: {user: {id: 'u-1'}}},
{member: {user: {id: 'u-2'}}},
{group: {id: 'offline', count: 1}},
{member: {user: {id: 'u-3'}}},
],
},
],
});
const listState = MemberSidebarStore.getList(guildId, listId);
expect(listState?.items.size).toBe(3);
expect(listState?.items.get(0)?.data.user.id).toBe('u-1');
expect(listState?.items.get(1)?.data.user.id).toBe('u-2');
expect(listState?.items.get(2)?.data.user.id).toBe('u-3');
});
test('moves members with delete and insert without breaking group boundaries', () => {
const guildId = 'guild-2';
const listId = 'list-2';
seedMembers(guildId, [
{id: 'u-1', name: 'Alpha'},
{id: 'u-2', name: 'Bravo'},
{id: 'u-3', name: 'Charlie'},
]);
MemberSidebarStore.handleListUpdate({
guildId,
listId,
memberCount: 3,
onlineCount: 2,
groups: [
{id: 'online', count: 2},
{id: 'offline', count: 1},
],
ops: [
{
op: 'SYNC',
range: [0, 4],
items: [
{group: {id: 'online', count: 2}},
{member: {user: {id: 'u-2'}}},
{member: {user: {id: 'u-1'}}},
{group: {id: 'offline', count: 1}},
{member: {user: {id: 'u-3'}}},
],
},
],
});
MemberSidebarStore.handleListUpdate({
guildId,
listId,
memberCount: 3,
onlineCount: 1,
groups: [
{id: 'online', count: 1},
{id: 'offline', count: 2},
],
ops: [
{op: 'DELETE', index: 2},
{op: 'INSERT', index: 4, item: {member: {user: {id: 'u-1'}}}},
],
});
const listState = MemberSidebarStore.getList(guildId, listId);
expect(listState?.items.get(0)?.data.user.id).toBe('u-2');
expect(listState?.items.get(1)?.data.user.id).toBe('u-3');
expect(listState?.items.get(2)?.data.user.id).toBe('u-1');
const layouts = buildMemberListLayout(listState?.groups ?? []);
const offlineLayout = layouts.find((layout) => layout.id === 'offline');
expect(offlineLayout?.memberStartIndex).toBe(1);
expect(offlineLayout?.memberEndIndex).toBe(2);
});
test('does not duplicate members when applying row-based deletes and inserts', () => {
const guildId = 'guild-2b';
const listId = 'list-2b';
seedMembers(guildId, [
{id: 'u-1', name: 'Alpha'},
{id: 'u-2', name: 'Bravo'},
{id: 'u-3', name: 'Charlie'},
]);
MemberSidebarStore.handleListUpdate({
guildId,
listId,
memberCount: 3,
onlineCount: 2,
groups: [
{id: 'online', count: 2},
{id: 'offline', count: 1},
],
ops: [
{
op: 'SYNC',
range: [0, 4],
items: [
{group: {id: 'online', count: 2}},
{member: {user: {id: 'u-1'}}},
{member: {user: {id: 'u-2'}}},
{group: {id: 'offline', count: 1}},
{member: {user: {id: 'u-3'}}},
],
},
],
});
MemberSidebarStore.handleListUpdate({
guildId,
listId,
memberCount: 3,
onlineCount: 1,
groups: [
{id: 'online', count: 1},
{id: 'offline', count: 2},
],
ops: [
{op: 'DELETE', index: 1},
{op: 'INSERT', index: 4, item: {member: {user: {id: 'u-1'}}}},
],
});
const listState = MemberSidebarStore.getList(guildId, listId);
const orderedUserIds = Array.from(listState?.items.values() ?? []).map((item) => item.data.user.id);
expect(new Set(orderedUserIds).size).toBe(orderedUserIds.length);
expect(orderedUserIds).toEqual(['u-2', 'u-3', 'u-1']);
});
test('invalidates row ranges and keeps remaining members in order', () => {
const guildId = 'guild-3';
const listId = 'list-3';
seedMembers(guildId, [
{id: 'u-1', name: 'Alpha'},
{id: 'u-2', name: 'Bravo'},
{id: 'u-3', name: 'Charlie'},
]);
MemberSidebarStore.handleListUpdate({
guildId,
listId,
memberCount: 3,
onlineCount: 3,
groups: [{id: 'online', count: 3}],
ops: [
{
op: 'SYNC',
range: [0, 3],
items: [
{group: {id: 'online', count: 3}},
{member: {user: {id: 'u-1'}}},
{member: {user: {id: 'u-2'}}},
{member: {user: {id: 'u-3'}}},
],
},
],
});
MemberSidebarStore.handleListUpdate({
guildId,
listId,
memberCount: 3,
onlineCount: 3,
groups: [{id: 'online', count: 3}],
ops: [{op: 'INVALIDATE', range: [2, 2]}],
});
const listState = MemberSidebarStore.getList(guildId, listId);
expect(listState?.items.get(0)?.data.user.id).toBe('u-1');
expect(listState?.items.get(1)).toBeUndefined();
expect(listState?.items.get(2)?.data.user.id).toBe('u-3');
expect(Array.from(listState?.items.values() ?? []).map((item) => item.data.user.id)).toEqual(['u-1', 'u-3']);
});
test('dedupes duplicate user rows and ignores rows outside group bounds', () => {
const guildId = 'guild-4';
const listId = 'list-4';
seedMembers(guildId, [
{id: 'u-1', name: 'Alpha'},
{id: 'u-2', name: 'Bravo'},
{id: 'u-3', name: 'Charlie'},
]);
MemberSidebarStore.handleListUpdate({
guildId,
listId,
memberCount: 3,
onlineCount: 2,
groups: [
{id: 'online', count: 2},
{id: 'offline', count: 1},
],
ops: [
{
op: 'SYNC',
range: [0, 5],
items: [
{group: {id: 'online', count: 2}},
{member: {user: {id: 'u-1'}}},
{member: {user: {id: 'u-2'}}},
{group: {id: 'offline', count: 1}},
{member: {user: {id: 'u-1'}}},
{member: {user: {id: 'u-3'}}},
],
},
],
});
const listState = MemberSidebarStore.getList(guildId, listId);
const orderedUserIds = Array.from(listState?.items.values() ?? []).map((item) => item.data.user.id);
expect(orderedUserIds).toEqual(['u-1', 'u-2']);
expect(listState?.items.get(0)?.data.user.id).toBe('u-1');
expect(listState?.items.get(1)?.data.user.id).toBe('u-2');
expect(listState?.items.get(2)).toBeUndefined();
expect(listState?.items.size).toBe(2);
});
test('drops stale trailing rows after a full sync shrink', () => {
const guildId = 'guild-5';
const listId = 'list-5';
seedMembers(guildId, [
{id: 'u-1', name: 'Alpha'},
{id: 'u-2', name: 'Bravo'},
{id: 'u-3', name: 'Charlie'},
{id: 'u-4', name: 'Delta'},
]);
MemberSidebarStore.handleListUpdate({
guildId,
listId,
memberCount: 4,
onlineCount: 4,
groups: [{id: 'online', count: 4}],
ops: [
{
op: 'SYNC',
range: [0, 4],
items: [
{group: {id: 'online', count: 4}},
{member: {user: {id: 'u-1'}}},
{member: {user: {id: 'u-2'}}},
{member: {user: {id: 'u-3'}}},
{member: {user: {id: 'u-4'}}},
],
},
],
});
MemberSidebarStore.handleListUpdate({
guildId,
listId,
memberCount: 2,
onlineCount: 2,
groups: [{id: 'online', count: 2}],
ops: [
{
op: 'SYNC',
range: [0, 2],
items: [{group: {id: 'online', count: 2}}, {member: {user: {id: 'u-1'}}}, {member: {user: {id: 'u-2'}}}],
},
],
});
const listState = MemberSidebarStore.getList(guildId, listId);
expect(Array.from(listState?.rows.keys() ?? [])).toEqual([0, 1, 2]);
expect(Array.from(listState?.items.values() ?? []).map((item) => item.data.user.id)).toEqual(['u-1', 'u-2']);
});
});

View File

@@ -17,14 +17,23 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {CustomStatus, GatewayCustomStatusPayload} from '@app/lib/CustomStatus';
import {fromGatewayCustomStatus} from '@app/lib/CustomStatus';
import {CustomStatusEmitter} from '@app/lib/CustomStatusEmitter';
import {Logger} from '@app/lib/Logger';
import type {GuildMemberRecord} from '@app/records/GuildMemberRecord';
import GuildMemberStore from '@app/stores/GuildMemberStore';
import GatewayConnectionStore from '@app/stores/gateway/GatewayConnectionStore';
import WindowStore from '@app/stores/WindowStore';
import {
buildMemberListLayout,
getGroupLayoutForRow,
getTotalMemberCount,
getTotalRowsFromLayout,
} from '@app/utils/MemberListLayout';
import type {StatusType} from '@fluxer/constants/src/StatusConstants';
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
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;
@@ -32,20 +41,28 @@ interface MemberListGroup {
}
interface MemberListItem {
type: 'member' | 'group';
data: GuildMemberRecord | MemberListGroup;
type: 'member';
data: GuildMemberRecord;
}
interface MemberListState {
memberCount: number;
onlineCount: number;
groups: Array<MemberListGroup>;
rows: Map<number, MemberListRow>;
items: Map<number, MemberListItem>;
subscribedRanges: Array<[number, number]>;
presences: Map<string, StatusType>;
customStatuses: Map<string, CustomStatus | null>;
}
interface MemberListRow {
type: 'group' | 'member';
group?: MemberListGroup;
userId?: string;
presence?: {status?: string; custom_status?: GatewayCustomStatusPayload | null} | null;
}
interface MemberListOperation {
op: 'SYNC' | 'INSERT' | 'UPDATE' | 'DELETE' | 'INVALIDATE';
range?: [number, number];
@@ -88,53 +105,8 @@ function areRangesEqual(left?: Array<[number, number]>, right?: Array<[number, n
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 {
private logger = new Logger('MemberSidebarStore');
lists: Record<string, Record<string, MemberListState>> = {};
channelListIds: Record<string, Record<string, string>> = {};
lastAccess: Record<string, Record<string, number>> = {};
@@ -210,6 +182,7 @@ class MemberSidebarStore {
memberCount: 0,
onlineCount: 0,
groups: [],
rows: new Map(),
items: new Map(),
subscribedRanges: [],
presences: new Map(),
@@ -218,9 +191,8 @@ class MemberSidebarStore {
}
const listState = guildLists[storageKey];
const newItems = new Map(listState.items);
const newPresences = new Map(listState.presences);
const newCustomStatuses = new Map(listState.customStatuses);
const newRows = new Map(listState.rows);
const changedCustomStatusUserIds = new Set<string>();
this.touchList(guildId, storageKey);
@@ -230,85 +202,66 @@ class MemberSidebarStore {
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);
newRows.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);
let nextIndex = start;
for (const rawItem of op.items) {
const row = this.convertRow(rawItem);
if (row) {
newRows.set(nextIndex, row);
}
nextIndex += 1;
}
}
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);
const shiftedRows = new Map<number, MemberListRow>();
for (const [index, existingRow] of newRows) {
if (index >= op.index) {
shiftedRows.set(index + 1, existingRow);
} else {
shiftedRows.set(index, existingRow);
}
}
const row = this.convertRow(op.item);
if (row) {
shiftedRows.set(op.index, row);
}
newRows.clear();
for (const [k, v] of shiftedRows) {
newRows.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);
const row = this.convertRow(op.item);
if (row) {
newRows.set(op.index, row);
} else {
newRows.delete(op.index);
}
}
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) {
const shiftedRows = new Map<number, MemberListRow>();
for (const [index, existingRow] of newRows) {
if (index > op.index) {
shiftedItems.set(index - 1, existingItem);
shiftedRows.set(index - 1, existingRow);
} else if (index !== op.index) {
shiftedItems.set(index, existingItem);
shiftedRows.set(index, existingRow);
}
}
newItems.clear();
for (const [k, v] of shiftedItems) {
newItems.set(k, v);
newRows.clear();
for (const [k, v] of shiftedRows) {
newRows.set(k, v);
}
}
break;
@@ -317,9 +270,7 @@ class MemberSidebarStore {
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);
newRows.delete(i);
}
}
break;
@@ -327,92 +278,157 @@ class MemberSidebarStore {
}
}
const groupLayouts = buildMemberListLayout(groups);
const totalMembers = Math.max(memberCount, getTotalMemberCount(groups));
const totalRows = groupLayouts.length > 0 ? getTotalRowsFromLayout(groupLayouts) : totalMembers;
const boundedRows = new Map<number, MemberListRow>();
for (const [index, row] of newRows) {
if (index < 0 || index >= totalRows) {
continue;
}
boundedRows.set(index, row);
}
const sortedRows = Array.from(boundedRows.entries()).sort(([left], [right]) => left - right);
const newItems = new Map<number, MemberListItem>();
const newPresences = new Map<string, StatusType>();
const newCustomStatuses = new Map<string, CustomStatus | null>();
const seenUserIds = new Set<string>();
const duplicateUserIds: Array<string> = [];
for (const [rowIndex, row] of sortedRows) {
if (row.type !== 'member' || !row.userId) {
continue;
}
let memberIndex: number | null = null;
if (groupLayouts.length === 0) {
if (rowIndex >= totalMembers) {
continue;
}
memberIndex = rowIndex;
} else {
const layout = getGroupLayoutForRow(groupLayouts, rowIndex);
if (!layout || rowIndex === layout.headerRowIndex) {
continue;
}
const resolvedMemberIndex = layout.memberStartIndex + (rowIndex - layout.headerRowIndex - 1);
if (resolvedMemberIndex < 0 || resolvedMemberIndex >= totalMembers) {
continue;
}
memberIndex = resolvedMemberIndex;
}
if (seenUserIds.has(row.userId)) {
duplicateUserIds.push(row.userId);
continue;
}
seenUserIds.add(row.userId);
const memberItem = this.convertItem(guildId, row.userId);
if (memberItem) {
newItems.set(memberIndex, memberItem);
}
const presenceStatus = this.extractPresenceFromRow(row);
if (presenceStatus) {
newPresences.set(row.userId, presenceStatus);
}
if (row.presence && Object.hasOwn(row.presence, 'custom_status')) {
const customStatus = fromGatewayCustomStatus(row.presence.custom_status ?? null);
newCustomStatuses.set(row.userId, customStatus);
}
}
const previousCustomStatuses = listState.customStatuses;
const allCustomStatusUserIds = new Set<string>([
...Array.from(previousCustomStatuses.keys()),
...Array.from(newCustomStatuses.keys()),
]);
for (const userId of allCustomStatusUserIds) {
const previousCustomStatus = previousCustomStatuses.has(userId)
? (previousCustomStatuses.get(userId) ?? null)
: undefined;
const nextCustomStatus = newCustomStatuses.has(userId) ? (newCustomStatuses.get(userId) ?? null) : undefined;
if (previousCustomStatus !== nextCustomStatus) {
changedCustomStatusUserIds.add(userId);
}
}
listState.memberCount = memberCount;
listState.onlineCount = onlineCount;
listState.groups = groups;
listState.rows = boundedRows;
listState.items = newItems;
listState.presences = newPresences;
listState.customStatuses = newCustomStatuses;
this.lists = {...this.lists, [guildId]: {...guildLists, [storageKey]: listState}};
if (duplicateUserIds.length > 0) {
const uniqueDuplicateUserIds = Array.from(new Set(duplicateUserIds));
this.logger.warn('Duplicate member rows received in list update:', {
guildId,
listId: storageKey,
duplicateCount: uniqueDuplicateUserIds.length,
userIds: uniqueDuplicateUserIds.slice(0, 25),
});
}
if (changedCustomStatusUserIds.size > 0) {
queueMicrotask(() => {
for (const userId of changedCustomStatusUserIds) {
CustomStatusEmitter.emitMemberListChange(guildId, storageKey, userId);
}
});
}
}
private convertItem(
guildId: string,
rawItem: {
member?: {
user: {id: string};
presence?: {status?: string; custom_status?: GatewayCustomStatusPayload | null} | null;
};
group?: MemberListGroup;
},
): MemberListItem | null {
private convertRow(rawItem: {
member?: {
user: {id: string};
presence?: {status?: string; custom_status?: GatewayCustomStatusPayload | null} | null;
};
group?: MemberListGroup;
}): MemberListRow | null {
if (rawItem.group) {
return {
type: 'group',
data: rawItem.group,
group: 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});
}
if (!rawItem.member?.user?.id) {
return null;
}
return {
type: 'member',
userId: rawItem.member.user.id,
presence: rawItem.member.presence ?? null,
};
}
private convertItem(guildId: string, userId: string): MemberListItem | null {
const member = GuildMemberStore.getMember(guildId, userId);
if (member) {
return {
type: 'member',
data: member,
};
}
this.logger.warn('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;
private extractPresenceFromRow(row: MemberListRow): StatusType | null {
const status = row.presence?.status;
if (!status) {
return null;
}
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);
return this.normalizeStatus(status);
}
private normalizeStatus(status: string): StatusType {
@@ -430,7 +446,7 @@ class MemberSidebarStore {
subscribeToChannel(guildId: string, channelId: string, ranges: Array<[number, number]>): void {
const storageKey = this.resolveListKey(guildId, channelId);
const socket = ConnectionStore.socket;
const socket = GatewayConnectionStore.socket;
const existingGuildLists = this.lists[guildId] ?? {};
const guildLists: Record<string, MemberListState> = {...existingGuildLists};
@@ -452,6 +468,7 @@ class MemberSidebarStore {
memberCount: 0,
onlineCount: 0,
groups: [],
rows: new Map(),
items: new Map(),
subscribedRanges: ranges,
presences: new Map(),
@@ -466,7 +483,7 @@ class MemberSidebarStore {
}
unsubscribeFromChannel(guildId: string, channelId: string): void {
const socket = ConnectionStore.socket;
const socket = GatewayConnectionStore.socket;
socket?.updateGuildSubscriptions({
subscriptions: {
[guildId]: {
@@ -497,7 +514,6 @@ class MemberSidebarStore {
if (!listState) {
return [];
}
this.touchList(guildId, storageKey);
const [start, end] = range;
const items: Array<MemberListItem> = [];
@@ -514,11 +530,7 @@ class MemberSidebarStore {
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;
return this.lists[guildId]?.[storageKey];
}
getMemberCount(guildId: string, listId: string): number {
@@ -537,7 +549,6 @@ class MemberSidebarStore {
if (!listState) {
return null;
}
this.touchList(guildId, storageKey);
return listState.presences.get(userId) ?? null;
}
@@ -547,7 +558,6 @@ class MemberSidebarStore {
if (!listState) {
return undefined;
}
this.touchList(guildId, storageKey);
if (!listState.customStatuses.has(userId)) {
return undefined;
}
@@ -590,6 +600,10 @@ class MemberSidebarStore {
}
private pruneExpiredLists(): void {
if (!WindowStore.focused) {
return;
}
const now = Date.now();
const ttlCutoff = now - MEMBER_LIST_TTL_MS;
const updatedLists: Record<string, Record<string, MemberListState>> = {...this.lists};
@@ -602,6 +616,12 @@ class MemberSidebarStore {
const guildMappings = {...(updatedMappings[guildId] ?? {})};
Object.entries(accessMap).forEach(([listId, lastSeen]) => {
const listState = guildLists[listId];
if (listState && listState.subscribedRanges.length > 0) {
guildAccess[listId] = now;
return;
}
if (lastSeen < ttlCutoff) {
delete guildLists[listId];
delete guildAccess[listId];
@@ -609,7 +629,7 @@ class MemberSidebarStore {
Object.entries(guildMappings).forEach(([channelId, mappedListId]) => {
if (mappedListId === listId) {
delete guildMappings[channelId];
const socket = ConnectionStore.socket;
const socket = GatewayConnectionStore.socket;
socket?.updateGuildSubscriptions({
subscriptions: {
[guildId]: {

View File

@@ -17,11 +17,11 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ComponentDispatch} from '@app/lib/ComponentDispatch';
import {Logger} from '@app/lib/Logger';
import {makePersistent} from '@app/lib/MobXPersistence';
import type {FavoriteMemeRecord} from '@app/records/FavoriteMemeRecord';
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;

View File

@@ -17,6 +17,7 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import AppStorage from '@app/lib/AppStorage';
import {makeAutoObservable, reaction} from 'mobx';
interface EditingState {
@@ -24,11 +25,15 @@ interface EditingState {
content: string;
}
const MESSAGE_EDIT_STORAGE_KEY = 'MessageEditStore';
class MessageEditStore {
private editingStates: Record<string, EditingState> = {};
editingDrafts: Record<string, string> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
AppStorage.removeItem(MESSAGE_EDIT_STORAGE_KEY);
}
startEditing(channelId: string, messageId: string, initialContent: string): void {
@@ -37,18 +42,32 @@ class MessageEditStore {
return;
}
const contentToUse = initialContent;
this.editingStates = {
...this.editingStates,
[channelId]: {
messageId,
content: initialContent,
content: contentToUse,
},
};
this.editingDrafts = {
...this.editingDrafts,
[messageId]: contentToUse,
};
}
stopEditing(channelId: string): void {
const state = this.editingStates[channelId];
const {[channelId]: _, ...remainingEdits} = this.editingStates;
this.editingStates = remainingEdits;
if (state) {
this.editingDrafts = {
...this.editingDrafts,
[state.messageId]: state.content,
};
}
}
isEditing(channelId: string, messageId: string): boolean {
@@ -73,6 +92,11 @@ class MessageEditStore {
content,
},
};
this.editingDrafts = {
...this.editingDrafts,
[messageId]: content,
};
}
getEditingContent(channelId: string, messageId: string): string | null {
@@ -84,6 +108,19 @@ class MessageEditStore {
return state.content;
}
getDraftContent(messageId: string): string | null {
return this.editingDrafts[messageId] ?? null;
}
clearDraftContent(messageId: string): void {
if (!(messageId in this.editingDrafts)) {
return;
}
const {[messageId]: _, ...remainingDrafts} = this.editingDrafts;
this.editingDrafts = remainingDrafts;
}
subscribe(callback: () => void): () => void {
return reaction(
() => this.editingStates,

View File

@@ -0,0 +1,110 @@
/*
* 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 {MessageRecord} from '@app/records/MessageRecord';
import KeyboardModeStore from '@app/stores/KeyboardModeStore';
import MessageStore from '@app/stores/MessageStore';
import {autorun, makeAutoObservable} from 'mobx';
class MessageFocusStore {
focusedChannelId: string | null = null;
focusedMessageId: string | null = null;
focusedMessage: MessageRecord | null = null;
retainFocus = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
autorun(() => {
if (!KeyboardModeStore.keyboardModeEnabled) {
this.clearFocus();
}
});
}
focusMessage(channelId: string, messageId: string, message?: MessageRecord): void {
if (!KeyboardModeStore.keyboardModeEnabled) {
return;
}
if (this.focusedChannelId === channelId && this.focusedMessageId === messageId) {
this.retainFocus = false;
if (message) {
this.focusedMessage = message;
}
return;
}
this.focusedChannelId = channelId;
this.focusedMessageId = messageId;
this.focusedMessage = message ?? null;
this.retainFocus = false;
}
blurMessage(channelId: string, messageId: string): void {
if (this.focusedChannelId !== channelId || this.focusedMessageId !== messageId) {
return;
}
if (this.retainFocus) {
return;
}
this.clearFocus();
}
holdContextFocus(channelId: string, messageId: string, message?: MessageRecord): void {
if (!KeyboardModeStore.keyboardModeEnabled) {
return;
}
this.focusMessage(channelId, messageId, message);
this.retainFocus = true;
}
releaseContextFocus(channelId: string, messageId: string): void {
if (this.focusedChannelId === channelId && this.focusedMessageId === messageId && this.retainFocus) {
this.retainFocus = false;
}
}
clearFocusedMessageIfMatches(channelId: string, messageId: string): void {
if (this.focusedChannelId === channelId && this.focusedMessageId === messageId) {
this.clearFocus();
}
}
clearFocus(): void {
this.focusedChannelId = null;
this.focusedMessageId = null;
this.focusedMessage = null;
this.retainFocus = false;
}
getFocusedMessage(): MessageRecord | null {
if (
this.focusedMessage &&
this.focusedMessageId === this.focusedMessage.id &&
this.focusedChannelId === this.focusedMessage.channelId
) {
return this.focusedMessage;
}
if (!this.focusedChannelId || !this.focusedMessageId) {
return null;
}
return MessageStore.getMessage(this.focusedChannelId, this.focusedMessageId) ?? null;
}
}
export default new MessageFocusStore();

View File

@@ -17,10 +17,11 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {UserRecord} from '@app/records/UserRecord';
import UserStore from '@app/stores/UserStore';
import {getReactionKey, type ReactionEmoji} from '@app/utils/ReactionUtils';
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
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>;

View File

@@ -17,12 +17,17 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Endpoints} from '@app/Endpoints';
import http from '@app/lib/HttpClient';
import {HttpError} from '@app/lib/HttpError';
import {Logger} from '@app/lib/Logger';
import {MessageRecord} from '@app/records/MessageRecord';
import ChannelStore from '@app/stores/ChannelStore';
import GuildNSFWAgreeStore from '@app/stores/GuildNSFWAgreeStore';
import MessageStore from '@app/stores/MessageStore';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
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');
@@ -31,11 +36,13 @@ export const MessageReferenceState = {
NOT_LOADED: 'NOT_LOADED',
DELETED: 'DELETED',
} as const;
export type MessageReferenceState = (typeof MessageReferenceState)[keyof typeof MessageReferenceState];
export type MessageReferenceState = ValueOf<typeof MessageReferenceState>;
class MessageReferenceStore {
deletedMessageIds = new Set<string>();
cachedMessages = new Map<string, MessageRecord>();
private referenceCount = new Map<string, Set<string>>();
private referencingMessages = new Map<string, {channelId: string; messageId: string}>();
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -45,14 +52,40 @@ class MessageReferenceStore {
return `${channelId}:${messageId}`;
}
private addReference(refChannelId: string, refMessageId: string, referencingMessageId: string): void {
const key = this.getKey(refChannelId, refMessageId);
let refs = this.referenceCount.get(key);
if (!refs) {
refs = new Set<string>();
this.referenceCount.set(key, refs);
}
refs.add(referencingMessageId);
this.referencingMessages.set(referencingMessageId, {channelId: refChannelId, messageId: refMessageId});
}
private removeReference(refChannelId: string, refMessageId: string, referencingMessageId: string): void {
const key = this.getKey(refChannelId, refMessageId);
const refs = this.referenceCount.get(key);
if (refs) {
refs.delete(referencingMessageId);
if (refs.size === 0) {
this.referenceCount.delete(key);
this.cachedMessages.delete(key);
}
}
this.referencingMessages.delete(referencingMessageId);
}
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 refMessageId = message.referenced_message.id;
const key = this.getKey(refChannelId, refMessageId);
if (!this.cachedMessages.has(key)) {
const referencedMessageRecord = new MessageRecord(message.referenced_message);
this.cachedMessages.set(key, referencedMessageRecord);
}
this.addReference(refChannelId, refMessageId, message.id);
}
}
@@ -60,6 +93,12 @@ class MessageReferenceStore {
const key = this.getKey(channelId, messageId);
this.deletedMessageIds.add(key);
this.cachedMessages.delete(key);
this.referenceCount.delete(key);
const referencedBy = this.referencingMessages.get(messageId);
if (referencedBy) {
this.removeReference(referencedBy.channelId, referencedBy.messageId, messageId);
}
}
handleMessageDeleteBulk(channelId: string, messageIds: Array<string>): void {
@@ -67,6 +106,12 @@ class MessageReferenceStore {
const key = this.getKey(channelId, messageId);
this.deletedMessageIds.add(key);
this.cachedMessages.delete(key);
this.referenceCount.delete(key);
const referencedBy = this.referencingMessages.get(messageId);
if (referencedBy) {
this.removeReference(referencedBy.channelId, referencedBy.messageId, messageId);
}
}
}
@@ -74,11 +119,13 @@ class MessageReferenceStore {
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 refMessageId = message.referenced_message.id;
const key = this.getKey(refChannelId, refMessageId);
if (!this.cachedMessages.has(key)) {
const referencedMessageRecord = new MessageRecord(message.referenced_message);
this.cachedMessages.set(key, referencedMessageRecord);
}
this.addReference(refChannelId, refMessageId, message.id);
}
}
@@ -87,6 +134,7 @@ class MessageReferenceStore {
.map((message) => ({
channelId: message.message_reference!.channel_id ?? channelId,
messageId: message.message_reference!.message_id,
referencingMessageId: message.id,
}))
.filter(
({channelId: refChannelId, messageId}) =>
@@ -95,11 +143,13 @@ class MessageReferenceStore {
!this.cachedMessages.has(this.getKey(refChannelId, messageId)),
);
if (potentiallyMissingMessageIds.length > 0) {
this.fetchMissingMessages(potentiallyMissingMessageIds);
for (const {channelId: refChannelId, messageId, referencingMessageId} of potentiallyMissingMessageIds) {
this.addReference(refChannelId, messageId, referencingMessageId);
}
this.cleanupCachedMessages(channelId, messages);
if (potentiallyMissingMessageIds.length > 0) {
this.fetchMissingMessages(potentiallyMissingMessageIds.map(({channelId, messageId}) => ({channelId, messageId})));
}
}
handleChannelDelete(channelId: string): void {
@@ -109,11 +159,53 @@ class MessageReferenceStore {
handleConnectionOpen(): void {
this.deletedMessageIds.clear();
this.cachedMessages.clear();
this.referenceCount.clear();
this.referencingMessages.clear();
}
handleMessageUpdate(message: Message): void {
const previousRef = this.referencingMessages.get(message.id);
const newRefChannelId = message.message_reference?.channel_id ?? message.channel_id;
const newRefMessageId = message.referenced_message?.id;
if (previousRef) {
const previousKey = this.getKey(previousRef.channelId, previousRef.messageId);
const newKey = newRefMessageId ? this.getKey(newRefChannelId, newRefMessageId) : null;
if (previousKey !== newKey) {
this.removeReference(previousRef.channelId, previousRef.messageId, message.id);
}
}
if (message.referenced_message) {
const refMessageId = message.referenced_message.id;
const key = this.getKey(newRefChannelId, refMessageId);
if (!this.cachedMessages.has(key)) {
const referencedMessageRecord = new MessageRecord(message.referenced_message);
this.cachedMessages.set(key, referencedMessageRecord);
}
this.addReference(newRefChannelId, refMessageId, message.id);
}
}
private fetchMissingMessages(refs: Array<{channelId: string; messageId: string}>): void {
const allowedRefs = refs.filter(({channelId}) => {
const channel = ChannelStore.getChannel(channelId);
if (!channel) {
// Be conservative: if we can't resolve the channel, don't fetch message content.
return false;
}
if (channel.isPrivate()) {
return true;
}
return !GuildNSFWAgreeStore.shouldShowGate({channelId: channel.id, guildId: channel.guildId ?? null});
});
if (allowedRefs.length === 0) {
return;
}
Promise.allSettled(
refs.map(({channelId, messageId}) =>
allowedRefs.map(({channelId, messageId}) =>
http
.get<Message>({
url: Endpoints.CHANNEL_MESSAGE(channelId, messageId),
@@ -131,13 +223,7 @@ class MessageReferenceStore {
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 {
@@ -151,19 +237,6 @@ class MessageReferenceStore {
}
}
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}:`;
@@ -178,6 +251,18 @@ class MessageReferenceStore {
this.cachedMessages.delete(key);
}
}
for (const key of Array.from(this.referenceCount.keys())) {
if (key.startsWith(channelPrefix)) {
this.referenceCount.delete(key);
}
}
for (const [messageId, ref] of Array.from(this.referencingMessages.entries())) {
if (ref.channelId === channelId) {
this.referencingMessages.delete(messageId);
}
}
}
getMessage(channelId: string, messageId: string): MessageRecord | null {

View File

@@ -17,9 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import AuthenticationStore from '@app/stores/AuthenticationStore';
import MessageStore from '@app/stores/MessageStore';
import {makeAutoObservable} from 'mobx';
import AuthenticationStore from '~/stores/AuthenticationStore';
import MessageStore from '~/stores/MessageStore';
type MessageReplyState = Readonly<{
messageId: string;

View File

@@ -17,32 +17,32 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as MessageActionCreators from '@app/actions/MessageActionCreators';
import type {JumpOptions} from '@app/lib/ChannelMessages';
import {ChannelMessages} from '@app/lib/ChannelMessages';
import type {MessageRecord} from '@app/records/MessageRecord';
import ChannelStore from '@app/stores/ChannelStore';
import DimensionStore from '@app/stores/DimensionStore';
import GuildStore from '@app/stores/GuildStore';
import GatewayConnectionStore from '@app/stores/gateway/GatewayConnectionStore';
import RelationshipStore from '@app/stores/RelationshipStore';
import SelectedChannelStore from '@app/stores/SelectedChannelStore';
import SelectedGuildStore from '@app/stores/SelectedGuildStore';
import UserStore from '@app/stores/UserStore';
import type {Presence} from '@app/types/gateway/GatewayPresenceTypes';
import type {ReactionEmoji} from '@app/utils/ReactionUtils';
import {FAVORITES_GUILD_ID, ME} from '@fluxer/constants/src/AppConstants';
import {MessageStates} from '@fluxer/constants/src/ChannelConstants';
import {MAX_MESSAGES_PER_CHANNEL} from '@fluxer/constants/src/LimitConstants';
import type {ChannelId} from '@fluxer/schema/src/branded/WireIds';
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
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;
member: GuildMemberData;
}
interface PresenceUpdateAction {
@@ -51,8 +51,8 @@ interface PresenceUpdateAction {
}
interface PendingMessageJump {
channelId: ChannelId;
messageId: MessageId;
channelId: string;
messageId: string;
}
class MessageStore {
@@ -73,31 +73,30 @@ class MessageStore {
this.updateCounter += 1;
}
getMessages(channelId: ChannelId): ChannelMessages {
getMessages(channelId: string): ChannelMessages {
return ChannelMessages.getOrCreate(channelId);
}
getMessage(channelId: ChannelId, messageId: MessageId): MessageRecord | undefined {
getCachedMessages(channelId: string): ChannelMessages | undefined {
return ChannelMessages.get(channelId);
}
getMessage(channelId: string, messageId: string): 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;
getLastEditableMessage(channelId: string): MessageRecord | undefined {
return this.getMessages(channelId).findNewest((message) => {
return message.isCurrentUserAuthor() && message.state === MessageStates.SENT && message.isUserMessage();
});
}
jumpedMessageId(channelId: ChannelId): MessageId | null | undefined {
jumpedMessageId(channelId: string): string | null | undefined {
const channel = ChannelMessages.get(channelId);
return channel?.jumpTargetId;
}
hasPresent(channelId: ChannelId): boolean {
hasPresent(channelId: string): boolean {
const channel = ChannelMessages.get(channelId);
return channel?.hasPresent() ?? false;
}
@@ -182,7 +181,7 @@ class MessageStore {
return didHydrateSelectedChannel;
}
private startChannelHydration(channelId: ChannelId, options: {forceScrollToBottom?: boolean} = {}): void {
private startChannelHydration(channelId: string, options: {forceScrollToBottom?: boolean} = {}): void {
if (!ChannelStore.getChannel(channelId)) return;
const {forceScrollToBottom = false} = options;
@@ -198,7 +197,7 @@ class MessageStore {
}
@action
handleChannelSelect(action: {guildId?: GuildId; channelId?: ChannelId | null; messageId?: MessageId}): boolean {
handleChannelSelect(action: {guildId?: string; channelId?: string | null; messageId?: string}): boolean {
const channelId = action.channelId ?? action.guildId;
if (channelId == null || channelId === ME) {
return false;
@@ -213,17 +212,17 @@ class MessageStore {
this.notifyChange();
}
if (action.messageId && !ConnectionStore.isConnected) {
if (action.messageId && !GatewayConnectionStore.isConnected) {
this.pendingMessageJump = {channelId, messageId: action.messageId};
return true;
}
if (ConnectionStore.isConnected && action.messageId) {
if (GatewayConnectionStore.isConnected && action.messageId) {
MessageActionCreators.jumpToMessage(channelId, action.messageId, true);
return false;
}
if (!ConnectionStore.isConnected || messages.loadingMore || messages.ready) {
if (!GatewayConnectionStore.isConnected || messages.loadingMore || messages.ready) {
if (messages.ready && DimensionStore.isAtBottom(channelId)) {
ChannelMessages.commit(messages.truncateTop(MAX_MESSAGES_PER_CHANNEL));
this.notifyChange();
@@ -255,7 +254,7 @@ class MessageStore {
}
@action
handleGuildUnavailable(guildId: GuildId, unavailable: boolean): boolean {
handleGuildUnavailable(guildId: string, unavailable: boolean): boolean {
if (!unavailable) {
return false;
}
@@ -289,7 +288,7 @@ class MessageStore {
}
@action
handleGuildCreate(action: {guild: {id: GuildId}}): boolean {
handleGuildCreate(action: {guild: {id: string}}): boolean {
if (SelectedGuildStore.selectedGuildId !== action.guild.id) {
return false;
}
@@ -318,7 +317,7 @@ class MessageStore {
}
@action
handleLoadMessages(action: {channelId: ChannelId; jump?: JumpOptions}): boolean {
handleLoadMessages(action: {channelId: string; jump?: JumpOptions}): boolean {
const messages = ChannelMessages.getOrCreate(action.channelId);
ChannelMessages.commit(messages.loadStart(action.jump));
this.notifyChange();
@@ -326,7 +325,7 @@ class MessageStore {
}
@action
handleTruncateMessages(action: {channelId: ChannelId; truncateBottom?: boolean; truncateTop?: boolean}): boolean {
handleTruncateMessages(action: {channelId: string; truncateBottom?: boolean; truncateTop?: boolean}): boolean {
const messages = ChannelMessages.getOrCreate(action.channelId).truncate(
action.truncateBottom ?? false,
action.truncateTop ?? false,
@@ -338,7 +337,7 @@ class MessageStore {
@action
handleLoadMessagesSuccessCached(action: {
channelId: ChannelId;
channelId: string;
jump?: JumpOptions;
before?: string;
after?: string;
@@ -367,7 +366,7 @@ class MessageStore {
@action
handleLoadMessagesSuccess(action: {
channelId: ChannelId;
channelId: string;
isBefore?: boolean;
isAfter?: boolean;
jump?: JumpOptions;
@@ -391,7 +390,7 @@ class MessageStore {
}
@action
handleLoadMessagesFailure(action: {channelId: ChannelId}): boolean {
handleLoadMessagesFailure(action: {channelId: string}): boolean {
const messages = ChannelMessages.getOrCreate(action.channelId);
ChannelMessages.commit(messages.mutate({loadingMore: false, error: true}));
this.notifyChange();
@@ -399,7 +398,18 @@ class MessageStore {
}
@action
handleIncomingMessage(action: {channelId: ChannelId; message: Message}): boolean {
handleLoadMessagesBlocked(action: {channelId: string}): boolean {
const messages = ChannelMessages.getOrCreate(action.channelId);
if (!messages.loadingMore && !messages.error) {
return false;
}
ChannelMessages.commit(messages.mutate({loadingMore: false, error: false}));
this.notifyChange();
return true;
}
@action
handleIncomingMessage(action: {channelId: string; message: Message}): boolean {
ChannelStore.handleMessageCreate({message: action.message});
const existing = ChannelMessages.get(action.channelId);
@@ -414,7 +424,7 @@ class MessageStore {
}
@action
handleSendFailed(action: {channelId: ChannelId; nonce: string}): boolean {
handleSendFailed(action: {channelId: string; nonce: string}): boolean {
const existing = ChannelMessages.get(action.channelId);
if (!existing || !existing.has(action.nonce)) return false;
@@ -425,7 +435,18 @@ class MessageStore {
}
@action
handleMessageDelete(action: {id: MessageId; channelId: ChannelId}): boolean {
handleSendRetry(action: {channelId: string; messageId: string}): boolean {
const existing = ChannelMessages.get(action.channelId);
if (!existing || !existing.has(action.messageId)) return false;
const updated = existing.update(action.messageId, (message) => message.withUpdates({state: MessageStates.SENDING}));
ChannelMessages.commit(updated);
this.notifyChange();
return true;
}
@action
handleMessageDelete(action: {id: string; channelId: string}): boolean {
const existing = ChannelMessages.get(action.channelId);
if (!existing || !existing.has(action.id)) {
return false;
@@ -446,7 +467,7 @@ class MessageStore {
}
@action
handleMessageDeleteBulk(action: {ids: Array<MessageId>; channelId: ChannelId}): boolean {
handleMessageDeleteBulk(action: {ids: Array<string>; channelId: string}): boolean {
const existing = ChannelMessages.get(action.channelId);
if (!existing) return false;
@@ -607,7 +628,7 @@ class MessageStore {
}
@action
handleMessageReveal(action: {channelId: ChannelId; messageId: MessageId | null}): boolean {
handleMessageReveal(action: {channelId: string; messageId: string | null}): boolean {
const messages = ChannelMessages.getOrCreate(action.channelId);
ChannelMessages.commit(messages.mutate({revealedMessageId: action.messageId}));
this.notifyChange();
@@ -615,7 +636,7 @@ class MessageStore {
}
@action
handleClearJumpTarget(action: {channelId: ChannelId}): boolean {
handleClearJumpTarget(action: {channelId: string}): boolean {
const messages = ChannelMessages.get(action.channelId);
if (messages?.jumpTargetId != null) {
ChannelMessages.commit(messages.mutate({jumpTargetId: null, jumped: false}));
@@ -628,8 +649,8 @@ class MessageStore {
@action
handleReaction(action: {
type: 'MESSAGE_REACTION_ADD' | 'MESSAGE_REACTION_REMOVE';
channelId: ChannelId;
messageId: MessageId;
channelId: string;
messageId: string;
userId: string;
emoji: ReactionEmoji;
optimistic?: boolean;
@@ -654,7 +675,7 @@ class MessageStore {
}
@action
handleRemoveAllReactions(action: {channelId: ChannelId; messageId: MessageId}): boolean {
handleRemoveAllReactions(action: {channelId: string; messageId: string}): boolean {
const existing = ChannelMessages.get(action.channelId);
if (!existing) return false;
@@ -664,6 +685,17 @@ class MessageStore {
return true;
}
@action
handleRemoveReactionEmoji(action: {channelId: string; messageId: string; emoji: ReactionEmoji}): boolean {
const existing = ChannelMessages.get(action.channelId);
if (!existing) return false;
const updated = existing.update(action.messageId, (message) => message.withoutReactionEmoji(action.emoji));
ChannelMessages.commit(updated);
this.notifyChange();
return true;
}
@action
handleMessagePreload(action: {messages: Record<ChannelId, Message>}): boolean {
let hasChanges = false;
@@ -689,8 +721,8 @@ class MessageStore {
@action
handleOptimisticEdit(action: {
channelId: ChannelId;
messageId: MessageId;
channelId: string;
messageId: string;
content: string;
}): {originalContent: string; originalEditedTimestamp: string | null} | null {
const {channelId, messageId, content} = action;
@@ -720,8 +752,8 @@ class MessageStore {
@action
handleEditRollback(action: {
channelId: ChannelId;
messageId: MessageId;
channelId: string;
messageId: string;
originalContent: string;
originalEditedTimestamp: string | null;
}): void {

View File

@@ -17,10 +17,10 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {Platform} from '@app/lib/Platform';
import WindowStore from '@app/stores/WindowStore';
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;

View File

@@ -1,67 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makeAutoObservable, observable} from 'mobx';
import type {MessageRecord} from '~/records/MessageRecord';
const MAX_QUEUE_LENGTH = 5;
class MobileMentionToastStore {
queue: Array<MessageRecord> = [];
constructor() {
makeAutoObservable(this, {queue: observable.shallow}, {autoBind: true});
}
enqueue(message: MessageRecord): void {
if (this.queue.some((entry) => entry.id === message.id)) {
return;
}
this.queue.push(message);
if (this.queue.length > MAX_QUEUE_LENGTH) {
this.queue.shift();
}
}
dequeue(targetId?: string): void {
if (!targetId) {
this.queue.shift();
return;
}
if (this.queue[0]?.id === targetId) {
this.queue.shift();
return;
}
this.queue = this.queue.filter((entry) => entry.id !== targetId);
}
get current(): MessageRecord | undefined {
return this.queue[0];
}
get count(): number {
return this.queue.length;
}
}
export default new MobileMentionToastStore();

View File

@@ -17,9 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelRecord} from '@app/records/ChannelRecord';
import type {UserRecord} from '@app/records/UserRecord';
import {makeAutoObservable} from 'mobx';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {UserRecord} from '~/records/UserRecord';
interface MockIncomingCallData {
channel: ChannelRecord;

View File

@@ -17,15 +17,28 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ModalRender} from '@app/actions/ModalRender';
import {Logger} from '@app/lib/Logger';
import ToastStore from '@app/stores/ToastStore';
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');
type KeyboardModeStateResolver = () => boolean;
type KeyboardModeRestoreCallback = (showIntro: boolean) => void;
let keyboardModeStateResolver: KeyboardModeStateResolver | undefined;
let keyboardModeRestoreCallback: KeyboardModeRestoreCallback | undefined;
export function registerKeyboardModeStateResolver(resolver: KeyboardModeStateResolver): void {
keyboardModeStateResolver = resolver;
}
export function registerKeyboardModeRestoreCallback(callback: KeyboardModeRestoreCallback): void {
keyboardModeRestoreCallback = callback;
}
const BASE_Z_INDEX = 10000;
const Z_INDEX_INCREMENT = 2;
@@ -49,6 +62,7 @@ interface ModalWithStackInfo extends Modal {
stackIndex: number;
isVisible: boolean;
needsBackdrop: boolean;
isTopmost: boolean;
}
interface PushOptions {
@@ -66,11 +80,13 @@ class ModalStore {
push(modal: ModalRender, key: string | number, options: PushOptions = {}): void {
const isBackground = options.isBackground ?? false;
const keyboardModeEnabled = keyboardModeStateResolver ? keyboardModeStateResolver() : false;
this.modals.push({
modal,
key: key.toString(),
focusReturnTarget: this.getActiveElement(),
keyboardModeEnabled: KeyboardModeStore.keyboardModeEnabled,
keyboardModeEnabled,
isBackground,
});
@@ -148,19 +164,35 @@ class ModalStore {
}
}
popByType<T>(component: React.ComponentType<T>): void {
const modalIndex = this.modals.findLastIndex((modal) => modal.modal().type === component);
if (modalIndex === -1) return;
const wasTopmost = modalIndex === this.modals.length - 1;
const [removed] = this.modals.splice(modalIndex, 1);
if (removed && wasTopmost) {
logger.debug(
`ModalStore.popByType restoring focus topmost=${wasTopmost} keyboardMode=${removed.keyboardModeEnabled}`,
);
this.scheduleFocus(removed.focusReturnTarget, removed.keyboardModeEnabled);
}
}
get orderedModals(): Array<ModalWithStackInfo> {
const topmostRegularIndex = this.modals.findLastIndex((m) => !m.isBackground);
const topmostIndex = this.modals.length - 1;
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);
const needsBackdrop = modal.isBackground || (!modal.isBackground && index === topmostRegularIndex);
return {
...modal,
stackIndex: index,
isVisible,
needsBackdrop,
isTopmost: index === topmostIndex,
};
});
}
@@ -203,13 +235,12 @@ class ModalStore {
logger.error('ModalStore.scheduleFocus failed to focus target', error as Error);
return;
}
if (keyboardModeEnabled) {
if (keyboardModeEnabled && keyboardModeRestoreCallback) {
logger.debug('ModalStore.scheduleFocus re-entering keyboard mode');
KeyboardModeStore.enterKeyboardMode(false);
keyboardModeRestoreCallback(false);
}
});
}
}
export type {PushOptions};
export default new ModalStore();

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
export interface NagbarSettings {
iosInstallDismissed: boolean;
@@ -34,6 +34,7 @@ export interface NagbarSettings {
pendingBulkDeletionDismissed: Record<string, boolean>;
invitesDisabledDismissed: Record<string, boolean>;
guildMembershipCtaDismissed: boolean;
visionaryMfaDismissed: boolean;
claimAccountModalShownThisSession: boolean;
forceOffline: boolean;
forceEmailVerification: boolean;
@@ -51,6 +52,7 @@ export interface NagbarSettings {
forceDesktopDownload: boolean;
forceMobileDownload: boolean;
forceGuildMembershipCta: boolean;
forceVisionaryMfa: boolean;
forceHideOffline: boolean;
forceHideEmailVerification: boolean;
forceHideIOSInstall: boolean;
@@ -67,6 +69,7 @@ export interface NagbarSettings {
forceHideDesktopDownload: boolean;
forceHideMobileDownload: boolean;
forceHideGuildMembershipCta: boolean;
forceHideVisionaryMfa: boolean;
}
export type NagbarToggleKey = Exclude<
@@ -88,6 +91,7 @@ export class NagbarStore implements NagbarSettings {
pendingBulkDeletionDismissed: Record<string, boolean> = {};
invitesDisabledDismissed: Record<string, boolean> = {};
guildMembershipCtaDismissed = false;
visionaryMfaDismissed = false;
claimAccountModalShownThisSession = false;
forceOffline = false;
forceEmailVerification = false;
@@ -105,6 +109,7 @@ export class NagbarStore implements NagbarSettings {
forceDesktopDownload = false;
forceMobileDownload = false;
forceGuildMembershipCta = false;
forceVisionaryMfa = false;
forceHideOffline = false;
forceHideEmailVerification = false;
@@ -122,6 +127,7 @@ export class NagbarStore implements NagbarSettings {
forceHideDesktopDownload = false;
forceHideMobileDownload = false;
forceHideGuildMembershipCta = false;
forceHideVisionaryMfa = false;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -143,6 +149,7 @@ export class NagbarStore implements NagbarSettings {
'pendingBulkDeletionDismissed',
'invitesDisabledDismissed',
'guildMembershipCtaDismissed',
'visionaryMfaDismissed',
]);
}
@@ -316,6 +323,7 @@ export class NagbarStore implements NagbarSettings {
this.pendingBulkDeletionDismissed = {};
this.invitesDisabledDismissed = {};
this.guildMembershipCtaDismissed = false;
this.visionaryMfaDismissed = false;
this.claimAccountModalShownThisSession = false;
this.forceOffline = false;
@@ -334,6 +342,7 @@ export class NagbarStore implements NagbarSettings {
this.forceDesktopDownload = false;
this.forceMobileDownload = false;
this.forceGuildMembershipCta = false;
this.forceVisionaryMfa = false;
this.forceHideOffline = false;
this.forceHideEmailVerification = false;
@@ -351,6 +360,7 @@ export class NagbarStore implements NagbarSettings {
this.forceHideDesktopDownload = false;
this.forceHideMobileDownload = false;
this.forceHideGuildMembershipCta = false;
this.forceHideVisionaryMfa = false;
}
handleGuildUpdate(action: {

View File

@@ -17,10 +17,10 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@app/lib/Logger';
import {checkNativePermission, type NativePermissionResult} from '@app/utils/NativePermissions';
import {getNativePlatform, isDesktop, type NativePlatform} from '@app/utils/NativeUtils';
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');

View File

@@ -17,9 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {buildStateFlags, saveCurrentWindowState} from '@app/utils/WindowStateUtils';
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
import {buildStateFlags, saveCurrentWindowState} from '~/utils/WindowStateUtils';
class NativeWindowStateStore {
rememberSizeAndPosition = true;

View File

@@ -0,0 +1,72 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@app/lib/Logger';
import MessageStore from '@app/stores/MessageStore';
import NavigationStore from '@app/stores/NavigationStore';
import NotificationStore from '@app/stores/NotificationStore';
import {reaction} from 'mobx';
const logger = new Logger('NavigationSideEffects');
class NavigationSideEffectsStore {
private lastChannelId: string | null = null;
private lastMessageId: string | null = null;
private disposer: (() => void) | null = null;
initialize(): void {
if (this.disposer) return;
this.disposer = reaction(
() => ({
guildId: NavigationStore.guildId,
channelId: NavigationStore.channelId,
messageId: NavigationStore.messageId,
}),
({guildId, channelId, messageId}) => {
this.handleRouteChange(guildId, channelId, messageId);
},
{fireImmediately: true},
);
}
private handleRouteChange(guildId: string | null, channelId: string | null, messageId: string | null): void {
const channelChanged = channelId !== this.lastChannelId;
const messageChanged = messageId !== this.lastMessageId;
if (!channelChanged && !messageChanged) return;
this.lastChannelId = channelId;
this.lastMessageId = messageId;
if (!channelId) return;
logger.debug(`Route change: guild=${guildId}, channel=${channelId}, message=${messageId}`);
MessageStore.handleChannelSelect({guildId: guildId ?? undefined, channelId, messageId: messageId ?? undefined});
NotificationStore.handleChannelSelect({channelId});
}
destroy(): void {
this.disposer?.();
this.disposer = null;
}
}
export default new NavigationSideEffectsStore();

View File

@@ -17,18 +17,20 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Logger} from '@app/lib/Logger';
import type {Router} from '@app/lib/router/RouterTypes';
import {Routes} from '@app/Routes';
import * as RouterUtils from '@app/utils/RouterUtils';
import {ME} from '@fluxer/constants/src/AppConstants';
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;
type NavigationMode = 'push' | 'replace';
const logger = new Logger('NavigationStore');
class NavigationStore {
guildId: GuildId | null = null;
channelId: ChannelId | null = null;
guildId: string | null = null;
channelId: string | null = null;
messageId: string | null = null;
private router: Router | null = null;
private unsubscribe: (() => void) | null = null;
@@ -60,15 +62,13 @@ class NavigationStore {
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.guildId = (params.guildId as string) ?? null;
this.channelId = (params.channelId as string) ?? null;
this.messageId = (params.messageId as string) ?? null;
this.updateCoordinator();
}
get pathname(): string {
@@ -83,26 +83,103 @@ class NavigationStore {
return this.currentLocation?.hash ?? '';
}
@action
private updateCoordinator(): void {
get context(): 'dm' | 'favorites' | 'guild' {
const guildId = this.guildId;
let context: NavState['context'];
if (!guildId || guildId === ME) return 'dm';
if (guildId === '@favorites') return 'favorites';
return 'guild';
}
if (guildId === ME || !guildId) {
context = {kind: 'dm'};
} else if (guildId === '@favorites') {
context = {kind: 'favorites'};
navigateToGuild(guildId: string, channelId?: string, messageId?: string, mode: NavigationMode = 'push'): void {
const path = this.buildGuildPath(guildId, channelId, messageId);
logger.debug(`navigateToGuild: ${path} (${mode})`);
this.applyNavigation(path, mode);
}
navigateToDM(channelId?: string, messageId?: string, mode: NavigationMode = 'push'): void {
const path = this.buildDMPath(channelId, messageId);
logger.debug(`navigateToDM: ${path} (${mode})`);
this.applyNavigation(path, mode);
}
navigateToFavorites(channelId?: string, messageId?: string, mode: NavigationMode = 'push'): void {
const path = this.buildFavoritesPath(channelId, messageId);
logger.debug(`navigateToFavorites: ${path} (${mode})`);
this.applyNavigation(path, mode);
}
clearMessageIdForChannel(channelId: string, mode: NavigationMode = 'replace'): void {
if (this.channelId !== channelId || !this.messageId) return;
const ctx = this.context;
let path: string;
if (ctx === 'dm') {
path = Routes.dmChannel(channelId);
} else if (ctx === 'favorites') {
path = Routes.favoritesChannel(channelId);
} else {
context = {kind: 'guild', guildId};
const guildId = this.guildId;
if (!guildId) return;
path = Routes.guildChannel(guildId, channelId);
}
const navState: NavState = {
context,
channelId: this.channelId,
messageId: this.messageId,
};
logger.debug(`clearMessageIdForChannel: ${path} (${mode})`);
this.applyNavigation(path, mode);
}
NavigationCoordinator.applyRoute(navState);
buildChannelPath(guildId: string | null | undefined, channelId: string): string {
if (!guildId || guildId === ME) {
return Routes.dmChannel(channelId);
}
if (guildId === '@favorites') {
return Routes.favoritesChannel(channelId);
}
return Routes.guildChannel(guildId, channelId);
}
buildMessagePath(guildId: string | null | undefined, channelId: string, messageId: string): string {
if (!guildId || guildId === ME) {
return Routes.dmChannelMessage(channelId, messageId);
}
if (guildId === '@favorites') {
return Routes.favoritesChannelMessage(channelId, messageId);
}
return Routes.channelMessage(guildId, channelId, messageId);
}
private buildGuildPath(guildId: string, channelId?: string, messageId?: string): string {
if (messageId && channelId) {
return Routes.channelMessage(guildId, channelId, messageId);
}
return Routes.guildChannel(guildId, channelId);
}
private buildDMPath(channelId?: string, messageId?: string): string {
if (messageId && channelId) {
return Routes.dmChannelMessage(channelId, messageId);
}
if (channelId) {
return Routes.dmChannel(channelId);
}
return Routes.ME;
}
private buildFavoritesPath(channelId?: string, messageId?: string): string {
if (messageId && channelId) {
return Routes.favoritesChannelMessage(channelId, messageId);
}
if (channelId) {
return Routes.favoritesChannel(channelId);
}
return Routes.FAVORITES;
}
private applyNavigation(path: string, mode: NavigationMode): void {
if (mode === 'replace') {
RouterUtils.replaceWith(path);
} else {
RouterUtils.transitionTo(path);
}
}
destroy(): void {

View File

@@ -17,18 +17,19 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
import {Logger} from '@app/lib/Logger';
import {makePersistent} from '@app/lib/MobXPersistence';
import VoiceSettingsStore from '@app/stores/VoiceSettingsStore';
import VoiceDevicePermissionStore from '@app/stores/voice/VoiceDevicePermissionStore';
import type {VoiceDeviceState} from '@app/utils/VoiceDeviceManager';
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');

View File

@@ -17,48 +17,45 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {IS_DEV} from '@app/lib/Env';
import {Logger} from '@app/lib/Logger';
import {makePersistent, stopPersistent} from '@app/lib/MobXPersistence';
import {parseAndRenderToPlaintext} from '@app/lib/markdown/Plaintext';
import {getParserFlagsForContext} from '@app/lib/markdown/renderers';
import {MarkdownContext} from '@app/lib/markdown/renderers/RendererTypes';
import {Routes} from '@app/Routes';
import type {ChannelRecord} from '@app/records/ChannelRecord';
import {MessageRecord} from '@app/records/MessageRecord';
import type {Relationship} from '@app/records/RelationshipRecord';
import type {UserRecord} from '@app/records/UserRecord';
import * as PushSubscriptionService from '@app/services/push/PushSubscriptionService';
import AccountManager from '@app/stores/AccountManager';
import AuthenticationStore from '@app/stores/AuthenticationStore';
import ChannelStore from '@app/stores/ChannelStore';
import FriendsTabStore from '@app/stores/FriendsTabStore';
import GuildNSFWAgreeStore from '@app/stores/GuildNSFWAgreeStore';
import GuildStore from '@app/stores/GuildStore';
import LocalPresenceStore from '@app/stores/LocalPresenceStore';
import RelationshipStore from '@app/stores/RelationshipStore';
import SelectedChannelStore from '@app/stores/SelectedChannelStore';
import UserGuildSettingsStore from '@app/stores/UserGuildSettingsStore';
import UserStore from '@app/stores/UserStore';
import * as AvatarUtils from '@app/utils/AvatarUtils';
import * as MessageUtils from '@app/utils/MessageUtils';
import * as NicknameUtils from '@app/utils/NicknameUtils';
import * as NotificationUtils from '@app/utils/NotificationUtils';
import {isInstalledPwa} from '@app/utils/PwaUtils';
import {SystemMessageUtils} from '@app/utils/SystemMessageUtils';
import {FAVORITES_GUILD_ID as ME} from '@fluxer/constants/src/AppConstants';
import {ChannelTypes, MessageFlags, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import {MessageNotifications} from '@fluxer/constants/src/NotificationConstants';
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
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();
@@ -280,7 +277,7 @@ class NotificationStore {
return null;
}
if (channel.isNSFW() && GuildNSFWAgreeStore.shouldShowGate(channel.id)) {
if (GuildNSFWAgreeStore.shouldShowGate({channelId: channel.id, guildId: channel.guildId ?? null})) {
return null;
}
@@ -470,7 +467,12 @@ class NotificationStore {
notificationTracker.clearChannel(channelId);
}
handleRelationshipNotification(relationship: Relationship): void {
handleRelationshipNotification(
relationship: Relationship,
options?: {
event?: 'add' | 'update';
},
): void {
if (!this.i18n) {
throw new Error('NotificationStore: i18n not initialized');
}
@@ -492,6 +494,10 @@ class NotificationStore {
return;
}
if (options?.event === 'update') {
return;
}
let title: string;
let body: string;

View File

@@ -24,29 +24,34 @@ const Z_INDEX_INCREMENT = 10;
class OverlayStackStore {
private counter = 0;
private sequence = 0;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
acquire(): number {
const zIndex = BASE_Z_INDEX + this.counter * Z_INDEX_INCREMENT;
const zIndex = BASE_Z_INDEX + this.sequence * Z_INDEX_INCREMENT;
this.sequence++;
this.counter++;
return zIndex;
}
release(): void {
if (this.counter > 0) {
this.counter--;
if (this.counter === 0) return;
this.counter--;
if (this.counter === 0) {
this.sequence = 0;
}
}
peek(): number {
return BASE_Z_INDEX + this.counter * Z_INDEX_INCREMENT;
return BASE_Z_INDEX + this.sequence * Z_INDEX_INCREMENT;
}
reset(): void {
this.counter = 0;
this.sequence = 0;
}
}

View File

@@ -17,9 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as PackActionCreators from '@app/actions/PackActionCreators';
import type {PackDashboardResponse} from '@fluxer/schema/src/domains/pack/PackSchemas';
import {makeAutoObservable, runInAction} from 'mobx';
import * as PackActionCreators from '~/actions/PackActionCreators';
import type {PackDashboardResponse} from '~/types/PackTypes';
type FetchStatus = 'idle' | 'pending' | 'success' | 'error';

View File

@@ -17,11 +17,16 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {getStreamKey} from '@app/components/voice/StreamKeys';
import {Logger} from '@app/lib/Logger';
import {makePersistent} from '@app/lib/MobXPersistence';
import StreamAudioPrefsStore from '@app/stores/StreamAudioPrefsStore';
import VoiceSettingsStore from '@app/stores/VoiceSettingsStore';
import VoiceConnectionManager from '@app/stores/voice/VoiceConnectionManager';
import {clampVoiceVolumePercent, voiceVolumePercentToTrackVolume} from '@app/utils/VoiceVolumeUtils';
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');
@@ -33,12 +38,33 @@ const idUser = (identity: string): string | null => {
return m ? m[1] : null;
};
const idConnection = (identity: string): string | null => {
const match = identity.match(/^user_(\d+)_(.+)$/);
return match ? match[2] : null;
};
function composeVolumePercent(...volumeParts: Array<number>): number {
const composed = volumeParts.reduce((accumulator, currentValue) => {
return accumulator * (clampVoiceVolumePercent(currentValue) / 100);
}, 100);
return clampVoiceVolumePercent(composed);
}
class ParticipantVolumeStore {
volumes: Record<string, number> = {};
localMutes: Record<string, boolean> = {};
connectionVolumesByLocalConnectionId: Record<string, Record<string, number>> = {};
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
makeAutoObservable(
this,
{
getVolume: false,
isLocalMuted: false,
getConnectionVolume: false,
},
{autoBind: true},
);
void this.initPersistence();
}
@@ -47,7 +73,7 @@ class ParticipantVolumeStore {
}
setVolume(userId: string, volume: number): void {
const clamped = Math.max(0, Math.min(200, volume));
const clamped = clampVoiceVolumePercent(volume);
this.volumes = {
...this.volumes,
[userId]: clamped,
@@ -63,8 +89,37 @@ class ParticipantVolumeStore {
logger.debug(`Set local mute for ${userId}: ${muted}`);
}
setConnectionVolume(connectionId: string, volume: number): void {
const localConnectionId = VoiceConnectionManager.connectionId;
if (!localConnectionId || !connectionId) {
return;
}
const clamped = clampVoiceVolumePercent(volume);
const existingBucket = this.connectionVolumesByLocalConnectionId[localConnectionId] ?? {};
this.connectionVolumesByLocalConnectionId = {
...this.connectionVolumesByLocalConnectionId,
[localConnectionId]: {
...existingBucket,
[connectionId]: clamped,
},
};
logger.debug(`Set connection volume for ${connectionId} from ${localConnectionId}: ${clamped}`);
}
getVolume(userId: string): number {
return this.volumes[userId] ?? 100;
return clampVoiceVolumePercent(this.volumes[userId] ?? 100);
}
getConnectionVolume(connectionId: string | null): number {
if (!connectionId) {
return 100;
}
const localConnectionId = VoiceConnectionManager.connectionId;
if (!localConnectionId) {
return 100;
}
const bucket = this.connectionVolumesByLocalConnectionId[localConnectionId];
return clampVoiceVolumePercent(bucket?.[connectionId] ?? 100);
}
isLocalMuted(userId: string): boolean {
@@ -93,18 +148,47 @@ class ParticipantVolumeStore {
const userId = idUser(participant.identity);
if (!userId) return;
const volume = this.getVolume(userId);
const connectionId = idConnection(participant.identity);
const streamKey = connectionId
? getStreamKey(VoiceConnectionManager.guildId, VoiceConnectionManager.channelId, connectionId)
: null;
const userVolume = this.getVolume(userId);
const connectionVolume = this.getConnectionVolume(connectionId);
const outputVolume = VoiceSettingsStore.getOutputVolume();
const locallyMuted = this.isLocalMuted(userId);
participant.audioTrackPublications.forEach((pub) => {
try {
const isScreenShareAudio = pub.source === Track.Source.ScreenShareAudio;
const streamVolume = streamKey ? StreamAudioPrefsStore.getVolume(streamKey) : 100;
const streamMuted = streamKey ? StreamAudioPrefsStore.isMuted(streamKey) : false;
const track = pub.track;
if (isRemoteAudioTrack(track)) {
track.setVolume(volume / 100);
const nextVolume = isScreenShareAudio
? voiceVolumePercentToTrackVolume(composeVolumePercent(streamVolume, outputVolume))
: voiceVolumePercentToTrackVolume(composeVolumePercent(userVolume, connectionVolume, outputVolume));
track.setVolume(nextVolume);
}
const shouldDisable = locallyMuted || selfDeaf;
const shouldDisable = locallyMuted || selfDeaf || (isScreenShareAudio && streamMuted);
if (isScreenShareAudio) {
logger.debug('Applying screen share audio prefs', {
participantIdentity: participant.identity,
trackSid: pub.trackSid,
streamKey,
streamVolume,
streamMuted,
locallyMuted,
selfDeaf,
shouldDisable,
});
}
pub.setEnabled(!shouldDisable);
if (isScreenShareAudio && streamKey && StreamAudioPrefsStore.hasEntry(streamKey)) {
StreamAudioPrefsStore.touchStream(streamKey);
}
} catch (error) {
logger.warn(`Failed to apply settings to participant ${userId}`, {error});
}

View File

@@ -17,8 +17,8 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {makeAutoObservable} from 'mobx';
import {makePersistent} from '~/lib/MobXPersistence';
export enum PermissionLayoutMode {
COMFY = 'comfy',

View File

@@ -17,18 +17,16 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {GuildRecord} from '@app/records/GuildRecord';
import type {UserRecord} from '@app/records/UserRecord';
import ChannelStore from '@app/stores/ChannelStore';
import GuildStore from '@app/stores/GuildStore';
import UserStore from '@app/stores/UserStore';
import * as PermissionUtils from '@app/utils/PermissionUtils';
import type {ChannelId, GuildId, UserId} from '@fluxer/schema/src/branded/WireIds';
import type {Channel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {Guild} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
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);
@@ -48,36 +46,33 @@ class PermissionStore {
makeAutoObservable(this, {}, {autoBind: true});
}
getChannelPermissions(channelId: ChannelId): bigint | undefined {
return this.channelPermissions.get(channelId);
getChannelPermissions(channelId: string): bigint | undefined {
return this.channelPermissions.get(channelId as ChannelId);
}
getGuildPermissions(guildId: GuildId): bigint | undefined {
return this.guildPermissions.get(guildId);
getGuildPermissions(guildId: string): bigint | undefined {
return this.guildPermissions.get(guildId as GuildId);
}
getGuildVersion(guildId: GuildId): number | undefined {
return this.guildVersions.get(guildId);
getGuildVersion(guildId: string): number | undefined {
return this.guildVersions.get(guildId as GuildId);
}
get version(): number {
return this.globalVersion;
}
can(
permission: bigint,
context: Channel | Guild | GuildRecord | {channelId?: ChannelId; guildId?: GuildId},
): boolean {
can(permission: bigint, context: Channel | Guild | GuildRecord | {channelId?: string; guildId?: string}): boolean {
let permissions = PermissionUtils.NONE;
if (isChannelLike(context)) {
permissions = this.channelPermissions.get(context.id) ?? PermissionUtils.NONE;
permissions = this.channelPermissions.get(context.id as ChannelId) ?? PermissionUtils.NONE;
} else if (isGuildLike(context)) {
permissions = this.guildPermissions.get(context.id) ?? PermissionUtils.NONE;
permissions = this.guildPermissions.get(context.id as GuildId) ?? PermissionUtils.NONE;
} else if (context.channelId) {
permissions = this.channelPermissions.get(context.channelId) ?? PermissionUtils.NONE;
permissions = this.channelPermissions.get(context.channelId as ChannelId) ?? PermissionUtils.NONE;
} else if (context.guildId) {
permissions = this.guildPermissions.get(context.guildId) ?? PermissionUtils.NONE;
permissions = this.guildPermissions.get(context.guildId as GuildId) ?? PermissionUtils.NONE;
}
return (permissions & permission) === permission;
@@ -129,7 +124,7 @@ class PermissionStore {
this.handleGuildMemberUpdate(userId);
}
handleChannelUpdate(channelId: ChannelId): void {
handleChannelUpdate(channelId: string): void {
const channel = ChannelStore.getChannel(channelId);
if (!channel) {
return;
@@ -138,27 +133,33 @@ class PermissionStore {
const currentUser = UserStore.currentUser;
if (!currentUser) return;
this.channelPermissions.set(channel.id, PermissionUtils.computePermissions(currentUser, channel.toJSON()));
this.channelPermissions.set(
channel.id as ChannelId,
PermissionUtils.computePermissions(currentUser, channel.toJSON()),
);
this.bumpGuildVersion(channel.guildId);
}
handleChannelDelete(channelId: ChannelId, guildId?: GuildId): void {
this.channelPermissions.delete(channelId);
handleChannelDelete(channelId: string, guildId?: string): void {
this.channelPermissions.delete(channelId as ChannelId);
this.bumpGuildVersion(guildId);
}
handleGuildRole(guildId: GuildId): void {
handleGuildRole(guildId: string): 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()));
this.guildPermissions.set(guildId as 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.channelPermissions.set(
channel.id as ChannelId,
PermissionUtils.computePermissions(currentUser, channel.toJSON()),
);
}
}
@@ -179,20 +180,23 @@ class PermissionStore {
this.channelPermissions.clear();
for (const guild of GuildStore.getGuilds()) {
this.guildPermissions.set(guild.id, PermissionUtils.computePermissions(user, guild.toJSON()));
this.guildPermissions.set(guild.id as GuildId, 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);
const guildPerms = this.guildPermissions.get(channel.guildId as GuildId) ?? PermissionUtils.NONE;
this.channelPermissions.set(channel.id as ChannelId, guildPerms);
} else {
this.channelPermissions.set(channel.id, PermissionUtils.NONE);
this.channelPermissions.set(channel.id as ChannelId, PermissionUtils.NONE);
}
} else {
this.channelPermissions.set(channel.id, PermissionUtils.computePermissions(user, channel.toJSON()));
this.channelPermissions.set(
channel.id as ChannelId,
PermissionUtils.computePermissions(user, channel.toJSON()),
);
}
this.bumpGuildVersion(channel.guildId);
@@ -205,10 +209,10 @@ class PermissionStore {
this.globalVersion += 1;
}
private bumpGuildVersion(guildId?: GuildId | null): void {
private bumpGuildVersion(guildId?: string | null): void {
if (!guildId) return;
const current = this.guildVersions.get(guildId) ?? 0;
this.guildVersions.set(guildId, current + 1);
const current = this.guildVersions.get(guildId as GuildId) ?? 0;
this.guildVersions.set(guildId as GuildId, current + 1);
this.bumpGlobalVersion();
}

View File

@@ -0,0 +1,156 @@
/*
* 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 AppStorage from '@app/lib/AppStorage';
import {makeAutoObservable, runInAction} from 'mobx';
type PiPContentType = 'stream' | 'camera';
type PiPCorner = 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left';
const PIP_DEFAULT_WIDTH = 320;
interface PiPContent {
type: PiPContentType;
participantIdentity: string;
channelId: string;
guildId: string | null;
connectionId: string;
userId: string;
}
const PIP_CORNER_STORAGE_KEY = 'pip_corner';
const PIP_WIDTH_STORAGE_KEY = 'pip_width';
const PIP_CORNERS: ReadonlyArray<PiPCorner> = ['top-left', 'top-right', 'bottom-right', 'bottom-left'];
function isPiPCorner(value: string | null): value is PiPCorner {
if (!value) return false;
return PIP_CORNERS.includes(value as PiPCorner);
}
function parsePiPWidth(value: string | null): number | null {
if (!value) return null;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
}
class PiPStore {
isOpen = false;
content: PiPContent | null = null;
focusedTileMirrorContent: PiPContent | null = null;
corner: PiPCorner = 'bottom-right';
temporaryCornerOverride: PiPCorner | null = null;
sessionDisable = false;
width = PIP_DEFAULT_WIDTH;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
const storedCorner = AppStorage.getItem(PIP_CORNER_STORAGE_KEY);
if (isPiPCorner(storedCorner)) {
this.corner = storedCorner;
}
const storedWidth = parsePiPWidth(AppStorage.getItem(PIP_WIDTH_STORAGE_KEY));
if (storedWidth != null) {
this.width = storedWidth;
}
}
open(content: PiPContent): void {
runInAction(() => {
this.isOpen = true;
this.content = content;
});
}
close(): void {
runInAction(() => {
this.isOpen = false;
this.content = null;
});
}
showFocusedTileMirror(content: PiPContent, corner: PiPCorner = 'top-right'): void {
runInAction(() => {
this.focusedTileMirrorContent = content;
this.temporaryCornerOverride = corner;
});
}
hideFocusedTileMirror(): void {
runInAction(() => {
this.focusedTileMirrorContent = null;
this.temporaryCornerOverride = null;
});
}
setSessionDisable(value: boolean): void {
runInAction(() => {
this.sessionDisable = value;
});
}
setCorner(corner: PiPCorner): void {
runInAction(() => {
this.corner = corner;
});
AppStorage.setItem(PIP_CORNER_STORAGE_KEY, corner);
}
setWidth(width: number): void {
runInAction(() => {
this.width = width;
});
AppStorage.setItem(PIP_WIDTH_STORAGE_KEY, `${width}`);
}
getContent(): PiPContent | null {
return this.content;
}
getActiveContent(): PiPContent | null {
return this.focusedTileMirrorContent ?? this.content;
}
getIsOpen(): boolean {
return this.isOpen;
}
getHasActiveOverlay(): boolean {
return this.focusedTileMirrorContent != null || this.isOpen;
}
getCorner(): PiPCorner {
return this.corner;
}
getEffectiveCorner(): PiPCorner {
return this.temporaryCornerOverride ?? this.corner;
}
getSessionDisable(): boolean {
return this.sessionDisable;
}
getWidth(): number {
return this.width;
}
}
export {PIP_DEFAULT_WIDTH};
export type {PiPContent, PiPContentType, PiPCorner};
export default new PiPStore();

View File

@@ -17,10 +17,10 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {Popout, PopoutKey} from '@app/components/uikit/popout';
import {Logger} from '@app/lib/Logger';
import KeyboardModeStore from '@app/stores/KeyboardModeStore';
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');

View File

@@ -17,28 +17,25 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {type CustomStatus, fromGatewayCustomStatus} from '@app/lib/CustomStatus';
import {CustomStatusEmitter} from '@app/lib/CustomStatusEmitter';
import {Logger} from '@app/lib/Logger';
import AuthenticationStore from '@app/stores/AuthenticationStore';
import ChannelStore from '@app/stores/ChannelStore';
import GuildMemberStore from '@app/stores/GuildMemberStore';
import GuildStore from '@app/stores/GuildStore';
import LocalPresenceStore from '@app/stores/LocalPresenceStore';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import RelationshipStore from '@app/stores/RelationshipStore';
import type {GuildReadyData} from '@app/types/gateway/GatewayGuildTypes';
import type {Presence} from '@app/types/gateway/GatewayPresenceTypes';
import {ME} from '@fluxer/constants/src/AppConstants';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {StatusType} from '@fluxer/constants/src/StatusConstants';
import {normalizeStatus, StatusTypes} from '@fluxer/constants/src/StatusConstants';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import type {UserPrivate} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
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;
@@ -52,6 +49,7 @@ interface FlattenedPresence {
type StatusListener = (userId: string, status: StatusType, isMobile: boolean) => void;
class PresenceStore {
private logger = new Logger('PresenceStore');
private presences = new Map<string, FlattenedPresence>();
private customStatuses = new Map<string, CustomStatus | null>();
@@ -325,6 +323,7 @@ class PresenceStore {
this.customStatuses.set(userId, customStatus);
this.updateStatusFromPresence(userId, flattened);
this.bumpPresenceVersion();
queueMicrotask(() => CustomStatusEmitter.emitPresenceChange(userId));
return;
}
@@ -351,6 +350,7 @@ class PresenceStore {
this.updateStatusFromPresence(userId, existing);
this.bumpPresenceVersion();
queueMicrotask(() => CustomStatusEmitter.emitPresenceChange(userId));
}
private handleReadyPresence(presence: Presence, initialGuildIds?: Set<string>, hasMeContext = false): void {
@@ -383,6 +383,7 @@ class PresenceStore {
this.customStatuses.set(userId, customStatus);
this.updateStatusFromPresence(userId, flattened);
this.bumpPresenceVersion();
queueMicrotask(() => CustomStatusEmitter.emitPresenceChange(userId));
}
private indexGuildMembers(
@@ -436,6 +437,8 @@ class PresenceStore {
if (changed) {
this.bumpPresenceVersion();
}
queueMicrotask(() => CustomStatusEmitter.emitPresenceChange(userId));
}
private buildMeContextUserIds(currentUserId: string): Set<string> {
@@ -478,7 +481,7 @@ class PresenceStore {
try {
listener(userId, status, isMobile);
} catch (error) {
console.error(`Error in status listener for user ${userId}:`, error);
this.logger.error(`Error in status listener for user ${userId}:`, error);
}
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {makeAutoObservable} from 'mobx';
class PrivacyPreferencesStore {
disableStreamPreviews = false;
showActiveNow = true;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
this.initPersistence();
}
private async initPersistence(): Promise<void> {
await makePersistent(this, 'PrivacyPreferencesStore', ['disableStreamPreviews', 'showActiveNow']);
}
getDisableStreamPreviews(): boolean {
return this.disableStreamPreviews;
}
getShowActiveNow(): boolean {
return this.showActiveNow;
}
setDisableStreamPreviews(value: boolean): void {
this.disableStreamPreviews = value;
}
setShowActiveNow(value: boolean): void {
this.showActiveNow = value;
}
}
export default new PrivacyPreferencesStore();

View File

@@ -17,57 +17,60 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {matchSorter, rankings} from 'match-sorter';
import {action, makeAutoObservable, reaction, runInAction} from 'mobx';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {
ChannelTypes,
FAVORITES_GUILD_ID,
type QuickSwitcherResultType,
QuickSwitcherResultTypes,
RelationshipTypes,
ThemeTypes,
} from '~/Constants';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as ThemePreferenceActionCreators from '@app/actions/ThemePreferenceActionCreators';
import {
getSettingsSubtabs,
getSettingsTabs,
type SettingsSubtab,
type SettingsTab,
} from '~/components/modals/utils/settingsConstants';
import {QuickSwitcherModal} from '~/components/quick-switcher/QuickSwitcherModal';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import type {UserRecord} from '~/records/UserRecord';
import AccessibilityStore from '~/stores/AccessibilityStore';
import ChannelStore from '~/stores/ChannelStore';
import DeveloperModeStore from '~/stores/DeveloperModeStore';
import FavoritesStore from '~/stores/FavoritesStore';
import FeatureFlagStore from '~/stores/FeatureFlagStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import MemberSearchStore, {type SearchContext, type TransformedMember} from '~/stores/MemberSearchStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import NavigationStore from '~/stores/NavigationStore';
import ReadStateStore from '~/stores/ReadStateStore';
import RelationshipStore from '~/stores/RelationshipStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import SelectedGuildStore from '~/stores/SelectedGuildStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import UserStore from '~/stores/UserStore';
import * as ChannelUtils from '~/utils/ChannelUtils';
import {parseChannelUrl} from '~/utils/DeepLinkUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
} from '@app/components/modals/utils/SettingsConstants';
import {QuickSwitcherModal} from '@app/components/quick_switcher/QuickSwitcherModal';
import {Logger} from '@app/lib/Logger';
import type {ChannelRecord} from '@app/records/ChannelRecord';
import type {GuildMemberRecord} from '@app/records/GuildMemberRecord';
import type {GuildRecord} from '@app/records/GuildRecord';
import type {UserRecord} from '@app/records/UserRecord';
import AccessibilityStore from '@app/stores/AccessibilityStore';
import ChannelStore from '@app/stores/ChannelStore';
import DeveloperModeStore from '@app/stores/DeveloperModeStore';
import FavoritesStore from '@app/stores/FavoritesStore';
import GuildMemberStore from '@app/stores/GuildMemberStore';
import GuildStore from '@app/stores/GuildStore';
import MemberSearchStore, {type SearchContext, type TransformedMember} from '@app/stores/MemberSearchStore';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import NavigationStore from '@app/stores/NavigationStore';
import ReadStateStore from '@app/stores/ReadStateStore';
import RelationshipStore from '@app/stores/RelationshipStore';
import SelectedChannelStore from '@app/stores/SelectedChannelStore';
import SelectedGuildStore from '@app/stores/SelectedGuildStore';
import ThemeStore from '@app/stores/ThemeStore';
import UserSettingsStore from '@app/stores/UserSettingsStore';
import UserStore from '@app/stores/UserStore';
import * as ChannelUtils from '@app/utils/ChannelUtils';
import {parseChannelUrl} from '@app/utils/DeepLinkUtils';
import * as NicknameUtils from '@app/utils/NicknameUtils';
import {hasManagedTrait} from '@app/utils/traits/UserTraits';
import {FAVORITES_GUILD_ID} from '@fluxer/constants/src/AppConstants';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {ManagedTraits} from '@fluxer/constants/src/ManagedTraits';
import type {QuickSwitcherResultType} from '@fluxer/constants/src/QuickSwitcherConstants';
import {QuickSwitcherResultTypes} from '@fluxer/constants/src/QuickSwitcherConstants';
import {RelationshipTypes, ThemeTypes} from '@fluxer/constants/src/UserConstants';
import {DAYS_PER_WEEK, MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {matchSorter, rankings} from 'match-sorter';
import {action, makeAutoObservable, reaction, runInAction} from 'mobx';
const MAX_GENERAL_RESULTS = 5;
const MAX_QUERY_MODE_RESULTS = 20;
const MAX_RECENT_RESULTS = 8;
const UNREAD_SORT_WEIGHT_BOOST = 7 * 24 * 60 * 60 * 1000;
const QUICK_SWITCHER_MODAL_KEY = 'quick-switcher';
const MAX_UNREAD_RESULTS = 8;
const UNREAD_SORT_WEIGHT_BOOST = DAYS_PER_WEEK * MS_PER_DAY;
const QUICK_SWITCHER_MODAL_KEY = 'quick_switcher';
const MEMBER_SEARCH_LIMIT = 25;
type QuickSwitcherQueryMode =
@@ -80,6 +83,12 @@ type QuickSwitcherQueryMode =
| typeof QuickSwitcherResultTypes.QUICK_ACTION
| typeof QuickSwitcherResultTypes.LINK;
interface ComputeResultsForQueryResult {
queryMode: QuickSwitcherQueryMode | null;
results: Array<QuickSwitcherResult>;
selectedIndex: number;
}
export interface HeaderResult {
type: typeof QuickSwitcherResultTypes.HEADER;
id: string;
@@ -250,6 +259,7 @@ export interface CandidateSets {
}
class QuickSwitcherStore {
private logger = new Logger('QuickSwitcherStore');
isOpen = false;
query = '';
queryMode: QuickSwitcherQueryMode | null = null;
@@ -337,7 +347,7 @@ class QuickSwitcherStore {
this.results = results;
this.selectedIndex = selectedIndex;
} catch (error) {
console.error('Quick switcher failed to precompute results', error);
this.logger.error('Quick switcher failed to precompute results', error);
this.results = [];
this.selectedIndex = -1;
}
@@ -411,7 +421,7 @@ class QuickSwitcherStore {
return;
}
const rawSearch = query.slice(1).trim();
const rawSearch = query['slice'](1).trim();
if (rawSearch.length === 0) {
if (this.memberSearchContext) {
this.memberSearchContext.clearQuery();
@@ -500,7 +510,7 @@ class QuickSwitcherStore {
this.selectedIndex = selectedIndex;
}
private computeResultsForQuery(query: string) {
private computeResultsForQuery(query: string): ComputeResultsForQueryResult {
const sets = this.buildCandidateSets();
const channelPath = parseChannelUrl(query);
@@ -526,17 +536,17 @@ class QuickSwitcherStore {
};
}
if (query.trim().length === 0) {
if (query['trim']().length === 0) {
const results = this.generateDefaultResults(sets);
return {
queryMode: null as QuickSwitcherQueryMode | null,
queryMode: null,
results,
selectedIndex: this.getFirstSelectableIndex(results),
};
}
const queryMode = this.getQueryMode(query);
const rawSearch = queryMode ? query.slice(1) : query;
const rawSearch = queryMode ? query['slice'](1) : query;
const trimmedSearch = rawSearch.trim();
let results: Array<QuickSwitcherResult>;
@@ -840,9 +850,9 @@ class QuickSwitcherStore {
const settingsCandidates: Array<SettingsCandidate> = [];
const hasExpressionPackAccess = FeatureFlagStore.isExpressionPacksEnabled(selectedGuildId ?? undefined);
const hasExpressionPackAccess = hasManagedTrait(ManagedTraits.EXPRESSION_PACKS);
const accessibleTabs = getSettingsTabs((msg) => this.i18n!._(msg)).filter((tab) => {
const accessibleTabs = getSettingsTabs(this.i18n!).filter((tab) => {
if (!DeveloperModeStore.isDeveloper && tab.category === 'staff_only') {
return false;
}
@@ -865,7 +875,7 @@ class QuickSwitcherStore {
});
}
for (const subtab of getSettingsSubtabs((msg) => this.i18n!._(msg))) {
for (const subtab of getSettingsSubtabs(this.i18n!)) {
const parentTab = accessibleTabs.find((t) => t.type === subtab.parentTab);
if (!parentTab) continue;
@@ -889,9 +899,9 @@ class QuickSwitcherStore {
title: this.i18n._(msg`Toggle Theme`),
subtitle: this.i18n._(msg`Switch between Light and Dark mode`),
action: () => {
const currentTheme = UserSettingsStore.getTheme();
const currentTheme = ThemeStore.effectiveTheme;
const newTheme = currentTheme === ThemeTypes.DARK ? ThemeTypes.LIGHT : ThemeTypes.DARK;
UserSettingsStore.saveSettings({theme: newTheme});
ThemePreferenceActionCreators.updateThemePreference(newTheme);
},
searchValues: ['theme', 'light', 'dark', 'mode', 'switch', 'toggle'],
sortWeight: 0,
@@ -972,8 +982,7 @@ class QuickSwitcherStore {
const recentVisits = SelectedChannelStore.recentChannelVisits;
const excludedIds = this.getExcludedChannelIds();
const recentResults: Array<QuickSwitcherExecutableResult> = [];
const recentEntries: Array<{channelId: string; result: QuickSwitcherExecutableResult}> = [];
for (const visit of recentVisits) {
if (excludedIds.has(visit.channelId)) continue;
@@ -981,17 +990,43 @@ class QuickSwitcherStore {
if (!channel) continue;
const result = this.createResultFromChannel(channel, sets, visit.guildId);
if (result) {
recentResults.push(result);
recentEntries.push({channelId: visit.channelId, result});
}
}
const recentSliced = recentResults.slice(0, MAX_RECENT_RESULTS);
const recentSlicedEntries = recentEntries.slice(0, MAX_RECENT_RESULTS);
const recentSliced = recentSlicedEntries.map(({result}) => result);
const recentChannelIds = new Set(recentSlicedEntries.map(({channelId}) => channelId));
const unreadResults = this.generateUnreadResults(sets, recentChannelIds);
return [...recentSliced, ...unreadResults];
}
if (recentSliced.length === 0) {
return [];
private generateUnreadResults(
sets: CandidateSets,
additionalExcludedChannelIds: ReadonlySet<string>,
): Array<QuickSwitcherExecutableResult> {
const excludedIds = this.getExcludedChannelIds();
const unreadChannels = ChannelStore.allChannels
.filter((channel) => {
if (excludedIds.has(channel.id) || additionalExcludedChannelIds.has(channel.id)) {
return false;
}
const unreadCount = ReadStateStore.getUnreadCount(channel.id);
const mentionCount = ReadStateStore.getMentionCount(channel.id);
return unreadCount > 0 || mentionCount > 0;
})
.sort((a, b) => this.getChannelRecency(b) - this.getChannelRecency(a))
.slice(0, MAX_UNREAD_RESULTS);
const results: Array<QuickSwitcherExecutableResult> = [];
for (const channel of unreadChannels) {
const result = this.createResultFromChannel(channel, sets);
if (result) {
results.push(result);
}
}
return [this.createHeaderResult('recent', this.i18n._(msg`Recently visited`)), ...recentSliced];
return results;
}
private generateQueryModeResults(

View File

@@ -0,0 +1,126 @@
/*
* 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 AuthenticationStore from '@app/stores/AuthenticationStore';
import ChannelStore from '@app/stores/ChannelStore';
import GuildMemberStore from '@app/stores/GuildMemberStore';
import ReadStateStore from '@app/stores/ReadStateStore';
import RelationshipStore from '@app/stores/RelationshipStore';
import UserGuildSettingsStore from '@app/stores/UserGuildSettingsStore';
import UserStore from '@app/stores/UserStore';
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import type {Channel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {beforeEach, describe, expect, test} from 'vitest';
const GUILD_ID = '100000000000000001';
const CHANNEL_ID = '100000000000000002';
const CURRENT_USER_ID = '100000000000000003';
const AUTHOR_ID = '100000000000000004';
const MENTIONED_ROLE_ID = '100000000000000005';
const OTHER_ROLE_ID = '100000000000000006';
function createUser(id: string, username: string): UserPartial {
return {
id,
username,
discriminator: '0001',
global_name: username,
avatar: null,
avatar_color: null,
flags: 0,
};
}
function createGuildMember(userId: string, roles: Array<string>): GuildMemberData {
return {
user: createUser(userId, 'Member'),
nick: null,
avatar: null,
banner: null,
accent_color: null,
roles,
joined_at: '2026-02-01T00:00:00.000Z',
mute: false,
deaf: false,
communication_disabled_until: null,
profile_flags: 0,
};
}
function createGuildChannel(): Channel {
return {
id: CHANNEL_ID,
guild_id: GUILD_ID,
name: 'general',
type: ChannelTypes.GUILD_TEXT,
last_message_id: null,
last_pin_timestamp: null,
};
}
function createIncomingMessage(mentionRoles: Array<string>): Message {
return {
id: '100000000000000099',
channel_id: CHANNEL_ID,
guild_id: GUILD_ID,
author: createUser(AUTHOR_ID, 'Author'),
type: MessageTypes.DEFAULT,
flags: 0,
pinned: false,
mention_everyone: false,
content: 'hello',
timestamp: '2026-02-01T00:00:00.000Z',
mentions: [],
mention_roles: mentionRoles,
attachments: [],
embeds: [],
reactions: [],
stickers: [],
};
}
describe('ReadStateStore role mention detection', () => {
beforeEach(() => {
ReadStateStore.clearAll();
GuildMemberStore.handleConnectionOpen([]);
UserGuildSettingsStore.handleConnectionOpen([]);
RelationshipStore.loadRelationships([]);
AuthenticationStore.setUserId(CURRENT_USER_ID);
UserStore.cacheUsers([createUser(CURRENT_USER_ID, 'CurrentUser'), createUser(AUTHOR_ID, 'Author')]);
ChannelStore.handleConnectionOpen({channels: [createGuildChannel()]});
});
test('does not treat unrelated role mentions as personal mentions', () => {
const state = ReadStateStore.get(CHANNEL_ID);
const message = createIncomingMessage([OTHER_ROLE_ID]);
expect(state.shouldMentionFor(message, CURRENT_USER_ID, false)).toBe(false);
});
test('treats matching role mentions as personal mentions', () => {
GuildMemberStore.handleMemberAdd(GUILD_ID, createGuildMember(CURRENT_USER_ID, [MENTIONED_ROLE_ID]));
const state = ReadStateStore.get(CHANNEL_ID);
const message = createIncomingMessage([MENTIONED_ROLE_ID]);
expect(state.shouldMentionFor(message, CURRENT_USER_ID, false)).toBe(true);
});
});

View File

@@ -17,45 +17,45 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Endpoints} from '@app/Endpoints';
import http from '@app/lib/HttpClient';
import {Logger} from '@app/lib/Logger';
import type {MessageRecord} from '@app/records/MessageRecord';
import AutoAckStore from '@app/stores/AutoAckStore';
import ChannelStore from '@app/stores/ChannelStore';
import DimensionStore from '@app/stores/DimensionStore';
import GuildAvailabilityStore from '@app/stores/GuildAvailabilityStore';
import GuildMemberStore from '@app/stores/GuildMemberStore';
import GuildStore from '@app/stores/GuildStore';
import MessageStore from '@app/stores/MessageStore';
import PermissionStore from '@app/stores/PermissionStore';
import RelationshipStore from '@app/stores/RelationshipStore';
import UserGuildSettingsStore from '@app/stores/UserGuildSettingsStore';
import UserStore from '@app/stores/UserStore';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
import type {ChannelId, GuildId} from '@fluxer/schema/src/branded/WireIds';
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {compare as compareSnowflakes, extractTimestamp, fromTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
import {action, makeAutoObservable, reaction} from 'mobx';
import {ChannelTypes, Permissions} from '~/Constants';
import {Endpoints} from '~/Endpoints';
import http from '~/lib/HttpClient';
import {Logger} from '~/lib/Logger';
import type {Message, MessageRecord} from '~/records/MessageRecord';
import {compare as compareSnowflakes, extractTimestamp, fromTimestamp} from '~/utils/SnowflakeUtils';
import AutoAckStore from './AutoAckStore';
import ChannelStore from './ChannelStore';
import DimensionStore from './DimensionStore';
import GuildAvailabilityStore from './GuildAvailabilityStore';
import GuildStore from './GuildStore';
import MessageStore from './MessageStore';
import PermissionStore from './PermissionStore';
import RelationshipStore from './RelationshipStore';
import UserGuildSettingsStore from './UserGuildSettingsStore';
import UserStore from './UserStore';
const logger = new Logger('ReadStateStore');
type ChannelId = string;
type MessageId = string;
type GuildId = string;
const CAN_READ_PERMISSIONS = Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY;
const OLD_MESSAGE_AGE_THRESHOLD = 7 * 24 * 60 * 60 * 1000;
const RECENT_MESSAGE_THRESHOLD = 3 * 24 * 60 * 60 * 1000;
const CAN_READ_PERMISSIONS = Permissions.VIEW_CHANNEL;
const OLD_MESSAGE_AGE_THRESHOLD = 7 * MS_PER_DAY;
const RECENT_MESSAGE_THRESHOLD = 3 * MS_PER_DAY;
export interface GatewayReadState {
id: ChannelId;
id: string;
mention_count?: number;
last_message_id?: string | null;
last_pin_timestamp?: string | null;
}
interface ChannelPayload {
id: ChannelId;
id: string;
type: number;
guild_id?: GuildId;
guild_id?: string;
last_message_id?: string | null;
last_pin_timestamp?: string | null;
}
@@ -67,14 +67,14 @@ function parseTimestamp(timestamp?: string | null): number {
}
class ReadStateEntry {
readonly channelId: ChannelId;
readonly channelId: string;
_guildId: GuildId | null = null;
_guildId: string | null = null;
loadedMessages = false;
private _lastMessageId: MessageId | null = null;
private _lastMessageId: string | null = null;
private _lastMessageTimestamp = 0;
private _ackMessageId: MessageId | null = null;
private _ackMessageId: string | null = null;
private _ackMessageTimestamp = 0;
ackPinTimestamp = 0;
@@ -82,15 +82,15 @@ class ReadStateEntry {
isManualAck = false;
private _oldestUnreadMessageId: MessageId | null = null;
private _oldestUnreadMessageId: string | null = null;
oldestUnreadMessageIdStale = false;
private _stickyUnreadMessageId: MessageId | null = null;
private _stickyUnreadMessageId: string | null = null;
estimated = false;
private _unreadCount = 0;
private _mentionCount = 0;
outgoingAck: MessageId | null = null;
outgoingAck: string | null = null;
private outgoingAckTimer: NodeJS.Timeout | null = null;
snapshot?: {
@@ -101,7 +101,7 @@ class ReadStateEntry {
takenAt: number;
};
constructor(channelId: ChannelId) {
constructor(channelId: string) {
this.channelId = channelId;
makeAutoObservable(this, {}, {autoBind: true});
}
@@ -110,16 +110,16 @@ class ReadStateEntry {
return Date.now();
}
get guildId(): GuildId | null {
get guildId(): string | null {
const channel = ChannelStore.getChannel(this.channelId);
return channel?.guildId ?? this._guildId ?? null;
}
get lastMessageId(): MessageId | null {
get lastMessageId(): string | null {
return this._lastMessageId;
}
set lastMessageId(messageId: MessageId | null) {
set lastMessageId(messageId: string | null) {
this._lastMessageId = messageId;
this._lastMessageTimestamp = messageId != null ? extractTimestamp(messageId) : 0;
}
@@ -128,33 +128,33 @@ class ReadStateEntry {
return this._lastMessageTimestamp;
}
get ackMessageId(): MessageId | null {
get ackMessageId(): string | null {
return this._ackMessageId;
}
set ackMessageId(messageId: MessageId | null) {
set ackMessageId(messageId: string | null) {
this._ackMessageId = messageId;
this._ackMessageTimestamp = messageId != null ? extractTimestamp(messageId) : 0;
}
get oldestUnreadMessageId(): MessageId | null {
get oldestUnreadMessageId(): string | null {
return this._oldestUnreadMessageId;
}
set oldestUnreadMessageId(messageId: MessageId | null) {
set oldestUnreadMessageId(messageId: string | null) {
this._oldestUnreadMessageId = messageId;
this.oldestUnreadMessageIdStale = false;
}
get stickyUnreadMessageId(): MessageId | null {
get stickyUnreadMessageId(): string | null {
return this._stickyUnreadMessageId;
}
set stickyUnreadMessageId(messageId: MessageId | null) {
set stickyUnreadMessageId(messageId: string | null) {
this._stickyUnreadMessageId = messageId;
}
get visualUnreadMessageId(): MessageId | null {
get visualUnreadMessageId(): string | null {
return this._stickyUnreadMessageId ?? this._oldestUnreadMessageId;
}
@@ -228,8 +228,7 @@ class ReadStateEntry {
return false;
}
const canTrack = channel.isPrivate() || PermissionStore.can(CAN_READ_PERMISSIONS, channel);
return canTrack;
return channel.isPrivate() || PermissionStore.can(CAN_READ_PERMISSIONS, channel);
}
canBeUnread(): boolean {
@@ -341,7 +340,7 @@ class ReadStateEntry {
};
}
rebuild(ackMessageId?: MessageId | null, {recomputeMentions = false}: {recomputeMentions?: boolean} = {}): void {
rebuild(ackMessageId?: string | null, {recomputeMentions = false}: {recomputeMentions?: boolean} = {}): void {
this.ackMessageId = ackMessageId ?? this._ackMessageId;
this.oldestUnreadMessageId = null;
this.estimated = false;
@@ -366,7 +365,7 @@ class ReadStateEntry {
let foundAckMessage = false;
let loadedOlderMessages = false;
let oldestUnread: MessageId | null = null;
let oldestUnread: string | null = null;
messages.forAll((message) => {
if (!foundAckMessage) {
@@ -408,7 +407,22 @@ class ReadStateEntry {
const hasUserMention = mentions?.some((m) => m.id === userId) ?? false;
const hasEveryoneMention = !suppressEveryone && !!mentionEveryone;
const hasRoleMention = !suppressRoles && (mentionRoles?.length ?? 0) > 0;
const hasRoleMention =
!suppressRoles &&
(mentionRoles?.length ?? 0) > 0 &&
(() => {
const guildId = this.guildId;
if (!guildId) {
return false;
}
const member = GuildMemberStore.getMember(guildId, userId);
if (!member) {
return false;
}
return mentionRoles?.some((roleId) => member.roles.has(roleId)) ?? false;
})();
let shouldMention = hasUserMention || hasEveryoneMention || hasRoleMention;
const isMuted = UserGuildSettingsStore.isGuildOrChannelMuted(this.guildId, this.channelId);
@@ -419,7 +433,7 @@ class ReadStateEntry {
return shouldMention;
}
computeMentionCountAfterAck(messageId: MessageId): number {
computeMentionCountAfterAck(messageId: string): number {
const currentUser = UserStore.getCurrentUser();
if (currentUser == null) {
return 0;
@@ -492,7 +506,7 @@ class ReadStateEntry {
}
ack(options: {
messageId?: MessageId | null;
messageId?: string | null;
local?: boolean;
immediate?: boolean;
force?: boolean;
@@ -590,9 +604,9 @@ class ReadStateStore {
const normalized = Math.max(0, mentionCount);
state.mentionCount = normalized;
if (normalized > 0 && state.canHaveMentions()) {
this.mentionChannels.add(state.channelId);
this.mentionChannels.add(state.channelId as ChannelId);
} else {
this.mentionChannels.delete(state.channelId);
this.mentionChannels.delete(state.channelId as ChannelId);
}
}
@@ -610,14 +624,14 @@ class ReadStateStore {
}
@action
private notifyChange(channelId?: ChannelId, {global = false}: {global?: boolean} = {}): void {
private notifyChange(channelId?: string, {global = false}: {global?: boolean} = {}): void {
if (global) {
this.pendingGlobalRecompute = true;
this.pendingChanges.clear();
} else if (channelId != null && !this.pendingGlobalRecompute) {
const entry = this.states.get(channelId);
const entry = this.states.get(channelId as ChannelId);
const guildId = entry?.guildId ?? null;
this.pendingChanges.set(channelId, guildId);
this.pendingChanges.set(channelId as ChannelId, guildId as GuildId | null);
}
this.updateCounter++;
}
@@ -626,11 +640,11 @@ class ReadStateStore {
consumePendingChanges(): {
all: boolean;
channelIds: Array<ChannelId>;
changes: Array<{channelId: ChannelId; guildId: GuildId | null}>;
changes: Array<{channelId: string; guildId: string | null}>;
} {
const all = this.pendingGlobalRecompute;
const changes: Array<{channelId: ChannelId; guildId: GuildId | null}> = [];
const changes: Array<{channelId: string; guildId: string | null}> = [];
const channelIds: Array<ChannelId> = [];
if (!all) {
@@ -645,21 +659,21 @@ class ReadStateStore {
return {all, channelIds, changes};
}
get(channelId: ChannelId): ReadStateEntry {
let entry = this.states.get(channelId);
get(channelId: string): ReadStateEntry {
let entry = this.states.get(channelId as ChannelId);
if (entry == null) {
entry = new ReadStateEntry(channelId);
this.states.set(channelId, entry);
this.states.set(channelId as ChannelId, entry);
}
return entry;
}
getIfExists(channelId: ChannelId): ReadStateEntry | undefined {
return this.states.get(channelId);
getIfExists(channelId: string): ReadStateEntry | undefined {
return this.states.get(channelId as ChannelId);
}
clear(channelId: ChannelId): boolean {
const entry = this.states.get(channelId);
clear(channelId: string): boolean {
const entry = this.states.get(channelId as ChannelId);
if (entry == null) {
return false;
}
@@ -667,8 +681,8 @@ class ReadStateStore {
this.notifyChange(channelId);
entry.dispose();
this.states.delete(channelId);
this.mentionChannels.delete(channelId);
this.states.delete(channelId as ChannelId);
this.mentionChannels.delete(channelId as ChannelId);
return true;
}
@@ -694,51 +708,51 @@ class ReadStateStore {
return ids;
}
isAutomaticAckEnabled(channelId: ChannelId): boolean {
isAutomaticAckEnabled(channelId: string): boolean {
return AutoAckStore.isAutomaticAckEnabled(channelId);
}
getUnreadCount(channelId: ChannelId): number {
getUnreadCount(channelId: string): number {
const state = this.getIfExists(channelId);
return state?.canBeUnread() ? state.unreadCount : 0;
}
getMentionCount(channelId: ChannelId): number {
getMentionCount(channelId: string): number {
const state = this.getIfExists(channelId);
return state?.canHaveMentions() ? state.mentionCount : 0;
}
getManualAckMentionCount(channelId: ChannelId, messageId: MessageId): number {
getManualAckMentionCount(channelId: string, messageId: string): number {
const state = this.getIfExists(channelId);
return state?.computeMentionCountAfterAck(messageId) ?? 0;
}
hasUnread(channelId: ChannelId): boolean {
hasUnread(channelId: string): boolean {
const state = this.getIfExists(channelId);
return !!(state?.canBeUnread() && state.hasUnread());
}
hasUnreadOrMentions(channelId: ChannelId): boolean {
hasUnreadOrMentions(channelId: string): boolean {
const state = this.getIfExists(channelId);
return !!(state?.canBeUnread() && state.hasUnreadOrMentions());
}
ackMessageId(channelId: ChannelId): MessageId | null {
ackMessageId(channelId: string): string | null {
const state = this.getIfExists(channelId);
return state?.canBeUnread() ? state.ackMessageId : null;
}
lastMessageId(channelId: ChannelId): MessageId | null {
lastMessageId(channelId: string): string | null {
const state = this.getIfExists(channelId);
return state?.lastMessageId ?? null;
}
getOldestUnreadMessageId(channelId: ChannelId): MessageId | null {
getOldestUnreadMessageId(channelId: string): string | null {
const state = this.getIfExists(channelId);
return state?.canTrackUnreads() ? state.oldestUnreadMessageId : null;
}
getVisualUnreadMessageId(channelId: ChannelId): MessageId | null {
getVisualUnreadMessageId(channelId: string): string | null {
const state = this.getIfExists(channelId);
return state?.canTrackUnreads() ? state.visualUnreadMessageId : null;
}
@@ -747,7 +761,7 @@ class ReadStateStore {
return Array.from(this.states.keys());
}
clearStickyUnread(channelId: ChannelId): void {
clearStickyUnread(channelId: string): void {
const state = this.getIfExists(channelId);
if (state != null) {
state.clearStickyUnread();
@@ -755,12 +769,12 @@ class ReadStateStore {
}
}
hasUnreadPins(channelId: ChannelId): boolean {
hasUnreadPins(channelId: string): boolean {
const state = this.getIfExists(channelId);
return !!(state?.canBeUnread() && state.lastPinTimestamp > state.ackPinTimestamp);
}
ackPins(channelId: ChannelId): void {
ackPins(channelId: string): void {
const state = this.get(channelId);
if (state.ackPins()) {
this.notifyChange(channelId);
@@ -774,7 +788,7 @@ class ReadStateStore {
const channelsWithReadState = new Set<ChannelId>();
for (const readState of action.readState) {
channelsWithReadState.add(readState.id);
channelsWithReadState.add(readState.id as ChannelId);
const state = this.get(readState.id);
this.setMentionCount(state, readState.mention_count ?? 0);
@@ -796,7 +810,7 @@ class ReadStateStore {
state.lastPinTimestamp = parseTimestamp(channel.last_pin_timestamp);
state._guildId = channel.guild_id ?? null;
if (!channelsWithReadState.has(channel.id)) {
if (!channelsWithReadState.has(channel.id as ChannelId)) {
state.ackMessageId = null;
this.setMentionCount(state, 0);
}
@@ -810,7 +824,7 @@ class ReadStateStore {
this.notifyChange(undefined, {global: true});
}
handleGuildCreate(action: {guild: {id: GuildId; channels?: ReadonlyArray<ChannelPayload>}}): void {
handleGuildCreate(action: {guild: {id: string; channels?: ReadonlyArray<ChannelPayload>}}): void {
if (action.guild.channels) {
for (const channel of action.guild.channels) {
if (channel.type === ChannelTypes.GUILD_VOICE) continue;
@@ -824,12 +838,17 @@ class ReadStateStore {
this.notifyChange(undefined, {global: true});
}
handleLoadMessages(action: {channelId: ChannelId; isAfter?: boolean; messages: Array<Message>}): void {
handleLoadMessages(action: {channelId: string; isAfter?: boolean; messages: Array<Message>}): void {
const state = this.get(action.channelId);
state.loadedMessages = true;
const messages = MessageStore.getMessages(action.channelId);
const newestMessage = messages.last();
if (newestMessage != null && compareSnowflakes(newestMessage.id, state.lastMessageId ?? '') > 0) {
state.lastMessageId = newestMessage.id;
}
if (messages.hasPresent() || (messages.jumpTargetId != null && messages.jumpTargetId === state.ackMessageId)) {
state.rebuild();
} else if (action.isAfter && state.ackMessageId != null && messages.has(state.ackMessageId, true)) {
@@ -839,7 +858,7 @@ class ReadStateStore {
this.notifyChange(action.channelId);
}
handleIncomingMessage(action: {channelId: ChannelId; message: Message}): void {
handleIncomingMessage(action: {channelId: string; message: Message}): void {
const state = this.get(action.channelId);
const currentUser = UserStore.getCurrentUser();
@@ -873,14 +892,14 @@ class ReadStateStore {
const shouldMention = state.shouldMentionFor(action.message, currentUser.id, state.isPrivate);
if (shouldMention) {
state.mentionCount++;
this.mentionChannels.add(state.channelId);
this.mentionChannels.add(state.channelId as ChannelId);
}
}
this.notifyChange(action.channelId);
}
handleMessageDelete(action: {channelId: ChannelId}): void {
handleMessageDelete(action: {channelId: string}): void {
const state = this.get(action.channelId);
state.rebuild();
this.notifyChange(action.channelId);
@@ -890,7 +909,8 @@ class ReadStateStore {
if (
action.channel.type !== ChannelTypes.DM &&
action.channel.type !== ChannelTypes.GROUP_DM &&
action.channel.type !== ChannelTypes.GUILD_TEXT
action.channel.type !== ChannelTypes.GUILD_TEXT &&
action.channel.type !== ChannelTypes.DM_PERSONAL_NOTES
) {
return;
}
@@ -901,7 +921,9 @@ class ReadStateStore {
state._guildId = action.channel.guild_id ?? null;
if (
(action.channel.type === ChannelTypes.DM || action.channel.type === ChannelTypes.GROUP_DM) &&
(action.channel.type === ChannelTypes.DM ||
action.channel.type === ChannelTypes.GROUP_DM ||
action.channel.type === ChannelTypes.DM_PERSONAL_NOTES) &&
action.channel.last_message_id != null
) {
state.ackMessageId = action.channel.last_message_id;
@@ -910,11 +932,11 @@ class ReadStateStore {
this.notifyChange(action.channel.id);
}
handleChannelDelete(action: {channel: {id: ChannelId}}): void {
handleChannelDelete(action: {channel: {id: string}}): void {
this.clear(action.channel.id);
}
handleChannelAck(action: {channelId: ChannelId; messageId?: MessageId; immediate?: boolean; force?: boolean}): void {
handleChannelAck(action: {channelId: string; messageId?: string; immediate?: boolean; force?: boolean}): void {
const state = this.get(action.channelId);
state.ack({
messageId: action.messageId,
@@ -925,7 +947,7 @@ class ReadStateStore {
this.notifyChange(action.channelId);
}
handleChannelAckWithStickyUnread(action: {channelId: ChannelId}): void {
handleChannelAckWithStickyUnread(action: {channelId: string}): void {
const state = this.get(action.channelId);
const lastMessageId = state.lastMessageId;
@@ -951,13 +973,13 @@ class ReadStateStore {
}
}
handleChannelPinsAck(action: {channelId: ChannelId; timestamp?: string}): void {
handleChannelPinsAck(action: {channelId: string; timestamp?: string}): void {
const state = this.get(action.channelId);
state.ackPins(action.timestamp);
this.notifyChange(action.channelId);
}
handleChannelPinsUpdate(action: {channelId: ChannelId; lastPinTimestamp: string}): void {
handleChannelPinsUpdate(action: {channelId: string; lastPinTimestamp: string}): void {
const state = this.get(action.channelId);
const newTimestamp = parseTimestamp(action.lastPinTimestamp);
@@ -967,7 +989,7 @@ class ReadStateStore {
}
}
handleMessageAck(action: {channelId: ChannelId; messageId: MessageId; mentionCount?: number; manual: boolean}): void {
handleMessageAck(action: {channelId: string; messageId: string; mentionCount?: number; manual: boolean}): void {
const state = this.get(action.channelId);
const mentionCount = action.mentionCount;
if (action.manual) {
@@ -999,7 +1021,7 @@ class ReadStateStore {
this.notifyChange(action.channelId);
}
handleClearManualAck(action: {channelId: ChannelId}): void {
handleClearManualAck(action: {channelId: string}): void {
const state = this.get(action.channelId);
if (state.isManualAck) {
state.isManualAck = false;

View File

@@ -17,17 +17,17 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {makePersistent} from '@app/lib/MobXPersistence';
import {MessageRecord, messageMentionsCurrentUser} from '@app/records/MessageRecord';
import AuthenticationStore from '@app/stores/AuthenticationStore';
import ChannelStore from '@app/stores/ChannelStore';
import GuildNSFWAgreeStore from '@app/stores/GuildNSFWAgreeStore';
import GuildStore from '@app/stores/GuildStore';
import type {ReactionEmoji} from '@app/utils/ReactionUtils';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {Channel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
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;
@@ -134,10 +134,7 @@ class RecentMentionsStore {
const channel = ChannelStore.getChannel(message.channel_id);
if (!channel) return false;
if (channel.isNSFW()) {
return !GuildNSFWAgreeStore.shouldShowGate(channel.id);
}
return true;
return !GuildNSFWAgreeStore.shouldShowGate({channelId: channel.id, guildId: channel.guildId ?? null});
});
}
@@ -161,7 +158,6 @@ class RecentMentionsStore {
handleMessageDelete(messageId: string): void {
this.recentMentions = this.recentMentions.filter((message) => message.id !== messageId);
MobileMentionToastStore.dequeue(messageId);
}
handleMessageCreate(message: Message): void {
@@ -172,15 +168,12 @@ class RecentMentionsStore {
const channel = ChannelStore.getChannel(message.channel_id);
if (!channel) return;
if (channel.isNSFW()) {
if (GuildNSFWAgreeStore.shouldShowGate(channel.id)) {
return;
}
if (GuildNSFWAgreeStore.shouldShowGate({channelId: channel.id, guildId: channel.guildId ?? null})) {
return;
}
const messageRecord = new MessageRecord(message);
this.recentMentions.unshift(messageRecord);
MobileMentionToastStore.enqueue(messageRecord);
}
private updateMessageWithReaction(messageId: string, updater: (message: MessageRecord) => MessageRecord): void {

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