Private
Public Access
Add @-mention highlighting, sidebar badge, and push customization (#39)
@username tokens in a sent message are parsed against the room's actual members (skipping fenced/inline code, so pasted code isn't misread) and recorded as MessageMention rows, reusing #38's read-tracking and offline-member broadcast infrastructure rather than building a parallel notification path: - Sidebar: a mentioned-and-unread room shows a distinct highlight- colored badge instead of (not alongside) the plain unread dot -- computed the same way as has_unread, just scoped to messages that mention the caller, and cleared by the same last_read_at mark-read flow. - Push notifications: a mentioned offline recipient gets "X mentioned you: ..." instead of the generic "X: ...", still per-recipient since the same message can page some room members and not others. - Message rendering: a validated @username is highlighted inline, implemented by turning it into a `[@username](mention:username)` link before markdown parsing and overriding link rendering to style `mention:`-scheme links as a span instead of an anchor -- reuses markdown-to-jsx's existing parser rather than hand-rolling text-node splitting. - Composer: typing @ opens an autocomplete dropdown of matching room members (arrow keys to navigate, Enter/Tab/click to insert, Escape or moving the cursor away to dismiss). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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 ###
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import RoomMembership, User
|
||||
|
||||
MENTION_PATTERN = re.compile(r"@([a-zA-Z0-9_.-]+)")
|
||||
|
||||
|
||||
def _strip_code_spans(content: str) -> str:
|
||||
"""Blanks out fenced code blocks and inline code spans (replacing with
|
||||
equal-length whitespace, so a bare '@' in pasted code -- a decorator, an
|
||||
email fragment -- doesn't page someone. Mirrors the same skip logic
|
||||
frontend/src/components/MessageContent.tsx already uses for emoji
|
||||
shortcode conversion."""
|
||||
lines = content.split("\n")
|
||||
in_fence = False
|
||||
out = []
|
||||
for line in lines:
|
||||
if re.match(r"^\s*```", line):
|
||||
in_fence = not in_fence
|
||||
out.append(line)
|
||||
continue
|
||||
if in_fence:
|
||||
out.append(line)
|
||||
continue
|
||||
parts = re.split(r"(`+[^`]*`+)", line)
|
||||
out.append("".join(part if i % 2 == 0 else " " * len(part) for i, part in enumerate(parts)))
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
async def extract_mentioned_user_ids(
|
||||
db: AsyncSession, room_id: uuid.UUID, content: str
|
||||
) -> set[uuid.UUID]:
|
||||
"""Resolves `@username` tokens in `content` against this room's actual
|
||||
members -- a bare '@' followed by prose that happens to not match
|
||||
anyone's username is just text, not a mention."""
|
||||
usernames = set(MENTION_PATTERN.findall(_strip_code_spans(content)))
|
||||
if not usernames:
|
||||
return set()
|
||||
|
||||
result = await db.execute(
|
||||
select(RoomMembership.user_id)
|
||||
.join(User, User.id == RoomMembership.user_id)
|
||||
.where(RoomMembership.room_id == room_id, User.username.in_(usernames))
|
||||
)
|
||||
return {row[0] for row in result.all()}
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Message, MessageFile, Room, RoomMembership, User
|
||||
from app.models import Message, MessageFile, MessageMention, Room, RoomMembership, User
|
||||
from app.schemas.message import ReactionSummary
|
||||
from app.services.push_service import send_push_to_user
|
||||
from app.services.webhook_service import dispatch_event
|
||||
@@ -31,6 +31,11 @@ async def _notify_offline_members(
|
||||
if not offline_ids:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(MessageMention.user_id).where(MessageMention.message_id == message.id)
|
||||
)
|
||||
mentioned_ids = {row[0] for row in result.all()}
|
||||
|
||||
# This is also exactly the right audience for "give this room an unread
|
||||
# dot": presence.connected_user_ids(room_id) means "has this room's
|
||||
# channel joined right now" -- which the client only does while the tab
|
||||
@@ -39,12 +44,20 @@ 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)
|
||||
for user_id in offline_ids:
|
||||
mentioned = user_id in mentioned_ids
|
||||
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:
|
||||
body = f"{sender.username} sent a file"
|
||||
else:
|
||||
@@ -54,7 +67,6 @@ async def _notify_offline_members(
|
||||
"body": body,
|
||||
"room_id": str(room_id),
|
||||
}
|
||||
for user_id in offline_ids:
|
||||
await send_push_to_user(db, user_id, payload)
|
||||
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Message, MessageReaction
|
||||
from app.models import Message, MessageMention, MessageReaction
|
||||
from app.schemas.message import ReactionSummary
|
||||
from app.services.mention_service import extract_mentioned_user_ids
|
||||
|
||||
|
||||
class MessageNotFoundError(Exception):
|
||||
@@ -30,6 +31,12 @@ async def create_message(
|
||||
room_id=room_id, user_id=user_id, content=content, image_id=image_id, file_id=file_id
|
||||
)
|
||||
db.add(message)
|
||||
# message.id is available immediately (a Python-side uuid4 default, not
|
||||
# server-generated), so mention rows can reference it without a flush.
|
||||
if content:
|
||||
mentioned_ids = await extract_mentioned_user_ids(db, room_id, content)
|
||||
for mentioned_id in mentioned_ids:
|
||||
db.add(MessageMention(message_id=message.id, user_id=mentioned_id))
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return message
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import Message, Room, RoomMembership, RoomRole, User
|
||||
from app.models import Message, MessageMention, Room, RoomMembership, RoomRole, User
|
||||
from app.schemas.room import RoomCreate, RoomUpdate
|
||||
from app.services.email_service import send_email
|
||||
|
||||
@@ -81,22 +81,38 @@ async def list_open_rooms(db: AsyncSession, user_id: uuid.UUID) -> list[tuple[Ro
|
||||
|
||||
async def list_member_rooms(
|
||||
db: AsyncSession, user_id: uuid.UUID
|
||||
) -> list[tuple[Room, RoomRole, bool]]:
|
||||
) -> list[tuple[Room, RoomRole, bool, bool]]:
|
||||
last_message_at = (
|
||||
select(func.max(Message.created_at))
|
||||
.where(Message.room_id == Room.id)
|
||||
.correlate(Room)
|
||||
.scalar_subquery()
|
||||
)
|
||||
# Unread AND mentions this user specifically -- a stronger signal than
|
||||
# plain has_unread, surfaced as its own field so the sidebar can show a
|
||||
# visually distinct badge instead of (not alongside) the plain dot.
|
||||
has_unread_mention = (
|
||||
select(MessageMention.message_id)
|
||||
.join(Message, Message.id == MessageMention.message_id)
|
||||
.where(
|
||||
MessageMention.user_id == user_id,
|
||||
Message.room_id == Room.id,
|
||||
Message.created_at > RoomMembership.last_read_at,
|
||||
)
|
||||
.correlate(Room, RoomMembership)
|
||||
.exists()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(Room, RoomMembership.role, RoomMembership.last_read_at, last_message_at)
|
||||
select(
|
||||
Room, RoomMembership.role, RoomMembership.last_read_at, last_message_at, has_unread_mention
|
||||
)
|
||||
.join(RoomMembership, RoomMembership.room_id == Room.id)
|
||||
.where(RoomMembership.user_id == user_id)
|
||||
.order_by(Room.created_at)
|
||||
)
|
||||
return [
|
||||
(room, role, last_message_at is not None and last_message_at > last_read_at)
|
||||
for room, role, last_read_at, last_message_at in result.all()
|
||||
(room, role, last_message_at is not None and last_message_at > last_read_at, has_mention)
|
||||
for room, role, last_read_at, last_message_at, has_mention in result.all()
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -184,7 +184,13 @@ export function ChatPane({
|
||||
onEdit={sendEdit}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
|
||||
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 fileInputRef = useRef<HTMLInputElement>(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<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) {
|
||||
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<HTMLTextAreaElement>) {
|
||||
const el = e.currentTarget
|
||||
const query = detectMentionQuery(el.value, el.selectionStart ?? 0)
|
||||
setMentionQuery(query)
|
||||
setMentionActiveIndex(0)
|
||||
}
|
||||
|
||||
async function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
@@ -205,6 +292,7 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="composer-textarea-wrap">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
@@ -213,11 +301,23 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
|
||||
onChange={(e) => {
|
||||
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 && (
|
||||
<MentionAutocomplete
|
||||
matches={mentionMatches}
|
||||
activeIndex={mentionActiveIndex}
|
||||
onPick={selectMention}
|
||||
onHover={setMentionActiveIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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<string>
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 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>): 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 <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(content))}</Markdown>
|
||||
export function MessageContent({ content, memberUsernames }: MessageContentProps) {
|
||||
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
|
||||
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(withMentions))}</Markdown>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [reactionPlacement, setReactionPlacement] = useState<'above' | 'below'>('below')
|
||||
const [previewFile, setPreviewFile] = useState<MessageFileInfo | null>(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 && (
|
||||
<div className="message-text">
|
||||
<MessageContent content={msg.content} />
|
||||
<MessageContent content={msg.content} memberUsernames={memberUsernames} />
|
||||
{msg.edited_at && <span className="message-edited"> (edited)</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,12 @@ export function RoomRow({ room, colorIndex, active }: RoomRowProps) {
|
||||
</div>
|
||||
{room.description && <div className="room-row-subtitle">{room.description}</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user