From 368e984d17cc269f26886bd95beb927729133a0e Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Tue, 1 Sep 2026 22:43:57 +0000 Subject: [PATCH] Add a mute setting for rooms (#391) Co-authored-by: Coracle-Bot --- src/app/components/RoomDetail.svelte | 38 ++++++++++++++++----- src/app/components/SpaceMenuRoomItem.svelte | 2 +- src/app/notifications.ts | 8 +++-- src/app/push/adapters/common.ts | 21 +++++++----- src/app/settings.ts | 36 ++++++++++++++++++- 5 files changed, 84 insertions(+), 21 deletions(-) diff --git a/src/app/components/RoomDetail.svelte b/src/app/components/RoomDetail.svelte index fe65ddbf..aa98007e 100644 --- a/src/app/components/RoomDetail.svelte +++ b/src/app/components/RoomDetail.svelte @@ -10,6 +10,7 @@ import Microphone from "@assets/icons/microphone.svg?dataurl" import Bookmark from "@assets/icons/bookmark.svg?dataurl" import Bell from "@assets/icons/bell.svg?dataurl" + import BellOff from "@assets/icons/bell-off.svg?dataurl" import Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" import MenuButton from "@lib/components/MenuButton.svelte" @@ -25,7 +26,12 @@ import RoomImage from "@app/components/RoomImage.svelte" import {roomLists, rooms} from "@app/core" import {deriveRoomMembers, deriveUserIsRoomAdmin, deriveUserRooms} from "@app/rooms" - import {deriveShouldNotify, toggleRoomNotifications} from "@app/settings" + import { + deriveIsMuted, + deriveShouldNotify, + toggleRoomMuted, + toggleRoomNotifications, + } from "@app/settings" import {pushModal} from "@app/modal" type Props = { @@ -43,6 +49,7 @@ const meta = $derived($room?.meta) const isFavorite = $derived($userRooms.includes(h)) const shouldNotify = deriveShouldNotify(url, h) + const isMuted = deriveIsMuted(url, h) const back = () => history.back() @@ -61,6 +68,10 @@ const toggleShouldNotify = () => { toggleRoomNotifications(url, h) } + + const toggleMuted = () => { + toggleRoomMuted(url, h) + } @@ -143,6 +154,23 @@
Room Settings
+
+ + Favorite +
+ +
+
+
+ +
+ Mute + Hide alerts and unread badges for this room +
+
+ +
+
Notifications @@ -151,15 +179,9 @@ type="checkbox" class="toggle" checked={$shouldNotify} + disabled={$isMuted} onchange={toggleShouldNotify} />
-
-
- - Favorite -
- -
diff --git a/src/app/components/SpaceMenuRoomItem.svelte b/src/app/components/SpaceMenuRoomItem.svelte index 70297125..eccd59b9 100644 --- a/src/app/components/SpaceMenuRoomItem.svelte +++ b/src/app/components/SpaceMenuRoomItem.svelte @@ -26,7 +26,7 @@ const shouldNotifyForSpace = deriveShouldNotify(url) const shouldNotifyForRoom = deriveShouldNotify(url, h) const showDifferenceIcon = $derived($shouldNotifyForRoom !== $shouldNotifyForSpace) - const notification = $derived($shouldNotifyForRoom ? $notifications.has(path) : false) + const notification = $derived($notifications.has(path)) const roomName = $derived($room?.meta?.name() || h) diff --git a/src/app/notifications.ts b/src/app/notifications.ts index e95f6f0b..a7468f1a 100644 --- a/src/app/notifications.ts +++ b/src/app/notifications.ts @@ -30,7 +30,7 @@ import {deriveEventsByIdByUrl} from "@app/repository" import {app, fromApp} from "@app/core" import {makeRoomPath, makeSpaceChatPath, makeChatPath, makeContentPath} from "@app/routes" import {CONTENT_KINDS, makeCommentFilter} from "@app/content" -import {notificationSettings} from "@app/settings" +import {getIsMuted, notificationSettings, userSettingsValues} from "@app/settings" import {chatsById} from "@app/chats" import {dufflepud, DUFFLEPUD_URL, PLATFORM_RELAYS} from "@app/env" import {kv} from "@app/storage" @@ -235,11 +235,12 @@ export const latestActivityByPath = derived( {kinds: [MESSAGE, ...CONTENT_KINDS]}, makeCommentFilter(CONTENT_KINDS), ]), + userSettingsValues, ], identity, ), ), - ([$app, $chatsById, $relays, $roomLists, eventsByIdByUrl]) => { + ([$app, $chatsById, $relays, $roomLists, eventsByIdByUrl, $settings]) => { const activity = new Map() for (const {pubkeys, messages} of $chatsById.values()) { @@ -256,7 +257,8 @@ export const latestActivityByPath = derived( if ($relays.get(url)?.hasNip(29)) { for (const [h, [latestEvent]] of groupBy(e => tagValue(tagSpec("h"), e.tags), events)) { - if (h) { + // A muted room is left out entirely, so it can't light up its own badge or the space's + if (h && !getIsMuted($settings, url, h)) { activity.set(makeRoomPath(url, h), latestEvent) } } diff --git a/src/app/push/adapters/common.ts b/src/app/push/adapters/common.ts index 84c5fc22..c70ab01d 100644 --- a/src/app/push/adapters/common.ts +++ b/src/app/push/adapters/common.ts @@ -8,7 +8,7 @@ import { } from "@capacitor/push-notifications" import type {PluginListenerHandle} from "@capacitor/core" import {goto} from "$app/navigation" -import {assoc, call, now, on, poll, spec, throttle} from "@welshman/lib" +import {assoc, call, now, on, poll, spec, throttle, uniq} from "@welshman/lib" import {LOCAL_RELAY_URL} from "@welshman/net" import type {RepositoryUpdate} from "@welshman/net" import { @@ -24,7 +24,7 @@ import {merged, withGetter} from "@welshman/store" import {User} from "@welshman/app" import {app, messagingRelayLists, network, roomLists} from "@app/core" import {DM_KINDS, CONTENT_KINDS, makeCommentFilter} from "@app/content" -import {notificationSettings, shouldNotify, userSettingsValues} from "@app/settings" +import {getMutedRooms, notificationSettings, shouldNotify, userSettingsValues} from "@app/settings" import {makeEventPath, goToSpace} from "@app/routes" export type PushSubscription = { @@ -165,23 +165,28 @@ export const syncRelaySubscriptions = ( notificationSettings, userSettingsValues, ]).subscribe( - throttle(3000, ([$spaceUrls, {spaces, mentions}, {alerts}]) => { + throttle(3000, ([$spaceUrls, {spaces, mentions}, $settings]) => { const baseFilters = [{kinds: [MESSAGE, ...CONTENT_KINDS]}, makeCommentFilter(CONTENT_KINDS)] for (const url of $spaceUrls) { - const {notify = true, exceptions = []} = alerts.find(spec({url})) || {} + const {notify = true, exceptions = []} = $settings.alerts.find(spec({url})) || {} + const muted = getMutedRooms($settings, url) const filters: Filter[] = [] const ignore: Filter[] = [] if (spaces) { if (notify) { - if (exceptions.length > 0) { - ignore.push({"#h": exceptions}) + const skipped = uniq([...exceptions, ...muted]) + + if (skipped.length > 0) { + ignore.push({"#h": skipped}) } filters.push(...baseFilters) } else { - if (exceptions.length > 0) { - filters.push(...baseFilters.map(f => ({...f, "#h": exceptions}))) + const included = exceptions.filter(h => !muted.includes(h)) + + if (included.length > 0) { + filters.push(...baseFilters.map(f => ({...f, "#h": included}))) } } } diff --git a/src/app/settings.ts b/src/app/settings.ts index 224eb3f1..269249eb 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -20,6 +20,8 @@ export type SpaceNotificationSettings = { url: string notify: boolean exceptions: string[] + // Absent in settings published before muting existed + muted?: string[] } export type SettingsValues = { @@ -113,7 +115,18 @@ export const zapAmounts = derived(userSettingsValues, $settings => $settings.zap export const getSetting = (key: K) => userSettingsValues.get()[key] -export const getShouldNotify = ({alerts}: SettingsValues, url: string, h?: string) => { +export const getMutedRooms = ({alerts}: SettingsValues, url: string) => + alerts.find(spec({url}))?.muted ?? [] + +export const getIsMuted = (settings: SettingsValues, url: string, h: string) => + getMutedRooms(settings, url).includes(h) + +export const deriveIsMuted = (url: string, h: string) => + derived(userSettingsValues, $settings => getIsMuted($settings, url, h)) + +// The stored notification preference, ignoring mute. Toggling notifications writes this, so it is +// preserved while a room is muted and comes back as it was when the room is unmuted. +export const getNotifyPreference = ({alerts}: SettingsValues, url: string, h?: string) => { const pref = alerts.find(spec({url})) if (!pref) return true @@ -122,6 +135,13 @@ export const getShouldNotify = ({alerts}: SettingsValues, url: string, h?: strin return pref.notify ? !pref.exceptions.includes(h) : pref.exceptions.includes(h) } +// Muting a room is stronger than turning its notifications off: it forces notifications off and +// also hides unread badges, which `notifications` handles by dropping muted rooms from the +// activity it tracks. This is what the room settings toggle displays; it is only editable when the +// room is not muted, so what it shows and what clicking it writes cannot disagree. +export const getShouldNotify = (settings: SettingsValues, url: string, h?: string) => + h && getIsMuted(settings, url, h) ? false : getNotifyPreference(settings, url, h) + export const shouldNotify = (url: string, h?: string) => getShouldNotify(userSettingsValues.get(), url, h) @@ -187,3 +207,17 @@ export const toggleRoomNotifications = (url: string, h: string) => { return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [h]}]}) } + +export const toggleRoomMuted = (url: string, h: string) => { + const alerts = getSetting("alerts") + const existing = alerts.find(spec({url})) + + if (existing) { + const current = existing.muted ?? [] + const muted = current.includes(h) ? remove(h, current) : append(h, current) + + return publishSettings({alerts: alerts.map(s => (s.url === url ? {...s, muted} : s))}) + } + + return publishSettings({alerts: [...alerts, {url, notify: true, exceptions: [], muted: [h]}]}) +}