Add @-mention highlighting, sidebar badge, and push customization (#39)

@username tokens in a sent message are parsed against the room's actual
members (skipping fenced/inline code, so pasted code isn't misread) and
recorded as MessageMention rows, reusing #38's read-tracking and
offline-member broadcast infrastructure rather than building a parallel
notification path:

- Sidebar: a mentioned-and-unread room shows a distinct highlight-
  colored badge instead of (not alongside) the plain unread dot --
  computed the same way as has_unread, just scoped to messages that
  mention the caller, and cleared by the same last_read_at mark-read
  flow.
- Push notifications: a mentioned offline recipient gets "X mentioned
  you: ..." instead of the generic "X: ...", still per-recipient since
  the same message can page some room members and not others.
- Message rendering: a validated @username is highlighted inline,
  implemented by turning it into a `[@username](mention:username)` link
  before markdown parsing and overriding link rendering to style
  `mention:`-scheme links as a span instead of an anchor -- reuses
  markdown-to-jsx's existing parser rather than hand-rolling text-node
  splitting.
- Composer: typing @ opens an autocomplete dropdown of matching room
  members (arrow keys to navigate, Enter/Tab/click to insert, Escape or
  moving the cursor away to dismiss).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 08:56:40 -06:00
co-authored by Claude Sonnet 5
parent bd3e621e9f
commit 4bac502b2c
23 changed files with 702 additions and 49 deletions
@@ -0,0 +1,40 @@
"""message mentions for at-mention highlighting
Revision ID: 05dbe3775e34
Revises: 8f1b4bf29c5d
Create Date: 2026-08-17 08:29:40.231511
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '05dbe3775e34'
down_revision: Union[str, Sequence[str], None] = '8f1b4bf29c5d'
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.create_table('message_mentions',
sa.Column('message_id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['message_id'], ['messages.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('message_id', 'user_id')
)
op.create_index(op.f('ix_message_mentions_user_id'), 'message_mentions', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_message_mentions_user_id'), table_name='message_mentions')
op.drop_table('message_mentions')
# ### end Alembic commands ###
+2
View File
@@ -8,6 +8,7 @@ from app.models.membership import RoomMembership, RoomRole
from app.models.message import Message
from app.models.message_file import MessageFile
from app.models.message_image import MessageImage
from app.models.message_mention import MessageMention
from app.models.message_reaction import MessageReaction
from app.models.password_reset import PasswordReset
from app.models.push_subscription import PushSubscription
@@ -27,6 +28,7 @@ __all__ = [
"Message",
"MessageFile",
"MessageImage",
"MessageMention",
"MessageReaction",
"InviteStatus",
"PasswordReset",
+15
View File
@@ -0,0 +1,15 @@
import uuid
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class MessageMention(Base):
__tablename__ = "message_mentions"
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("messages.id"), primary_key=True)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id"), primary_key=True, index=True
)
+2 -1
View File
@@ -140,8 +140,9 @@ async def list_my_rooms_endpoint(
created_at=room.created_at,
role=role,
has_unread=has_unread,
has_mention=has_mention,
)
for room, role, has_unread in rooms
for room, role, has_unread, has_mention in rooms
]
+4
View File
@@ -39,6 +39,10 @@ class MyRoomItem(RoomRead):
# computed by the router/service, not a stored column on Room itself
# (it's inherently per-viewer, unlike everything else on RoomRead).
has_unread: bool
# Unread AND mentions this user specifically -- takes visual priority
# over has_unread in the sidebar (see RoomRow.tsx), not shown alongside
# it.
has_mention: bool
class RoomMemberRead(BaseModel):
+49
View File
@@ -0,0 +1,49 @@
import re
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import RoomMembership, User
MENTION_PATTERN = re.compile(r"@([a-zA-Z0-9_.-]+)")
def _strip_code_spans(content: str) -> str:
"""Blanks out fenced code blocks and inline code spans (replacing with
equal-length whitespace, so a bare '@' in pasted code -- a decorator, an
email fragment -- doesn't page someone. Mirrors the same skip logic
frontend/src/components/MessageContent.tsx already uses for emoji
shortcode conversion."""
lines = content.split("\n")
in_fence = False
out = []
for line in lines:
if re.match(r"^\s*```", line):
in_fence = not in_fence
out.append(line)
continue
if in_fence:
out.append(line)
continue
parts = re.split(r"(`+[^`]*`+)", line)
out.append("".join(part if i % 2 == 0 else " " * len(part) for i, part in enumerate(parts)))
return "\n".join(out)
async def extract_mentioned_user_ids(
db: AsyncSession, room_id: uuid.UUID, content: str
) -> set[uuid.UUID]:
"""Resolves `@username` tokens in `content` against this room's actual
members -- a bare '@' followed by prose that happens to not match
anyone's username is just text, not a mention."""
usernames = set(MENTION_PATTERN.findall(_strip_code_spans(content)))
if not usernames:
return set()
result = await db.execute(
select(RoomMembership.user_id)
.join(User, User.id == RoomMembership.user_id)
.where(RoomMembership.room_id == room_id, User.username.in_(usernames))
)
return {row[0] for row in result.all()}
+25 -13
View File
@@ -3,7 +3,7 @@ import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Message, MessageFile, Room, RoomMembership, User
from app.models import Message, MessageFile, MessageMention, Room, RoomMembership, User
from app.schemas.message import ReactionSummary
from app.services.push_service import send_push_to_user
from app.services.webhook_service import dispatch_event
@@ -31,6 +31,11 @@ async def _notify_offline_members(
if not offline_ids:
return
result = await db.execute(
select(MessageMention.user_id).where(MessageMention.message_id == message.id)
)
mentioned_ids = {row[0] for row in result.all()}
# This is also exactly the right audience for "give this room an unread
# dot": presence.connected_user_ids(room_id) means "has this room's
# channel joined right now" -- which the client only does while the tab
@@ -39,22 +44,29 @@ async def _notify_offline_members(
# not just rooms that aren't open at all.
for user_id in offline_ids:
await broadcaster.publish_to_user(
user_id, {"type": "unread_update", "room_id": str(room_id)}
user_id,
{
"type": "unread_update",
"room_id": str(room_id),
"mentioned": user_id in mentioned_ids,
},
)
room = await db.get(Room, room_id)
if message.content:
body = f"{sender.username}: {message.content}"[:120]
elif message.file_id:
body = f"{sender.username} sent a file"
else:
body = f"{sender.username} sent an image"
payload = {
"title": f"#{room.name}" if room else "New message",
"body": body,
"room_id": str(room_id),
}
for user_id in offline_ids:
mentioned = user_id in mentioned_ids
if message.content:
prefix = f"{sender.username} mentioned you: " if mentioned else f"{sender.username}: "
body = (prefix + message.content)[:120]
elif message.file_id:
body = f"{sender.username} sent a file"
else:
body = f"{sender.username} sent an image"
payload = {
"title": f"#{room.name}" if room else "New message",
"body": body,
"room_id": str(room_id),
}
await send_push_to_user(db, user_id, payload)
+8 -1
View File
@@ -6,8 +6,9 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Message, MessageReaction
from app.models import Message, MessageMention, MessageReaction
from app.schemas.message import ReactionSummary
from app.services.mention_service import extract_mentioned_user_ids
class MessageNotFoundError(Exception):
@@ -30,6 +31,12 @@ async def create_message(
room_id=room_id, user_id=user_id, content=content, image_id=image_id, file_id=file_id
)
db.add(message)
# message.id is available immediately (a Python-side uuid4 default, not
# server-generated), so mention rows can reference it without a flush.
if content:
mentioned_ids = await extract_mentioned_user_ids(db, room_id, content)
for mentioned_id in mentioned_ids:
db.add(MessageMention(message_id=message.id, user_id=mentioned_id))
await db.commit()
await db.refresh(message)
return message
+21 -5
View File
@@ -5,7 +5,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Message, Room, RoomMembership, RoomRole, User
from app.models import Message, MessageMention, Room, RoomMembership, RoomRole, User
from app.schemas.room import RoomCreate, RoomUpdate
from app.services.email_service import send_email
@@ -81,22 +81,38 @@ async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Ro
async def list_member_rooms(
db: AsyncSession, user_id: uuid.UUID
) -> list[tuple[Room, RoomRole, bool]]:
) -> list[tuple[Room, RoomRole, bool, bool]]:
last_message_at = (
select(func.max(Message.created_at))
.where(Message.room_id == Room.id)
.correlate(Room)
.scalar_subquery()
)
# Unread AND mentions this user specifically -- a stronger signal than
# plain has_unread, surfaced as its own field so the sidebar can show a
# visually distinct badge instead of (not alongside) the plain dot.
has_unread_mention = (
select(MessageMention.message_id)
.join(Message, Message.id == MessageMention.message_id)
.where(
MessageMention.user_id == user_id,
Message.room_id == Room.id,
Message.created_at > RoomMembership.last_read_at,
)
.correlate(Room, RoomMembership)
.exists()
)
result = await db.execute(
select(Room, RoomMembership.role, RoomMembership.last_read_at, last_message_at)
select(
Room, RoomMembership.role, RoomMembership.last_read_at, last_message_at, has_unread_mention
)
.join(RoomMembership, RoomMembership.room_id == Room.id)
.where(RoomMembership.user_id == user_id)
.order_by(Room.created_at)
)
return [
(room, role, last_message_at is not None and last_message_at > last_read_at)
for room, role, last_read_at, last_message_at in result.all()
(room, role, last_message_at is not None and last_message_at > last_read_at, has_mention)
for room, role, last_read_at, last_message_at, has_mention in result.all()
]
+211
View File
@@ -0,0 +1,211 @@
import uuid
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _recv(ws) -> dict:
"""Reads the next frame, discarding member_updated presence-change
broadcasts -- another connection going online/offline is real, expected
noise these tests aren't about."""
while True:
msg = ws.receive_json()
if msg.get("type") != "member_updated":
return msg
def _register_ws(ws_client, username: str) -> dict:
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
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 _has_mention(rooms: list[dict], room_id: str) -> bool:
return next(r for r in rooms if r["id"] == room_id)["has_mention"]
def _send_and_sync(ws, room_id: str, content: str) -> dict:
"""See test_unread.py's identical helper -- a sync barrier so the
message frame's full handling (including the offline-notify step this
feature hooks into) is guaranteed complete before checking anything."""
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 test_mention_notifies_only_the_mentioned_offline_member(ws_client_factory):
instance1 = ws_client_factory()
instance2 = ws_client_factory()
instance3 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
bob = _register_ws(instance2, _unique("bob"))
instance2.post(f"/api/rooms/{room['id']}/join")
carol = _register_ws(instance3, _unique("carol"))
instance3.post(f"/api/rooms/{room['id']}/join")
with instance2.websocket_connect("/ws/chat") as bob_ws, instance3.websocket_connect(
"/ws/chat"
) as carol_ws:
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"
message = _send_and_sync(alice_ws, room["id"], f"hey @{bob['username']}, look at this")
assert message["type"] == "message"
bob_update = _recv(bob_ws)
assert bob_update == {"type": "unread_update", "room_id": room["id"], "mentioned": True}
carol_update = _recv(carol_ws)
assert carol_update == {"type": "unread_update", "room_id": room["id"], "mentioned": False}
bob_rooms = instance2.get("/api/rooms/mine").json()
assert _has_mention(bob_rooms, room["id"]) is True
carol_rooms = instance3.get("/api/rooms/mine").json()
assert _has_mention(carol_rooms, room["id"]) is False
def test_mention_of_non_member_is_not_a_mention(ws_client_factory):
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
_register_ws(instance2, _unique("bob"))
instance2.post(f"/api/rooms/{room['id']}/join")
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"
message = _send_and_sync(alice_ws, room["id"], "hey @nobody-by-this-name, anyone home?")
assert message["type"] == "message"
assert _has_mention(instance2.get("/api/rooms/mine").json(), room["id"]) is False
def test_mention_inside_code_span_is_not_a_mention(ws_client_factory):
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
bob = _register_ws(instance2, _unique("bob"))
instance2.post(f"/api/rooms/{room['id']}/join")
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"
message = _send_and_sync(alice_ws, room["id"], f"check this out: `@{bob['username']}`")
assert message["type"] == "message"
assert _has_mention(instance2.get("/api/rooms/mine").json(), room["id"]) is False
def test_mention_reading_the_room_clears_it(ws_client_factory):
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
bob = _register_ws(instance2, _unique("bob"))
instance2.post(f"/api/rooms/{room['id']}/join")
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"
message = _send_and_sync(alice_ws, room["id"], f"@{bob['username']} ping")
assert message["type"] == "message"
assert _has_mention(instance2.get("/api/rooms/mine").json(), room["id"]) is True
resp = instance2.post(f"/api/rooms/{room['id']}/read")
assert resp.status_code == 204
assert _has_mention(instance2.get("/api/rooms/mine").json(), room["id"]) is False
def test_mention_customizes_push_body(ws_client_factory, monkeypatch):
calls = []
monkeypatch.setattr("app.services.push_service.webpush", lambda **kw: calls.append(kw))
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
bob = _register_ws(instance2, _unique("bob"))
instance2.post(f"/api/rooms/{room['id']}/join")
instance2.post(
"/api/push/subscribe",
json={
"endpoint": f"https://push.example.com/ep-{bob['id']}",
"keys": {"p256dh": "p256dh-bob", "auth": "auth-bob"},
},
)
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"
message = _send_and_sync(alice_ws, room["id"], f"@{bob['username']} check this out")
assert message["type"] == "message"
assert len(calls) == 1
assert "mentioned you" in calls[0]["data"]
assert alice["username"] in calls[0]["data"]
def test_mention_requires_room_membership_to_count(ws_client_factory):
# A username that exists on the site but isn't a member of *this* room
# must not be resolvable as a mention here -- membership, not just
# username existence, is what @username matches against.
instance1 = ws_client_factory()
instance2 = ws_client_factory()
alice = _register_ws(instance1, _unique("alice"))
room = instance1.post("/api/rooms", json={"name": _unique("general")}).json()
outsider = _register_ws(instance2, _unique("outsider"))
# Deliberately not joining `room`.
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"
message = _send_and_sync(alice_ws, room["id"], f"@{outsider['username']} are you there?")
assert message["type"] == "message"
async def _mention_count() -> int:
from sqlalchemy import select
from app.models import MessageMention
async with instance1.session_factory() as session:
result = await session.execute(
select(MessageMention).where(MessageMention.message_id == uuid.UUID(message["id"]))
)
return len(result.scalars().all())
assert instance1.portal.call(_mention_count) == 0
+1 -1
View File
@@ -78,7 +78,7 @@ def test_message_marks_room_unread_and_notifies_offline_member(ws_client_factory
assert message["type"] == "message"
update = _recv(bob_ws)
assert update == {"type": "unread_update", "room_id": room["id"]}
assert update == {"type": "unread_update", "room_id": room["id"], "mentioned": False}
bob_rooms = instance2.get("/api/rooms/mine").json()
assert _has_unread(bob_rooms, room["id"]) is True