Add a mute setting for rooms (#391)

Co-authored-by: Coracle-Bot <npub1klq6260@nostr.local>
This commit is contained in:
Coracle-Bot 2026-09-01 22:43:57 +00:00 committed by hodlbod
parent f83019a401
commit 368e984d17
5 changed files with 84 additions and 21 deletions

View file

@ -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)
}
</script>
<Modal>
@ -143,6 +154,23 @@
<div class="card card-sm flex flex-col gap-4">
<strong class="text-lg">Room Settings</strong>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon={Bookmark} />
<span>Favorite</span>
</div>
<input type="checkbox" class="toggle" checked={isFavorite} onchange={toggleFavorite} />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-2">
<Icon icon={BellOff} />
<div class="flex min-w-0 flex-col">
<span>Mute</span>
<span class="text-sm opacity-75">Hide alerts and unread badges for this room</span>
</div>
</div>
<input type="checkbox" class="toggle" checked={$isMuted} onchange={toggleMuted} />
</div>
<div class="flex items-center justify-between" class:opacity-50={$isMuted}>
<div class="flex items-center gap-2">
<Icon icon={Bell} />
<span>Notifications</span>
@ -151,15 +179,9 @@
type="checkbox"
class="toggle"
checked={$shouldNotify}
disabled={$isMuted}
onchange={toggleShouldNotify} />
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon={Bookmark} />
<span>Favorite</span>
</div>
<input type="checkbox" class="toggle" checked={isFavorite} onchange={toggleFavorite} />
</div>
</div>
</ModalBody>
<ModalFooter>

View file

@ -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)
</script>

View file

@ -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<string, TrustedEvent>()
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)
}
}

View file

@ -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})))
}
}
}

View file

@ -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 = <K extends keyof SettingsValues>(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]}]})
}