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 import Message
from app.models.message_file import MessageFile from app.models.message_file import MessageFile
from app.models.message_image import MessageImage from app.models.message_image import MessageImage
from app.models.message_mention import MessageMention
from app.models.message_reaction import MessageReaction from app.models.message_reaction import MessageReaction
from app.models.password_reset import PasswordReset from app.models.password_reset import PasswordReset
from app.models.push_subscription import PushSubscription from app.models.push_subscription import PushSubscription
@@ -27,6 +28,7 @@ __all__ = [
"Message", "Message",
"MessageFile", "MessageFile",
"MessageImage", "MessageImage",
"MessageMention",
"MessageReaction", "MessageReaction",
"InviteStatus", "InviteStatus",
"PasswordReset", "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, created_at=room.created_at,
role=role, role=role,
has_unread=has_unread, 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 # computed by the router/service, not a stored column on Room itself
# (it's inherently per-viewer, unlike everything else on RoomRead). # (it's inherently per-viewer, unlike everything else on RoomRead).
has_unread: bool 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): 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()}
+16 -4
View File
@@ -3,7 +3,7 @@ import uuid
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.schemas.message import ReactionSummary
from app.services.push_service import send_push_to_user from app.services.push_service import send_push_to_user
from app.services.webhook_service import dispatch_event from app.services.webhook_service import dispatch_event
@@ -31,6 +31,11 @@ async def _notify_offline_members(
if not offline_ids: if not offline_ids:
return 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 # 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 # dot": presence.connected_user_ids(room_id) means "has this room's
# channel joined right now" -- which the client only does while the tab # channel joined right now" -- which the client only does while the tab
@@ -39,12 +44,20 @@ async def _notify_offline_members(
# not just rooms that aren't open at all. # not just rooms that aren't open at all.
for user_id in offline_ids: for user_id in offline_ids:
await broadcaster.publish_to_user( 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) room = await db.get(Room, room_id)
for user_id in offline_ids:
mentioned = user_id in mentioned_ids
if message.content: if message.content:
body = f"{sender.username}: {message.content}"[:120] prefix = f"{sender.username} mentioned you: " if mentioned else f"{sender.username}: "
body = (prefix + message.content)[:120]
elif message.file_id: elif message.file_id:
body = f"{sender.username} sent a file" body = f"{sender.username} sent a file"
else: else:
@@ -54,7 +67,6 @@ async def _notify_offline_members(
"body": body, "body": body,
"room_id": str(room_id), "room_id": str(room_id),
} }
for user_id in offline_ids:
await send_push_to_user(db, user_id, payload) 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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.schemas.message import ReactionSummary
from app.services.mention_service import extract_mentioned_user_ids
class MessageNotFoundError(Exception): 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 room_id=room_id, user_id=user_id, content=content, image_id=image_id, file_id=file_id
) )
db.add(message) 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.commit()
await db.refresh(message) await db.refresh(message)
return message return message
+21 -5
View File
@@ -5,7 +5,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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.schemas.room import RoomCreate, RoomUpdate
from app.services.email_service import send_email 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( async def list_member_rooms(
db: AsyncSession, user_id: uuid.UUID db: AsyncSession, user_id: uuid.UUID
) -> list[tuple[Room, RoomRole, bool]]: ) -> list[tuple[Room, RoomRole, bool, bool]]:
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)
.correlate(Room) .correlate(Room)
.scalar_subquery() .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( 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) .join(RoomMembership, RoomMembership.room_id == Room.id)
.where(RoomMembership.user_id == user_id) .where(RoomMembership.user_id == user_id)
.order_by(Room.created_at) .order_by(Room.created_at)
) )
return [ return [
(room, role, last_message_at is not None and last_message_at > last_read_at) (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 in result.all() 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" assert message["type"] == "message"
update = _recv(bob_ws) 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() bob_rooms = instance2.get("/api/rooms/mine").json()
assert _has_unread(bob_rooms, room["id"]) is True assert _has_unread(bob_rooms, room["id"]) is True
+7 -1
View File
@@ -184,7 +184,13 @@ export function ChatPane({
onEdit={sendEdit} onEdit={sendEdit}
onReact={sendReaction} onReact={sendReaction}
/> />
<Composer roomId={room.id} roomName={room.name} disabled={!connected} onSend={send} /> <Composer
roomId={room.id}
roomName={room.name}
members={members}
disabled={!connected}
onSend={send}
/>
</section> </section>
) )
} }
+6 -1
View File
@@ -13,9 +13,14 @@
align-items: flex-end; align-items: flex-end;
} }
.composer-box textarea { .composer-textarea-wrap {
position: relative;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
}
.composer-box textarea {
width: 100%;
resize: none; resize: none;
background: var(--ds-surface-2); background: var(--ds-surface-2);
border: 1px solid var(--ds-border); border: 1px solid var(--ds-border);
+102 -2
View File
@@ -1,20 +1,49 @@
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react' import {
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type FormEvent,
type KeyboardEvent,
} from 'react'
import { useOnlineStatus } from '../hooks/useOnlineStatus' import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomFile, uploadRoomImage } from '../api/rooms' import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
import { getUploadLimit } from '../api/uploads' import { getUploadLimit } from '../api/uploads'
import { formatFileSize } from '../lib/fileSize' import { formatFileSize } from '../lib/fileSize'
import type { RoomMember } from '../types'
import { EmojiPicker } from './EmojiPicker' import { EmojiPicker } from './EmojiPicker'
import { MentionAutocomplete } from './MentionAutocomplete'
import './Composer.css' import './Composer.css'
interface ComposerProps { interface ComposerProps {
roomId: string roomId: string
roomName: string roomName: string
members: RoomMember[]
disabled?: boolean disabled?: boolean
onSend: (content: string, imageId?: string, fileId?: string) => void onSend: (content: string, imageId?: string, fileId?: string) => void
} }
interface MentionQuery {
start: number
end: number
text: string
}
export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps) { // Scans left from the cursor for an active "@partial" token -- an '@' not
// preceded by a word character (so "foo@bar" mid-email doesn't trigger)
// with only mention-safe characters between it and the cursor (a space
// breaks out of the query entirely, closing the dropdown).
function detectMentionQuery(text: string, cursor: number): MentionQuery | null {
let i = cursor - 1
while (i >= 0 && /[a-zA-Z0-9_.-]/.test(text[i])) i--
if (i < 0 || text[i] !== '@') return null
const prevChar = text[i - 1]
if (prevChar && /\w/.test(prevChar)) return null
return { start: i, end: cursor, text: text.slice(i + 1, cursor) }
}
export function Composer({ roomId, roomName, members, disabled, onSend }: ComposerProps) {
const [value, setValue] = useState('') const [value, setValue] = useState('')
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null) const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
const [pendingFile, setPendingFile] = useState<{ id: string; filename: string; size: number } | null>( const [pendingFile, setPendingFile] = useState<{ id: string; filename: string; size: number } | null>(
@@ -24,10 +53,18 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
const [uploadError, setUploadError] = useState<string | null>(null) const [uploadError, setUploadError] = useState<string | null>(null)
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false) const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
const [maxUploadBytes, setMaxUploadBytes] = useState<number | null>(null) const [maxUploadBytes, setMaxUploadBytes] = useState<number | null>(null)
const [mentionQuery, setMentionQuery] = useState<MentionQuery | null>(null)
const [mentionActiveIndex, setMentionActiveIndex] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const online = useOnlineStatus() const online = useOnlineStatus()
const mentionMatches = useMemo(() => {
if (!mentionQuery) return []
const q = mentionQuery.text.toLowerCase()
return members.filter((m) => m.username.toLowerCase().startsWith(q)).slice(0, 8)
}, [mentionQuery, members])
useEffect(() => { useEffect(() => {
getUploadLimit() getUploadLimit()
.then((limit) => setMaxUploadBytes(limit.max_upload_bytes)) .then((limit) => setMaxUploadBytes(limit.max_upload_bytes))
@@ -49,18 +86,68 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
if (!trimmed && !pendingImage && !pendingFile) return if (!trimmed && !pendingImage && !pendingFile) return
onSend(trimmed, pendingImage?.id, pendingFile?.id) onSend(trimmed, pendingImage?.id, pendingFile?.id)
setValue('') setValue('')
setMentionQuery(null)
removePendingImage() removePendingImage()
setPendingFile(null) setPendingFile(null)
requestAnimationFrame(autoGrow) requestAnimationFrame(autoGrow)
} }
function selectMention(username: string) {
const query = mentionQuery
if (!query) return
const el = textareaRef.current
const next = value.slice(0, query.start) + '@' + username + ' ' + value.slice(query.end)
setValue(next)
setMentionQuery(null)
requestAnimationFrame(() => {
if (!el) return
el.focus()
const cursor = query.start + username.length + 2 // '@' + username + trailing space
el.setSelectionRange(cursor, cursor)
autoGrow()
})
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) { function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (mentionQuery && mentionMatches.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setMentionActiveIndex((i) => (i + 1) % mentionMatches.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setMentionActiveIndex((i) => (i - 1 + mentionMatches.length) % mentionMatches.length)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
selectMention(mentionMatches[mentionActiveIndex].username)
return
}
if (e.key === 'Escape') {
e.preventDefault()
setMentionQuery(null)
return
}
}
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() e.preventDefault()
handleSend() handleSend()
} }
} }
// Re-detects the active @query on every cursor move, not just typing --
// React's onSelect fires for clicks and arrow-key navigation too, so
// moving the cursor out of a partial mention (without deleting it) still
// correctly closes the dropdown.
function handleSelectionChange(e: FormEvent<HTMLTextAreaElement>) {
const el = e.currentTarget
const query = detectMentionQuery(el.value, el.selectionStart ?? 0)
setMentionQuery(query)
setMentionActiveIndex(0)
}
async function handleFileSelected(e: ChangeEvent<HTMLInputElement>) { async function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0] const file = e.target.files?.[0]
e.target.value = '' e.target.value = ''
@@ -205,6 +292,7 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
/> />
)} )}
</div> </div>
<div className="composer-textarea-wrap">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
rows={1} rows={1}
@@ -213,11 +301,23 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
onChange={(e) => { onChange={(e) => {
setValue(e.target.value) setValue(e.target.value)
autoGrow() autoGrow()
setMentionQuery(detectMentionQuery(e.target.value, e.target.selectionStart ?? 0))
setMentionActiveIndex(0)
}} }}
onSelect={handleSelectionChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`} placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`}
spellCheck spellCheck
/> />
{mentionQuery && mentionMatches.length > 0 && (
<MentionAutocomplete
matches={mentionMatches}
activeIndex={mentionActiveIndex}
onPick={selectMention}
onHover={setMentionActiveIndex}
/>
)}
</div>
<button <button
type="button" type="button"
className="composer-send" className="composer-send"
@@ -0,0 +1,49 @@
.mention-autocomplete {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
z-index: 31;
width: 240px;
max-height: 220px;
overflow-y: auto;
background: var(--ds-surface);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
.mention-autocomplete-item {
display: flex;
align-items: baseline;
gap: 6px;
background: transparent;
border: none;
border-radius: 6px;
padding: 6px 8px;
text-align: left;
cursor: pointer;
color: var(--ds-text);
}
.mention-autocomplete-item-active,
.mention-autocomplete-item:hover {
background: var(--ds-surface-2);
}
.mention-autocomplete-username {
font-size: 0.84rem;
font-weight: 700;
color: var(--ds-accent);
}
.mention-autocomplete-display-name {
font-size: 0.76rem;
color: var(--ds-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,36 @@
import type { RoomMember } from '../types'
import './MentionAutocomplete.css'
interface MentionAutocompleteProps {
matches: RoomMember[]
activeIndex: number
onPick: (username: string) => void
onHover: (index: number) => void
}
export function MentionAutocomplete({ matches, activeIndex, onPick, onHover }: MentionAutocompleteProps) {
return (
<div className="mention-autocomplete" role="listbox">
{matches.map((member, i) => (
<button
key={member.user_id}
type="button"
role="option"
aria-selected={i === activeIndex}
className={`mention-autocomplete-item${i === activeIndex ? ' mention-autocomplete-item-active' : ''}`}
// Selecting must survive the textarea's blur (which would
// otherwise fire first and could dismiss the dropdown) --
// onMouseDown fires before blur, onClick fires after.
onMouseDown={(e) => e.preventDefault()}
onClick={() => onPick(member.username)}
onMouseEnter={() => onHover(i)}
>
<span className="mention-autocomplete-username">@{member.username}</span>
{member.display_name && (
<span className="mention-autocomplete-display-name">{member.display_name}</span>
)}
</button>
))}
</div>
)
}
+66 -3
View File
@@ -1,8 +1,14 @@
import Markdown from 'markdown-to-jsx' import Markdown from 'markdown-to-jsx'
import type { ReactNode } from 'react'
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes' import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
interface MessageContentProps { interface MessageContentProps {
content: string content: string
// Validated against actual room members so a bare '@' in prose can't
// false-positive -- optional since FilePreviewModal reuses this same
// component for markdown file previews, where "@mentioning a person"
// doesn't apply.
memberUsernames?: Set<string>
} }
interface MarkdownImageLinkProps { interface MarkdownImageLinkProps {
@@ -24,6 +30,27 @@ function MarkdownImageLink({ src, alt, title }: MarkdownImageLinkProps) {
) )
} }
interface MarkdownLinkProps {
href?: string
children?: ReactNode
}
// highlightMentions (below) turns a validated @username into a
// `[@username](mention:username)` link so markdown-to-jsx parses it as a
// normal link node -- this override is what turns that back into a styled
// span instead of an actual anchor. Everything else renders as a real link,
// same as before mentions existed.
function MarkdownLink({ href, children }: MarkdownLinkProps) {
if (href?.startsWith('mention:')) {
return <span className="message-mention">{children}</span>
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
)
}
const SHORTCODE_PATTERN = /:([a-z0-9_+-]+):/g const SHORTCODE_PATTERN = /:([a-z0-9_+-]+):/g
// Converts a complete `:name:` shortcode to its emoji, skipping fenced code // Converts a complete `:name:` shortcode to its emoji, skipping fenced code
@@ -56,6 +83,41 @@ function convertShortcodes(text: string): string {
.join('\n') .join('\n')
} }
const MENTION_PATTERN = /@([a-zA-Z0-9_.-]+)/g
// Turns a validated @username into `[@username](mention:username)` --
// markdown-to-jsx parses that as an ordinary link node, which the `a`
// override above then renders as a styled span instead of an anchor. Skips
// fenced code blocks and inline code spans, same convention (and same
// reasoning) as convertShortcodes above -- pasted code containing a bare
// '@' shouldn't light up as if someone were paged. Kept in sync with
// backend/app/services/mention_service.py's equivalent server-side skip
// logic, which decides who actually gets notified.
function highlightMentions(text: string, memberUsernames: Set<string>): string {
if (memberUsernames.size === 0) return text
const lines = text.split('\n')
let inFence = false
return lines
.map((line) => {
if (/^\s*```/.test(line)) {
inFence = !inFence
return line
}
if (inFence) return line
return line
.split(/(`+[^`]*`+)/g)
.map((part, i) =>
i % 2 === 0
? part.replace(MENTION_PATTERN, (match, username) =>
memberUsernames.has(username) ? `[${match}](mention:${username})` : match,
)
: part,
)
.join('')
})
.join('\n')
}
// CommonMark treats a single newline as a soft break (rendered as a space), // CommonMark treats a single newline as a soft break (rendered as a space),
// not a visible line break -- only a trailing double-space or blank line // not a visible line break -- only a trailing double-space or blank line
// produces one. The Composer's Shift+Enter has always inserted a plain // produces one. The Composer's Shift+Enter has always inserted a plain
@@ -87,11 +149,12 @@ export const MARKDOWN_OPTIONS = {
// and printed literally instead of being parsed into elements. // and printed literally instead of being parsed into elements.
disableParsingRawHTML: true, disableParsingRawHTML: true,
overrides: { overrides: {
a: { props: { target: '_blank', rel: 'noopener noreferrer' } }, a: { component: MarkdownLink },
img: { component: MarkdownImageLink }, img: { component: MarkdownImageLink },
}, },
} }
export function MessageContent({ content }: MessageContentProps) { export function MessageContent({ content, memberUsernames }: MessageContentProps) {
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(content))}</Markdown> const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(withMentions))}</Markdown>
} }
+8
View File
@@ -120,6 +120,14 @@
margin-bottom: 0; margin-bottom: 0;
} }
.message-mention {
background: color-mix(in srgb, var(--ds-highlight) 18%, transparent);
color: var(--ds-highlight);
border-radius: 5px;
padding: 0 4px;
font-weight: 700;
}
.message-text code { .message-text code {
background: var(--ds-surface-2); background: var(--ds-surface-2);
padding: 1px 5px; padding: 1px 5px;
+3 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms' import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { formatFileSize } from '../lib/fileSize' import { formatFileSize } from '../lib/fileSize'
@@ -76,6 +76,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
const [reactingId, setReactingId] = useState<string | null>(null) const [reactingId, setReactingId] = useState<string | null>(null)
const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below') const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null) const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(null)
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members])
function displayNameForUserId(userId: string): string { function displayNameForUserId(userId: string): string {
const member = members.find((m) => m.user_id === userId) const member = members.find((m) => m.user_id === userId)
@@ -166,7 +167,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
)} )}
{msg.content && ( {msg.content && (
<div className="message-text"> <div className="message-text">
<MessageContent content={msg.content} /> <MessageContent content={msg.content} memberUsernames={memberUsernames} />
{msg.edited_at && <span className="message-edited"> (edited)</span>} {msg.edited_at && <span className="message-edited"> (edited)</span>}
</div> </div>
)} )}
+11
View File
@@ -58,3 +58,14 @@
border-radius: 50%; border-radius: 50%;
background: var(--ds-accent); background: var(--ds-accent);
} }
/* Distinct from the plain unread dot -- --ds-highlight is already this
app's second brand color (see tokens.css), reused here rather than
introducing a new semantic color just for mentions. */
.room-row-mention-dot {
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ds-highlight);
}
+6 -1
View File
@@ -32,7 +32,12 @@ export function RoomRow({ room, colorIndex, active }: RoomRowProps) {
</div> </div>
{room.description && <div className="room-row-subtitle">{room.description}</div>} {room.description && <div className="room-row-subtitle">{room.description}</div>}
</div> </div>
{room.has_unread && !active && <span className="room-row-unread-dot" aria-label="Unread messages" />} {!active && room.has_mention && (
<span className="room-row-mention-dot" aria-label="You were mentioned" />
)}
{!active && !room.has_mention && room.has_unread && (
<span className="room-row-unread-dot" aria-label="Unread messages" />
)}
</Link> </Link>
) )
} }
+13 -5
View File
@@ -59,8 +59,8 @@ export function ChatShellPage() {
const socket = useChatSocketContext() const socket = useChatSocketContext()
const setRoomUnread = useCallback((id: string, hasUnread: boolean) => { const clearRoomIndicators = useCallback((id: string) => {
setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: hasUnread } : r))) setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: false, has_mention: false } : r)))
}, []) }, [])
useEffect( useEffect(
@@ -68,9 +68,17 @@ export function ChatShellPage() {
socket.subscribe((envelope) => { socket.subscribe((envelope) => {
if (envelope.type === 'room_added') refreshRooms() if (envelope.type === 'room_added') refreshRooms()
else if (envelope.type === 'member_updated' && envelope.room_id === roomId) refreshMembers() else if (envelope.type === 'member_updated' && envelope.room_id === roomId) refreshMembers()
else if (envelope.type === 'unread_update') setRoomUnread(envelope.room_id, true) else if (envelope.type === 'unread_update') {
setRooms((prev) =>
prev.map((r) =>
r.id === envelope.room_id
? { ...r, has_unread: true, has_mention: r.has_mention || envelope.mentioned }
: r,
),
)
}
}), }),
[socket, refreshRooms, refreshMembers, roomId, setRoomUnread], [socket, refreshRooms, refreshMembers, roomId],
) )
useEffect(() => { useEffect(() => {
@@ -115,7 +123,7 @@ export function ChatShellPage() {
onToggleInfo={() => setInfoOpen((v) => !v)} onToggleInfo={() => setInfoOpen((v) => !v)}
infoOpen={infoOpen} infoOpen={infoOpen}
socket={socket} socket={socket}
onRoomRead={(id) => setRoomUnread(id, false)} onRoomRead={clearRoomIndicators}
/> />
) : ( ) : (
!isMobile && ( !isMobile && (
+4
View File
@@ -67,6 +67,9 @@ export interface RoomListItem extends Room {
export interface MyRoomItem extends Room { export interface MyRoomItem extends Room {
role: RoomRole role: RoomRole
has_unread: boolean has_unread: boolean
// Unread and mentions the current user -- takes visual priority over
// has_unread in the sidebar (see RoomRow.tsx), not shown alongside it.
has_mention: boolean
} }
export interface RoomMember { export interface RoomMember {
@@ -171,6 +174,7 @@ export interface ChatMemberUpdatedEnvelope {
export interface ChatUnreadUpdateEnvelope { export interface ChatUnreadUpdateEnvelope {
type: 'unread_update' type: 'unread_update'
room_id: string room_id: string
mentioned: boolean
} }
export type ServerEnvelope = export type ServerEnvelope =