diff --git a/backend/alembic/versions/05dbe3775e34_message_mentions_for_at_mention_.py b/backend/alembic/versions/05dbe3775e34_message_mentions_for_at_mention_.py new file mode 100644 index 0000000..471c115 --- /dev/null +++ b/backend/alembic/versions/05dbe3775e34_message_mentions_for_at_mention_.py @@ -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 ### diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d260166..1e30e09 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/message_mention.py b/backend/app/models/message_mention.py new file mode 100644 index 0000000..d2ef374 --- /dev/null +++ b/backend/app/models/message_mention.py @@ -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 + ) diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index c2eab0a..0626b7c 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -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 ] diff --git a/backend/app/schemas/room.py b/backend/app/schemas/room.py index 83227ed..16e504a 100644 --- a/backend/app/schemas/room.py +++ b/backend/app/schemas/room.py @@ -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): diff --git a/backend/app/services/mention_service.py b/backend/app/services/mention_service.py new file mode 100644 index 0000000..ffb7c14 --- /dev/null +++ b/backend/app/services/mention_service.py @@ -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()} diff --git a/backend/app/services/message_events.py b/backend/app/services/message_events.py index ddd81a0..3d22e40 100644 --- a/backend/app/services/message_events.py +++ b/backend/app/services/message_events.py @@ -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) diff --git a/backend/app/services/message_service.py b/backend/app/services/message_service.py index 6f900e0..2334ab6 100644 --- a/backend/app/services/message_service.py +++ b/backend/app/services/message_service.py @@ -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 diff --git a/backend/app/services/room_service.py b/backend/app/services/room_service.py index 722e320..f4a6392 100644 --- a/backend/app/services/room_service.py +++ b/backend/app/services/room_service.py @@ -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() ] diff --git a/backend/tests/test_mentions.py b/backend/tests/test_mentions.py new file mode 100644 index 0000000..f2caf1e --- /dev/null +++ b/backend/tests/test_mentions.py @@ -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 diff --git a/backend/tests/test_unread.py b/backend/tests/test_unread.py index 3af8b9e..6e9048a 100644 --- a/backend/tests/test_unread.py +++ b/backend/tests/test_unread.py @@ -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 diff --git a/frontend/src/components/ChatPane.tsx b/frontend/src/components/ChatPane.tsx index 797736c..a9015e3 100644 --- a/frontend/src/components/ChatPane.tsx +++ b/frontend/src/components/ChatPane.tsx @@ -184,7 +184,13 @@ export function ChatPane({ onEdit={sendEdit} onReact={sendReaction} /> - + ) } diff --git a/frontend/src/components/Composer.css b/frontend/src/components/Composer.css index 37a1fb6..7bcef36 100644 --- a/frontend/src/components/Composer.css +++ b/frontend/src/components/Composer.css @@ -13,9 +13,14 @@ align-items: flex-end; } -.composer-box textarea { +.composer-textarea-wrap { + position: relative; flex: 1; min-width: 0; +} + +.composer-box textarea { + width: 100%; resize: none; background: var(--ds-surface-2); border: 1px solid var(--ds-border); diff --git a/frontend/src/components/Composer.tsx b/frontend/src/components/Composer.tsx index 16ce668..2a8c263 100644 --- a/frontend/src/components/Composer.tsx +++ b/frontend/src/components/Composer.tsx @@ -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 { uploadRoomFile, uploadRoomImage } from '../api/rooms' import { getUploadLimit } from '../api/uploads' import { formatFileSize } from '../lib/fileSize' +import type { RoomMember } from '../types' import { EmojiPicker } from './EmojiPicker' +import { MentionAutocomplete } from './MentionAutocomplete' import './Composer.css' interface ComposerProps { roomId: string roomName: string + members: RoomMember[] disabled?: boolean 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 [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(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(null) const [emojiPickerOpen, setEmojiPickerOpen] = useState(false) const [maxUploadBytes, setMaxUploadBytes] = useState(null) + const [mentionQuery, setMentionQuery] = useState(null) + const [mentionActiveIndex, setMentionActiveIndex] = useState(0) const textareaRef = useRef(null) const fileInputRef = useRef(null) 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(() => { getUploadLimit() .then((limit) => setMaxUploadBytes(limit.max_upload_bytes)) @@ -49,18 +86,68 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps) if (!trimmed && !pendingImage && !pendingFile) return onSend(trimmed, pendingImage?.id, pendingFile?.id) setValue('') + setMentionQuery(null) removePendingImage() setPendingFile(null) 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) { + 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) { e.preventDefault() 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) { + const el = e.currentTarget + const query = detectMentionQuery(el.value, el.selectionStart ?? 0) + setMentionQuery(query) + setMentionActiveIndex(0) + } + async function handleFileSelected(e: ChangeEvent) { const file = e.target.files?.[0] e.target.value = '' @@ -205,19 +292,32 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps) /> )} - { - setValue(e.target.value) - autoGrow() - }} - onKeyDown={handleKeyDown} - placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`} - spellCheck - /> + + { + setValue(e.target.value) + autoGrow() + setMentionQuery(detectMentionQuery(e.target.value, e.target.selectionStart ?? 0)) + setMentionActiveIndex(0) + }} + onSelect={handleSelectionChange} + onKeyDown={handleKeyDown} + placeholder={disabled ? (online ? 'Connecting…' : "You're offline") : `Message #${roomName}`} + spellCheck + /> + {mentionQuery && mentionMatches.length > 0 && ( + + )} + void + onHover: (index: number) => void +} + +export function MentionAutocomplete({ matches, activeIndex, onPick, onHover }: MentionAutocompleteProps) { + return ( + + {matches.map((member, i) => ( + e.preventDefault()} + onClick={() => onPick(member.username)} + onMouseEnter={() => onHover(i)} + > + @{member.username} + {member.display_name && ( + {member.display_name} + )} + + ))} + + ) +} diff --git a/frontend/src/components/MessageContent.tsx b/frontend/src/components/MessageContent.tsx index 28aee27..9df78a1 100644 --- a/frontend/src/components/MessageContent.tsx +++ b/frontend/src/components/MessageContent.tsx @@ -1,8 +1,14 @@ import Markdown from 'markdown-to-jsx' +import type { ReactNode } from 'react' import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes' interface MessageContentProps { 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 } 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 {children} + } + return ( + + {children} + + ) +} + const SHORTCODE_PATTERN = /:([a-z0-9_+-]+):/g // Converts a complete `:name:` shortcode to its emoji, skipping fenced code @@ -56,6 +83,41 @@ function convertShortcodes(text: string): string { .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 { + 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), // 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 @@ -87,11 +149,12 @@ export const MARKDOWN_OPTIONS = { // and printed literally instead of being parsed into elements. disableParsingRawHTML: true, overrides: { - a: { props: { target: '_blank', rel: 'noopener noreferrer' } }, + a: { component: MarkdownLink }, img: { component: MarkdownImageLink }, }, } -export function MessageContent({ content }: MessageContentProps) { - return {preserveLineBreaks(convertShortcodes(content))} +export function MessageContent({ content, memberUsernames }: MessageContentProps) { + const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content + return {preserveLineBreaks(convertShortcodes(withMentions))} } diff --git a/frontend/src/components/MessageList.css b/frontend/src/components/MessageList.css index 3131119..8a83114 100644 --- a/frontend/src/components/MessageList.css +++ b/frontend/src/components/MessageList.css @@ -120,6 +120,14 @@ 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 { background: var(--ds-surface-2); padding: 1px 5px; diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index d864726..b7aa1d0 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { getRoomFileUrl, getRoomImageUrl } from '../api/rooms' import { useAuth } from '../context/AuthContext' import { formatFileSize } from '../lib/fileSize' @@ -76,6 +76,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess const [reactingId, setReactingId] = useState(null) const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below') const [previewFile, setPreviewFile] = useState(null) + const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]) function displayNameForUserId(userId: string): string { 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.edited_at && (edited)} )} diff --git a/frontend/src/components/RoomRow.css b/frontend/src/components/RoomRow.css index 0972a65..584699b 100644 --- a/frontend/src/components/RoomRow.css +++ b/frontend/src/components/RoomRow.css @@ -58,3 +58,14 @@ border-radius: 50%; 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); +} diff --git a/frontend/src/components/RoomRow.tsx b/frontend/src/components/RoomRow.tsx index 06e0ada..f71bc1f 100644 --- a/frontend/src/components/RoomRow.tsx +++ b/frontend/src/components/RoomRow.tsx @@ -32,7 +32,12 @@ export function RoomRow({ room, colorIndex, active }: RoomRowProps) { {room.description && {room.description}} - {room.has_unread && !active && } + {!active && room.has_mention && ( + + )} + {!active && !room.has_mention && room.has_unread && ( + + )} ) } diff --git a/frontend/src/pages/ChatShellPage.tsx b/frontend/src/pages/ChatShellPage.tsx index 4be92da..c2aa5aa 100644 --- a/frontend/src/pages/ChatShellPage.tsx +++ b/frontend/src/pages/ChatShellPage.tsx @@ -59,8 +59,8 @@ export function ChatShellPage() { const socket = useChatSocketContext() - const setRoomUnread = useCallback((id: string, hasUnread: boolean) => { - setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: hasUnread } : r))) + const clearRoomIndicators = useCallback((id: string) => { + setRooms((prev) => prev.map((r) => (r.id === id ? { ...r, has_unread: false, has_mention: false } : r))) }, []) useEffect( @@ -68,9 +68,17 @@ export function ChatShellPage() { socket.subscribe((envelope) => { if (envelope.type === 'room_added') refreshRooms() 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(() => { @@ -115,7 +123,7 @@ export function ChatShellPage() { onToggleInfo={() => setInfoOpen((v) => !v)} infoOpen={infoOpen} socket={socket} - onRoomRead={(id) => setRoomUnread(id, false)} + onRoomRead={clearRoomIndicators} /> ) : ( !isMobile && ( diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 57e0ee9..9cf0ed4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -67,6 +67,9 @@ export interface RoomListItem extends Room { export interface MyRoomItem extends Room { role: RoomRole 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 { @@ -171,6 +174,7 @@ export interface ChatMemberUpdatedEnvelope { export interface ChatUnreadUpdateEnvelope { type: 'unread_update' room_id: string + mentioned: boolean } export type ServerEnvelope =