From 12264b4d1894a3d0a8da54a96af2a32fe226372e Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Mon, 17 Aug 2026 19:03:18 -0600 Subject: [PATCH] Add #roomname references in chat messages (#47) Mirrors the existing @-mention system's shape: a regex finds #roomname tokens, extract_referenced_room_ids validates them against rooms the *sender* actually belongs to (mirrors mentions' "must be a real member" rule -- referencing a private room the sender isn't in would otherwise leak its existence), and a MessageRoomReference join row is stored per match in create_message. No notification/unread layer, unlike mentions -- referencing a room has no "you were referenced" semantics. Rendering is the same markdown-link rewrite trick MessageContent.tsx already uses for mentions (#username -> [#username](mention:username)), but resolved against the *viewer's* own room list (threaded down from ChatShellPage's room state through ChatPane/MessageList) rather than the stored server-side reference -- a reference to a room the current viewer isn't in quietly renders as plain text instead of a link, same as an @mention of someone outside the room does. The href scheme renders a real react-router Link instead of mentions' inert span, since a room reference is meant to be navigable. mention_service.strip_code_spans (was _strip_code_spans) is now shared between both extraction paths rather than private to one module. Co-Authored-By: Claude Sonnet 5 --- ...1cb3a_message_room_references_for_hash_.py | 44 +++++++ backend/app/models/__init__.py | 2 + backend/app/models/message_room_reference.py | 15 +++ backend/app/services/mention_service.py | 12 +- backend/app/services/message_service.py | 6 +- .../app/services/room_reference_service.py | 31 +++++ backend/tests/test_room_references.py | 119 ++++++++++++++++++ frontend/src/components/ChatPane.tsx | 8 ++ frontend/src/components/MessageContent.tsx | 61 ++++++++- frontend/src/components/MessageList.css | 13 ++ frontend/src/components/MessageList.tsx | 5 +- frontend/src/pages/ChatShellPage.tsx | 1 + 12 files changed, 305 insertions(+), 12 deletions(-) create mode 100644 backend/alembic/versions/9484fbd1cb3a_message_room_references_for_hash_.py create mode 100644 backend/app/models/message_room_reference.py create mode 100644 backend/app/services/room_reference_service.py create mode 100644 backend/tests/test_room_references.py diff --git a/backend/alembic/versions/9484fbd1cb3a_message_room_references_for_hash_.py b/backend/alembic/versions/9484fbd1cb3a_message_room_references_for_hash_.py new file mode 100644 index 0000000..33b8ee9 --- /dev/null +++ b/backend/alembic/versions/9484fbd1cb3a_message_room_references_for_hash_.py @@ -0,0 +1,44 @@ +"""message room references for hash roomname links + +Revision ID: 9484fbd1cb3a +Revises: f3ec1c1d1992 +Create Date: 2026-08-17 18:57:16.584680 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9484fbd1cb3a' +down_revision: Union[str, Sequence[str], None] = 'f3ec1c1d1992' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "message_room_references", + sa.Column("message_id", sa.Uuid(), nullable=False), + sa.Column("room_id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(["message_id"], ["messages.id"]), + sa.ForeignKeyConstraint(["room_id"], ["rooms.id"]), + sa.PrimaryKeyConstraint("message_id", "room_id"), + ) + op.create_index( + op.f("ix_message_room_references_room_id"), + "message_room_references", + ["room_id"], + unique=False, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index( + op.f("ix_message_room_references_room_id"), table_name="message_room_references" + ) + op.drop_table("message_room_references") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 253de1c..226f785 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,6 +11,7 @@ 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.message_room_reference import MessageRoomReference from app.models.password_reset import PasswordReset from app.models.push_subscription import PushSubscription from app.models.room import Room @@ -31,6 +32,7 @@ __all__ = [ "MessageImage", "MessageMention", "MessageReaction", + "MessageRoomReference", "InviteStatus", "PasswordReset", "SiteInvite", diff --git a/backend/app/models/message_room_reference.py b/backend/app/models/message_room_reference.py new file mode 100644 index 0000000..98427cc --- /dev/null +++ b/backend/app/models/message_room_reference.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 MessageRoomReference(Base): + __tablename__ = "message_room_references" + + message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("messages.id"), primary_key=True) + room_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("rooms.id"), primary_key=True, index=True + ) diff --git a/backend/app/services/mention_service.py b/backend/app/services/mention_service.py index ffb7c14..9ae4505 100644 --- a/backend/app/services/mention_service.py +++ b/backend/app/services/mention_service.py @@ -9,12 +9,14 @@ from app.models import RoomMembership, User MENTION_PATTERN = re.compile(r"@([a-zA-Z0-9_.-]+)") -def _strip_code_spans(content: str) -> str: +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 + equal-length whitespace, so a bare '@'/'#' in pasted code -- a + decorator, an email fragment, a shell comment -- doesn't trigger a + mention or room reference. Mirrors the same skip logic frontend/src/components/MessageContent.tsx already uses for emoji - shortcode conversion.""" + shortcode conversion. Shared with room_reference_service, not private + to this module anymore.""" lines = content.split("\n") in_fence = False out = [] @@ -37,7 +39,7 @@ async def extract_mentioned_user_ids( """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))) + usernames = set(MENTION_PATTERN.findall(strip_code_spans(content))) if not usernames: return set() diff --git a/backend/app/services/message_service.py b/backend/app/services/message_service.py index d2c5f1b..a48cf76 100644 --- a/backend/app/services/message_service.py +++ b/backend/app/services/message_service.py @@ -6,10 +6,11 @@ from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.models import Message, MessageMention, MessageReaction +from app.models import Message, MessageMention, MessageReaction, MessageRoomReference from app.schemas.message import ReactionSummary from app.services.link_preview_service import extract_first_url from app.services.mention_service import extract_mentioned_user_ids +from app.services.room_reference_service import extract_referenced_room_ids class MessageNotFoundError(Exception): @@ -43,6 +44,9 @@ async def create_message( 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)) + referenced_room_ids = await extract_referenced_room_ids(db, user_id, content) + for referenced_room_id in referenced_room_ids: + db.add(MessageRoomReference(message_id=message.id, room_id=referenced_room_id)) await db.commit() await db.refresh(message) return message diff --git a/backend/app/services/room_reference_service.py b/backend/app/services/room_reference_service.py new file mode 100644 index 0000000..49abeac --- /dev/null +++ b/backend/app/services/room_reference_service.py @@ -0,0 +1,31 @@ +import re +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Room, RoomMembership +from app.services.mention_service import strip_code_spans + +ROOM_REFERENCE_PATTERN = re.compile(r"#([a-zA-Z0-9_.-]+)") + + +async def extract_referenced_room_ids( + db: AsyncSession, sender_id: uuid.UUID, content: str +) -> set[uuid.UUID]: + """Resolves `#roomname` tokens in `content` against rooms the *sender* + is a member of -- deliberately not the room the message is being sent + in (the whole point is referencing a *different* room), and not open to + arbitrary site rooms either (#47: referencing a private room the sender + isn't in would leak its existence to anyone reading the message, even + though they aren't in it either).""" + room_names = set(ROOM_REFERENCE_PATTERN.findall(strip_code_spans(content))) + if not room_names: + return set() + + result = await db.execute( + select(Room.id) + .join(RoomMembership, RoomMembership.room_id == Room.id) + .where(RoomMembership.user_id == sender_id, Room.name.in_(room_names)) + ) + return {row[0] for row in result.all()} diff --git a/backend/tests/test_room_references.py b/backend/tests/test_room_references.py new file mode 100644 index 0000000..c15c1b9 --- /dev/null +++ b/backend/tests/test_room_references.py @@ -0,0 +1,119 @@ +import uuid + +from sqlalchemy import select + +from app.models import MessageRoomReference +from app.schemas.user import UserCreate +from app.services.auth_service import register_user + + +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 -- same convention as test_mentions.py.""" + while True: + msg = ws.receive_json() + if msg.get("type") != "member_updated": + return msg + + +def _register_ws(ws_client, username: str) -> dict: + 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 _send_and_sync(ws, room_id: str, content: str) -> dict: + """Sync barrier -- see test_mentions.py's identical helper. Proves the + message frame's full handling (including room-reference extraction) has + completed before the test checks 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 _referenced_room_ids(ws_client, message_id: str) -> set[str]: + async def _query(): + async with ws_client.session_factory() as session: + result = await session.execute( + select(MessageRoomReference.room_id).where( + MessageRoomReference.message_id == uuid.UUID(message_id) + ) + ) + return {str(row[0]) for row in result.all()} + + return ws_client.portal.call(_query) + + +def test_room_reference_to_own_room_is_stored(ws_client): + username = _unique("alice") + _register_ws(ws_client, username=username) + room_a = ws_client.post("/api/rooms", json={"name": _unique("general")}).json() + room_b = ws_client.post("/api/rooms", json={"name": _unique("random")}).json() + + with ws_client.websocket_connect("/ws/chat") as ws: + ws.send_json({"type": "join", "room_id": room_a["id"]}) + assert _recv(ws)["type"] == "joined" + message = _send_and_sync(ws, room_a["id"], f"see #{room_b['name']} for details") + + assert _referenced_room_ids(ws_client, message["id"]) == {room_b["id"]} + + +def test_room_reference_to_room_sender_is_not_in_is_not_stored(ws_client_factory): + instance1 = ws_client_factory() + instance2 = ws_client_factory() + + _register_ws(instance1, _unique("alice")) + room_a = instance1.post("/api/rooms", json={"name": _unique("general")}).json() + + _register_ws(instance2, _unique("bob")) + other_room = instance2.post("/api/rooms", json={"name": _unique("bobs-room")}).json() + # Deliberately not joining other_room from instance1. + + with instance1.websocket_connect("/ws/chat") as ws: + ws.send_json({"type": "join", "room_id": room_a["id"]}) + assert _recv(ws)["type"] == "joined" + message = _send_and_sync(ws, room_a["id"], f"check #{other_room['name']} sometime") + + assert _referenced_room_ids(instance1, message["id"]) == set() + + +def test_room_reference_inside_code_span_is_not_stored(ws_client): + username = _unique("alice") + _register_ws(ws_client, username=username) + room_a = ws_client.post("/api/rooms", json={"name": _unique("general")}).json() + room_b = ws_client.post("/api/rooms", json={"name": _unique("random")}).json() + + with ws_client.websocket_connect("/ws/chat") as ws: + ws.send_json({"type": "join", "room_id": room_a["id"]}) + assert _recv(ws)["type"] == "joined" + message = _send_and_sync(ws, room_a["id"], f"like this: `#{room_b['name']}`") + + assert _referenced_room_ids(ws_client, message["id"]) == set() + + +def test_room_reference_without_space_does_not_render_as_heading(): + # #47: confirms the char-class match itself is unaffected by CommonMark + # heading syntax concerns (a real heading needs "# text" with a space -- + # this is a backend-extraction test, not a markdown-rendering one, but + # asserts the regex matches "#roomname" with no space, which is the + # whole point of the feature). + from app.services.room_reference_service import ROOM_REFERENCE_PATTERN + + assert ROOM_REFERENCE_PATTERN.findall("check #general now") == ["general"] + assert ROOM_REFERENCE_PATTERN.findall("# general now") == [] diff --git a/frontend/src/components/ChatPane.tsx b/frontend/src/components/ChatPane.tsx index 9ea1045..76a6f01 100644 --- a/frontend/src/components/ChatPane.tsx +++ b/frontend/src/components/ChatPane.tsx @@ -9,6 +9,7 @@ import './ChatPane.css' interface ChatPaneProps { room: MyRoomItem + rooms: MyRoomItem[] members: RoomMember[] isMobile: boolean onBack: () => void @@ -20,6 +21,7 @@ interface ChatPaneProps { export function ChatPane({ room, + rooms, members, isMobile, onBack, @@ -192,6 +194,11 @@ export function ChatPane({ [history, live], ) + // #47: name -> id for every room this user belongs to, so #roomname + // references can resolve to a real link -- deliberately the viewer's own + // rooms, not the sender's (see MessageContent.tsx's myRooms prop comment). + const myRooms = useMemo(() => new Map(rooms.map((r) => [r.name, r.id])), [rooms]) + const connected = socket.connected const send = useCallback( (content: string, imageId?: string, fileId?: string) => socket.send(room.id, content, imageId, fileId), @@ -246,6 +253,7 @@ export function ChatPane({ roomId={room.id} messages={messages} members={members} + myRooms={myRooms} onEdit={sendEdit} onReact={sendReaction} /> diff --git a/frontend/src/components/MessageContent.tsx b/frontend/src/components/MessageContent.tsx index 9df78a1..c1890e2 100644 --- a/frontend/src/components/MessageContent.tsx +++ b/frontend/src/components/MessageContent.tsx @@ -1,5 +1,6 @@ import Markdown from 'markdown-to-jsx' import type { ReactNode } from 'react' +import { Link } from 'react-router-dom' import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes' interface MessageContentProps { @@ -9,6 +10,13 @@ interface MessageContentProps { // component for markdown file previews, where "@mentioning a person" // doesn't apply. memberUsernames?: Set + // #47: room-name -> id, scoped to rooms the *viewer* belongs to (not the + // sender, and not every site room) -- resolving purely against the + // viewer's own room list means a reference to a private room the viewer + // isn't in quietly renders as plain text instead of a link, the same way + // an @mention of someone outside the room does. Optional for the same + // reason memberUsernames is (FilePreviewModal reuse). + myRooms?: Map } interface MarkdownImageLinkProps { @@ -38,12 +46,22 @@ interface MarkdownLinkProps { // 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. +// span instead of an actual anchor. highlightRoomReferences does the same +// trick for #roomname, but a room reference *is* meant to be navigable, so +// it becomes a real (client-side-routed) Link instead of an inert span. +// Everything else renders as a real external link, same as before mentions +// existed. function MarkdownLink({ href, children }: MarkdownLinkProps) { if (href?.startsWith('mention:')) { return {children} } + if (href?.startsWith('room:')) { + return ( + + {children} + + ) + } return ( {children} @@ -118,6 +136,40 @@ function highlightMentions(text: string, memberUsernames: Set): string { .join('\n') } +const ROOM_REFERENCE_PATTERN = /#([a-zA-Z0-9_.-]+)/g + +// Same trick as highlightMentions, targeting #roomname instead of +// @username -- turns a validated reference into `[#roomname](room:id)` so +// the `a` override above renders it as a real link. Kept in sync with +// backend/app/services/room_reference_service.py's matching pattern (which +// decides what actually gets stored server-side; this is purely a display- +// time lookup against rooms the viewer already knows about). +function highlightRoomReferences(text: string, myRooms: Map): string { + if (myRooms.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(ROOM_REFERENCE_PATTERN, (match, roomName) => { + const roomId = myRooms.get(roomName) + return roomId ? `[${match}](room:${roomId})` : 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 @@ -154,7 +206,8 @@ export const MARKDOWN_OPTIONS = { }, } -export function MessageContent({ content, memberUsernames }: MessageContentProps) { +export function MessageContent({ content, memberUsernames, myRooms }: MessageContentProps) { const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content - return {preserveLineBreaks(convertShortcodes(withMentions))} + const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions + return {preserveLineBreaks(convertShortcodes(withRoomRefs))} } diff --git a/frontend/src/components/MessageList.css b/frontend/src/components/MessageList.css index 8a83114..a77a9a8 100644 --- a/frontend/src/components/MessageList.css +++ b/frontend/src/components/MessageList.css @@ -128,6 +128,19 @@ font-weight: 700; } +.message-room-reference { + background: color-mix(in srgb, var(--ds-accent) 18%, transparent); + color: var(--ds-accent); + border-radius: 5px; + padding: 0 4px; + font-weight: 700; + text-decoration: none; +} + +.message-room-reference:hover { + text-decoration: underline; +} + .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 66424c5..3557253 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -64,11 +64,12 @@ interface MessageListProps { roomId: string messages: (Message | ChatMessageEnvelope)[] members: RoomMember[] + myRooms: Map onEdit: (messageId: string, content: string) => void onReact: (messageId: string, emoji: string) => void } -export function MessageList({ roomId, messages, members, onEdit, onReact }: MessageListProps) { +export function MessageList({ roomId, messages, members, myRooms, onEdit, onReact }: MessageListProps) { const { user } = useAuth() const bottomRef = useRef(null) const [editingId, setEditingId] = useState(null) @@ -168,7 +169,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess )} {msg.content && (
- + {msg.edited_at && (edited)}
)} diff --git a/frontend/src/pages/ChatShellPage.tsx b/frontend/src/pages/ChatShellPage.tsx index c2aa5aa..6717719 100644 --- a/frontend/src/pages/ChatShellPage.tsx +++ b/frontend/src/pages/ChatShellPage.tsx @@ -117,6 +117,7 @@ export function ChatShellPage() { navigate('/rooms')}