refactor(app): name mute and unread state accessors by intent (#1859)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:32:55 +02:00
1 parent 52781cfa2d
commit a2099fb0e2
33 files changed
+173 -490

No files matched your search

@@ -75,8 +75,8 @@ const setBadge = (badge: number): void => {
export const AppBadge: React.FC = observer(() => { export const AppBadge: React.FC = observer(() => {
const relationships = Relationships.getRelationships(); const relationships = Relationships.getRelationships();
const unreadMessageBadgeEnabled = Notification.unreadMessageBadgeEnabled; const unreadMessageBadgeEnabled = Notification.unreadMessageBadgeEnabled;
const mentionCount = GuildReadState.getTotalMentionCount(); const mentionCount = GuildReadState.mentionCountAcrossGuilds();
const hasUnread = GuildReadState.hasAnyUnread; const hasUnread = GuildReadState.anyGuildUnread;
const pendingCount = RuntimeConfig.directMessagesDisabled const pendingCount = RuntimeConfig.directMessagesDisabled
? 0 ? 0
: relationships.filter((relationship) => relationship.type === RelationshipTypes.INCOMING_REQUEST).length; : relationships.filter((relationship) => relationship.type === RelationshipTypes.INCOMING_REQUEST).length;
@@ -34,7 +34,7 @@ export const FavoritesGuildHeaderBottomSheet: React.FC<FavoritesGuildHeaderBotto
({isOpen, onClose}) => { ({isOpen, onClose}) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const hideMutedChannels = Favorites.hideMutedChannels; const hideMutedChannels = Favorites.hideMutedChannels;
const settings = UserGuildSettings.getSettings(FAVORITES_GUILD_ID); const settings = UserGuildSettings.getSettingsForScope(FAVORITES_GUILD_ID);
const isMuted = settings?.muted ?? false; const isMuted = settings?.muted ?? false;
const handleAddChannel = () => { const handleAddChannel = () => {
ModalCommands.pushAfterBottomSheetClose( ModalCommands.pushAfterBottomSheetClose(
@@ -360,7 +360,7 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
const messageGroupSpacing = Accessibility.getMessageGroupSpacingValue(messageDisplayCompact); const messageGroupSpacing = Accessibility.getMessageGroupSpacingValue(messageDisplayCompact);
const mentionCount = ReadStates.getMentionCount(channel.id); const mentionCount = ReadStates.getMentionCount(channel.id);
const isCollapsed = UnreadChannels.isCollapsed(channel.id); const isCollapsed = UnreadChannels.isCollapsed(channel.id);
const isMuted = UserGuildSettings.isGuildOrCategoryOrChannelMuted(channel.guildId ?? null, channel.id); const isMuted = UserGuildSettings.isMutedAtAnyLevel(channel.guildId ?? null, channel.id);
const previewMessages = previewData?.messages ?? []; const previewMessages = previewData?.messages ?? [];
const oldestUnreadMessageId = previewData?.oldestUnreadMessageId ?? null; const oldestUnreadMessageId = previewData?.oldestUnreadMessageId ?? null;
const totalUnreadCount = ReadStates.getUnreadCount(channel.id); const totalUnreadCount = ReadStates.getUnreadCount(channel.id);
@@ -238,7 +238,7 @@ export const ChannelItem = observer(
const canInvite = InviteUtils.canInviteToChannel(channel.id, channel.guildId); const canInvite = InviteUtils.canInviteToChannel(channel.id, channel.guildId);
const mobileLayout = MobileLayout; const mobileLayout = MobileLayout;
const isMuted = UserGuildSettings.isGuildOrChannelMuted(guild.id, channel.id); const isMuted = UserGuildSettings.isGuildOrChannelMuted(guild.id, channel.id);
const isChannelDirectlyMuted = UserGuildSettings.isChannelMuted(guild.id, channel.id); const isChannelDirectlyMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, channel.id);
const currentUserCount = const currentUserCount =
channelIsVoice && channel.userLimit != null && channel.userLimit > 0 channelIsVoice && channel.userLimit != null && channel.userLimit > 0
? Object.keys(MediaEngine.getAllVoiceStatesInChannel(guild.id, channel.id)).length ? Object.keys(MediaEngine.getAllVoiceStatesInChannel(guild.id, channel.id)).length
@@ -164,7 +164,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
const {i18n} = useLingui(); const {i18n} = useLingui();
const channels = Channels.getGuildChannels(guild.id); const channels = Channels.getGuildChannels(guild.id);
const location = useLocation(); const location = useLocation();
const userGuildSettings = UserGuildSettings.getSettings(guild.id); const userGuildSettings = UserGuildSettings.getSettingsForScope(guild.id);
const isDraggingAnything = useDragLayer((monitor) => { const isDraggingAnything = useDragLayer((monitor) => {
if (!monitor.isDragging()) return false; if (!monitor.isDragging()) return false;
const itemType = monitor.getItemType(); const itemType = monitor.getItemType();
@@ -337,7 +337,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
const unreadCount = ReadStates.getUnreadCount(channelId); const unreadCount = ReadStates.getUnreadCount(channelId);
const mentionCount = ReadStates.getMentionCount(channelId); const mentionCount = ReadStates.getMentionCount(channelId);
const isMuted = const isMuted =
UserGuildSettings.isCategoryMuted(guild.id, channelId) || UserGuildSettings.isChannelMuted(guild.id, channelId); UserGuildSettings.isParentCategoryMuted(guild.id, channelId) || UserGuildSettings.isChannelDirectlyMuted(guild.id, channelId);
const channel = Channels.getChannel(channelId); const channel = Channels.getChannel(channelId);
const unreadBadgesLevel = channel const unreadBadgesLevel = channel
? UserGuildSettings.resolvedUnreadBadgesLevel({ ? UserGuildSettings.resolvedUnreadBadgesLevel({
@@ -360,13 +360,13 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
for (const group of channelGroups) { for (const group of channelGroups) {
const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false; const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false;
const isNullSpace = !group.category; const isNullSpace = !group.category;
const isCategoryMuted = group.category ? UserGuildSettings.isChannelMuted(guild.id, group.category.id) : false; const isCategoryMuted = group.category ? UserGuildSettings.isChannelDirectlyMuted(guild.id, group.category.id) : false;
let filteredTextChannels: Array<Channel>; let filteredTextChannels: Array<Channel>;
let filteredVoiceChannels: Array<Channel>; let filteredVoiceChannels: Array<Channel>;
if (hideMutedChannels) { if (hideMutedChannels) {
filteredTextChannels = []; filteredTextChannels = [];
for (const ch of group.textChannels) { for (const ch of group.textChannels) {
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id); const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
if ( if (
shouldShowChannelWhenHidingMutedChannels({ shouldShowChannelWhenHidingMutedChannels({
isMuted, isMuted,
@@ -380,7 +380,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
} }
filteredVoiceChannels = []; filteredVoiceChannels = [];
for (const ch of group.voiceChannels) { for (const ch of group.voiceChannels) {
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id); const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
if ( if (
shouldShowChannelWhenHidingMutedChannels({ shouldShowChannelWhenHidingMutedChannels({
isMuted, isMuted,
@@ -206,11 +206,11 @@ const FavoriteChannelResolvedItem = observer(
const channelDisplayName = channel.isPrivate() ? ChannelUtils.getDMDisplayName(channel) : channel.name; const channelDisplayName = channel.isPrivate() ? ChannelUtils.getDMDisplayName(channel) : channel.name;
const displayName = favoriteChannel.nickname || channelDisplayName || i18n._(UNKNOWN_CHANNEL_DESCRIPTOR); const displayName = favoriteChannel.nickname || channelDisplayName || i18n._(UNKNOWN_CHANNEL_DESCRIPTOR);
const isChannelDirectlyMuted = channel.guildId const isChannelDirectlyMuted = channel.guildId
? UserGuildSettings.isChannelMuted(channel.guildId, channel.id) ? UserGuildSettings.isChannelDirectlyMuted(channel.guildId, channel.id)
: false; : false;
const isMuted = const isMuted =
isChannelDirectlyMuted || isChannelDirectlyMuted ||
(channel.guildId ? UserGuildSettings.isCategoryMuted(channel.guildId, channel.id) : false); (channel.guildId ? UserGuildSettings.isParentCategoryMuted(channel.guildId, channel.id) : false);
const unreadBadgesLevel = channel.guildId const unreadBadgesLevel = channel.guildId
? UserGuildSettings.resolvedUnreadBadgesLevel({ ? UserGuildSettings.resolvedUnreadBadgesLevel({
id: channel.id, id: channel.id,
@@ -665,7 +665,7 @@ function getDMScrollIndicatorSeverity(channelId: string): ScrollIndicatorSeverit
const unreadState = getChannelUnreadState({ const unreadState = getChannelUnreadState({
unreadCount: ReadStates.getPrivateChannelUnreadCount(channelId), unreadCount: ReadStates.getPrivateChannelUnreadCount(channelId),
mentionCount, mentionCount,
isMuted: UserGuildSettings.isChannelMuted(null, channelId), isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels, showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels,
}); });
if (mentionCount > 0) return 'mention'; if (mentionCount > 0) return 'mention';
@@ -674,8 +674,8 @@ function getDMScrollIndicatorSeverity(channelId: string): ScrollIndicatorSeverit
} }
function getGuildScrollIndicatorSeverity(guildId: string): ScrollIndicatorSeverity | null { function getGuildScrollIndicatorSeverity(guildId: string): ScrollIndicatorSeverity | null {
if (GuildReadState.getMentionCount(guildId) > 0) return 'mention'; if (GuildReadState.mentionCountForGuild(guildId) > 0) return 'mention';
if (GuildReadState.hasUnread(guildId)) return 'unread'; if (GuildReadState.guildIsUnread(guildId)) return 'unread';
return null; return null;
} }
@@ -1327,10 +1327,10 @@ const GUILD_RAIL_INDICATORS_BY_TOKEN: Record<string, SkeletonGuildRailItemIndica
function resolveGuildRailIndicator(guildIds: ReadonlyArray<string>): SkeletonGuildRailItemIndicator { function resolveGuildRailIndicator(guildIds: ReadonlyArray<string>): SkeletonGuildRailItemIndicator {
let hasUnread = false; let hasUnread = false;
for (const guildId of guildIds) { for (const guildId of guildIds) {
if (GuildReadState.getMentionCount(guildId) > 0) { if (GuildReadState.mentionCountForGuild(guildId) > 0) {
return SkeletonGuildRailItemIndicator.MENTION; return SkeletonGuildRailItemIndicator.MENTION;
} }
if (GuildReadState.hasUnread(guildId)) { if (GuildReadState.guildIsUnread(guildId)) {
hasUnread = true; hasUnread = true;
} }
} }
@@ -316,8 +316,8 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
}) as CSSProperties, }) as CSSProperties,
[folderColor], [folderColor],
); );
const hasUnreadMessages = guilds.some((guild) => GuildReadState.hasUnread(guild.id)); const hasUnreadMessages = guilds.some((guild) => GuildReadState.guildIsUnread(guild.id));
const totalMentionCount = guilds.reduce((sum, guild) => sum + GuildReadState.getMentionCount(guild.id), 0); const totalMentionCount = guilds.reduce((sum, guild) => sum + GuildReadState.mentionCountForGuild(guild.id), 0);
let folderScrollTargetRef: React.RefCallback<HTMLElement> | null = null; let folderScrollTargetRef: React.RefCallback<HTMLElement> | null = null;
if (registerScrollTarget != null) { if (registerScrollTarget != null) {
folderScrollTargetRef = registerScrollTarget(folderId); folderScrollTargetRef = registerScrollTarget(folderId);
@@ -72,7 +72,7 @@ export function resolveDMListItemUnreadState(channelId: string): ChannelUnreadSt
return getChannelUnreadState({ return getChannelUnreadState({
unreadCount: ReadStates.getUnreadCount(channelId), unreadCount: ReadStates.getUnreadCount(channelId),
mentionCount: ReadStates.getMentionCount(channelId), mentionCount: ReadStates.getMentionCount(channelId),
isMuted: UserGuildSettings.isChannelMuted(null, channelId), isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels, showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels,
unreadBadgesLevel: null, unreadBadgesLevel: null,
}); });
@@ -124,7 +124,7 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
const contextMenuOpen = useContextMenuHoverState(buttonRef, !isMobileExperience); const contextMenuOpen = useContextMenuHoverState(buttonRef, !isMobileExperience);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const {keyboardModeEnabled} = KeyboardMode; const {keyboardModeEnabled} = KeyboardMode;
const isMuted = UserGuildSettings.isChannelMuted(null, channel.id); const isMuted = UserGuildSettings.isChannelDirectlyMuted(null, channel.id);
const mentionCount = ReadStates.getMentionCount(channel.id); const mentionCount = ReadStates.getMentionCount(channel.id);
const unreadState = resolveDMListItemUnreadState(channel.id); const unreadState = resolveDMListItemUnreadState(channel.id);
const directMessageName = recipient ? NicknameUtils.getNickname(recipient, null, channel.id) : null; const directMessageName = recipient ? NicknameUtils.getNickname(recipient, null, channel.id) : null;
@@ -107,9 +107,9 @@ export function useGuildListItemState({
isSelected, isSelected,
selectedGuildIndex, selectedGuildIndex,
}: UseGuildListItemStateOptions): GuildListItemState { }: UseGuildListItemStateOptions): GuildListItemState {
const hasUnreadMessages = GuildReadState.hasUnread(guild.id); const hasUnreadMessages = GuildReadState.guildIsUnread(guild.id);
const mentionCount = GuildReadState.getMentionCount(guild.id); const mentionCount = GuildReadState.mentionCountForGuild(guild.id);
const settings = UserGuildSettings.getSettings(guild.id); const settings = UserGuildSettings.getSettingsForScope(guild.id);
const isMuted = settings.muted; const isMuted = settings.muted;
const muteConfig = settings.mute_config; const muteConfig = settings.mute_config;
let muteEndTime: string | null = null; let muteEndTime: string | null = null;
@@ -132,7 +132,7 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
: TypingIndicator.isTyping(channel.id, recipientId); : TypingIndicator.isTyping(channel.id, recipientId);
const hasUnreadMessages = () => ReadStates.hasUnread(channel.id); const hasUnreadMessages = () => ReadStates.hasUnread(channel.id);
const isMobile = MobileLayout.isMobileLayout(); const isMobile = MobileLayout.isMobileLayout();
const isMuted = UserGuildSettings.isChannelMuted(null, channel.id); const isMuted = UserGuildSettings.isChannelDirectlyMuted(null, channel.id);
const mentionCount = ReadStates.getMentionCount(channel.id); const mentionCount = ReadStates.getMentionCount(channel.id);
const unreadState = getChannelUnreadState({ const unreadState = getChannelUnreadState({
unreadCount: ReadStates.getUnreadCount(channel.id), unreadCount: ReadStates.getUnreadCount(channel.id),
@@ -166,7 +166,7 @@ export const GuildNotificationSettingsModal = observer(({guildId}: {guildId: str
const {i18n} = useLingui(); const {i18n} = useLingui();
const muteChannelLabel = i18n._(MUTE_CHANNEL_DESCRIPTOR); const muteChannelLabel = i18n._(MUTE_CHANNEL_DESCRIPTOR);
const guild = Guilds.getGuild(guildId); const guild = Guilds.getGuild(guildId);
const settings = UserGuildSettings.getSettings(guildId); const settings = UserGuildSettings.getSettingsForScope(guildId);
if (!guild || !settings) return null; if (!guild || !settings) return null;
const channels = Channels.getGuildChannels(guildId); const channels = Channels.getGuildChannels(guildId);
const categories = channels.filter((c) => c.type === ChannelTypes.GUILD_CATEGORY); const categories = channels.filter((c) => c.type === ChannelTypes.GUILD_CATEGORY);
@@ -171,11 +171,11 @@ export const GuildHeaderPopout = observer(({guild}: {guild: Guild}) => {
canCreateExpressions || canCreateExpressions ||
canBanMembers; canBanMembers;
const canEditCommunityProfile = Users.getCurrentUser()?.isClaimed() ?? true; const canEditCommunityProfile = Users.getCurrentUser()?.isClaimed() ?? true;
const settings = UserGuildSettings.getSettings(guild.id); const settings = UserGuildSettings.getSettingsForScope(guild.id);
const hideMutedChannels = settings?.hide_muted_channels ?? false; const hideMutedChannels = settings?.hide_muted_channels ?? false;
const handleToggleHideMutedChannels = useCallback( const handleToggleHideMutedChannels = useCallback(
(checked: boolean) => { (checked: boolean) => {
const currentSettings = UserGuildSettings.getSettings(guild.id); const currentSettings = UserGuildSettings.getSettingsForScope(guild.id);
const currentValue = currentSettings?.hide_muted_channels ?? false; const currentValue = currentSettings?.hide_muted_channels ?? false;
if (checked === currentValue) return; if (checked === currentValue) return;
UserGuildSettingsCommands.toggleHideMutedChannels(guild.id); UserGuildSettingsCommands.toggleHideMutedChannels(guild.id);
@@ -20,18 +20,18 @@ const PRIVATE_CHANNEL_SENTINEL = ME;
class GuildReadStateEntry { class GuildReadStateEntry {
unread = observable.box(false); unread = observable.box(false);
unreadChannelId = observable.box<ChannelId | null>(null); firstUnreadChannelId = observable.box<ChannelId | null>(null);
mentionCount = observable.box(0); mentionCount = observable.box(0);
mentionChannels = observable.set(new Set<ChannelId>()); mentionChannels = observable.set(new Set<ChannelId>());
sentinel = observable.box(0); changeTicket = observable.box(0);
incrementSentinel(): void { bumpChangeTicket(): void {
this.sentinel.set(this.sentinel.get() + 1); this.changeTicket.set(this.changeTicket.get() + 1);
} }
reset(): void { reset(): void {
this.unread.set(false); this.unread.set(false);
this.unreadChannelId.set(null); this.firstUnreadChannelId.set(null);
this.mentionCount.set(0); this.mentionCount.set(0);
this.mentionChannels.clear(); this.mentionChannels.clear();
} }
@@ -43,12 +43,12 @@ type ContributeChannel = {
guildId?: string | null; guildId?: string | null;
parentId?: string | null; parentId?: string | null;
isPrivate(): boolean; isPrivate(): boolean;
isGuildVocal?(): boolean; isGuildAudioChannel?(): boolean;
}; };
function isChannelMutedForUnread(channel: ContributeChannel): boolean { function isChannelMutedForUnread(channel: ContributeChannel): boolean {
if (channel.isPrivate()) return false; if (channel.isPrivate()) return false;
return UserGuildSettings.isGuildOrCategoryOrChannelMuted(channel.guildId ?? null, channel.id); return UserGuildSettings.isMutedAtAnyLevel(channel.guildId ?? null, channel.id);
} }
function resolveUnreadBadgesLevel(channel: ContributeChannel): number | null { function resolveUnreadBadgesLevel(channel: ContributeChannel): number | null {
@@ -77,7 +77,7 @@ function getChannelContribution(channel: ContributeChannel, channelId: string):
class GuildReadState { class GuildReadState {
private readonly guildStates = observable.map(new Map<GuildId, GuildReadStateEntry>()); private readonly guildStates = observable.map(new Map<GuildId, GuildReadStateEntry>());
private readonly unreadGuilds = observable.set(new Set<GuildId>()); private readonly unreadGuildIds = observable.set(new Set<GuildId>());
updateCounter = 0; updateCounter = 0;
private readStateReactionInstalled = false; private readStateReactionInstalled = false;
@@ -156,9 +156,9 @@ class GuildReadState {
this.updateCounter++; this.updateCounter++;
} }
private incrementSentinel(guildId: string | null): void { private bumpChangeTicket(guildId: string | null): void {
const state = this.getOrCreate(guildId); const state = this.getOrCreate(guildId);
state.incrementSentinel(); state.bumpChangeTicket();
this.notifyChange(); this.notifyChange();
} }
@@ -166,18 +166,18 @@ class GuildReadState {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL; const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
const state = this.getOrCreate(id); const state = this.getOrCreate(id);
const previousUnread = state.unread.get(); const previousUnread = state.unread.get();
const previousUnreadChannelId = state.unreadChannelId.get(); const previousFirstUnread = state.firstUnreadChannelId.get();
const previousMentionCount = state.mentionCount.get(); const previousMentionCount = state.mentionCount.get();
const mentionChannelUpdates = new Map<ChannelId, boolean>(); const mentionChannelUpdates = new Map<ChannelId, boolean>();
let foundUnread = false; let foundUnread = false;
let shouldClearUnreadChannelId = false; let shouldClearFirstUnread = false;
let nextUnreadChannelId = previousUnreadChannelId; let nextFirstUnread = previousFirstUnread;
for (const channelId of channelIds) { for (const channelId of channelIds) {
const channel = Channels.getChannel(channelId); const channel = Channels.getChannel(channelId);
if (channel == null) { if (channel == null) {
mentionChannelUpdates.set(channelId, false); mentionChannelUpdates.set(channelId, false);
if (nextUnreadChannelId === channelId) { if (nextFirstUnread === channelId) {
shouldClearUnreadChannelId = true; shouldClearFirstUnread = true;
} }
continue; continue;
} }
@@ -194,14 +194,14 @@ class GuildReadState {
mentionChannelUpdates.set(channelId, contribution.mentionAllowed); mentionChannelUpdates.set(channelId, contribution.mentionAllowed);
if (guildId != null && !foundUnread && contribution.unreadAllowed) { if (guildId != null && !foundUnread && contribution.unreadAllowed) {
foundUnread = true; foundUnread = true;
nextUnreadChannelId = channelId; nextFirstUnread = channelId;
} else if (!contribution.unreadAllowed && nextUnreadChannelId === channelId) { } else if (!contribution.unreadAllowed && nextFirstUnread === channelId) {
shouldClearUnreadChannelId = true; shouldClearFirstUnread = true;
} }
} }
const nextUnread = foundUnread; const nextUnread = foundUnread;
if (!nextUnread && shouldClearUnreadChannelId) { if (!nextUnread && shouldClearFirstUnread) {
nextUnreadChannelId = null; nextFirstUnread = null;
} }
if (previousUnread && !nextUnread) { if (previousUnread && !nextUnread) {
return this.recomputeAll(guildId); return this.recomputeAll(guildId);
@@ -219,9 +219,7 @@ class GuildReadState {
} }
} }
const changed = const changed =
nextUnread !== previousUnread || nextUnread !== previousUnread || nextFirstUnread !== previousFirstUnread || mentionTotal !== previousMentionCount;
nextUnreadChannelId !== previousUnreadChannelId ||
mentionTotal !== previousMentionCount;
if (!changed && mentionChannelUpdates.size === 0) { if (!changed && mentionChannelUpdates.size === 0) {
return false; return false;
} }
@@ -237,16 +235,16 @@ class GuildReadState {
return; return;
} }
state.unread.set(nextUnread); state.unread.set(nextUnread);
state.unreadChannelId.set(nextUnreadChannelId); state.firstUnreadChannelId.set(nextFirstUnread);
state.mentionCount.set(mentionTotal); state.mentionCount.set(mentionTotal);
if (id !== PRIVATE_CHANNEL_SENTINEL) { if (id !== PRIVATE_CHANNEL_SENTINEL) {
if (nextUnread) { if (nextUnread) {
this.unreadGuilds.add(id as GuildId); this.unreadGuildIds.add(id as GuildId);
} else { } else {
this.unreadGuilds.delete(id as GuildId); this.unreadGuildIds.delete(id as GuildId);
} }
} }
this.incrementSentinel(guildId); this.bumpChangeTicket(guildId);
}); });
return changed; return changed;
} }
@@ -264,11 +262,11 @@ class GuildReadState {
} }
if (!newState.unread.get() && contribution.unreadAllowed) { if (!newState.unread.get() && contribution.unreadAllowed) {
newState.unread.set(true); newState.unread.set(true);
newState.unreadChannelId.set(channel.id as ChannelId); newState.firstUnreadChannelId.set(channel.id as ChannelId);
} }
} }
} else { } else {
const isGuildMuted = UserGuildSettings.isMuted(guildId); const isGuildMuted = UserGuildSettings.isWholeGuildMuted(guildId);
if (isGuildMuted && skipIfMuted) { if (isGuildMuted && skipIfMuted) {
return false; return false;
} }
@@ -281,7 +279,7 @@ class GuildReadState {
} }
if (!newState.unread.get() && contribution.unreadAllowed) { if (!newState.unread.get() && contribution.unreadAllowed) {
newState.unread.set(true); newState.unread.set(true);
newState.unreadChannelId.set(channel.id as ChannelId); newState.firstUnreadChannelId.set(channel.id as ChannelId);
} }
} }
} }
@@ -292,7 +290,7 @@ class GuildReadState {
private commitState(guildId: string, newState: GuildReadStateEntry, prevState: GuildReadStateEntry): boolean { private commitState(guildId: string, newState: GuildReadStateEntry, prevState: GuildReadStateEntry): boolean {
if ( if (
newState.unread.get() === prevState.unread.get() && newState.unread.get() === prevState.unread.get() &&
newState.unreadChannelId.get() === prevState.unreadChannelId.get() && newState.firstUnreadChannelId.get() === prevState.firstUnreadChannelId.get() &&
newState.mentionCount.get() === prevState.mentionCount.get() newState.mentionCount.get() === prevState.mentionCount.get()
) { ) {
return false; return false;
@@ -301,12 +299,12 @@ class GuildReadState {
this.guildStates.set(guildId as GuildId, newState); this.guildStates.set(guildId as GuildId, newState);
if (guildId !== PRIVATE_CHANNEL_SENTINEL) { if (guildId !== PRIVATE_CHANNEL_SENTINEL) {
if (newState.unread.get()) { if (newState.unread.get()) {
this.unreadGuilds.add(guildId as GuildId); this.unreadGuildIds.add(guildId as GuildId);
} else { } else {
this.unreadGuilds.delete(guildId as GuildId); this.unreadGuildIds.delete(guildId as GuildId);
} }
} }
this.incrementSentinel(guildId === PRIVATE_CHANNEL_SENTINEL ? null : guildId); this.bumpChangeTicket(guildId === PRIVATE_CHANNEL_SENTINEL ? null : guildId);
}); });
return true; return true;
} }
@@ -335,21 +333,21 @@ class GuildReadState {
} }
} }
get hasAnyUnread(): boolean { get anyGuildUnread(): boolean {
return this.unreadGuilds.size > 0; return this.unreadGuildIds.size > 0;
} }
hasUnread(guildId: string): boolean { guildIsUnread(guildId: string): boolean {
return this.unreadGuilds.has(guildId as GuildId); return this.unreadGuildIds.has(guildId as GuildId);
} }
getMentionCount(guildId: string | null): number { mentionCountForGuild(guildId: string | null): number {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL; const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
const state = this.guildStates.get(id as GuildId); const state = this.guildStates.get(id as GuildId);
return state?.mentionCount.get() ?? 0; return state?.mentionCount.get() ?? 0;
} }
getTotalMentionCount(excludePrivate = false): number { mentionCountAcrossGuilds(excludePrivate = false): number {
let total = 0; let total = 0;
for (const [guildId, state] of this.guildStates.entries()) { for (const [guildId, state] of this.guildStates.entries()) {
if (excludePrivate && guildId === PRIVATE_CHANNEL_SENTINEL) continue; if (excludePrivate && guildId === PRIVATE_CHANNEL_SENTINEL) continue;
@@ -358,22 +356,22 @@ class GuildReadState {
return total; return total;
} }
getPrivateChannelMentionCount(): number { directMessageMentionCount(): number {
const state = this.guildStates.get(PRIVATE_CHANNEL_SENTINEL as GuildId); const state = this.guildStates.get(PRIVATE_CHANNEL_SENTINEL as GuildId);
return state?.mentionCount.get() ?? 0; return state?.mentionCount.get() ?? 0;
} }
getMentionCountForPrivateChannel(channelId: string): number { directMessageMentionCountFor(channelId: string): number {
return ReadStates.getMentionCount(channelId); return ReadStates.getMentionCount(channelId);
} }
getGuildChangeSentinel(guildId: string | null): number { guildChangeTicketFor(guildId: string | null): number {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL; const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
const state = this.guildStates.get(id as GuildId); const state = this.guildStates.get(id as GuildId);
return state?.sentinel.get() ?? 0; return state?.changeTicket.get() ?? 0;
} }
getGuildHasUnreadIgnoreMuted(guildId: string): boolean { guildUnreadIgnoringMute(guildId: string): boolean {
const channels = Channels.getGuildChannels(guildId); const channels = Channels.getGuildChannels(guildId);
for (const channel of channels) { for (const channel of channels) {
if (!GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type)) { if (!GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
@@ -388,7 +386,7 @@ class GuildReadState {
handleConnectionOpen(): void { handleConnectionOpen(): void {
this.guildStates.clear(); this.guildStates.clear();
this.unreadGuilds.clear(); this.unreadGuildIds.clear();
this.updateCounter = 0; this.updateCounter = 0;
this.recomputeAll(null); this.recomputeAll(null);
for (const guildId of Guilds.getGuildIds()) { for (const guildId of Guilds.getGuildIds()) {
@@ -411,7 +409,7 @@ class GuildReadState {
}; };
}): void { }): void {
this.guildStates.delete(action.guild.id as GuildId); this.guildStates.delete(action.guild.id as GuildId);
this.unreadGuilds.delete(action.guild.id as GuildId); this.unreadGuildIds.delete(action.guild.id as GuildId);
this.notifyChange(); this.notifyChange();
} }
@@ -459,7 +457,7 @@ class GuildReadState {
this.handleConnectionOpen(); this.handleConnectionOpen();
} }
handleWindowFocus(): void { handleWindowFocused(): void {
this.notifyChange(); this.notifyChange();
} }
@@ -553,12 +553,12 @@ export class Message {
export const messageMentionsCurrentUser = (message: WireMessage): boolean => { export const messageMentionsCurrentUser = (message: WireMessage): boolean => {
const channel = Channels.getChannel(message.channel_id); const channel = Channels.getChannel(message.channel_id);
if (!channel) return false; if (!channel) return false;
if (message.mention_everyone && !UserGuildSettings.isSuppressEveryoneEnabled(channel.guildId ?? null)) return true; if (message.mention_everyone && !UserGuildSettings.isEveryoneMentionSuppressed(channel.guildId ?? null)) return true;
if (message.mentions?.some((user) => user.id === Authentication.currentUserId)) { if (message.mentions?.some((user) => user.id === Authentication.currentUserId)) {
return true; return true;
} }
if (!channel.guildId) return false; if (!channel.guildId) return false;
if (UserGuildSettings.isSuppressRolesEnabled(channel.guildId)) return false; if (UserGuildSettings.isRoleMentionSuppressed(channel.guildId)) return false;
const guild = Guilds.getGuild(channel.guildId); const guild = Guilds.getGuild(channel.guildId);
if (!guild) return false; if (!guild) return false;
const guildMember = GuildMembers.getMember(guild.id, Authentication.currentUserId); const guildMember = GuildMembers.getMember(guild.id, Authentication.currentUserId);
@@ -16,7 +16,7 @@ export function isMentioned(user: User, message: Message): boolean {
logger.warn(`${message.channelId} does not exist!`); logger.warn(`${message.channelId} does not exist!`);
return false; return false;
} }
const suppressEveryone = UserGuildSettings.isSuppressEveryoneEnabled(channel.guildId ?? null); const suppressEveryone = UserGuildSettings.isEveryoneMentionSuppressed(channel.guildId ?? null);
const mentionEveryone = message.mentionEveryone && !suppressEveryone; const mentionEveryone = message.mentionEveryone && !suppressEveryone;
if (mentionEveryone) { if (mentionEveryone) {
return true; return true;
@@ -35,6 +35,6 @@ export function isMentioned(user: User, message: Message): boolean {
if (!guildMember) { if (!guildMember) {
return false; return false;
} }
const suppressRoles = UserGuildSettings.isSuppressRolesEnabled(channel.guildId); const suppressRoles = UserGuildSettings.isRoleMentionSuppressed(channel.guildId);
return !suppressRoles && message.mentionRoles.some((roleId) => guildMember.roles.has(roleId)); return !suppressRoles && message.mentionRoles.some((roleId) => guildMember.roles.has(roleId));
} }
@@ -43,7 +43,7 @@ export function useMuteSheet(params: UseMuteSheetParams): UseMuteSheetReturn {
const additionalMutePayload = isGuildMode ? undefined : params.additionalMutePayload; const additionalMutePayload = isGuildMode ? undefined : params.additionalMutePayload;
const {onMuteSuccess, onUnmuteSuccess, onClose} = params; const {onMuteSuccess, onUnmuteSuccess, onClose} = params;
const muteConfig = isGuildMode const muteConfig = isGuildMode
? UserGuildSettings.getSettings(guildId)?.mute_config ? UserGuildSettings.getSettingsForScope(guildId)?.mute_config
: UserGuildSettings.getChannelOverride(guildId, channelId!)?.mute_config; : UserGuildSettings.getChannelOverride(guildId, channelId!)?.mute_config;
const openMuteSheet = useCallback(() => { const openMuteSheet = useCallback(() => {
setMuteSheetOpen(true); setMuteSheetOpen(true);
@@ -231,8 +231,8 @@ export class ReadStateEntry {
const userId = currentUser.id; const userId = currentUser.id;
const guildId = this.guildId; const guildId = this.guildId;
const channelId = this.channelId; const channelId = this.channelId;
const suppressEveryone = recomputeMentions ? UserGuildSettings.isSuppressEveryoneEnabled(guildId) : false; const suppressEveryone = recomputeMentions ? UserGuildSettings.isEveryoneMentionSuppressed(guildId) : false;
const suppressRoles = recomputeMentions ? UserGuildSettings.isSuppressRolesEnabled(guildId) : false; const suppressRoles = recomputeMentions ? UserGuildSettings.isRoleMentionSuppressed(guildId) : false;
const isMuted = recomputeMentions ? UserGuildSettings.isGuildOrChannelMuted(guildId, channelId) : false; const isMuted = recomputeMentions ? UserGuildSettings.isGuildOrChannelMuted(guildId, channelId) : false;
const member = recomputeMentions && guildId ? GuildMembers.getMember(guildId, userId) : null; const member = recomputeMentions && guildId ? GuildMembers.getMember(guildId, userId) : null;
const memberRoles = member?.roles ?? null; const memberRoles = member?.roles ?? null;
@@ -285,8 +285,8 @@ export class ReadStateEntry {
shouldMentionFor(message: MessageModel | WireMessage, userId: string, isPrivate: boolean): boolean { shouldMentionFor(message: MessageModel | WireMessage, userId: string, isPrivate: boolean): boolean {
const authorBlocked = Relationships.isBlocked(message.author.id); const authorBlocked = Relationships.isBlocked(message.author.id);
const suppressEveryone = UserGuildSettings.isSuppressEveryoneEnabled(this.guildId); const suppressEveryone = UserGuildSettings.isEveryoneMentionSuppressed(this.guildId);
const suppressRoles = UserGuildSettings.isSuppressRolesEnabled(this.guildId); const suppressRoles = UserGuildSettings.isRoleMentionSuppressed(this.guildId);
const mentions = message.mentions; const mentions = message.mentions;
const mentionEveryone = 'mentionEveryone' in message ? message.mentionEveryone : message.mention_everyone; const mentionEveryone = 'mentionEveryone' in message ? message.mentionEveryone : message.mention_everyone;
const mentionRoles = 'mentionRoles' in message ? message.mentionRoles : message.mention_roles; const mentionRoles = 'mentionRoles' in message ? message.mentionRoles : message.mention_roles;
@@ -38,7 +38,7 @@ export const ChannelListContextMenu: React.FC<ChannelListContextMenuProps> = obs
}); });
const invitableChannelId = InviteUtils.getInvitableChannelId(guild.id); const invitableChannelId = InviteUtils.getInvitableChannelId(guild.id);
const canInvite = InviteUtils.canInviteToChannel(invitableChannelId, guild.id); const canInvite = InviteUtils.canInviteToChannel(invitableChannelId, guild.id);
const hideMutedChannels = UserGuildSettings.getSettings(guild.id)?.hide_muted_channels ?? false; const hideMutedChannels = UserGuildSettings.getSettingsForScope(guild.id)?.hide_muted_channels ?? false;
const handleToggleHideMutedChannels = useCallback(() => { const handleToggleHideMutedChannels = useCallback(() => {
UserGuildSettingsCommands.toggleHideMutedChannels(guild.id); UserGuildSettingsCommands.toggleHideMutedChannels(guild.id);
}, [guild.id]); }, [guild.id]);
@@ -22,7 +22,7 @@ interface FavoritesGuildContextMenuProps {
export const FavoritesGuildContextMenu: React.FC<FavoritesGuildContextMenuProps> = observer(({onClose}) => { export const FavoritesGuildContextMenu: React.FC<FavoritesGuildContextMenuProps> = observer(({onClose}) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const settings = UserGuildSettings.getSettings(FAVORITES_GUILD_ID); const settings = UserGuildSettings.getSettingsForScope(FAVORITES_GUILD_ID);
const isMuted = settings?.muted ?? false; const isMuted = settings?.muted ?? false;
const handleToggleMute = () => { const handleToggleMute = () => {
UserGuildSettingsCommands.updateGuildSettings(FAVORITES_GUILD_ID, {muted: !isMuted}); UserGuildSettingsCommands.updateGuildSettings(FAVORITES_GUILD_ID, {muted: !isMuted});
@@ -34,7 +34,7 @@ interface GuildFolderContextMenuProps {
} }
function guildHasUnreadState(guildId: string): boolean { function guildHasUnreadState(guildId: string): boolean {
return GuildReadState.hasUnread(guildId) || GuildReadState.getGuildHasUnreadIgnoreMuted(guildId); return GuildReadState.guildIsUnread(guildId) || GuildReadState.guildUnreadIgnoringMute(guildId);
} }
export const GuildFolderContextMenu: React.FC<GuildFolderContextMenuProps> = observer(({folder, guilds, onClose}) => { export const GuildFolderContextMenu: React.FC<GuildFolderContextMenuProps> = observer(({folder, guilds, onClose}) => {
@@ -132,11 +132,11 @@ function getCategoryMenuState(category: Channel): CategoryMenuState {
(ch) => ch.parentId === category.id && ch.type !== ChannelTypes.GUILD_CATEGORY, (ch) => ch.parentId === category.id && ch.type !== ChannelTypes.GUILD_CATEGORY,
); );
const hasUnread = channelsInCategory.some((ch) => ReadStates.hasUnread(ch.id)); const hasUnread = channelsInCategory.some((ch) => ReadStates.hasUnread(ch.id));
const isCollapsed = UserGuildSettings.isChannelCollapsed(guildId, category.id); const isCollapsed = UserGuildSettings.isChannelSectionCollapsed(guildId, category.id);
const categoryIds = channels.filter((ch) => ch.type === ChannelTypes.GUILD_CATEGORY).map((ch) => ch.id); const categoryIds = channels.filter((ch) => ch.type === ChannelTypes.GUILD_CATEGORY).map((ch) => ch.id);
const allCategoriesCollapsed = const allCategoriesCollapsed =
categoryIds.length > 0 categoryIds.length > 0
? categoryIds.every((categoryId) => UserGuildSettings.isChannelCollapsed(guildId, categoryId)) ? categoryIds.every((categoryId) => UserGuildSettings.isChannelSectionCollapsed(guildId, categoryId))
: false; : false;
const categoryOverride = UserGuildSettings.getChannelOverride(guildId, category.id); const categoryOverride = UserGuildSettings.getChannelOverride(guildId, category.id);
const isMuted = categoryOverride?.muted ?? false; const isMuted = categoryOverride?.muted ?? false;
@@ -113,7 +113,7 @@ export const MarkCategoryAsReadMenuItem: React.FC<CategoryMenuItemProps> = obser
export const CollapseCategoryMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => { export const CollapseCategoryMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const guildId = category.guildId!; const guildId = category.guildId!;
const isCollapsed = UserGuildSettings.isChannelCollapsed(guildId, category.id); const isCollapsed = UserGuildSettings.isChannelSectionCollapsed(guildId, category.id);
const handleToggleCollapse = useCallback(() => { const handleToggleCollapse = useCallback(() => {
UserGuildSettingsCommands.toggleChannelCollapsed(guildId, category.id); UserGuildSettingsCommands.toggleChannelCollapsed(guildId, category.id);
onClose(); onClose();
@@ -138,7 +138,7 @@ export const CollapseAllCategoriesMenuItem: React.FC<CategoryMenuItemProps> = ob
); );
const allCategoriesCollapsed = useMemo(() => { const allCategoriesCollapsed = useMemo(() => {
if (categoryIds.length === 0) return false; if (categoryIds.length === 0) return false;
return categoryIds.every((categoryId) => UserGuildSettings.isChannelCollapsed(guildId, categoryId)); return categoryIds.every((categoryId) => UserGuildSettings.isChannelSectionCollapsed(guildId, categoryId));
}, [guildId, categoryIds]); }, [guildId, categoryIds]);
const handleToggleCollapseAll = useCallback(() => { const handleToggleCollapseAll = useCallback(() => {
UserGuildSettingsCommands.toggleAllCategoriesCollapsed(guildId, categoryIds); UserGuildSettingsCommands.toggleAllCategoriesCollapsed(guildId, categoryIds);
@@ -164,7 +164,7 @@ export function useGuildMenuData(guild: Guild, options: UseGuildMenuDataOptions)
const isOwner = guild.isOwner(Authentication.currentUserId); const isOwner = guild.isOwner(Authentication.currentUserId);
const developerMode = UserSettings.developerMode; const developerMode = UserSettings.developerMode;
const canEditCommunityProfile = Users.getCurrentUser()?.isClaimed() ?? true; const canEditCommunityProfile = Users.getCurrentUser()?.isClaimed() ?? true;
const settings = UserGuildSettings.getSettings(guild.id); const settings = UserGuildSettings.getSettingsForScope(guild.id);
const hideMutedChannels = settings?.hide_muted_channels ?? false; const hideMutedChannels = settings?.hide_muted_channels ?? false;
const isMuted = settings?.muted ?? false; const isMuted = settings?.muted ?? false;
const muteConfig = settings?.mute_config; const muteConfig = settings?.mute_config;
@@ -298,7 +298,7 @@ export function useGuildMenuData(guild: Guild, options: UseGuildMenuDataOptions)
ModalCommands.runAfterBottomSheetClose(onClose, () => openReportGuildModal({i18n, guild})); ModalCommands.runAfterBottomSheetClose(onClose, () => openReportGuildModal({i18n, guild}));
}, },
handleToggleHideMutedChannels: (checked: boolean) => { handleToggleHideMutedChannels: (checked: boolean) => {
const currentSettings = UserGuildSettings.getSettings(guild.id); const currentSettings = UserGuildSettings.getSettingsForScope(guild.id);
const currentValue = currentSettings?.hide_muted_channels ?? false; const currentValue = currentSettings?.hide_muted_channels ?? false;
if (checked === currentValue) return; if (checked === currentValue) return;
UserGuildSettingsCommands.toggleHideMutedChannels(guild.id); UserGuildSettingsCommands.toggleHideMutedChannels(guild.id);
@@ -148,7 +148,7 @@ export const InvitePeopleMenuItem: React.FC<GuildMenuItemProps> = observer(({gui
}); });
export const MuteCommunityMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => { export const MuteCommunityMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const settings = UserGuildSettings.getSettings(guild.id); const settings = UserGuildSettings.getSettingsForScope(guild.id);
const isMuted = settings?.muted ?? false; const isMuted = settings?.muted ?? false;
const muteConfig = settings?.mute_config; const muteConfig = settings?.mute_config;
const mutedText = getMutedText(isMuted, muteConfig); const mutedText = getMutedText(isMuted, muteConfig);
@@ -219,7 +219,7 @@ export const MuteCommunityMenuItem: React.FC<GuildMenuItemProps> = observer(({gu
}); });
export const NotificationSettingsMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => { export const NotificationSettingsMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const settings = UserGuildSettings.getSettings(guild.id); const settings = UserGuildSettings.getSettingsForScope(guild.id);
const suppressEveryone = settings?.suppress_everyone ?? false; const suppressEveryone = settings?.suppress_everyone ?? false;
const suppressRoles = settings?.suppress_roles ?? false; const suppressRoles = settings?.suppress_roles ?? false;
const mobilePush = settings?.mobile_push ?? true; const mobilePush = settings?.mobile_push ?? true;
@@ -323,11 +323,11 @@ export const NotificationSettingsMenuItem: React.FC<GuildMenuItemProps> = observ
}); });
export const HideMutedChannelsMenuItem: React.FC<GuildMenuItemProps> = observer(({guild}) => { export const HideMutedChannelsMenuItem: React.FC<GuildMenuItemProps> = observer(({guild}) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const settings = UserGuildSettings.getSettings(guild.id); const settings = UserGuildSettings.getSettingsForScope(guild.id);
const hideMutedChannels = settings?.hide_muted_channels ?? false; const hideMutedChannels = settings?.hide_muted_channels ?? false;
const handleToggle = useCallback( const handleToggle = useCallback(
(checked: boolean) => { (checked: boolean) => {
const currentSettings = UserGuildSettings.getSettings(guild.id); const currentSettings = UserGuildSettings.getSettingsForScope(guild.id);
const currentValue = currentSettings?.hide_muted_channels ?? false; const currentValue = currentSettings?.hide_muted_channels ?? false;
if (checked === currentValue) return; if (checked === currentValue) return;
UserGuildSettingsCommands.toggleHideMutedChannels(guild.id); UserGuildSettingsCommands.toggleHideMutedChannels(guild.id);
@@ -223,7 +223,7 @@ class NotificationState {
} }
private shouldNotifyBasedOnSettings(channel: Channel, messageRecord: Message, currentUser: User): boolean { private shouldNotifyBasedOnSettings(channel: Channel, messageRecord: Message, currentUser: User): boolean {
const level = UserGuildSettings.resolvedMessageNotifications({ const level = UserGuildSettings.resolveEffectiveMessageNotifications({
id: channel.id, id: channel.id,
guildId: channel.guildId, guildId: channel.guildId,
parentId: channel.parentId ?? undefined, parentId: channel.parentId ?? undefined,
@@ -261,7 +261,7 @@ class NotificationState {
return null; return null;
} }
if ( if (
UserGuildSettings.allowNoMessages({ UserGuildSettings.resolvesToNoMessages({
id: channel.id, id: channel.id,
guildId: channel.guildId, guildId: channel.guildId,
parentId: channel.parentId ?? undefined, parentId: channel.parentId ?? undefined,
@@ -127,7 +127,7 @@ export function updateGuildSettings(
updates: UserGuildSettingsPartial, updates: UserGuildSettingsPartial,
options?: PersistenceOptions, options?: PersistenceOptions,
): void { ): void {
UserGuildSettings.getSettings(guildId); UserGuildSettings.getSettingsForScope(guildId);
UserGuildSettings.updateGuildSettings(guildId, updates as Partial<GatewayGuildSettings>); UserGuildSettings.updateGuildSettings(guildId, updates as Partial<GatewayGuildSettings>);
scheduleUpdate(guildId, updates, options); scheduleUpdate(guildId, updates, options);
} }
@@ -139,7 +139,7 @@ export function bulkUpdateGuildSettings(
const uniqueGuildIds = Array.from(new Set(guildIds)); const uniqueGuildIds = Array.from(new Set(guildIds));
if (uniqueGuildIds.length === 0) return; if (uniqueGuildIds.length === 0) return;
for (const guildId of uniqueGuildIds) { for (const guildId of uniqueGuildIds) {
UserGuildSettings.getSettings(guildId); UserGuildSettings.getSettingsForScope(guildId);
UserGuildSettings.updateGuildSettings(guildId, updates as Partial<GatewayGuildSettings>); UserGuildSettings.updateGuildSettings(guildId, updates as Partial<GatewayGuildSettings>);
const key = getUpdateKey(guildId); const key = getUpdateKey(guildId);
addPendingPayload(key, updates); addPendingPayload(key, updates);
@@ -148,7 +148,7 @@ export function bulkUpdateGuildSettings(
} }
export function toggleHideMutedChannels(guildId: string | null): void { export function toggleHideMutedChannels(guildId: string | null): void {
const currentSettings = UserGuildSettings.getSettings(guildId); const currentSettings = UserGuildSettings.getSettingsForScope(guildId);
const newValue = !currentSettings.hide_muted_channels; const newValue = !currentSettings.hide_muted_channels;
updateGuildSettings(guildId, {hide_muted_channels: newValue}); updateGuildSettings(guildId, {hide_muted_channels: newValue});
} }
@@ -159,7 +159,7 @@ export function updateChannelOverride(
override: ChannelOverrideUpdate, override: ChannelOverrideUpdate,
options?: PersistenceOptions, options?: PersistenceOptions,
): void { ): void {
const currentSettings = UserGuildSettings.getSettings(guildId); const currentSettings = UserGuildSettings.getSettingsForScope(guildId);
const currentOverride = UserGuildSettings.getChannelOverride(guildId, channelId); const currentOverride = UserGuildSettings.getChannelOverride(guildId, channelId);
let newOverride: ChannelOverride | null = null; let newOverride: ChannelOverride | null = null;
if (override != null) { if (override != null) {
@@ -182,7 +182,7 @@ export function bulkUpdateChannelOverrides(
): void { ): void {
const uniqueChannelIds = Array.from(new Set(channelIds)); const uniqueChannelIds = Array.from(new Set(channelIds));
if (uniqueChannelIds.length === 0) return; if (uniqueChannelIds.length === 0) return;
const currentSettings = UserGuildSettings.getSettings(guildId); const currentSettings = UserGuildSettings.getSettingsForScope(guildId);
const newChannelOverrides: Record<string, ChannelOverride> = {...(currentSettings.channel_overrides ?? {})}; const newChannelOverrides: Record<string, ChannelOverride> = {...(currentSettings.channel_overrides ?? {})};
for (const channelId of uniqueChannelIds) { for (const channelId of uniqueChannelIds) {
const currentOverride = UserGuildSettings.getChannelOverride(guildId, channelId); const currentOverride = UserGuildSettings.getChannelOverride(guildId, channelId);
@@ -196,7 +196,7 @@ export function bulkUpdateChannelOverrides(
} }
export function toggleChannelCollapsed(guildId: string | null, channelId: string): void { export function toggleChannelCollapsed(guildId: string | null, channelId: string): void {
const isCollapsed = UserGuildSettings.isChannelCollapsed(guildId, channelId); const isCollapsed = UserGuildSettings.isChannelSectionCollapsed(guildId, channelId);
updateChannelOverride(guildId, channelId, {collapsed: !isCollapsed}); updateChannelOverride(guildId, channelId, {collapsed: !isCollapsed});
} }
@@ -228,7 +228,7 @@ export function updateUnreadBadgesLevel(
} }
export function toggleChannelMuted(guildId: string | null, channelId: string, options?: PersistenceOptions): void { export function toggleChannelMuted(guildId: string | null, channelId: string, options?: PersistenceOptions): void {
const isMuted = UserGuildSettings.isChannelMuted(guildId, channelId); const isMuted = UserGuildSettings.isChannelDirectlyMuted(guildId, channelId);
updateChannelOverride(guildId, channelId, {muted: !isMuted}, options); updateChannelOverride(guildId, channelId, {muted: !isMuted}, options);
} }
@@ -236,13 +236,13 @@ export function toggleAllCategoriesCollapsed(guildId: string | null, categoryIds
const uniqueCategoryIds = Array.from(new Set(categoryIds)); const uniqueCategoryIds = Array.from(new Set(categoryIds));
if (uniqueCategoryIds.length === 0) return; if (uniqueCategoryIds.length === 0) return;
const allCollapsed = uniqueCategoryIds.every((categoryId) => const allCollapsed = uniqueCategoryIds.every((categoryId) =>
UserGuildSettings.isChannelCollapsed(guildId, categoryId), UserGuildSettings.isChannelSectionCollapsed(guildId, categoryId),
); );
const newCollapsedState = !allCollapsed; const newCollapsedState = !allCollapsed;
for (const categoryId of uniqueCategoryIds) { for (const categoryId of uniqueCategoryIds) {
UserGuildSettings.updateChannelOverride(guildId, categoryId, {collapsed: newCollapsedState}); UserGuildSettings.updateChannelOverride(guildId, categoryId, {collapsed: newCollapsedState});
} }
const currentSettings = UserGuildSettings.getSettings(guildId); const currentSettings = UserGuildSettings.getSettingsForScope(guildId);
const newChannelOverrides: Record<string, ChannelOverride> = {...(currentSettings.channel_overrides ?? {})}; const newChannelOverrides: Record<string, ChannelOverride> = {...(currentSettings.channel_overrides ?? {})};
for (const categoryId of uniqueCategoryIds) { for (const categoryId of uniqueCategoryIds) {
const currentOverride = newChannelOverrides[categoryId]; const currentOverride = newChannelOverrides[categoryId];
@@ -27,7 +27,7 @@ export interface ChannelOverride {
unread_badges?: number | null; unread_badges?: number | null;
} }
interface GuildSettings { interface StoredGuildSettings {
guild_id: string; guild_id: string;
suppress_everyone: boolean; suppress_everyone: boolean;
suppress_roles: boolean; suppress_roles: boolean;
@@ -63,7 +63,7 @@ export interface GatewayGuildSettings {
unread_badges?: number | null; unread_badges?: number | null;
} }
const DEFAULT_GUILD_SETTINGS: Omit<GuildSettings, 'guild_id'> = { const DEFAULT_GUILD_SETTINGS: Omit<StoredGuildSettings, 'guild_id'> = {
suppress_everyone: false, suppress_everyone: false,
suppress_roles: false, suppress_roles: false,
mute_scheduled_events: false, mute_scheduled_events: false,
@@ -95,7 +95,7 @@ function parseEndTime(end_time?: string): number | null {
return Number.isFinite(ms) ? ms : null; return Number.isFinite(ms) ? ms : null;
} }
function isMuted(config: { function isMuteActive(config: {
muted?: boolean; muted?: boolean;
mute_config?: { mute_config?: {
end_time?: string; end_time?: string;
@@ -114,8 +114,8 @@ interface UnreadInboxReadState {
} }
class UserGuildSettings { class UserGuildSettings {
private readonly guildSettings = observable.map<GuildId, GuildSettings>(); private readonly guildSettings = observable.map<GuildId, StoredGuildSettings>();
private readonly mutedChannels = observable.map<GuildId, Set<ChannelId>>(); private readonly mutedChannelsByGuild = observable.map<GuildId, Set<ChannelId>>();
updateCounter = 0; updateCounter = 0;
private readonly pendingGuildUpdates = new Set<GuildId | null>(); private readonly pendingGuildUpdates = new Set<GuildId | null>();
private readonly guildMuteTimers = new Map<GuildId, NodeJS.Timeout>(); private readonly guildMuteTimers = new Map<GuildId, NodeJS.Timeout>();
@@ -149,7 +149,7 @@ class UserGuildSettings {
this.pendingGuildUpdates.add(guildId as GuildId | null); this.pendingGuildUpdates.add(guildId as GuildId | null);
} }
private getDefaultSettings(guildId: string): GuildSettings { private getDefaultSettings(guildId: string): StoredGuildSettings {
return { return {
...DEFAULT_GUILD_SETTINGS, ...DEFAULT_GUILD_SETTINGS,
guild_id: guildId, guild_id: guildId,
@@ -159,12 +159,12 @@ class UserGuildSettings {
}; };
} }
private getStoredSettings(guildId: string | null): GuildSettings | undefined { private getStoredSettings(guildId: string | null): StoredGuildSettings | undefined {
const key = this.storageKeyFor(guildId); const key = this.storageKeyFor(guildId);
return this.guildSettings.get(key as GuildId); return this.guildSettings.get(key as GuildId);
} }
private ensureGuildSettings(guildId: string | null): GuildSettings { private ensureGuildSettings(guildId: string | null): StoredGuildSettings {
const key = this.storageKeyFor(guildId); const key = this.storageKeyFor(guildId);
let settings = this.getStoredSettings(key); let settings = this.getStoredSettings(key);
if (settings == null) { if (settings == null) {
@@ -174,12 +174,12 @@ class UserGuildSettings {
return settings; return settings;
} }
getGuildSettings(guildId: string | null): GuildSettings { getGuildSettings(guildId: string | null): StoredGuildSettings {
const key = this.storageKeyFor(guildId); const key = this.storageKeyFor(guildId);
return this.getStoredSettings(key) ?? this.getDefaultSettings(key); return this.getStoredSettings(key) ?? this.getDefaultSettings(key);
} }
getSettings(guildId: string | null): GuildSettings { getSettingsForScope(guildId: string | null): StoredGuildSettings {
return this.getGuildSettings(guildId); return this.getGuildSettings(guildId);
} }
@@ -208,17 +208,17 @@ class UserGuildSettings {
return result as Record<ChannelId, ChannelOverride>; return result as Record<ChannelId, ChannelOverride>;
} }
private updateMutedChannelsCache(guildId: string, settings: GuildSettings): void { private updateMutedChannelsCache(guildId: string, settings: StoredGuildSettings): void {
const set = new Set<ChannelId>(); const set = new Set<ChannelId>();
for (const [channelId, override] of Object.entries(settings.channel_overrides)) { for (const [channelId, override] of Object.entries(settings.channel_overrides)) {
if (isMuted(override)) { if (isMuteActive(override)) {
set.add(channelId as ChannelId); set.add(channelId as ChannelId);
} }
} }
if (set.size > 0) { if (set.size > 0) {
this.mutedChannels.set(guildId as GuildId, set); this.mutedChannelsByGuild.set(guildId as GuildId, set);
} else { } else {
this.mutedChannels.delete(guildId as GuildId); this.mutedChannelsByGuild.delete(guildId as GuildId);
} }
} }
@@ -251,7 +251,7 @@ class UserGuildSettings {
} }
} }
private setupMuteTimers(guildId: string, settings: GuildSettings): void { private setupMuteTimers(guildId: string, settings: StoredGuildSettings): void {
this.clearGuildMuteTimer(guildId); this.clearGuildMuteTimer(guildId);
this.clearGuildChannelTimers(guildId); this.clearGuildChannelTimers(guildId);
if (settings.muted && settings.mute_config?.end_time) { if (settings.muted && settings.mute_config?.end_time) {
@@ -287,7 +287,7 @@ class UserGuildSettings {
} }
} }
private sanitizeGuildMute(settings: GuildSettings): GuildSettings { private sanitizeGuildMute(settings: StoredGuildSettings): StoredGuildSettings {
if (!settings.muted) return settings; if (!settings.muted) return settings;
const endMs = parseEndTime(settings.mute_config?.end_time); const endMs = parseEndTime(settings.mute_config?.end_time);
if (endMs != null && endMs <= Date.now()) { if (endMs != null && endMs <= Date.now()) {
@@ -296,7 +296,7 @@ class UserGuildSettings {
return settings; return settings;
} }
private sanitizeChannelMutes(settings: GuildSettings): GuildSettings { private sanitizeChannelMutes(settings: StoredGuildSettings): StoredGuildSettings {
let changed = false; let changed = false;
const overrides: Record<string, ChannelOverride> = {...settings.channel_overrides}; const overrides: Record<string, ChannelOverride> = {...settings.channel_overrides};
for (const [channelId, override] of Object.entries(overrides)) { for (const [channelId, override] of Object.entries(overrides)) {
@@ -314,7 +314,7 @@ class UserGuildSettings {
const key = this.storageKeyFor(guildId); const key = this.storageKeyFor(guildId);
const existing = this.ensureGuildSettings(key); const existing = this.ensureGuildSettings(key);
const overridesInput = 'channel_overrides' in updates ? updates.channel_overrides : existing.channel_overrides; const overridesInput = 'channel_overrides' in updates ? updates.channel_overrides : existing.channel_overrides;
const merged: GuildSettings = { const merged: StoredGuildSettings = {
...existing, ...existing,
...updates, ...updates,
guild_id: key, guild_id: key,
@@ -333,7 +333,7 @@ class UserGuildSettings {
const settings = this.ensureGuildSettings(key); const settings = this.ensureGuildSettings(key);
const existing = settings.channel_overrides[channelId as ChannelId] ?? DEFAULT_CHANNEL_OVERRIDE(channelId); const existing = settings.channel_overrides[channelId as ChannelId] ?? DEFAULT_CHANNEL_OVERRIDE(channelId);
const updated = {...existing, ...updates, channel_id: channelId}; const updated = {...existing, ...updates, channel_id: channelId};
const next: GuildSettings = { const next: StoredGuildSettings = {
...settings, ...settings,
channel_overrides: {...settings.channel_overrides, [channelId]: updated} as Record<ChannelId, ChannelOverride>, channel_overrides: {...settings.channel_overrides, [channelId]: updated} as Record<ChannelId, ChannelOverride>,
}; };
@@ -353,7 +353,10 @@ class UserGuildSettings {
const existing = nextOverrides[channelId] ?? DEFAULT_CHANNEL_OVERRIDE(channelId); const existing = nextOverrides[channelId] ?? DEFAULT_CHANNEL_OVERRIDE(channelId);
nextOverrides[channelId] = {...existing, ...updates, channel_id: channelId}; nextOverrides[channelId] = {...existing, ...updates, channel_id: channelId};
} }
const next: GuildSettings = {...settings, channel_overrides: nextOverrides as Record<ChannelId, ChannelOverride>}; const next: StoredGuildSettings = {
...settings,
channel_overrides: nextOverrides as Record<ChannelId, ChannelOverride>,
};
const sanitized = this.sanitizeChannelMutes(this.sanitizeGuildMute(next)); const sanitized = this.sanitizeChannelMutes(this.sanitizeGuildMute(next));
this.guildSettings.set(key as GuildId, sanitized); this.guildSettings.set(key as GuildId, sanitized);
this.updateMutedChannelsCache(key, sanitized); this.updateMutedChannelsCache(key, sanitized);
@@ -362,24 +365,24 @@ class UserGuildSettings {
this.notifyChange(); this.notifyChange();
} }
isSuppressEveryoneEnabled(guildId: string | null): boolean { isEveryoneMentionSuppressed(guildId: string | null): boolean {
return guildId != null && this.getGuildSettings(guildId).suppress_everyone; return guildId != null && this.getGuildSettings(guildId).suppress_everyone;
} }
isSuppressRolesEnabled(guildId: string | null): boolean { isRoleMentionSuppressed(guildId: string | null): boolean {
return guildId != null && this.getGuildSettings(guildId).suppress_roles; return guildId != null && this.getGuildSettings(guildId).suppress_roles;
} }
isMuteScheduledEventsEnabled(guildId: string | null): boolean { areScheduledEventsMuted(guildId: string | null): boolean {
return guildId != null && this.getGuildSettings(guildId).mute_scheduled_events; return guildId != null && this.getGuildSettings(guildId).mute_scheduled_events;
} }
isMobilePushEnabled(guildId: string | null): boolean { isMobilePushAllowed(guildId: string | null): boolean {
return guildId == null || this.getGuildSettings(guildId).mobile_push; return guildId == null || this.getGuildSettings(guildId).mobile_push;
} }
isMuted(guildId: string | null): boolean { isWholeGuildMuted(guildId: string | null): boolean {
return isMuted(this.getGuildSettings(guildId)); return isMuteActive(this.getGuildSettings(guildId));
} }
getGuildMessageNotifications(guildId: string | null): number { getGuildMessageNotifications(guildId: string | null): number {
@@ -401,7 +404,7 @@ class UserGuildSettings {
return settings.message_notifications; return settings.message_notifications;
} }
getGuildIds(): Array<GuildId> { getKnownGuildIds(): Array<GuildId> {
return Array.from(this.guildSettings.keys()); return Array.from(this.guildSettings.keys());
} }
@@ -409,7 +412,7 @@ class UserGuildSettings {
return this.getGuildSettings(guildId).message_notifications; return this.getGuildSettings(guildId).message_notifications;
} }
getChannelOverrides(guildId: string | null): Record<ChannelId, ChannelOverride> { getAllChannelOverrides(guildId: string | null): Record<ChannelId, ChannelOverride> {
return this.getGuildSettings(guildId).channel_overrides; return this.getGuildSettings(guildId).channel_overrides;
} }
@@ -417,50 +420,59 @@ class UserGuildSettings {
return this.getGuildSettings(guildId).channel_overrides[channelId as ChannelId]; return this.getGuildSettings(guildId).channel_overrides[channelId as ChannelId];
} }
getChannelMessageNotifications(guildId: string | null, channelId: string): number { getChannelNotificationLevel(guildId: string | null, channelId: string): number {
return ( return (
this.getGuildSettings(guildId).channel_overrides[channelId as ChannelId]?.message_notifications ?? this.getGuildSettings(guildId).channel_overrides[channelId as ChannelId]?.message_notifications ??
MessageNotifications.NULL MessageNotifications.NULL
); );
} }
isChannelMuted(guildId: string | null, channelId: string): boolean { isChannelDirectlyMuted(guildId: string | null, channelId: string): boolean {
const override = this.getGuildSettings(guildId).channel_overrides[channelId as ChannelId]; const override = this.getGuildSettings(guildId).channel_overrides[channelId as ChannelId];
return override != null && isMuted(override); return override != null && isMuteActive(override);
} }
isCategoryMuted(guildId: string | null, channelId: string): boolean { isParentCategoryMuted(guildId: string | null, channelId: string): boolean {
if (guildId == null) return false; if (guildId == null) return false;
const channel = Channels.getChannel(channelId); const channel = Channels.getChannel(channelId);
return channel?.parentId != null && this.isChannelMuted(guildId, channel.parentId); return channel?.parentId != null && this.isChannelDirectlyMuted(guildId, channel.parentId);
} }
isGuildOrCategoryOrChannelMuted(guildId: string | null, channelId: string): boolean { isMutedAtAnyLevel(guildId: string | null, channelId: string): boolean {
return this.isMuted(guildId) || this.isCategoryMuted(guildId, channelId) || this.isChannelMuted(guildId, channelId); return (
this.isWholeGuildMuted(guildId) ||
this.isParentCategoryMuted(guildId, channelId) ||
this.isChannelDirectlyMuted(guildId, channelId)
);
} }
isGuildOrChannelMuted(guildId: string | null, channelId: string): boolean { isGuildOrChannelMuted(guildId: string | null, channelId: string): boolean {
return this.isMuted(guildId) || this.isChannelMuted(guildId, channelId); return this.isWholeGuildMuted(guildId) || this.isChannelDirectlyMuted(guildId, channelId);
} }
isCategoryOrChannelMuted(guildId: string | null, channelId: string): boolean { isCategoryOrChannelMuted(guildId: string | null, channelId: string): boolean {
return this.isCategoryMuted(guildId, channelId) || this.isChannelMuted(guildId, channelId); return this.isParentCategoryMuted(guildId, channelId) || this.isChannelDirectlyMuted(guildId, channelId);
} }
getMutedChannels(guildId: string): Set<ChannelId> { getMutedChannelIds(guildId: string): Set<ChannelId> {
return new Set(this.mutedChannels.get(guildId as GuildId) ?? []); return new Set(this.mutedChannelsByGuild.get(guildId as GuildId) ?? []);
} }
isChannelCollapsed(guildId: string | null, channelId: string): boolean { isChannelSectionCollapsed(guildId: string | null, channelId: string): boolean {
return this.getChannelOverride(guildId, channelId)?.collapsed ?? false; return this.getChannelOverride(guildId, channelId)?.collapsed ?? false;
} }
resolvedMessageNotifications(channel: {id: string; guildId?: string; parentId?: string; type: number}): number { resolveEffectiveMessageNotifications(channel: {
id: string;
guildId?: string;
parentId?: string;
type: number;
}): number {
const guildId = channel.guildId ?? null; const guildId = channel.guildId ?? null;
const direct = this.getChannelMessageNotifications(guildId, channel.id); const direct = this.getChannelNotificationLevel(guildId, channel.id);
if (direct !== MessageNotifications.NULL && direct !== MessageNotifications.INHERIT) return direct; if (direct !== MessageNotifications.NULL && direct !== MessageNotifications.INHERIT) return direct;
if (channel.parentId != null && guildId != null) { if (channel.parentId != null && guildId != null) {
const parent = this.getChannelMessageNotifications(guildId, channel.parentId); const parent = this.getChannelNotificationLevel(guildId, channel.parentId);
if (parent !== MessageNotifications.NULL && parent !== MessageNotifications.INHERIT) return parent; if (parent !== MessageNotifications.NULL && parent !== MessageNotifications.INHERIT) return parent;
} }
return this.getGuildMessageNotifications(guildId); return this.getGuildMessageNotifications(guildId);
@@ -480,7 +492,7 @@ class UserGuildSettings {
private getLockedCommunityUnreadBadgesLevel(guildId: string | null): number | null { private getLockedCommunityUnreadBadgesLevel(guildId: string | null): number | null {
if (guildId == null) return null; if (guildId == null) return null;
if (this.isMuted(guildId)) return MessageNotifications.NO_MESSAGES; if (this.isWholeGuildMuted(guildId)) return MessageNotifications.NO_MESSAGES;
const notificationLevel = this.getGuildMessageNotifications(guildId); const notificationLevel = this.getGuildMessageNotifications(guildId);
if (notificationLevel === MessageNotifications.ALL_MESSAGES) return MessageNotifications.ALL_MESSAGES; if (notificationLevel === MessageNotifications.ALL_MESSAGES) return MessageNotifications.ALL_MESSAGES;
if (notificationLevel === MessageNotifications.NO_MESSAGES) return MessageNotifications.NO_MESSAGES; if (notificationLevel === MessageNotifications.NO_MESSAGES) return MessageNotifications.NO_MESSAGES;
@@ -527,35 +539,35 @@ class UserGuildSettings {
const guildId = channel.guildId ?? null; const guildId = channel.guildId ?? null;
return resolveUnreadInboxVisibility({ return resolveUnreadInboxVisibility({
unreadBadgesLevel: this.resolvedUnreadBadgesLevel(channel), unreadBadgesLevel: this.resolvedUnreadBadgesLevel(channel),
isMuted: this.isGuildOrCategoryOrChannelMuted(guildId, channel.id), isMuted: this.isMutedAtAnyLevel(guildId, channel.id),
hasUnread: readState.hasUnread, hasUnread: readState.hasUnread,
hasMentions: readState.hasMentions, hasMentions: readState.hasMentions,
}); });
} }
resolveUnreadSetting(channel: {id: string; guildId?: string; parentId?: string; type: number}): string { getUnreadSettingKey(channel: {id: string; guildId?: string; parentId?: string; type: number}): string {
const level = this.resolvedMessageNotifications(channel); const level = this.resolveEffectiveMessageNotifications(channel);
return level === MessageNotifications.ALL_MESSAGES ? 'all_messages' : 'only_mentions'; return level === MessageNotifications.ALL_MESSAGES ? 'all_messages' : 'only_mentions';
} }
allowNoMessages(channel: {id: string; guildId?: string; parentId?: string; type: number}): boolean { resolvesToNoMessages(channel: {id: string; guildId?: string; parentId?: string; type: number}): boolean {
return ( return (
this.isGuildOrChannelMuted(channel.guildId ?? null, channel.id) || this.isGuildOrChannelMuted(channel.guildId ?? null, channel.id) ||
this.resolvedMessageNotifications(channel) === MessageNotifications.NO_MESSAGES this.resolveEffectiveMessageNotifications(channel) === MessageNotifications.NO_MESSAGES
); );
} }
allowAllMessages(channel: {id: string; guildId?: string; parentId?: string; type: number}): boolean { resolvesToAllMessages(channel: {id: string; guildId?: string; parentId?: string; type: number}): boolean {
return ( return (
!this.isGuildOrChannelMuted(channel.guildId ?? null, channel.id) && !this.isGuildOrChannelMuted(channel.guildId ?? null, channel.id) &&
this.resolvedMessageNotifications(channel) === MessageNotifications.ALL_MESSAGES this.resolveEffectiveMessageNotifications(channel) === MessageNotifications.ALL_MESSAGES
); );
} }
handleConnectionOpen(userGuildSettings: Array<GatewayGuildSettings>): void { handleConnectionOpen(userGuildSettings: Array<GatewayGuildSettings>): void {
this.clearAllTimers(); this.clearAllTimers();
this.guildSettings.clear(); this.guildSettings.clear();
this.mutedChannels.clear(); this.mutedChannelsByGuild.clear();
for (const settings of userGuildSettings) { for (const settings of userGuildSettings) {
this.updateGuildSettings(settings.guild_id, settings); this.updateGuildSettings(settings.guild_id, settings);
} }
@@ -1,192 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/app/state/RuntimeConfig', () => ({
default: {staticCdnEndpoint: 'https://cdn.test'},
}));
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
default: {
getDeepFilterNoiseSuppression: () => true,
getDeepFilterNoiseSuppressionLevel: () => 80,
},
}));
const deepFilterHarness = vi.hoisted(() => ({
instances: [] as Array<{
audioContext: AudioContext | null;
processedTrack: MediaStreamTrack | null;
sampleRateAtInit: number | null;
}>,
}));
vi.mock('deepfilternet3-noise-filter', () => ({
DeepFilterNoiseFilterProcessor: class {
audioContext: AudioContext | null = null;
processedTrack: MediaStreamTrack | null = null;
sampleRateAtInit: number | null = null;
constructor() {
deepFilterHarness.instances.push(this);
}
async init(): Promise<void> {
this.sampleRateAtInit = this.audioContext?.sampleRate ?? null;
this.processedTrack = {} as MediaStreamTrack;
}
async destroy(): Promise<void> {}
},
}));
const MODEL_SAMPLE_RATE = 48000;
interface ContextHarness {
requestedRates: Array<number | undefined>;
closedRates: Array<number>;
}
function installAudioGraph(config: {hardwareSampleRate: number; canBridgeSampleRates: boolean}): ContextHarness {
const harness: ContextHarness = {requestedRates: [], closedRates: []};
class FakeAudioContext {
state: AudioContextState = 'running';
sampleRate: number;
constructor(options?: AudioContextOptions) {
harness.requestedRates.push(options?.sampleRate);
this.sampleRate = options?.sampleRate ?? config.hardwareSampleRate;
}
createMediaStreamSource(): {disconnect: () => void} {
if (!config.canBridgeSampleRates && this.sampleRate !== config.hardwareSampleRate) {
throw new DOMException(
'Connecting AudioNodes from AudioContexts with different sample-rate is currently not supported.',
'NotSupportedError',
);
}
return {disconnect: (): void => undefined};
}
async close(): Promise<void> {
harness.closedRates.push(this.sampleRate);
this.state = 'closed';
}
}
vi.stubGlobal('window', {AudioContext: FakeAudioContext});
vi.stubGlobal('MediaStream', class {});
return harness;
}
async function loadModule() {
vi.resetModules();
return await import('@app/features/voice/utils/DeepFilterNoiseProcessor');
}
function createSourceContext(sampleRate: number): AudioContext {
return {sampleRate} as unknown as AudioContext;
}
const feedTrack = {} as MediaStreamTrack;
describe('resolveDeepFilterAudioContext', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('runs the worklet at the model rate when the capture graph already runs there', async () => {
const harness = installAudioGraph({hardwareSampleRate: MODEL_SAMPLE_RATE, canBridgeSampleRates: false});
const {resolveDeepFilterAudioContext} = await loadModule();
const context = resolveDeepFilterAudioContext(createSourceContext(MODEL_SAMPLE_RATE), feedTrack);
expect(context?.sampleRate).toBe(MODEL_SAMPLE_RATE);
expect(harness.requestedRates).toEqual([MODEL_SAMPLE_RATE]);
expect(harness.closedRates).toEqual([]);
});
it('keeps the model rate on browsers that resample across graphs', async () => {
const harness = installAudioGraph({hardwareSampleRate: 44100, canBridgeSampleRates: true});
const {resolveDeepFilterAudioContext} = await loadModule();
const context = resolveDeepFilterAudioContext(createSourceContext(44100), feedTrack);
expect(context?.sampleRate).toBe(MODEL_SAMPLE_RATE);
expect(harness.requestedRates).toEqual([MODEL_SAMPLE_RATE]);
expect(harness.closedRates).toEqual([]);
});
it('falls back to the capture rate when the browser refuses to bridge sample rates', async () => {
const harness = installAudioGraph({hardwareSampleRate: 44100, canBridgeSampleRates: false});
const {resolveDeepFilterAudioContext} = await loadModule();
const context = resolveDeepFilterAudioContext(createSourceContext(44100), feedTrack);
expect(context?.sampleRate).toBe(44100);
expect(harness.requestedRates).toEqual([MODEL_SAMPLE_RATE, 44100]);
expect(harness.closedRates).toEqual([MODEL_SAMPLE_RATE]);
});
it('falls back to the capture rate for a bluetooth headset running well below the model rate', async () => {
installAudioGraph({hardwareSampleRate: 16000, canBridgeSampleRates: false});
const {resolveDeepFilterAudioContext} = await loadModule();
const context = resolveDeepFilterAudioContext(createSourceContext(16000), feedTrack);
expect(context?.sampleRate).toBe(16000);
});
});
function createCaptureContext(sampleRate: number): AudioContext {
const track = {stop: (): void => undefined} as unknown as MediaStreamTrack;
const node = {
connect: (next: unknown): unknown => next,
disconnect: (): void => undefined,
stream: {getAudioTracks: (): Array<MediaStreamTrack> => [track]},
type: '',
frequency: {value: 0},
Q: {value: 0},
threshold: {value: 0},
knee: {value: 0},
ratio: {value: 0},
attack: {value: 0},
release: {value: 0},
};
return {
sampleRate,
createMediaStreamDestination: () => node,
createMediaStreamSource: () => node,
createBiquadFilter: () => node,
createDynamicsCompressor: () => node,
} as unknown as AudioContext;
}
describe('buildDeepFilterAudioChain', () => {
beforeEach(() => {
deepFilterHarness.instances.length = 0;
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('gives the processor its audio context before the processor builds its own graph', async () => {
installAudioGraph({hardwareSampleRate: MODEL_SAMPLE_RATE, canBridgeSampleRates: false});
const {buildDeepFilterAudioChain} = await loadModule();
await buildDeepFilterAudioChain({audioContext: createCaptureContext(MODEL_SAMPLE_RATE)});
expect(deepFilterHarness.instances[0]?.sampleRateAtInit).toBe(MODEL_SAMPLE_RATE);
});
it('gives the processor a capture-rate context when the browser refuses to bridge sample rates', async () => {
installAudioGraph({hardwareSampleRate: 44100, canBridgeSampleRates: false});
const {buildDeepFilterAudioChain} = await loadModule();
await buildDeepFilterAudioChain({audioContext: createCaptureContext(44100)});
expect(deepFilterHarness.instances[0]?.sampleRateAtInit).toBe(44100);
});
});
@@ -1,68 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeEach, describe, expect, it, vi} from 'vitest';
const master = {connect: vi.fn(), disconnect: vi.fn()};
const createdGains: Array<{connect: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn>}> = [];
let captureActive = true;
vi.mock('@app/features/notification/utils/SoundUtils', () => ({
getSoundCaptureAudioContext: () => ({
createMediaElementSource: vi.fn(() => ({connect: vi.fn(), disconnect: vi.fn()})),
createGain: vi.fn(() => {
const gain = {connect: vi.fn(), disconnect: vi.fn(), gain: {value: 1}};
createdGains.push(gain);
return gain;
}),
}),
getSoundCaptureMasterGainNode: () => master,
isSoundCaptureActive: () => captureActive,
onSoundCaptureActivated: () => () => undefined,
}));
const {routeMediaElementForSoundCapture} = await import('@app/features/voice/utils/InAppMediaSoundCapture');
function createElement(): HTMLMediaElement {
const listeners = new Map<string, () => void>();
return {
volume: 1,
muted: false,
addEventListener: (type: string, listener: () => void) => listeners.set(type, listener),
removeEventListener: (type: string) => listeners.delete(type),
listenerCount: () => listeners.size,
} as unknown as HTMLMediaElement & {listenerCount: () => number};
}
describe('in-app media sound capture', () => {
beforeEach(() => {
createdGains.length = 0;
captureActive = true;
});
it('disconnects the element from the master graph when the caller disposes', () => {
const element = createElement();
const dispose = routeMediaElementForSoundCapture(element);
const gain = createdGains.at(-1);
expect(gain?.connect).toHaveBeenCalledWith(master);
dispose();
expect(gain?.disconnect).toHaveBeenCalled();
});
it('removes the volume listener when the caller disposes', () => {
const element = createElement() as HTMLMediaElement & {listenerCount: () => number};
const dispose = routeMediaElementForSoundCapture(element);
expect(element.listenerCount()).toBe(1);
dispose();
expect(element.listenerCount()).toBe(0);
});
it('reuses the existing source node when the same element is routed again', () => {
const element = createElement();
routeMediaElementForSoundCapture(element)();
const gainsAfterFirst = createdGains.length;
const dispose = routeMediaElementForSoundCapture(element);
expect(createdGains.length).toBe(gainsAfterFirst);
expect(createdGains.at(-1)?.connect).toHaveBeenCalledTimes(2);
dispose();
});
});
@@ -1,67 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
FOCUSED_VOICE_DEEP_FILTER_NOISE_REDUCTION_LEVEL,
resolveVoiceProcessing,
type VoiceProcessingSettingsLike,
} from '@app/features/voice/utils/VoiceProcessingProfile';
import {describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/voice/engine/VoiceDevicePermissionState', () => ({
default: {getState: () => ({inputDevices: []})},
}));
vi.mock('@app/features/voice/utils/VoiceDeviceManager', () => ({
resolveEffectiveDeviceId: () => null,
}));
function createSettings(overrides: Partial<VoiceProcessingSettingsLike> = {}): VoiceProcessingSettingsLike {
return {
voiceProcessingMode: 'voice',
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
deepFilterNoiseSuppression: true,
deepFilterNoiseSuppressionLevel: 80,
...overrides,
};
}
describe('resolveVoiceProcessing', () => {
it('keeps automatic gain control on for focused voice at factory defaults', () => {
const resolved = resolveVoiceProcessing(createSettings());
expect(resolved.mode).toBe('voice');
expect(resolved.autoGainControl).toBe(true);
expect(resolved.deepFilter).toBe(true);
expect(resolved.deepFilterNoiseReductionLevel).toBe(FOCUSED_VOICE_DEEP_FILTER_NOISE_REDUCTION_LEVEL);
});
it('honours an explicit opt out of automatic gain control in focused voice', () => {
const resolved = resolveVoiceProcessing(createSettings({autoGainControl: false}));
expect(resolved.autoGainControl).toBe(false);
});
it('keeps automatic gain control on in custom mode while deep filter is enabled', () => {
const resolved = resolveVoiceProcessing(
createSettings({voiceProcessingMode: 'custom', deepFilterNoiseSuppression: true}),
);
expect(resolved.autoGainControl).toBe(true);
expect(resolved.deepFilter).toBe(true);
});
it('still suppresses browser noise suppression when deep filter is enabled in custom mode', () => {
const resolved = resolveVoiceProcessing(
createSettings({voiceProcessingMode: 'custom', noiseSuppression: true, deepFilterNoiseSuppression: true}),
);
expect(resolved.browserNoiseSuppression).toBe(false);
});
it('leaves studio mode as the untouched bypass', () => {
const resolved = resolveVoiceProcessing(createSettings({voiceProcessingMode: 'studio'}));
expect(resolved.autoGainControl).toBe(false);
expect(resolved.echoCancellation).toBe(false);
expect(resolved.browserNoiseSuppression).toBe(false);
expect(resolved.deepFilter).toBe(false);
expect(resolved.contentHint).toBe('music');
});
});
@@ -419,7 +419,7 @@ function handleIncomingTtsMessage(message: Message): void {
} }
if ( if (
UserGuildSettings.isGuildOrChannelMuted(channel.guildId ?? null, channel.id) || UserGuildSettings.isGuildOrChannelMuted(channel.guildId ?? null, channel.id) ||
UserGuildSettings.isCategoryMuted(channel.guildId ?? null, channel.id) UserGuildSettings.isParentCategoryMuted(channel.guildId ?? null, channel.id)
) { ) {
return; return;
} }
@@ -6,7 +6,7 @@ import Notification from '@app/features/ui/state/Notification';
import Window from '@app/features/window/state/Window'; import Window from '@app/features/window/state/Window';
function applyFocusSideEffects(focused: boolean): void { function applyFocusSideEffects(focused: boolean): void {
GuildReadState.handleWindowFocus(); GuildReadState.handleWindowFocused();
Notification.handleWindowFocus({focused}); Notification.handleWindowFocus({focused});
if (focused) { if (focused) {
Idle.recordActivity(); Idle.recordActivity();