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
+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()
]