Files
fluxer/fluxer_app/src/stores/UserGuildSettingsStore.tsx
Hampus Kraft 2f557eda8c initial commit
2026-01-01 21:05:54 +00:00

553 lines
17 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {action, computed, makeObservable, observable, reaction} from 'mobx';
import {FAVORITES_GUILD_ID, ME, MessageNotifications} from '~/Constants';
import {Logger} from '~/lib/Logger';
import ChannelStore from './ChannelStore';
import GuildStore from './GuildStore';
type GuildId = string;
type ChannelId = string;
const logger = new Logger('UserGuildSettingsStore');
const PRIVATE_CHANNEL_SENTINEL: GuildId = ME;
const isGuildContextId = (guildId: GuildId): boolean =>
guildId !== PRIVATE_CHANNEL_SENTINEL && guildId !== FAVORITES_GUILD_ID;
export interface ChannelOverride {
channel_id: ChannelId;
collapsed: boolean;
message_notifications: number;
muted: boolean;
mute_config?: {
selected_time_window?: number;
end_time?: string;
} | null;
}
interface GuildSettings {
guild_id: GuildId;
suppress_everyone: boolean;
suppress_roles: boolean;
mute_scheduled_events: boolean;
mobile_push: boolean;
muted: boolean;
message_notifications: number;
channel_overrides: Record<ChannelId, ChannelOverride>;
mute_config?: {
selected_time_window?: number;
end_time?: string;
} | null;
version?: number;
hide_muted_channels?: boolean;
}
export interface GatewayGuildSettings {
guild_id: GuildId;
suppress_everyone?: boolean;
suppress_roles?: boolean;
mute_scheduled_events?: boolean;
mobile_push?: boolean;
muted?: boolean;
message_notifications?: number;
channel_overrides?: Array<ChannelOverride> | Record<ChannelId, ChannelOverride> | null;
mute_config?: {
selected_time_window?: number;
end_time?: string;
} | null;
version?: number;
hide_muted_channels?: boolean;
}
const DEFAULT_GUILD_SETTINGS: Omit<GuildSettings, 'guild_id'> = {
suppress_everyone: false,
suppress_roles: false,
mute_scheduled_events: false,
mobile_push: true,
muted: false,
message_notifications: MessageNotifications.ALL_MESSAGES,
channel_overrides: {},
mute_config: null,
version: -1,
hide_muted_channels: false,
};
const DEFAULT_CHANNEL_OVERRIDE = (channelId: ChannelId): ChannelOverride => ({
channel_id: channelId,
collapsed: false,
message_notifications: MessageNotifications.INHERIT,
muted: false,
mute_config: null,
});
function parseEndTime(end_time?: string): number | null {
if (end_time == null) return null;
const ms = Date.parse(end_time);
return Number.isFinite(ms) ? ms : null;
}
function isMuted(config: {muted?: boolean; mute_config?: {end_time?: string} | null}): boolean {
if (config.muted !== true) return false;
const end = config.mute_config?.end_time;
if (end == null) return true;
const endMs = parseEndTime(end);
return endMs == null ? true : endMs > Date.now();
}
class UserGuildSettingsStore {
private readonly guildSettings = observable.map<GuildId, GuildSettings>();
private readonly mutedChannels = observable.map<GuildId, Set<ChannelId>>();
updateCounter = 0;
private readonly pendingGuildUpdates = new Set<GuildId | null>();
private readonly guildMuteTimers = new Map<GuildId, NodeJS.Timeout>();
private readonly channelMuteTimers = new Map<string, NodeJS.Timeout>();
constructor() {
makeObservable<this, 'notifyChange'>(this, {
updateCounter: observable,
version: computed,
updateGuildSettings: action,
updateChannelOverride: action,
updateChannelOverrides: action,
handleConnectionOpen: action,
notifyChange: action,
});
}
get version(): number {
return this.updateCounter;
}
private storageKeyFor(guildId: GuildId | null): GuildId {
return guildId ?? PRIVATE_CHANNEL_SENTINEL;
}
private notifyChange(): void {
this.updateCounter++;
}
private markGuildUpdated(guildId: GuildId | null): void {
this.pendingGuildUpdates.add(guildId);
}
private getDefaultSettings(guildId: GuildId): GuildSettings {
return {
...DEFAULT_GUILD_SETTINGS,
guild_id: guildId,
message_notifications: isGuildContextId(guildId)
? MessageNotifications.INHERIT
: DEFAULT_GUILD_SETTINGS.message_notifications,
};
}
getGuildSettings(guildId: GuildId | null): GuildSettings {
const key = this.storageKeyFor(guildId);
let settings = this.guildSettings.get(key);
if (settings == null) {
settings = this.getDefaultSettings(key);
this.guildSettings.set(key, settings);
}
return settings;
}
getSettings(guildId: GuildId | null): GuildSettings {
return this.getGuildSettings(guildId);
}
private normalizeOverride(channelId: ChannelId, override?: Partial<ChannelOverride> | null): ChannelOverride {
return {
...DEFAULT_CHANNEL_OVERRIDE(channelId),
...(override ?? {}),
channel_id: channelId,
};
}
private normalizeChannelOverrides(
overrides?: Array<ChannelOverride> | Record<ChannelId, ChannelOverride> | null,
): Record<ChannelId, ChannelOverride> {
if (overrides == null) return {};
const result: Record<ChannelId, ChannelOverride> = {};
if (Array.isArray(overrides)) {
for (const o of overrides) {
result[o.channel_id] = this.normalizeOverride(o.channel_id, o);
}
return result;
}
for (const [id, o] of Object.entries(overrides)) {
result[id] = this.normalizeOverride(id, o);
}
return result;
}
private updateMutedChannelsCache(guildId: GuildId, settings: GuildSettings): void {
const set = new Set<ChannelId>();
for (const [channelId, override] of Object.entries(settings.channel_overrides)) {
if (isMuted(override)) {
set.add(channelId);
}
}
if (set.size > 0) {
this.mutedChannels.set(guildId, set);
} else {
this.mutedChannels.delete(guildId);
}
}
private channelTimerKey(guildId: GuildId, channelId: ChannelId): string {
return `${guildId}:${channelId}`;
}
private clearAllTimers(): void {
for (const t of this.guildMuteTimers.values()) clearTimeout(t);
for (const t of this.channelMuteTimers.values()) clearTimeout(t);
this.guildMuteTimers.clear();
this.channelMuteTimers.clear();
}
private clearGuildMuteTimer(guildId: GuildId): void {
const t = this.guildMuteTimers.get(guildId);
if (t) {
clearTimeout(t);
this.guildMuteTimers.delete(guildId);
}
}
private clearGuildChannelTimers(guildId: GuildId): void {
const prefix = `${guildId}:`;
for (const key of Array.from(this.channelMuteTimers.keys())) {
if (key.startsWith(prefix)) {
clearTimeout(this.channelMuteTimers.get(key)!);
this.channelMuteTimers.delete(key);
}
}
}
private setupMuteTimers(guildId: GuildId, settings: GuildSettings): void {
this.clearGuildMuteTimer(guildId);
this.clearGuildChannelTimers(guildId);
if (settings.muted && settings.mute_config?.end_time) {
const endMs = parseEndTime(settings.mute_config.end_time);
if (endMs != null) {
const delay = endMs - Date.now();
if (delay > 0) {
this.guildMuteTimers.set(
guildId,
setTimeout(() => {
this.updateGuildSettings(guildId, {muted: false, mute_config: null});
logger.debug(`Guild mute expired`, {guildId});
}, delay),
);
}
}
}
for (const [channelId, override] of Object.entries(settings.channel_overrides)) {
if (override.muted && override.mute_config?.end_time) {
const endMs = parseEndTime(override.mute_config.end_time);
if (endMs == null) continue;
const delay = endMs - Date.now();
if (delay > 0) {
this.channelMuteTimers.set(
this.channelTimerKey(guildId, channelId),
setTimeout(() => {
this.updateChannelOverride(guildId, channelId, {muted: false, mute_config: null});
logger.debug(`Channel mute expired`, {guildId, channelId});
}, delay),
);
}
}
}
}
private sanitizeGuildMute(settings: GuildSettings): GuildSettings {
if (!settings.muted) return settings;
const endMs = parseEndTime(settings.mute_config?.end_time);
if (endMs != null && endMs <= Date.now()) {
return {...settings, muted: false, mute_config: null};
}
return settings;
}
private sanitizeChannelMutes(settings: GuildSettings): GuildSettings {
let changed = false;
const overrides: Record<ChannelId, ChannelOverride> = {...settings.channel_overrides};
for (const [channelId, override] of Object.entries(overrides)) {
if (!override.muted) continue;
const endMs = parseEndTime(override.mute_config?.end_time);
if (endMs != null && endMs <= Date.now()) {
overrides[channelId] = {...override, muted: false, mute_config: null};
changed = true;
}
}
return changed ? {...settings, channel_overrides: overrides} : settings;
}
updateGuildSettings(guildId: GuildId | null, updates: Partial<GatewayGuildSettings>): void {
const key = this.storageKeyFor(guildId);
const existing = this.getGuildSettings(key);
const overridesInput = 'channel_overrides' in updates ? updates.channel_overrides : existing.channel_overrides;
const merged: GuildSettings = {
...existing,
...updates,
guild_id: key,
channel_overrides: this.normalizeChannelOverrides(overridesInput),
};
const sanitized = this.sanitizeChannelMutes(this.sanitizeGuildMute(merged));
this.guildSettings.set(key, sanitized);
this.updateMutedChannelsCache(key, sanitized);
this.setupMuteTimers(key, sanitized);
this.markGuildUpdated(guildId);
this.notifyChange();
}
updateChannelOverride(guildId: GuildId | null, channelId: ChannelId, updates: Partial<ChannelOverride>): void {
const key = this.storageKeyFor(guildId);
const settings = this.getGuildSettings(key);
const existing = settings.channel_overrides[channelId] ?? DEFAULT_CHANNEL_OVERRIDE(channelId);
const updated = {...existing, ...updates, channel_id: channelId};
const next: GuildSettings = {
...settings,
channel_overrides: {...settings.channel_overrides, [channelId]: updated},
};
const sanitized = this.sanitizeChannelMutes(this.sanitizeGuildMute(next));
this.guildSettings.set(key, sanitized);
this.updateMutedChannelsCache(key, sanitized);
this.setupMuteTimers(key, sanitized);
this.markGuildUpdated(guildId);
this.notifyChange();
}
updateChannelOverrides(guildId: GuildId | null, overrides: Record<ChannelId, Partial<ChannelOverride>>): void {
const key = this.storageKeyFor(guildId);
const settings = this.getGuildSettings(key);
const nextOverrides: Record<ChannelId, ChannelOverride> = {...settings.channel_overrides};
for (const [channelId, updates] of Object.entries(overrides)) {
const existing = nextOverrides[channelId] ?? DEFAULT_CHANNEL_OVERRIDE(channelId);
nextOverrides[channelId] = {...existing, ...updates, channel_id: channelId};
}
const next: GuildSettings = {...settings, channel_overrides: nextOverrides};
const sanitized = this.sanitizeChannelMutes(this.sanitizeGuildMute(next));
this.guildSettings.set(key, sanitized);
this.updateMutedChannelsCache(key, sanitized);
this.setupMuteTimers(key, sanitized);
this.markGuildUpdated(guildId);
this.notifyChange();
}
isSuppressEveryoneEnabled(guildId: GuildId | null): boolean {
return guildId != null && this.getGuildSettings(guildId).suppress_everyone;
}
isSuppressRolesEnabled(guildId: GuildId | null): boolean {
return guildId != null && this.getGuildSettings(guildId).suppress_roles;
}
isMuteScheduledEventsEnabled(guildId: GuildId | null): boolean {
return guildId != null && this.getGuildSettings(guildId).mute_scheduled_events;
}
isMobilePushEnabled(guildId: GuildId | null): boolean {
return guildId == null || this.getGuildSettings(guildId).mobile_push;
}
isMuted(guildId: GuildId | null): boolean {
return guildId != null && isMuted(this.getGuildSettings(guildId));
}
getGuildMessageNotifications(guildId: GuildId | null): number {
if (guildId == null) {
return MessageNotifications.ALL_MESSAGES;
}
const settings = this.getGuildSettings(guildId);
if (settings.message_notifications === MessageNotifications.INHERIT) {
const guild = GuildStore.getGuild(guildId);
return guild?.effectiveMessageNotifications ?? MessageNotifications.ALL_MESSAGES;
}
return settings.message_notifications;
}
getGuildIds(): Array<GuildId> {
return Array.from(this.guildSettings.keys());
}
getStoredGuildMessageNotifications(guildId: GuildId): number {
return this.getGuildSettings(guildId).message_notifications;
}
getChannelOverrides(guildId: GuildId | null): Record<ChannelId, ChannelOverride> {
return this.getGuildSettings(guildId).channel_overrides;
}
getChannelOverride(guildId: GuildId | null, channelId: ChannelId): ChannelOverride | undefined {
return this.getGuildSettings(guildId).channel_overrides[channelId];
}
getChannelMessageNotifications(guildId: GuildId | null, channelId: ChannelId): number {
if (guildId == null) return MessageNotifications.NULL;
return (
this.getGuildSettings(guildId).channel_overrides[channelId]?.message_notifications ?? MessageNotifications.NULL
);
}
isChannelMuted(guildId: GuildId | null, channelId: ChannelId): boolean {
const override = this.getGuildSettings(guildId).channel_overrides[channelId];
return override != null && isMuted(override);
}
isCategoryMuted(guildId: GuildId | null, channelId: ChannelId): boolean {
if (guildId == null) return false;
const channel = ChannelStore.getChannel(channelId);
return channel?.parentId != null && this.isChannelMuted(guildId, channel.parentId);
}
isGuildOrCategoryOrChannelMuted(guildId: GuildId | null, channelId: ChannelId): boolean {
return this.isMuted(guildId) || this.isCategoryMuted(guildId, channelId) || this.isChannelMuted(guildId, channelId);
}
isGuildOrChannelMuted(guildId: GuildId | null, channelId: ChannelId): boolean {
return this.isMuted(guildId) || this.isChannelMuted(guildId, channelId);
}
getMutedChannels(guildId: GuildId): Set<ChannelId> {
return new Set(this.mutedChannels.get(guildId) ?? []);
}
isChannelCollapsed(guildId: GuildId | null, channelId: ChannelId): boolean {
return this.getChannelOverride(guildId, channelId)?.collapsed ?? false;
}
resolvedMessageNotifications(channel: {
id: ChannelId;
guildId?: GuildId;
parentId?: ChannelId;
type: number;
}): number {
if (channel.guildId == null) return MessageNotifications.ALL_MESSAGES;
const direct = this.getChannelMessageNotifications(channel.guildId, channel.id);
if (direct !== MessageNotifications.NULL) return direct;
if (channel.parentId != null) {
const parent = this.getChannelMessageNotifications(channel.guildId, channel.parentId);
if (parent !== MessageNotifications.NULL) return parent;
}
return this.getGuildMessageNotifications(channel.guildId);
}
resolveUnreadSetting(channel: {id: ChannelId; guildId?: GuildId; parentId?: ChannelId; type: number}): string {
const level = this.resolvedMessageNotifications(channel);
return level === MessageNotifications.ALL_MESSAGES ? 'all_messages' : 'only_mentions';
}
allowNoMessages(channel: {id: ChannelId; guildId?: GuildId; parentId?: ChannelId; type: number}): boolean {
return (
this.isGuildOrChannelMuted(channel.guildId ?? null, channel.id) ||
this.resolvedMessageNotifications(channel) === MessageNotifications.NO_MESSAGES
);
}
allowAllMessages(channel: {id: ChannelId; guildId?: GuildId; parentId?: ChannelId; type: number}): boolean {
return (
!this.isGuildOrChannelMuted(channel.guildId ?? null, channel.id) &&
this.resolvedMessageNotifications(channel) === MessageNotifications.ALL_MESSAGES
);
}
handleConnectionOpen(userGuildSettings: Array<GatewayGuildSettings>): void {
this.clearAllTimers();
this.guildSettings.clear();
this.mutedChannels.clear();
for (const settings of userGuildSettings) {
this.updateGuildSettings(settings.guild_id, settings);
}
this.notifyChange();
}
handleGuildSettingsUpdate(action: {guildId: GuildId; settings: Partial<GatewayGuildSettings>}): void {
this.updateGuildSettings(action.guildId, action.settings);
}
handleChannelSettingsUpdate(action: {
guildId: GuildId;
channelId: ChannelId;
settings: Partial<ChannelOverride>;
}): void {
this.updateChannelOverride(action.guildId, action.channelId, action.settings);
}
handleBulkChannelSettingsUpdate(action: {
guildId: GuildId;
overrides: Record<ChannelId, Partial<ChannelOverride>>;
}): void {
this.updateChannelOverrides(action.guildId, action.overrides);
}
handleUserGuildSettingsUpdate(data: GatewayGuildSettings): void {
this.updateGuildSettings(data.guild_id, data);
}
handleGuildCreate(data: {id: GuildId}): void {
this.getGuildSettings(data.id);
this.markGuildUpdated(data.id);
this.notifyChange();
}
consumePendingGuildUpdates(): Array<GuildId | null> {
if (this.pendingGuildUpdates.size === 0) return [];
const ids = Array.from(this.pendingGuildUpdates);
this.pendingGuildUpdates.clear();
return ids;
}
subscribe(callback: () => void): () => void {
return reaction(
() => this.version,
() => callback(),
{fireImmediately: true},
);
}
}
export default new UserGuildSettingsStore();