Add per-room opt-in email notifications on mention (#67)

Lets a member subscribe to email when they're @mentioned in a room
while offline, alongside #66's always-on DM email. Debounced the same
way #66 is (one email per unread burst, not one per mention), and
deliberately scoped to regular rooms only -- DMs already have #66's
automatic offline email with no separate toggle needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 19:45:04 -06:00
co-authored by Claude Sonnet 5
parent 3dabf0022c
commit 250bb862f6
10 changed files with 441 additions and 6 deletions
+10
View File
@@ -68,6 +68,16 @@ export function hideDm(roomId: string): Promise<void> {
return apiFetch<void>(`/api/rooms/${roomId}/hide`, { method: 'POST' })
}
// #67: per-viewer opt-in for email-on-mention in this room. 400s for a DM
// (backend rejects it -- see set_room_email_notifications), so callers
// should only expose the toggle for a non-DM room.
export function updateRoomNotifications(roomId: string, emailNotifications: boolean): Promise<void> {
return apiFetch<void>(`/api/rooms/${roomId}/notifications`, {
method: 'PATCH',
body: JSON.stringify({ email_notifications: emailNotifications }),
})
}
export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
}
+38 -1
View File
@@ -13,6 +13,7 @@ import {
removeMember,
transferOwnership,
updateRoom,
updateRoomNotifications,
} from '../api/rooms'
import {
createEventSubscription,
@@ -106,6 +107,8 @@ export function RoomInfoPanel({
const [descDraft, setDescDraft] = useState(room.description ?? '')
const [isPrivateDraft, setIsPrivateDraft] = useState(room.is_private)
const [roomError, setRoomError] = useState<string | null>(null)
const [emailNotifications, setEmailNotifications] = useState(room.email_notifications)
const [notificationsError, setNotificationsError] = useState<string | null>(null)
const [integrationsOpen, setIntegrationsOpen] = useState(false)
const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncoming[]>([])
@@ -129,6 +132,7 @@ export function RoomInfoPanel({
setNameDraft(room.name)
setDescDraft(room.description ?? '')
setIsPrivateDraft(room.is_private)
setEmailNotifications(room.email_notifications)
if (canManage) {
listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([]))
listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([]))
@@ -138,7 +142,7 @@ export function RoomInfoPanel({
setEventSubscriptions([])
setDirectoryUsers([])
}
}, [room.id, room.name, room.description, room.is_private, canManage])
}, [room.id, room.name, room.description, room.is_private, room.email_notifications, canManage])
useEffect(() => {
// Fetched lazily (only once expanded), not alongside the section above
@@ -152,6 +156,19 @@ export function RoomInfoPanel({
.catch((err) => setAttachmentsError(err instanceof ApiError ? err.message : String(err)))
}, [filesOpen, room.id])
async function handleToggleEmailNotifications(enabled: boolean) {
const previous = emailNotifications
setEmailNotifications(enabled)
setNotificationsError(null)
try {
await updateRoomNotifications(room.id, enabled)
onRoomUpdated()
} catch (err) {
setEmailNotifications(previous)
setNotificationsError(err instanceof ApiError ? err.message : String(err))
}
}
async function handleAddMember(target: UserDirectoryEntry) {
setInviteError(null)
try {
@@ -377,6 +394,26 @@ export function RoomInfoPanel({
})}
</div>
{!room.is_dm && (
<div className="room-info-section">
<div className="toggle-row">
<div className="toggle-label">
<span className="t">Email me on mentions</span>
<span className="d">Sent only while you're offline</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={emailNotifications}
onChange={(e) => handleToggleEmailNotifications(e.target.checked)}
/>
<span className="track" />
</label>
</div>
{notificationsError && <p className="room-info-error">{notificationsError}</p>}
</div>
)}
<div className="room-info-section">
<button type="button" className="room-info-settings-toggle" onClick={() => setFilesOpen((v) => !v)}>
<DisclosureChevron open={filesOpen} /> Files
+3
View File
@@ -87,6 +87,9 @@ export interface MyRoomItem extends Room {
// #52: the other participant, only for is_dm rooms -- see backend
// schemas/room.py's MyRoomItem for why this is precomputed server-side.
dm_partner: DmPartnerInfo | null
// #67: this viewer's own opt-in for "email me when mentioned here while
// offline" -- always false for a DM (see backend's message_events.py).
email_notifications: boolean
}
export interface RoomMember {