/* * 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 . */ 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; 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 | Record | null; mute_config?: { selected_time_window?: number; end_time?: string; } | null; version?: number; hide_muted_channels?: boolean; } const DEFAULT_GUILD_SETTINGS: Omit = { 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(); private readonly mutedChannels = observable.map>(); updateCounter = 0; private readonly pendingGuildUpdates = new Set(); private readonly guildMuteTimers = new Map(); private readonly channelMuteTimers = new Map(); constructor() { makeObservable(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 | null): ChannelOverride { return { ...DEFAULT_CHANNEL_OVERRIDE(channelId), ...(override ?? {}), channel_id: channelId, }; } private normalizeChannelOverrides( overrides?: Array | Record | null, ): Record { if (overrides == null) return {}; const result: Record = {}; 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(); 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 = {...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): 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): 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>): void { const key = this.storageKeyFor(guildId); const settings = this.getGuildSettings(key); const nextOverrides: Record = {...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 { return Array.from(this.guildSettings.keys()); } getStoredGuildMessageNotifications(guildId: GuildId): number { return this.getGuildSettings(guildId).message_notifications; } getChannelOverrides(guildId: GuildId | null): Record { 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 { 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): 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}): void { this.updateGuildSettings(action.guildId, action.settings); } handleChannelSettingsUpdate(action: { guildId: GuildId; channelId: ChannelId; settings: Partial; }): void { this.updateChannelOverride(action.guildId, action.channelId, action.settings); } handleBulkChannelSettingsUpdate(action: { guildId: GuildId; overrides: Record>; }): 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 { 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();