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:
ksmithandClaude Sonnet 5 committed 2026-08-17 08:56:40 -06:00
1 parent bd3e621e9f
commit 4bac502b2c
23 files changed
+702 -49

No files matched your search

+66 -3
View File
@@ -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>
}