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()}
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user