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) /> )} -