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:
1 parent
bd3e621e9f
commit
4bac502b2c
23 files changed
+702
-49
No files matched your search
@@ -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>
|
||||
}
|
||||
Reference in new issue
Block a user