refactor progress
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
114
fluxer_app/src/stores/CompactVoiceCallHeightStore.tsx
Normal file
114
fluxer_app/src/stores/CompactVoiceCallHeightStore.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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();
|
||||
82
fluxer_app/src/stores/CompactVoiceCallPiPPositionStore.tsx
Normal file
82
fluxer_app/src/stores/CompactVoiceCallPiPPositionStore.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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();
|
||||
@@ -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';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
98
fluxer_app/src/stores/DiscoveryStore.tsx
Normal file
98
fluxer_app/src/stores/DiscoveryStore.tsx
Normal 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();
|
||||
@@ -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;
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
117
fluxer_app/src/stores/EmojiStore.test.tsx
Normal file
117
fluxer_app/src/stores/EmojiStore.test.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
323
fluxer_app/src/stores/InstanceConfigStore.tsx
Normal file
323
fluxer_app/src/stores/InstanceConfigStore.tsx
Normal 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();
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
70
fluxer_app/src/stores/LimitOverrideStore.tsx
Normal file
70
fluxer_app/src/stores/LimitOverrideStore.tsx
Normal 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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
360
fluxer_app/src/stores/MemberSidebarStore.test.tsx
Normal file
360
fluxer_app/src/stores/MemberSidebarStore.test.tsx
Normal 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']);
|
||||
});
|
||||
});
|
||||
@@ -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]: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
110
fluxer_app/src/stores/MessageFocusStore.tsx
Normal file
110
fluxer_app/src/stores/MessageFocusStore.tsx
Normal 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();
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
72
fluxer_app/src/stores/NavigationSideEffectsStore.tsx
Normal file
72
fluxer_app/src/stores/NavigationSideEffectsStore.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
156
fluxer_app/src/stores/PiPStore.tsx
Normal file
156
fluxer_app/src/stores/PiPStore.tsx
Normal 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();
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
53
fluxer_app/src/stores/PrivacyPreferencesStore.tsx
Normal file
53
fluxer_app/src/stores/PrivacyPreferencesStore.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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();
|
||||
@@ -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(
|
||||
|
||||
126
fluxer_app/src/stores/ReadStateStore.test.tsx
Normal file
126
fluxer_app/src/stores/ReadStateStore.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user