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
@@ -0,0 +1,32 @@
"""room email notifications opt-in
Revision ID: 05b28b0a2261
Revises: e2fc4d65f93e
Create Date: 2026-08-28 19:34:23.507200
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '05b28b0a2261'
down_revision: Union[str, Sequence[str], None] = 'e2fc4d65f93e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('room_memberships', sa.Column('email_notifications', sa.Boolean(), server_default='false', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('room_memberships', 'email_notifications')
# ### end Alembic commands ###
+8 -1
View File
@@ -2,7 +2,7 @@ import enum
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, PrimaryKeyConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base from app.models.base import Base
@@ -42,6 +42,13 @@ class RoomMembership(Base):
# arrives in the room, or when find_or_create_dm resolves back to it -- # arrives in the room, or when find_or_create_dm resolves back to it --
# both count as the conversation being active again. # both count as the conversation being active again.
hidden_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) hidden_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# #67: opt-in, per-member -- email when a message in this room mentions
# them while they're offline. Deliberately separate from #66's DM
# emails (always-on, no toggle) rather than a shared flag, since DMs
# are explicitly out of scope for this setting (see message_events.py).
email_notifications: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", nullable=False
)
room = relationship("Room", back_populates="memberships") room = relationship("Room", back_populates="memberships")
user = relationship("User") user = relationship("User")
+18 -1
View File
@@ -25,6 +25,7 @@ from app.schemas.room import (
RoomMemberAdd, RoomMemberAdd,
RoomMemberRead, RoomMemberRead,
RoomMemberRoleUpdate, RoomMemberRoleUpdate,
RoomNotificationSettingsUpdate,
RoomRead, RoomRead,
RoomUpdate, RoomUpdate,
StartDmRequest, StartDmRequest,
@@ -72,6 +73,7 @@ from app.services.room_service import (
list_room_members, list_room_members,
mark_room_read, mark_room_read,
remove_member, remove_member,
set_room_email_notifications,
transfer_ownership, transfer_ownership,
update_room, update_room,
) )
@@ -179,6 +181,7 @@ async def list_my_rooms_endpoint(
role=role, role=role,
has_unread=has_unread, has_unread=has_unread,
has_mention=has_mention, has_mention=has_mention,
email_notifications=email_notifications,
dm_partner=( dm_partner=(
DmPartnerInfo( DmPartnerInfo(
user_id=partner.id, user_id=partner.id,
@@ -191,7 +194,7 @@ async def list_my_rooms_endpoint(
else None else None
), ),
) )
for room, role, has_unread, has_mention, partner in rooms for room, role, has_unread, has_mention, email_notifications, partner in rooms
] ]
@@ -293,6 +296,20 @@ async def mark_room_read_endpoint(
await mark_room_read(db, room_id, current_user.id) await mark_room_read(db, room_id, current_user.id)
@router.patch("/{room_id}/notifications", status_code=204)
async def update_room_notifications_endpoint(
room_id: uuid.UUID,
data: RoomNotificationSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_member(room_id, current_user, db)
try:
await set_room_email_notifications(db, room_id, current_user.id, data.email_notifications)
except CannotModifyDmError:
raise HTTPException(status_code=400, detail="Email notifications aren't available for DMs")
def _member_status(user: User, online_ids: set[uuid.UUID]) -> str: def _member_status(user: User, online_ids: set[uuid.UUID]) -> str:
# appear_offline always wins, regardless of actual connection -- that's # appear_offline always wins, regardless of actual connection -- that's
# the whole point of the override (lurking in a room undetected). # the whole point of the override (lurking in a room undetected).
+8
View File
@@ -63,12 +63,20 @@ class MyRoomItem(RoomRead):
# avatar, not this room's internal `name`) without a second fetch per # avatar, not this room's internal `name`) without a second fetch per
# row. None for a regular room. # row. None for a regular room.
dm_partner: DmPartnerInfo | None = None dm_partner: DmPartnerInfo | None = None
# #67: this viewer's own opt-in for "email me when mentioned here while
# offline" -- always false for a DM (see message_events.py, deliberately
# out of scope; DMs already get #66's automatic offline email).
email_notifications: bool = False
class StartDmRequest(BaseModel): class StartDmRequest(BaseModel):
other_user_id: uuid.UUID other_user_id: uuid.UUID
class RoomNotificationSettingsUpdate(BaseModel):
email_notifications: bool
class RoomMemberRead(BaseModel): class RoomMemberRead(BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
username: str username: str
+74
View File
@@ -249,6 +249,79 @@ async def _maybe_email_dm_notification(
) )
async def _maybe_email_room_mention_notifications(
db: AsyncSession,
global_presence: GlobalPresence,
base_url: str,
room_id: uuid.UUID,
sender: User,
message: Message,
) -> None:
"""#67: opt-in, per-room email on mention -- deliberately scoped to
regular rooms only (see set_room_email_notifications: DMs already get
#66's always-on offline email, no separate toggle). Debounced the same
shape as #66 -- only the first unread mention in this room emails, not
one per mention while the recipient is away.
"""
room = await db.get(Room, room_id)
if room is None or room.is_dm:
return
result = await db.execute(
select(MessageMention.user_id).where(MessageMention.message_id == message.id)
)
mentioned_ids = {row[0] for row in result.all()} - {sender.id}
if not mentioned_ids:
return
for user_id in mentioned_ids:
membership_result = await db.execute(
select(RoomMembership).where(
RoomMembership.room_id == room_id, RoomMembership.user_id == user_id
)
)
membership = membership_result.scalar_one_or_none()
if membership is None or not membership.email_notifications:
continue
recipient = await db.get(User, user_id)
if recipient is None:
continue
# appear_offline always wins here too, same as _maybe_email_dm_notification.
if not recipient.appear_offline and await global_presence.is_online(recipient.id):
continue
already_unread_mention = await db.execute(
select(Message.id)
.join(MessageMention, MessageMention.message_id == Message.id)
.where(
MessageMention.user_id == user_id,
Message.room_id == room_id,
Message.id != message.id,
Message.created_at > membership.last_read_at,
)
.limit(1)
)
if already_unread_mention.scalar_one_or_none() is not None:
continue
body_line = (
f"{sender.username} mentioned you: {message.content[:200]}"
if message.content
else f"{sender.username} mentioned you"
)
link = f"{base_url.rstrip('/')}/rooms/{room_id}"
await send_email(
db,
recipient.email,
f"New mention in #{room.name}",
[body_line],
cta_label="Open room",
cta_url=link,
theme_user=recipient,
)
async def broadcast_new_message( async def broadcast_new_message(
db: AsyncSession, db: AsyncSession,
broadcaster: Broadcaster, broadcaster: Broadcaster,
@@ -288,6 +361,7 @@ async def broadcast_new_message(
) )
await _notify_offline_members(db, broadcaster, presence, focus_presence, room_id, sender, message) await _notify_offline_members(db, broadcaster, presence, focus_presence, room_id, sender, message)
await _maybe_email_dm_notification(db, global_presence, base_url, room_id, sender, message) await _maybe_email_dm_notification(db, global_presence, base_url, room_id, sender, message)
await _maybe_email_room_mention_notifications(db, global_presence, base_url, room_id, sender, message)
await dispatch_event(db, "message.created", room_id, payload) await dispatch_event(db, "message.created", room_id, payload)
_maybe_fetch_link_preview(broadcaster, room_id, message) _maybe_fetch_link_preview(broadcaster, room_id, message)
+25 -3
View File
@@ -182,7 +182,7 @@ async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Ro
async def list_member_rooms( async def list_member_rooms(
db: AsyncSession, user_id: uuid.UUID db: AsyncSession, user_id: uuid.UUID
) -> list[tuple[Room, RoomRole, bool, bool, User | None]]: ) -> list[tuple[Room, RoomRole, bool, bool, bool, User | None]]:
last_message_at = ( last_message_at = (
select(func.max(Message.created_at)) select(func.max(Message.created_at))
.where(Message.room_id == Room.id) .where(Message.room_id == Room.id)
@@ -205,7 +205,12 @@ async def list_member_rooms(
) )
result = await db.execute( result = await db.execute(
select( select(
Room, RoomMembership.role, RoomMembership.last_read_at, last_message_at, has_unread_mention Room,
RoomMembership.role,
RoomMembership.last_read_at,
last_message_at,
has_unread_mention,
RoomMembership.email_notifications,
) )
.join(RoomMembership, RoomMembership.room_id == Room.id) .join(RoomMembership, RoomMembership.room_id == Room.id)
.where(RoomMembership.user_id == user_id, RoomMembership.hidden_at.is_(None)) .where(RoomMembership.user_id == user_id, RoomMembership.hidden_at.is_(None))
@@ -238,9 +243,10 @@ async def list_member_rooms(
role, role,
last_message_at is not None and last_message_at > last_read_at, last_message_at is not None and last_message_at > last_read_at,
has_mention, has_mention,
email_notifications,
partners_by_room.get(room.id), partners_by_room.get(room.id),
) )
for room, role, last_read_at, last_message_at, has_mention in rows for room, role, last_read_at, last_message_at, has_mention, email_notifications in rows
] ]
@@ -486,6 +492,22 @@ async def mark_room_read(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUI
await db.commit() await db.commit()
async def set_room_email_notifications(
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, enabled: bool
) -> None:
"""#67: DMs are deliberately excluded -- they already get #66's
automatic offline email with no opt-in needed, and this setting only
makes sense for a regular room's mention-based notifications."""
room = await db.get(Room, room_id)
if room is None:
raise RoomNotFoundError()
if room.is_dm:
raise CannotModifyDmError()
membership = await _get_membership(db, room_id, user_id)
membership.email_notifications = enabled
await db.commit()
async def leave_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None: async def leave_room(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> None:
membership = await _get_membership(db, room_id, user_id) membership = await _get_membership(db, room_id, user_id)
if membership.role == RoomRole.owner: if membership.role == RoomRole.owner:
@@ -0,0 +1,225 @@
import uuid
from app.models import SmtpSettings
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _fake_smtp(monkeypatch):
calls = []
async def fake_send(message, **kwargs):
calls.append({"message": message, **kwargs})
monkeypatch.setattr("app.services.email_service.aiosmtplib.send", fake_send)
# See test_dm_email_notifications.py's identical helper for why this
# monkeypatches get_smtp_settings directly instead of configuring a real
# row through the admin endpoint.
fake_settings = SmtpSettings(
host="smtp.example.com",
port=587,
username="bot",
password_encrypted=None,
from_address="noreply@example.com",
use_tls=True,
)
async def fake_get_smtp_settings(db):
return fake_settings
monkeypatch.setattr("app.services.email_service.get_smtp_settings", fake_get_smtp_settings)
return calls
def _register_ws(ws_client, username: str) -> dict:
async def _seed():
async with ws_client.session_factory() as session:
await register_user(
session,
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
)
ws_client.portal.call(_seed)
resp = ws_client.post(
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
)
assert resp.status_code == 200, resp.text
return resp.json()
def _send_and_sync(ws, room_id: str, content: str) -> dict:
"""See test_dm_email_notifications.py's identical helper -- the
"message" ack alone proves nothing about whether the email step
(awaited afterward in the same handler) has finished; a second,
idempotent join's ack only arrives once the whole frame is done."""
ws.send_json({"type": "message", "room_id": room_id, "content": content})
message = ws.receive_json()
ws.send_json({"type": "join", "room_id": room_id})
assert ws.receive_json()["type"] == "joined"
return message
def _subscribe(ws_client, room_id: str, username: str, password: str = "password123") -> None:
login = ws_client.post(
"/api/auth/login", json={"username_or_email": username, "password": password}
)
assert login.status_code == 200, login.text
resp = ws_client.patch(f"/api/rooms/{room_id}/notifications", json={"email_notifications": True})
assert resp.status_code == 204, resp.text
def test_room_mention_emails_offline_subscribed_member(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
instance2.post(f"/api/rooms/{room['id']}/join")
_subscribe(instance2, room["id"], bob["username"])
# bob never connects via WS -- genuinely offline.
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], f"hey @{bob['username']}, look at this")
assert len(calls) == 1
email = calls[0]["message"]
assert email["To"] == bob["email"]
assert f"New mention in #{room['name']}" in email["Subject"]
body = email.get_body(preferencelist=("plain",)).get_content()
assert alice["username"] in body
assert f"/rooms/{room['id']}" in body
def test_room_message_without_mention_does_not_email_subscribed_member(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
_register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
instance2.post(f"/api/rooms/{room['id']}/join")
_subscribe(instance2, room["id"], bob["username"])
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], "hello room, no mention here")
assert calls == []
def test_room_mention_does_not_email_unsubscribed_member(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
instance2.post(f"/api/rooms/{room['id']}/join")
# bob never opts in -- email_notifications defaults to False.
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], f"hey @{bob['username']}")
assert calls == []
def test_room_mention_does_not_email_online_subscribed_member(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
_register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
instance2.post(f"/api/rooms/{room['id']}/join")
_subscribe(instance2, room["id"], bob["username"])
with instance2.websocket_connect("/ws/chat"):
# bob has an open connection -- genuinely online -- even though he
# never joins this room's own channel.
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], f"hey @{bob['username']}")
assert calls == []
def test_room_mention_email_debounced_to_first_unread_then_resets_after_read(ws_client_factory, monkeypatch):
calls = _fake_smtp(monkeypatch)
instance1 = ws_client_factory()
instance2 = ws_client_factory()
_register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
instance2.post(f"/api/rooms/{room['id']}/join")
_subscribe(instance2, room["id"], bob["username"])
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], f"@{bob['username']} first mention")
assert len(calls) == 1
# A second mention while bob still hasn't read the first -- no
# second email for the same burst.
_send_and_sync(alice_ws, room["id"], f"@{bob['username']} second mention")
assert len(calls) == 1
instance2.post(
"/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
)
read_resp = instance2.post(f"/api/rooms/{room['id']}/read")
assert read_resp.status_code == 204
with instance1.websocket_connect("/ws/chat") as alice_ws:
alice_ws.send_json({"type": "join", "room_id": room["id"]})
assert alice_ws.receive_json()["type"] == "joined"
_send_and_sync(alice_ws, room["id"], f"@{bob['username']} third mention")
assert len(calls) == 2
def test_enabling_notifications_rejected_for_dm(ws_client_factory):
instance1 = ws_client_factory()
instance2 = ws_client_factory()
_register_ws(instance1, _unique("alice"))
bob = _register_ws(instance2, _unique("bob"))
dm = instance1.post("/api/rooms/dm", json={"other_user_id": bob["id"]}).json()
resp = instance1.patch(f"/api/rooms/{dm['id']}/notifications", json={"email_notifications": True})
assert resp.status_code == 400
def test_notifications_setting_reflected_in_my_rooms(ws_client_factory):
instance1 = ws_client_factory()
_register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
rooms = instance1.get("/api/rooms/mine").json()
mine = next(r for r in rooms if r["id"] == room["id"])
assert mine["email_notifications"] is False
resp = instance1.patch(f"/api/rooms/{room['id']}/notifications", json={"email_notifications": True})
assert resp.status_code == 204
rooms = instance1.get("/api/rooms/mine").json()
mine = next(r for r in rooms if r["id"] == room["id"])
assert mine["email_notifications"] is True
+10
View File
@@ -68,6 +68,16 @@ export function hideDm(roomId: string): Promise<void> {
return apiFetch<void>(`/api/rooms/${roomId}/hide`, { method: 'POST' }) 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[]> { export function listRoomMembers(roomId: string): Promise<RoomMember[]> {
return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`) return apiFetch<RoomMember[]>(`/api/rooms/${roomId}/members`)
} }
+38 -1
View File
@@ -13,6 +13,7 @@ import {
removeMember, removeMember,
transferOwnership, transferOwnership,
updateRoom, updateRoom,
updateRoomNotifications,
} from '../api/rooms' } from '../api/rooms'
import { import {
createEventSubscription, createEventSubscription,
@@ -106,6 +107,8 @@ export function RoomInfoPanel({
const [descDraft, setDescDraft] = useState(room.description ?? '') const [descDraft, setDescDraft] = useState(room.description ?? '')
const [isPrivateDraft, setIsPrivateDraft] = useState(room.is_private) const [isPrivateDraft, setIsPrivateDraft] = useState(room.is_private)
const [roomError, setRoomError] = useState<string | null>(null) 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 [integrationsOpen, setIntegrationsOpen] = useState(false)
const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncoming[]>([]) const [incomingWebhooks, setIncomingWebhooks] = useState<WebhookIncoming[]>([])
@@ -129,6 +132,7 @@ export function RoomInfoPanel({
setNameDraft(room.name) setNameDraft(room.name)
setDescDraft(room.description ?? '') setDescDraft(room.description ?? '')
setIsPrivateDraft(room.is_private) setIsPrivateDraft(room.is_private)
setEmailNotifications(room.email_notifications)
if (canManage) { if (canManage) {
listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([])) listIncomingWebhooks(room.id).then(setIncomingWebhooks).catch(() => setIncomingWebhooks([]))
listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([])) listEventSubscriptions(room.id).then(setEventSubscriptions).catch(() => setEventSubscriptions([]))
@@ -138,7 +142,7 @@ export function RoomInfoPanel({
setEventSubscriptions([]) setEventSubscriptions([])
setDirectoryUsers([]) 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(() => { useEffect(() => {
// Fetched lazily (only once expanded), not alongside the section above // 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))) .catch((err) => setAttachmentsError(err instanceof ApiError ? err.message : String(err)))
}, [filesOpen, room.id]) }, [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) { async function handleAddMember(target: UserDirectoryEntry) {
setInviteError(null) setInviteError(null)
try { try {
@@ -377,6 +394,26 @@ export function RoomInfoPanel({
})} })}
</div> </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"> <div className="room-info-section">
<button type="button" className="room-info-settings-toggle" onClick={() => setFilesOpen((v) => !v)}> <button type="button" className="room-info-settings-toggle" onClick={() => setFilesOpen((v) => !v)}>
<DisclosureChevron open={filesOpen} /> Files <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 // #52: the other participant, only for is_dm rooms -- see backend
// schemas/room.py's MyRoomItem for why this is precomputed server-side. // schemas/room.py's MyRoomItem for why this is precomputed server-side.
dm_partner: DmPartnerInfo | null 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 { export interface RoomMember {