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 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 19:03:18 -06:00
co-authored by Claude Sonnet 5
parent 8716fc5356
commit 12264b4d18
12 changed files with 305 additions and 12 deletions
@@ -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")
+2
View File
@@ -11,6 +11,7 @@ from app.models.message_file import MessageFile
from app.models.message_image import MessageImage from app.models.message_image import MessageImage
from app.models.message_mention import MessageMention from app.models.message_mention import MessageMention
from app.models.message_reaction import MessageReaction 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.password_reset import PasswordReset
from app.models.push_subscription import PushSubscription from app.models.push_subscription import PushSubscription
from app.models.room import Room from app.models.room import Room
@@ -31,6 +32,7 @@ __all__ = [
"MessageImage", "MessageImage",
"MessageMention", "MessageMention",
"MessageReaction", "MessageReaction",
"MessageRoomReference",
"InviteStatus", "InviteStatus",
"PasswordReset", "PasswordReset",
"SiteInvite", "SiteInvite",
@@ -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
)
+7 -5
View File
@@ -9,12 +9,14 @@ from app.models import RoomMembership, User
MENTION_PATTERN = re.compile(r"@([a-zA-Z0-9_.-]+)") 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 """Blanks out fenced code blocks and inline code spans (replacing with
equal-length whitespace, so a bare '@' in pasted code -- a decorator, an equal-length whitespace, so a bare '@'/'#' in pasted code -- a
email fragment -- doesn't page someone. Mirrors the same skip logic 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 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") lines = content.split("\n")
in_fence = False in_fence = False
out = [] out = []
@@ -37,7 +39,7 @@ async def extract_mentioned_user_ids(
"""Resolves `@username` tokens in `content` against this room's actual """Resolves `@username` tokens in `content` against this room's actual
members -- a bare '@' followed by prose that happens to not match members -- a bare '@' followed by prose that happens to not match
anyone's username is just text, not a mention.""" 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: if not usernames:
return set() return set()
+5 -1
View File
@@ -6,10 +6,11 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.models import Message, MessageMention, MessageReaction from app.models import Message, MessageMention, MessageReaction, MessageRoomReference
from app.schemas.message import ReactionSummary from app.schemas.message import ReactionSummary
from app.services.link_preview_service import extract_first_url from app.services.link_preview_service import extract_first_url
from app.services.mention_service import extract_mentioned_user_ids from app.services.mention_service import extract_mentioned_user_ids
from app.services.room_reference_service import extract_referenced_room_ids
class MessageNotFoundError(Exception): class MessageNotFoundError(Exception):
@@ -43,6 +44,9 @@ async def create_message(
mentioned_ids = await extract_mentioned_user_ids(db, room_id, content) mentioned_ids = await extract_mentioned_user_ids(db, room_id, content)
for mentioned_id in mentioned_ids: for mentioned_id in mentioned_ids:
db.add(MessageMention(message_id=message.id, user_id=mentioned_id)) 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.commit()
await db.refresh(message) await db.refresh(message)
return message return message
@@ -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()}
+119
View File
@@ -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") == []
+8
View File
@@ -9,6 +9,7 @@ import './ChatPane.css'
interface ChatPaneProps { interface ChatPaneProps {
room: MyRoomItem room: MyRoomItem
rooms: MyRoomItem[]
members: RoomMember[] members: RoomMember[]
isMobile: boolean isMobile: boolean
onBack: () => void onBack: () => void
@@ -20,6 +21,7 @@ interface ChatPaneProps {
export function ChatPane({ export function ChatPane({
room, room,
rooms,
members, members,
isMobile, isMobile,
onBack, onBack,
@@ -192,6 +194,11 @@ export function ChatPane({
[history, live], [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 connected = socket.connected
const send = useCallback( const send = useCallback(
(content: string, imageId?: string, fileId?: string) => socket.send(room.id, content, imageId, fileId), (content: string, imageId?: string, fileId?: string) => socket.send(room.id, content, imageId, fileId),
@@ -246,6 +253,7 @@ export function ChatPane({
roomId={room.id} roomId={room.id}
messages={messages} messages={messages}
members={members} members={members}
myRooms={myRooms}
onEdit={sendEdit} onEdit={sendEdit}
onReact={sendReaction} onReact={sendReaction}
/> />
+57 -4
View File
@@ -1,5 +1,6 @@
import Markdown from 'markdown-to-jsx' import Markdown from 'markdown-to-jsx'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes' import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
interface MessageContentProps { interface MessageContentProps {
@@ -9,6 +10,13 @@ interface MessageContentProps {
// component for markdown file previews, where "@mentioning a person" // component for markdown file previews, where "@mentioning a person"
// doesn't apply. // doesn't apply.
memberUsernames?: Set<string> memberUsernames?: Set<string>
// #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<string, string>
} }
interface MarkdownImageLinkProps { interface MarkdownImageLinkProps {
@@ -38,12 +46,22 @@ interface MarkdownLinkProps {
// highlightMentions (below) turns a validated @username into a // highlightMentions (below) turns a validated @username into a
// `[@username](mention:username)` link so markdown-to-jsx parses it as 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 // 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, // span instead of an actual anchor. highlightRoomReferences does the same
// same as before mentions existed. // 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) { function MarkdownLink({ href, children }: MarkdownLinkProps) {
if (href?.startsWith('mention:')) { if (href?.startsWith('mention:')) {
return <span className="message-mention">{children}</span> return <span className="message-mention">{children}</span>
} }
if (href?.startsWith('room:')) {
return (
<Link to={`/rooms/${href.slice('room:'.length)}`} className="message-room-reference">
{children}
</Link>
)
}
return ( return (
<a href={href} target="_blank" rel="noopener noreferrer"> <a href={href} target="_blank" rel="noopener noreferrer">
{children} {children}
@@ -118,6 +136,40 @@ function highlightMentions(text: string, memberUsernames: Set<string>): string {
.join('\n') .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, string>): 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), // CommonMark treats a single newline as a soft break (rendered as a space),
// not a visible line break -- only a trailing double-space or blank line // not a visible line break -- only a trailing double-space or blank line
// produces one. The Composer's Shift+Enter has always inserted a plain // produces one. The Composer's Shift+Enter has always inserted a plain
@@ -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 const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(withMentions))}</Markdown> const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(withRoomRefs))}</Markdown>
} }
+13
View File
@@ -128,6 +128,19 @@
font-weight: 700; 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 { .message-text code {
background: var(--ds-surface-2); background: var(--ds-surface-2);
padding: 1px 5px; padding: 1px 5px;
+3 -2
View File
@@ -64,11 +64,12 @@ interface MessageListProps {
roomId: string roomId: string
messages: (Message | ChatMessageEnvelope)[] messages: (Message | ChatMessageEnvelope)[]
members: RoomMember[] members: RoomMember[]
myRooms: Map<string, string>
onEdit: (messageId: string, content: string) => void onEdit: (messageId: string, content: string) => void
onReact: (messageId: string, emoji: 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 { user } = useAuth()
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
@@ -168,7 +169,7 @@ export function MessageList({ roomId, messages, members, onEdit, onReact }: Mess
)} )}
{msg.content && ( {msg.content && (
<div className="message-text"> <div className="message-text">
<MessageContent content={msg.content} memberUsernames={memberUsernames} /> <MessageContent content={msg.content} memberUsernames={memberUsernames} myRooms={myRooms} />
{msg.edited_at && <span className="message-edited"> (edited)</span>} {msg.edited_at && <span className="message-edited"> (edited)</span>}
</div> </div>
)} )}
+1
View File
@@ -117,6 +117,7 @@ export function ChatShellPage() {
<ChatPane <ChatPane
key={activeRoom.id} key={activeRoom.id}
room={activeRoom} room={activeRoom}
rooms={rooms}
members={members} members={members}
isMobile={isMobile} isMobile={isMobile}
onBack={() => navigate('/rooms')} onBack={() => navigate('/rooms')}