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(() => {
const relationships = Relationships.getRelationships();
const unreadMessageBadgeEnabled = Notification.unreadMessageBadgeEnabled;
const mentionCount = GuildReadState.getTotalMentionCount();
const hasUnread = GuildReadState.hasAnyUnread;
const mentionCount = GuildReadState.mentionCountAcrossGuilds();
const hasUnread = GuildReadState.anyGuildUnread;
const pendingCount = RuntimeConfig.directMessagesDisabled
? 0
: relationships.filter((relationship) => relationship.type === RelationshipTypes.INCOMING_REQUEST).length;
@@ -34,7 +34,7 @@ export const FavoritesGuildHeaderBottomSheet: React.FC<FavoritesGuildHeaderBotto
({isOpen, onClose}) => {
const {i18n} = useLingui();
const hideMutedChannels = Favorites.hideMutedChannels;
const settings = UserGuildSettings.getSettings(FAVORITES_GUILD_ID);
const settings = UserGuildSettings.getSettingsForScope(FAVORITES_GUILD_ID);
const isMuted = settings?.muted ?? false;
const handleAddChannel = () => {
ModalCommands.pushAfterBottomSheetClose(
@@ -360,7 +360,7 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
const messageGroupSpacing = Accessibility.getMessageGroupSpacingValue(messageDisplayCompact);
const mentionCount = ReadStates.getMentionCount(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 oldestUnreadMessageId = previewData?.oldestUnreadMessageId ?? null;
const totalUnreadCount = ReadStates.getUnreadCount(channel.id);
@@ -238,7 +238,7 @@ export const ChannelItem = observer(
const canInvite = InviteUtils.canInviteToChannel(channel.id, channel.guildId);
const mobileLayout = MobileLayout;
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 =
channelIsVoice && channel.userLimit != null && channel.userLimit > 0
? 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 channels = Channels.getGuildChannels(guild.id);
const location = useLocation();
const userGuildSettings = UserGuildSettings.getSettings(guild.id);
const userGuildSettings = UserGuildSettings.getSettingsForScope(guild.id);
const isDraggingAnything = useDragLayer((monitor) => {
if (!monitor.isDragging()) return false;
const itemType = monitor.getItemType();
@@ -337,7 +337,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
const unreadCount = ReadStates.getUnreadCount(channelId);
const mentionCount = ReadStates.getMentionCount(channelId);
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 unreadBadgesLevel = channel
? UserGuildSettings.resolvedUnreadBadgesLevel({
@@ -360,13 +360,13 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
for (const group of channelGroups) {
const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false;
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 filteredVoiceChannels: Array<Channel>;
if (hideMutedChannels) {
filteredTextChannels = [];
for (const ch of group.textChannels) {
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
if (
shouldShowChannelWhenHidingMutedChannels({
isMuted,
@@ -380,7 +380,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
}
filteredVoiceChannels = [];
for (const ch of group.voiceChannels) {
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
const isMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, ch.id);
if (
shouldShowChannelWhenHidingMutedChannels({
isMuted,
@@ -206,11 +206,11 @@ const FavoriteChannelResolvedItem = observer(
const channelDisplayName = channel.isPrivate() ? ChannelUtils.getDMDisplayName(channel) : channel.name;
const displayName = favoriteChannel.nickname || channelDisplayName || i18n._(UNKNOWN_CHANNEL_DESCRIPTOR);
const isChannelDirectlyMuted = channel.guildId
? UserGuildSettings.isChannelMuted(channel.guildId, channel.id)
? UserGuildSettings.isChannelDirectlyMuted(channel.guildId, channel.id)
: false;
const isMuted =
isChannelDirectlyMuted ||
(channel.guildId ? UserGuildSettings.isCategoryMuted(channel.guildId, channel.id) : false);
(channel.guildId ? UserGuildSettings.isParentCategoryMuted(channel.guildId, channel.id) : false);
const unreadBadgesLevel = channel.guildId
? UserGuildSettings.resolvedUnreadBadgesLevel({
id: channel.id,
@@ -665,7 +665,7 @@ function getDMScrollIndicatorSeverity(channelId: string): ScrollIndicatorSeverit
const unreadState = getChannelUnreadState({
unreadCount: ReadStates.getPrivateChannelUnreadCount(channelId),
mentionCount,
isMuted: UserGuildSettings.isChannelMuted(null, channelId),
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels,
});
if (mentionCount > 0) return 'mention';
@@ -674,8 +674,8 @@ function getDMScrollIndicatorSeverity(channelId: string): ScrollIndicatorSeverit
}
function getGuildScrollIndicatorSeverity(guildId: string): ScrollIndicatorSeverity | null {
if (GuildReadState.getMentionCount(guildId) > 0) return 'mention';
if (GuildReadState.hasUnread(guildId)) return 'unread';
if (GuildReadState.mentionCountForGuild(guildId) > 0) return 'mention';
if (GuildReadState.guildIsUnread(guildId)) return 'unread';
return null;
}
@@ -1327,10 +1327,10 @@ const GUILD_RAIL_INDICATORS_BY_TOKEN: Record<string, SkeletonGuildRailItemIndica
function resolveGuildRailIndicator(guildIds: ReadonlyArray<string>): SkeletonGuildRailItemIndicator {
let hasUnread = false;
for (const guildId of guildIds) {
if (GuildReadState.getMentionCount(guildId) > 0) {
if (GuildReadState.mentionCountForGuild(guildId) > 0) {
return SkeletonGuildRailItemIndicator.MENTION;
}
if (GuildReadState.hasUnread(guildId)) {
if (GuildReadState.guildIsUnread(guildId)) {
hasUnread = true;
}
}
@@ -316,8 +316,8 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
}) as CSSProperties,
[folderColor],
);
const hasUnreadMessages = guilds.some((guild) => GuildReadState.hasUnread(guild.id));
const totalMentionCount = guilds.reduce((sum, guild) => sum + GuildReadState.getMentionCount(guild.id), 0);
const hasUnreadMessages = guilds.some((guild) => GuildReadState.guildIsUnread(guild.id));
const totalMentionCount = guilds.reduce((sum, guild) => sum + GuildReadState.mentionCountForGuild(guild.id), 0);
let folderScrollTargetRef: React.RefCallback<HTMLElement> | null = null;
if (registerScrollTarget != null) {
folderScrollTargetRef = registerScrollTarget(folderId);
@@ -72,7 +72,7 @@ export function resolveDMListItemUnreadState(channelId: string): ChannelUnreadSt
return getChannelUnreadState({
unreadCount: ReadStates.getUnreadCount(channelId),
mentionCount: ReadStates.getMentionCount(channelId),
isMuted: UserGuildSettings.isChannelMuted(null, channelId),
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels,
unreadBadgesLevel: null,
});
@@ -124,7 +124,7 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
const contextMenuOpen = useContextMenuHoverState(buttonRef, !isMobileExperience);
const [isFocused, setIsFocused] = useState(false);
const {keyboardModeEnabled} = KeyboardMode;
const isMuted = UserGuildSettings.isChannelMuted(null, channel.id);
const isMuted = UserGuildSettings.isChannelDirectlyMuted(null, channel.id);
const mentionCount = ReadStates.getMentionCount(channel.id);
const unreadState = resolveDMListItemUnreadState(channel.id);
const directMessageName = recipient ? NicknameUtils.getNickname(recipient, null, channel.id) : null;
@@ -107,9 +107,9 @@ export function useGuildListItemState({
isSelected,
selectedGuildIndex,
}: UseGuildListItemStateOptions): GuildListItemState {
const hasUnreadMessages = GuildReadState.hasUnread(guild.id);
const mentionCount = GuildReadState.getMentionCount(guild.id);
const settings = UserGuildSettings.getSettings(guild.id);
const hasUnreadMessages = GuildReadState.guildIsUnread(guild.id);
const mentionCount = GuildReadState.mentionCountForGuild(guild.id);
const settings = UserGuildSettings.getSettingsForScope(guild.id);
const isMuted = settings.muted;
const muteConfig = settings.mute_config;
let muteEndTime: string | null = null;
@@ -132,7 +132,7 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
: TypingIndicator.isTyping(channel.id, recipientId);
const hasUnreadMessages = () => ReadStates.hasUnread(channel.id);
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 unreadState = getChannelUnreadState({
unreadCount: ReadStates.getUnreadCount(channel.id),
@@ -166,7 +166,7 @@ export const GuildNotificationSettingsModal = observer(({guildId}: {guildId: str
const {i18n} = useLingui();
const muteChannelLabel = i18n._(MUTE_CHANNEL_DESCRIPTOR);
const guild = Guilds.getGuild(guildId);
const settings = UserGuildSettings.getSettings(guildId);
const settings = UserGuildSettings.getSettingsForScope(guildId);
if (!guild || !settings) return null;
const channels = Channels.getGuildChannels(guildId);
const categories = channels.filter((c) => c.type === ChannelTypes.GUILD_CATEGORY);
@@ -171,11 +171,11 @@ export const GuildHeaderPopout = observer(({guild}: {guild: Guild}) => {
canCreateExpressions ||
canBanMembers;
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 handleToggleHideMutedChannels = useCallback(
(checked: boolean) => {
const currentSettings = UserGuildSettings.getSettings(guild.id);
const currentSettings = UserGuildSettings.getSettingsForScope(guild.id);
const currentValue = currentSettings?.hide_muted_channels ?? false;
if (checked === currentValue) return;
UserGuildSettingsCommands.toggleHideMutedChannels(guild.id);
@@ -20,18 +20,18 @@ const PRIVATE_CHANNEL_SENTINEL = ME;
class GuildReadStateEntry {
unread = observable.box(false);
unreadChannelId = observable.box<ChannelId | null>(null);
firstUnreadChannelId = observable.box<ChannelId | null>(null);
mentionCount = observable.box(0);
mentionChannels = observable.set(new Set<ChannelId>());
sentinel = observable.box(0);
changeTicket = observable.box(0);
incrementSentinel(): void {
this.sentinel.set(this.sentinel.get() + 1);
bumpChangeTicket(): void {
this.changeTicket.set(this.changeTicket.get() + 1);
}
reset(): void {
this.unread.set(false);
this.unreadChannelId.set(null);
this.firstUnreadChannelId.set(null);
this.mentionCount.set(0);
this.mentionChannels.clear();
}
@@ -43,12 +43,12 @@ type ContributeChannel = {
guildId?: string | null;
parentId?: string | null;
isPrivate(): boolean;
isGuildVocal?(): boolean;
isGuildAudioChannel?(): boolean;
};
function isChannelMutedForUnread(channel: ContributeChannel): boolean {
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 {
@@ -77,7 +77,7 @@ function getChannelContribution(channel: ContributeChannel, channelId: string):
class GuildReadState {
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;
private readStateReactionInstalled = false;
@@ -156,9 +156,9 @@ class GuildReadState {
this.updateCounter++;
}
private incrementSentinel(guildId: string | null): void {
private bumpChangeTicket(guildId: string | null): void {
const state = this.getOrCreate(guildId);
state.incrementSentinel();
state.bumpChangeTicket();
this.notifyChange();
}
@@ -166,18 +166,18 @@ class GuildReadState {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
const state = this.getOrCreate(id);
const previousUnread = state.unread.get();
const previousUnreadChannelId = state.unreadChannelId.get();
const previousFirstUnread = state.firstUnreadChannelId.get();
const previousMentionCount = state.mentionCount.get();
const mentionChannelUpdates = new Map<ChannelId, boolean>();
let foundUnread = false;
let shouldClearUnreadChannelId = false;
let nextUnreadChannelId = previousUnreadChannelId;
let shouldClearFirstUnread = false;
let nextFirstUnread = previousFirstUnread;
for (const channelId of channelIds) {
const channel = Channels.getChannel(channelId);
if (channel == null) {
mentionChannelUpdates.set(channelId, false);
if (nextUnreadChannelId === channelId) {
shouldClearUnreadChannelId = true;
if (nextFirstUnread === channelId) {
shouldClearFirstUnread = true;
}
continue;
}
@@ -194,14 +194,14 @@ class GuildReadState {
mentionChannelUpdates.set(channelId, contribution.mentionAllowed);
if (guildId != null && !foundUnread && contribution.unreadAllowed) {
foundUnread = true;
nextUnreadChannelId = channelId;
} else if (!contribution.unreadAllowed && nextUnreadChannelId === channelId) {
shouldClearUnreadChannelId = true;
nextFirstUnread = channelId;
} else if (!contribution.unreadAllowed && nextFirstUnread === channelId) {
shouldClearFirstUnread = true;
}
}
const nextUnread = foundUnread;
if (!nextUnread && shouldClearUnreadChannelId) {
nextUnreadChannelId = null;
if (!nextUnread && shouldClearFirstUnread) {
nextFirstUnread = null;
}
if (previousUnread && !nextUnread) {
return this.recomputeAll(guildId);
@@ -219,9 +219,7 @@ class GuildReadState {
}
}
const changed =
nextUnread !== previousUnread ||
nextUnreadChannelId !== previousUnreadChannelId ||
mentionTotal !== previousMentionCount;
nextUnread !== previousUnread || nextFirstUnread !== previousFirstUnread || mentionTotal !== previousMentionCount;
if (!changed && mentionChannelUpdates.size === 0) {
return false;
}
@@ -237,16 +235,16 @@ class GuildReadState {
return;
}
state.unread.set(nextUnread);
state.unreadChannelId.set(nextUnreadChannelId);
state.firstUnreadChannelId.set(nextFirstUnread);
state.mentionCount.set(mentionTotal);
if (id !== PRIVATE_CHANNEL_SENTINEL) {
if (nextUnread) {
this.unreadGuilds.add(id as GuildId);
this.unreadGuildIds.add(id as GuildId);
} else {
this.unreadGuilds.delete(id as GuildId);
this.unreadGuildIds.delete(id as GuildId);
}
}
this.incrementSentinel(guildId);
this.bumpChangeTicket(guildId);
});
return changed;
}
@@ -264,11 +262,11 @@ class GuildReadState {
}
if (!newState.unread.get() && contribution.unreadAllowed) {
newState.unread.set(true);
newState.unreadChannelId.set(channel.id as ChannelId);
newState.firstUnreadChannelId.set(channel.id as ChannelId);
}
}
} else {
const isGuildMuted = UserGuildSettings.isMuted(guildId);
const isGuildMuted = UserGuildSettings.isWholeGuildMuted(guildId);
if (isGuildMuted && skipIfMuted) {
return false;
}
@@ -281,7 +279,7 @@ class GuildReadState {
}
if (!newState.unread.get() && contribution.unreadAllowed) {
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 {
if (
newState.unread.get() === prevState.unread.get() &&
newState.unreadChannelId.get() === prevState.unreadChannelId.get() &&
newState.firstUnreadChannelId.get() === prevState.firstUnreadChannelId.get() &&
newState.mentionCount.get() === prevState.mentionCount.get()
) {
return false;
@@ -301,12 +299,12 @@ class GuildReadState {
this.guildStates.set(guildId as GuildId, newState);
if (guildId !== PRIVATE_CHANNEL_SENTINEL) {
if (newState.unread.get()) {
this.unreadGuilds.add(guildId as GuildId);
this.unreadGuildIds.add(guildId as GuildId);
} 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;
}
@@ -335,21 +333,21 @@ class GuildReadState {
}
}
get hasAnyUnread(): boolean {
return this.unreadGuilds.size > 0;
get anyGuildUnread(): boolean {
return this.unreadGuildIds.size > 0;
}
hasUnread(guildId: string): boolean {
return this.unreadGuilds.has(guildId as GuildId);
guildIsUnread(guildId: string): boolean {
return this.unreadGuildIds.has(guildId as GuildId);
}
getMentionCount(guildId: string | null): number {
mentionCountForGuild(guildId: string | null): number {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
const state = this.guildStates.get(id as GuildId);
return state?.mentionCount.get() ?? 0;
}
getTotalMentionCount(excludePrivate = false): number {
mentionCountAcrossGuilds(excludePrivate = false): number {
let total = 0;
for (const [guildId, state] of this.guildStates.entries()) {
if (excludePrivate && guildId === PRIVATE_CHANNEL_SENTINEL) continue;
@@ -358,22 +356,22 @@ class GuildReadState {
return total;
}
getPrivateChannelMentionCount(): number {
directMessageMentionCount(): number {
const state = this.guildStates.get(PRIVATE_CHANNEL_SENTINEL as GuildId);
return state?.mentionCount.get() ?? 0;
}
getMentionCountForPrivateChannel(channelId: string): number {
directMessageMentionCountFor(channelId: string): number {
return ReadStates.getMentionCount(channelId);
}
getGuildChangeSentinel(guildId: string | null): number {
guildChangeTicketFor(guildId: string | null): number {
const id = guildId ?? PRIVATE_CHANNEL_SENTINEL;
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);
for (const channel of channels) {
if (!GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
@@ -388,7 +386,7 @@ class GuildReadState {
handleConnectionOpen(): void {
this.guildStates.clear();
this.unreadGuilds.clear();
this.unreadGuildIds.clear();
this.updateCounter = 0;
this.recomputeAll(null);
for (const guildId of Guilds.getGuildIds()) {
@@ -411,7 +409,7 @@ class GuildReadState {
};
}): void {
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();
}
@@ -459,7 +457,7 @@ class GuildReadState {
this.handleConnectionOpen();
}
handleWindowFocus(): void {
handleWindowFocused(): void {
this.notifyChange();
}
@@ -553,12 +553,12 @@ export class Message {
export const messageMentionsCurrentUser = (message: WireMessage): boolean => {
const channel = Channels.getChannel(message.channel_id);
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)) {
return true;
}
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);
if (!guild) return false;
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!`);
return false;
}
const suppressEveryone = UserGuildSettings.isSuppressEveryoneEnabled(channel.guildId ?? null);
const suppressEveryone = UserGuildSettings.isEveryoneMentionSuppressed(channel.guildId ?? null);
const mentionEveryone = message.mentionEveryone && !suppressEveryone;
if (mentionEveryone) {
return true;
@@ -35,6 +35,6 @@ export function isMentioned(user: User, message: Message): boolean {
if (!guildMember) {
return false;
}
const suppressRoles = UserGuildSettings.isSuppressRolesEnabled(channel.guildId);
const suppressRoles = UserGuildSettings.isRoleMentionSuppressed(channel.guildId);
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 {onMuteSuccess, onUnmuteSuccess, onClose} = params;
const muteConfig = isGuildMode
? UserGuildSettings.getSettings(guildId)?.mute_config
? UserGuildSettings.getSettingsForScope(guildId)?.mute_config
: UserGuildSettings.getChannelOverride(guildId, channelId!)?.mute_config;
const openMuteSheet = useCallback(() => {
setMuteSheetOpen(true);
@@ -231,8 +231,8 @@ export class ReadStateEntry {
const userId = currentUser.id;
const guildId = this.guildId;
const channelId = this.channelId;
const suppressEveryone = recomputeMentions ? UserGuildSettings.isSuppressEveryoneEnabled(guildId) : false;
const suppressRoles = recomputeMentions ? UserGuildSettings.isSuppressRolesEnabled(guildId) : false;
const suppressEveryone = recomputeMentions ? UserGuildSettings.isEveryoneMentionSuppressed(guildId) : false;
const suppressRoles = recomputeMentions ? UserGuildSettings.isRoleMentionSuppressed(guildId) : false;
const isMuted = recomputeMentions ? UserGuildSettings.isGuildOrChannelMuted(guildId, channelId) : false;
const member = recomputeMentions && guildId ? GuildMembers.getMember(guildId, userId) : null;
const memberRoles = member?.roles ?? null;
@@ -285,8 +285,8 @@ export class ReadStateEntry {
shouldMentionFor(message: MessageModel | WireMessage, userId: string, isPrivate: boolean): boolean {
const authorBlocked = Relationships.isBlocked(message.author.id);
const suppressEveryone = UserGuildSettings.isSuppressEveryoneEnabled(this.guildId);
const suppressRoles = UserGuildSettings.isSuppressRolesEnabled(this.guildId);
const suppressEveryone = UserGuildSettings.isEveryoneMentionSuppressed(this.guildId);
const suppressRoles = UserGuildSettings.isRoleMentionSuppressed(this.guildId);
const mentions = message.mentions;
const mentionEveryone = 'mentionEveryone' in message ? message.mentionEveryone : message.mention_everyone;
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 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(() => {
UserGuildSettingsCommands.toggleHideMutedChannels(guild.id);
}, [guild.id]);
@@ -22,7 +22,7 @@ interface FavoritesGuildContextMenuProps {
export const FavoritesGuildContextMenu: React.FC<FavoritesGuildContextMenuProps> = observer(({onClose}) => {
const {i18n} = useLingui();
const settings = UserGuildSettings.getSettings(FAVORITES_GUILD_ID);
const settings = UserGuildSettings.getSettingsForScope(FAVORITES_GUILD_ID);
const isMuted = settings?.muted ?? false;
const handleToggleMute = () => {
UserGuildSettingsCommands.updateGuildSettings(FAVORITES_GUILD_ID, {muted: !isMuted});
@@ -34,7 +34,7 @@ interface GuildFolderContextMenuProps {
}
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}) => {
@@ -132,11 +132,11 @@ function getCategoryMenuState(category: Channel): CategoryMenuState {
(ch) => ch.parentId === category.id && ch.type !== ChannelTypes.GUILD_CATEGORY,
);
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 allCategoriesCollapsed =
categoryIds.length > 0
? categoryIds.every((categoryId) => UserGuildSettings.isChannelCollapsed(guildId, categoryId))
? categoryIds.every((categoryId) => UserGuildSettings.isChannelSectionCollapsed(guildId, categoryId))
: false;
const categoryOverride = UserGuildSettings.getChannelOverride(guildId, category.id);
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}) => {
const {i18n} = useLingui();
const guildId = category.guildId!;
const isCollapsed = UserGuildSettings.isChannelCollapsed(guildId, category.id);
const isCollapsed = UserGuildSettings.isChannelSectionCollapsed(guildId, category.id);
const handleToggleCollapse = useCallback(() => {
UserGuildSettingsCommands.toggleChannelCollapsed(guildId, category.id);
onClose();
@@ -138,7 +138,7 @@ export const CollapseAllCategoriesMenuItem: React.FC<CategoryMenuItemProps> = ob
);
const allCategoriesCollapsed = useMemo(() => {
if (categoryIds.length === 0) return false;
return categoryIds.every((categoryId) => UserGuildSettings.isChannelCollapsed(guildId, categoryId));
return categoryIds.every((categoryId) => UserGuildSettings.isChannelSectionCollapsed(guildId, categoryId));
}, [guildId, categoryIds]);
const handleToggleCollapseAll = useCallback(() => {
UserGuildSettingsCommands.toggleAllCategoriesCollapsed(guildId, categoryIds);
@@ -164,7 +164,7 @@ export function useGuildMenuData(guild: Guild, options: UseGuildMenuDataOptions)
const isOwner = guild.isOwner(Authentication.currentUserId);
const developerMode = UserSettings.developerMode;
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 isMuted = settings?.muted ?? false;
const muteConfig = settings?.mute_config;
@@ -298,7 +298,7 @@ export function useGuildMenuData(guild: Guild, options: UseGuildMenuDataOptions)
ModalCommands.runAfterBottomSheetClose(onClose, () => openReportGuildModal({i18n, guild}));
},
handleToggleHideMutedChannels: (checked: boolean) => {
const currentSettings = UserGuildSettings.getSettings(guild.id);
const currentSettings = UserGuildSettings.getSettingsForScope(guild.id);
const currentValue = currentSettings?.hide_muted_channels ?? false;
if (checked === currentValue) return;
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}) => {
const {i18n} = useLingui();
const settings = UserGuildSettings.getSettings(guild.id);
const settings = UserGuildSettings.getSettingsForScope(guild.id);
const isMuted = settings?.muted ?? false;
const muteConfig = settings?.mute_config;
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}) => {
const {i18n} = useLingui();
const settings = UserGuildSettings.getSettings(guild.id);
const settings = UserGuildSettings.getSettingsForScope(guild.id);
const suppressEveryone = settings?.suppress_everyone ?? false;
const suppressRoles = settings?.suppress_roles ?? false;
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}) => {
const {i18n} = useLingui();
const settings = UserGuildSettings.getSettings(guild.id);
const settings = UserGuildSettings.getSettingsForScope(guild.id);
const hideMutedChannels = settings?.hide_muted_channels ?? false;
const handleToggle = useCallback(
(checked: boolean) => {
const currentSettings = UserGuildSettings.getSettings(guild.id);
const currentSettings = UserGuildSettings.getSettingsForScope(guild.id);
const currentValue = currentSettings?.hide_muted_channels ?? false;
if (checked === currentValue) return;
UserGuildSettingsCommands.toggleHideMutedChannels(guild.id);
@@ -223,7 +223,7 @@ class NotificationState {
}
private shouldNotifyBasedOnSettings(channel: Channel, messageRecord: Message, currentUser: User): boolean {
const level = UserGuildSettings.resolvedMessageNotifications({
const level = UserGuildSettings.resolveEffectiveMessageNotifications({
id: channel.id,
guildId: channel.guildId,
parentId: channel.parentId ?? undefined,
@@ -261,7 +261,7 @@ class NotificationState {
return null;
}
if (
UserGuildSettings.allowNoMessages({
UserGuildSettings.resolvesToNoMessages({
id: channel.id,
guildId: channel.guildId,
parentId: channel.parentId ?? undefined,
@@ -127,7 +127,7 @@ export function updateGuildSettings(
updates: UserGuildSettingsPartial,
options?: PersistenceOptions,
): void {
UserGuildSettings.getSettings(guildId);
UserGuildSettings.getSettingsForScope(guildId);
UserGuildSettings.updateGuildSettings(guildId, updates as Partial<GatewayGuildSettings>);
scheduleUpdate(guildId, updates, options);
}
@@ -139,7 +139,7 @@ export function bulkUpdateGuildSettings(
const uniqueGuildIds = Array.from(new Set(guildIds));
if (uniqueGuildIds.length === 0) return;
for (const guildId of uniqueGuildIds) {
UserGuildSettings.getSettings(guildId);
UserGuildSettings.getSettingsForScope(guildId);
UserGuildSettings.updateGuildSettings(guildId, updates as Partial<GatewayGuildSettings>);
const key = getUpdateKey(guildId);
addPendingPayload(key, updates);
@@ -148,7 +148,7 @@ export function bulkUpdateGuildSettings(
}
export function toggleHideMutedChannels(guildId: string | null): void {
const currentSettings = UserGuildSettings.getSettings(guildId);
const currentSettings = UserGuildSettings.getSettingsForScope(guildId);
const newValue = !currentSettings.hide_muted_channels;
updateGuildSettings(guildId, {hide_muted_channels: newValue});
}
@@ -159,7 +159,7 @@ export function updateChannelOverride(
override: ChannelOverrideUpdate,
options?: PersistenceOptions,
): void {
const currentSettings = UserGuildSettings.getSettings(guildId);
const currentSettings = UserGuildSettings.getSettingsForScope(guildId);
const currentOverride = UserGuildSettings.getChannelOverride(guildId, channelId);
let newOverride: ChannelOverride | null = null;
if (override != null) {
@@ -182,7 +182,7 @@ export function bulkUpdateChannelOverrides(
): void {
const uniqueChannelIds = Array.from(new Set(channelIds));
if (uniqueChannelIds.length === 0) return;
const currentSettings = UserGuildSettings.getSettings(guildId);
const currentSettings = UserGuildSettings.getSettingsForScope(guildId);
const newChannelOverrides: Record<string, ChannelOverride> = {...(currentSettings.channel_overrides ?? {})};
for (const channelId of uniqueChannelIds) {
const currentOverride = UserGuildSettings.getChannelOverride(guildId, channelId);
@@ -196,7 +196,7 @@ export function bulkUpdateChannelOverrides(
}
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});
}
@@ -228,7 +228,7 @@ export function updateUnreadBadgesLevel(
}
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);
}
@@ -236,13 +236,13 @@ export function toggleAllCategoriesCollapsed(guildId: string | null, categoryIds
const uniqueCategoryIds = Array.from(new Set(categoryIds));
if (uniqueCategoryIds.length === 0) return;
const allCollapsed = uniqueCategoryIds.every((categoryId) =>
UserGuildSettings.isChannelCollapsed(guildId, categoryId),
UserGuildSettings.isChannelSectionCollapsed(guildId, categoryId),
);
const newCollapsedState = !allCollapsed;
for (const categoryId of uniqueCategoryIds) {
UserGuildSettings.updateChannelOverride(guildId, categoryId, {collapsed: newCollapsedState});
}
const currentSettings = UserGuildSettings.getSettings(guildId);
const currentSettings = UserGuildSettings.getSettingsForScope(guildId);
const newChannelOverrides: Record<string, ChannelOverride> = {...(currentSettings.channel_overrides ?? {})};
for (const categoryId of uniqueCategoryIds) {
const currentOverride = newChannelOverrides[categoryId];
@@ -27,7 +27,7 @@ export interface ChannelOverride {
unread_badges?: number | null;
}
interface GuildSettings {
interface StoredGuildSettings {
guild_id: string;
suppress_everyone: boolean;
suppress_roles: boolean;
@@ -63,7 +63,7 @@ export interface GatewayGuildSettings {
unread_badges?: number | null;
}
const DEFAULT_GUILD_SETTINGS: Omit<GuildSettings, 'guild_id'> = {
const DEFAULT_GUILD_SETTINGS: Omit<StoredGuildSettings, 'guild_id'> = {
suppress_everyone: false,
suppress_roles: false,
mute_scheduled_events: false,
@@ -95,7 +95,7 @@ function parseEndTime(end_time?: string): number | null {
return Number.isFinite(ms) ? ms : null;
}
function isMuted(config: {
function isMuteActive(config: {
muted?: boolean;
mute_config?: {
end_time?: string;
@@ -114,8 +114,8 @@ interface UnreadInboxReadState {
}
class UserGuildSettings {
private readonly guildSettings = observable.map<GuildId, GuildSettings>();
private readonly mutedChannels = observable.map<GuildId, Set<ChannelId>>();
private readonly guildSettings = observable.map<GuildId, StoredGuildSettings>();
private readonly mutedChannelsByGuild = observable.map<GuildId, Set<ChannelId>>();
updateCounter = 0;
private readonly pendingGuildUpdates = new Set<GuildId | null>();
private readonly guildMuteTimers = new Map<GuildId, NodeJS.Timeout>();
@@ -149,7 +149,7 @@ class UserGuildSettings {
this.pendingGuildUpdates.add(guildId as GuildId | null);
}
private getDefaultSettings(guildId: string): GuildSettings {
private getDefaultSettings(guildId: string): StoredGuildSettings {
return {
...DEFAULT_GUILD_SETTINGS,
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);
return this.guildSettings.get(key as GuildId);
}
private ensureGuildSettings(guildId: string | null): GuildSettings {
private ensureGuildSettings(guildId: string | null): StoredGuildSettings {
const key = this.storageKeyFor(guildId);
let settings = this.getStoredSettings(key);
if (settings == null) {
@@ -174,12 +174,12 @@ class UserGuildSettings {
return settings;
}
getGuildSettings(guildId: string | null): GuildSettings {
getGuildSettings(guildId: string | null): StoredGuildSettings {
const key = this.storageKeyFor(guildId);
return this.getStoredSettings(key) ?? this.getDefaultSettings(key);
}
getSettings(guildId: string | null): GuildSettings {
getSettingsForScope(guildId: string | null): StoredGuildSettings {
return this.getGuildSettings(guildId);
}
@@ -208,17 +208,17 @@ class UserGuildSettings {
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>();
for (const [channelId, override] of Object.entries(settings.channel_overrides)) {
if (isMuted(override)) {
if (isMuteActive(override)) {
set.add(channelId as ChannelId);
}
}
if (set.size > 0) {
this.mutedChannels.set(guildId as GuildId, set);
this.mutedChannelsByGuild.set(guildId as GuildId, set);
} 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.clearGuildChannelTimers(guildId);
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;
const endMs = parseEndTime(settings.mute_config?.end_time);
if (endMs != null && endMs <= Date.now()) {
@@ -296,7 +296,7 @@ class UserGuildSettings {
return settings;
}
private sanitizeChannelMutes(settings: GuildSettings): GuildSettings {
private sanitizeChannelMutes(settings: StoredGuildSettings): StoredGuildSettings {
let changed = false;
const overrides: Record<string, ChannelOverride> = {...settings.channel_overrides};
for (const [channelId, override] of Object.entries(overrides)) {
@@ -314,7 +314,7 @@ class UserGuildSettings {
const key = this.storageKeyFor(guildId);
const existing = this.ensureGuildSettings(key);
const overridesInput = 'channel_overrides' in updates ? updates.channel_overrides : existing.channel_overrides;
const merged: GuildSettings = {
const merged: StoredGuildSettings = {
...existing,
...updates,
guild_id: key,
@@ -333,7 +333,7 @@ class UserGuildSettings {
const settings = this.ensureGuildSettings(key);
const existing = settings.channel_overrides[channelId as ChannelId] ?? DEFAULT_CHANNEL_OVERRIDE(channelId);
const updated = {...existing, ...updates, channel_id: channelId};
const next: GuildSettings = {
const next: StoredGuildSettings = {
...settings,
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);
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));
this.guildSettings.set(key as GuildId, sanitized);
this.updateMutedChannelsCache(key, sanitized);
@@ -362,24 +365,24 @@ class UserGuildSettings {
this.notifyChange();
}
isSuppressEveryoneEnabled(guildId: string | null): boolean {
isEveryoneMentionSuppressed(guildId: string | null): boolean {
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;
}
isMuteScheduledEventsEnabled(guildId: string | null): boolean {
areScheduledEventsMuted(guildId: string | null): boolean {
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;
}
isMuted(guildId: string | null): boolean {
return isMuted(this.getGuildSettings(guildId));
isWholeGuildMuted(guildId: string | null): boolean {
return isMuteActive(this.getGuildSettings(guildId));
}
getGuildMessageNotifications(guildId: string | null): number {
@@ -401,7 +404,7 @@ class UserGuildSettings {
return settings.message_notifications;
}
getGuildIds(): Array<GuildId> {
getKnownGuildIds(): Array<GuildId> {
return Array.from(this.guildSettings.keys());
}
@@ -409,7 +412,7 @@ class UserGuildSettings {
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;
}
@@ -417,50 +420,59 @@ class UserGuildSettings {
return this.getGuildSettings(guildId).channel_overrides[channelId as ChannelId];
}
getChannelMessageNotifications(guildId: string | null, channelId: string): number {
getChannelNotificationLevel(guildId: string | null, channelId: string): number {
return (
this.getGuildSettings(guildId).channel_overrides[channelId as ChannelId]?.message_notifications ??
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];
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;
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 {
return this.isMuted(guildId) || this.isCategoryMuted(guildId, channelId) || this.isChannelMuted(guildId, channelId);
isMutedAtAnyLevel(guildId: string | null, channelId: string): boolean {
return (
this.isWholeGuildMuted(guildId) ||
this.isParentCategoryMuted(guildId, channelId) ||
this.isChannelDirectlyMuted(guildId, channelId)
);
}
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 {
return this.isCategoryMuted(guildId, channelId) || this.isChannelMuted(guildId, channelId);
return this.isParentCategoryMuted(guildId, channelId) || this.isChannelDirectlyMuted(guildId, channelId);
}
getMutedChannels(guildId: string): Set<ChannelId> {
return new Set(this.mutedChannels.get(guildId as GuildId) ?? []);
getMutedChannelIds(guildId: string): Set<ChannelId> {
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;
}
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 direct = this.getChannelMessageNotifications(guildId, channel.id);
const direct = this.getChannelNotificationLevel(guildId, channel.id);
if (direct !== MessageNotifications.NULL && direct !== MessageNotifications.INHERIT) return direct;
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;
}
return this.getGuildMessageNotifications(guildId);
@@ -480,7 +492,7 @@ class UserGuildSettings {
private getLockedCommunityUnreadBadgesLevel(guildId: string | null): number | 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);
if (notificationLevel === MessageNotifications.ALL_MESSAGES) return MessageNotifications.ALL_MESSAGES;
if (notificationLevel === MessageNotifications.NO_MESSAGES) return MessageNotifications.NO_MESSAGES;
@@ -527,35 +539,35 @@ class UserGuildSettings {
const guildId = channel.guildId ?? null;
return resolveUnreadInboxVisibility({
unreadBadgesLevel: this.resolvedUnreadBadgesLevel(channel),
isMuted: this.isGuildOrCategoryOrChannelMuted(guildId, channel.id),
isMuted: this.isMutedAtAnyLevel(guildId, channel.id),
hasUnread: readState.hasUnread,
hasMentions: readState.hasMentions,
});
}
resolveUnreadSetting(channel: {id: string; guildId?: string; parentId?: string; type: number}): string {
const level = this.resolvedMessageNotifications(channel);
getUnreadSettingKey(channel: {id: string; guildId?: string; parentId?: string; type: number}): string {
const level = this.resolveEffectiveMessageNotifications(channel);
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 (
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 (
!this.isGuildOrChannelMuted(channel.guildId ?? null, channel.id) &&
this.resolvedMessageNotifications(channel) === MessageNotifications.ALL_MESSAGES
this.resolveEffectiveMessageNotifications(channel) === MessageNotifications.ALL_MESSAGES
);
}
handleConnectionOpen(userGuildSettings: Array<GatewayGuildSettings>): void {
this.clearAllTimers();
this.guildSettings.clear();
this.mutedChannels.clear();
this.mutedChannelsByGuild.clear();
for (const settings of userGuildSettings) {
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 (
UserGuildSettings.isGuildOrChannelMuted(channel.guildId ?? null, channel.id) ||
UserGuildSettings.isCategoryMuted(channel.guildId ?? null, channel.id)
UserGuildSettings.isParentCategoryMuted(channel.guildId ?? null, channel.id)
) {
return;
}
@@ -6,7 +6,7 @@ import Notification from '@app/features/ui/state/Notification';
import Window from '@app/features/window/state/Window';
function applyFocusSideEffects(focused: boolean): void {
GuildReadState.handleWindowFocus();
GuildReadState.handleWindowFocused();
Notification.handleWindowFocus({focused});
if (focused) {
Idle.recordActivity();