Private
Public Access
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:
@@ -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()}
|
||||
Reference in New Issue
Block a user