Files
ds-chat/frontend/src/components/MessageContent.tsx
T
ksmithandClaude Sonnet 5 03cc16f236 Remove auto-large emoji-only messages, keep manual emoji size (#71)
The manual emoji-size preference already covers this well enough on
its own; the automatic 2.5x bump for emoji-only messages was extra
behavior on top of it that wasn't needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 18:33:50 -06:00

450 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Markdown from 'markdown-to-jsx'
import type { CSSProperties, ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { getCustomEmojiUrl } from '../api/customEmoji'
import { useAuth } from '../context/AuthContext'
import { useCustomEmoji } from '../context/CustomEmojiContext'
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
import './MessageContent.css'
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>
// #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 {
src?: string
alt?: string
title?: string
}
// Markdown image embeds (`![alt](url)`) degrade to a link instead of an
// <img> -- the app already has a first-class image-attachment upload
// (MessageImage), and a second, unmoderated remote-image path would both
// duplicate that and leak the viewer's IP/UA to arbitrary URLs.
function MarkdownImageLink({ src, alt, title }: MarkdownImageLinkProps) {
if (!src) return null
return (
<a href={src} target="_blank" rel="noopener noreferrer" title={title}>
{alt || src}
</a>
)
}
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. 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.
// convertSubSuperscript (#21) reuses the identical trick for `~sub~`/`^sup^`
// -- markdown-to-jsx has no plugin hook for new inline syntax, but a link is
// something it already parses correctly, so `sub:`/`sup:` "URLs" are just
// another carrier for meaning the parser was never told about. Everything
// else renders as a real external link, same as before mentions existed.
function MarkdownLink({ href, children }: MarkdownLinkProps) {
if (href?.startsWith('mention:')) {
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>
)
}
if (href === 'sub:') {
return <sub>{children}</sub>
}
if (href === 'sup:') {
return <sup>{children}</sup>
}
if (href?.startsWith('emoji:')) {
const shortcode = href.slice('emoji:'.length)
return (
<img
src={getCustomEmojiUrl(shortcode)}
alt={`:${shortcode}:`}
title={`:${shortcode}:`}
className="message-custom-emoji"
/>
)
}
// #71: a raw unicode emoji has no element of its own to size independently
// of the surrounding text -- it's just characters in a string. Wrapping
// each one individually (see wrapEmojiGlyphs below) gives it one, purely
// so the emoji-size preference can scale it via CSS the same way it
// already scales a custom emoji's <img>.
if (href === 'glyph:') {
return <span className="inline-emoji">{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
// blocks and inline code spans -- someone pasting code containing
// `:something:` (a Ruby symbol, a dict key) shouldn't get it silently
// turned into an emoji. This is render-time only: stored/sent content
// always keeps the literal `:name:` text, matching how markdown itself is
// never converted until display.
function convertShortcodes(text: string): string {
const lines = text.split('\n')
let inFence = false
return lines
.map((line) => {
if (/^\s*```/.test(line)) {
inFence = !inFence
return line
}
if (inFence) return line
// Splitting on backtick-delimited spans keeps inline code (`:foo:`)
// untouched -- odd-indexed segments are the code spans themselves.
return line
.split(/(`+[^`]*`+)/g)
.map((part, i) =>
i % 2 === 0
? part.replace(SHORTCODE_PATTERN, (match, name) => EMOJI_SHORTCODES[name] ?? match)
: part,
)
.join('')
})
.join('\n')
}
// #21: single tilde/caret delimiters, no spaces inside, and not doubled --
// `~~text~~` is strikethrough (already natively supported) so a leading or
// trailing extra `~` excludes the match, matching markdownguide.org's
// extended syntax for both constructs. Converts a complete `~sub~`/`^sup^`
// span to `[sub](sub:)`/`[sup](sup:)` -- see MarkdownLink's comment for why
// a link is the carrier.
const SUBSCRIPT_PATTERN = /(?<!~)~([^~\s]+)~(?!~)/g
const SUPERSCRIPT_PATTERN = /\^([^^\s]+)\^/g
function convertSubSuperscript(text: string): string {
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(SUBSCRIPT_PATTERN, (_match, inner: string) => `[${inner}](sub:)`)
.replace(SUPERSCRIPT_PATTERN, (_match, inner: string) => `[${inner}](sup:)`)
: part,
)
.join('')
})
.join('\n')
}
// #21: markdown-to-jsx has no option for an explicit heading anchor --
// every heading already gets an auto-generated slug from its own text
// (useful for linking within a message), and `{#custom-id}` is meant to
// *override* that slug, not add a second id next to it. There's no plugin
// hook for new block syntax either, so this strips the marker from the
// heading's own text (same fence-skipping convention as the functions
// above) and remembers the association by that now-bare text -- the one
// hook markdown-to-jsx *does* expose, `slugify` (see createMarkdownOptions
// below), gets called with exactly that text, letting the requested id
// stand in for the auto-generated one.
const HEADING_ID_PATTERN = /^(#{1,6}\s+.*?)\s*\{#([a-zA-Z0-9_-]+)\}\s*$/
function extractHeadingIds(text: string): { text: string; headingIds: Map<string, string> } {
const headingIds = new Map<string, string>()
const lines = text.split('\n')
let inFence = false
const nextLines = lines.map((line) => {
if (/^\s*```/.test(line)) {
inFence = !inFence
return line
}
if (inFence) return line
const match = line.match(HEADING_ID_PATTERN)
if (!match) return line
const [, headingLine, customId] = match
headingIds.set(headingLine.replace(/^#{1,6}\s+/, ''), customId)
return headingLine
})
return { text: nextLines.join('\n'), headingIds }
}
// #18: a *complete* `:name:` that survived convertShortcodes above (it only
// replaces names it recognizes, so an unmatched one -- built-in or not --
// passes through untouched) and matches a shortcode this install actually
// has a custom emoji for. Turns it into `[​:name:​](emoji:name)`, the same
// link-trick MarkdownLink's other branches use -- deliberately reusing the
// exact fence/code-span-skip convention every other converter in this file
// follows, for the same reason (a pasted `:some_key:` in code shouldn't
// light up as an emoji any more than an unrelated one should).
const CUSTOM_EMOJI_PATTERN = /:([a-z0-9_-]+):/g
function convertCustomEmojiShortcodes(text: string, shortcodes: Set<string>): string {
if (shortcodes.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(CUSTOM_EMOJI_PATTERN, (match, name) =>
shortcodes.has(name) ? `[${match}](emoji:${name})` : match,
)
: part,
)
.join('')
})
.join('\n')
}
// Reaction pills and the "recently used" emoji row don't go through the
// markdown pipeline at all -- they render a single stored value directly.
// A custom emoji's value there is its literal `:shortcode:` (see
// backend's MessageReaction.emoji); this is the equivalent one-value
// resolution for those spots, so a deleted-since-reacted-with custom
// emoji degrades to plain `:shortcode:` text instead of a broken image.
interface EmojiGlyphProps {
value: string
}
export function EmojiGlyph({ value }: EmojiGlyphProps) {
const { byShortcode } = useCustomEmoji()
const match = /^:([a-z0-9_-]+):$/.exec(value)
const shortcode = match?.[1]
if (shortcode && byShortcode.has(shortcode)) {
return (
<img
src={getCustomEmojiUrl(shortcode)}
alt={value}
title={value}
className="message-custom-emoji"
/>
)
}
// Wrapped the same way wrapEmojiGlyphs wraps a raw emoji in message text
// (see .inline-emoji), so a --emoji-scale set on an ancestor (the
// reaction pill's own span in MessageList.tsx) scales this the same way
// it scales the .message-custom-emoji img above -- and falls back to a
// no-op 1x everywhere else (the picker) with no --emoji-scale set at all.
return <span className="inline-emoji">{value}</span>
}
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')
}
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),
// 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
// newline expecting a visible line break, so without this, existing
// multi-line messages would silently collapse onto one line once markdown
// parsing is introduced. This restores that behavior by appending a hard-
// break marker to single newlines, while leaving fenced code blocks (where
// trailing whitespace shouldn't be added) and blank-line paragraph breaks
// untouched.
function preserveLineBreaks(text: string): string {
const lines = text.split('\n')
let inFence = false
return lines
.map((line, i) => {
if (/^\s*```/.test(line)) inFence = !inFence
const isLast = i === lines.length - 1
const nextIsBlank = !isLast && lines[i + 1] === ''
if (inFence || isLast || nextIsBlank || line === '') return line
return line + ' '
})
.join('\n')
}
// Shared with FilePreviewModal so both render paths carry the exact same
// XSS mitigation (disableParsingRawHTML) and #21's heading-id/sub/superscript
// support -- duplicating this would risk the two drifting out of sync if
// one gets edited later. A function, not a plain constant, since `slugify`
// needs each render's own headingIds map (see extractHeadingIds above) --
// there's no per-render state to close over in a module-level object.
export function createMarkdownOptions(headingIds: Map<string, string>) {
return {
// The core XSS mitigation: raw HTML in message content is escaped
// and printed literally instead of being parsed into elements.
disableParsingRawHTML: true,
overrides: {
a: { component: MarkdownLink },
img: { component: MarkdownImageLink },
},
slugify: (input: string, defaultFn: (input: string) => string) =>
headingIds.get(input) ?? defaultFn(input),
}
}
// #21: preprocessing shared by MessageContent and FilePreviewModal --
// subscript/superscript and heading-id overrides are general markdown
// features, not chat-specific like mentions/shortcodes/room-references, so
// a plain file preview gets them too.
export function preprocessMarkdown(text: string): { text: string; headingIds: Map<string, string> } {
return extractHeadingIds(convertSubSuperscript(text))
}
// #71: gives every individual unicode emoji its own element (see
// MarkdownLink's `glyph:` branch) purely so the emoji-size preference can
// scale it independently of the surrounding text -- a raw emoji is just
// characters in a string otherwise, with nothing CSS can address on its
// own. Runs after convertShortcodes so a built-in `:name:` that just
// became a glyph is wrapped too ("all emoji", not just ones typed as
// literal unicode); same fence/code-span skip convention as every other
// converter here.
const EMOJI_GLYPH_PATTERN = /\p{Extended_Pictographic}(?:\p{Emoji_Modifier}|\u200D\p{Extended_Pictographic}|\uFE0F)*/gu
function wrapEmojiGlyphs(text: string): string {
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(EMOJI_GLYPH_PATTERN, (match) => `[${match}](glyph:)`) : part))
.join('')
})
.join('\n')
}
// Exported so MessageList's reaction pills can apply the same viewer
// preference to their own EmojiGlyph -- reactions render outside the
// markdown pipeline entirely (see EmojiGlyph's own comment above), so they
// need this looked up independently rather than inheriting --emoji-scale
// from this component's wrapper div.
export const EMOJI_SCALE_MULTIPLIER: Record<string, number> = {
small: 0.8,
normal: 1,
large: 1.5,
xlarge: 2,
}
export function MessageContent({ content, memberUsernames, myRooms }: MessageContentProps) {
const { user } = useAuth()
const { byShortcode } = useCustomEmoji()
const customShortcodes = new Set(byShortcode.keys())
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions
const withCustomEmoji = convertCustomEmojiShortcodes(convertShortcodes(withRoomRefs), customShortcodes)
const withEmojiGlyphs = wrapEmojiGlyphs(withCustomEmoji)
const { text, headingIds } = preprocessMarkdown(withEmojiGlyphs)
// #71: scoped to this element (not a :root-level variable) so it only
// ever affects emoji rendered in message text -- not the same
// .message-custom-emoji/EmojiGlyph markup reused by the emoji picker's
// grid, where a bigger image would just break its fixed-size layout
// instead of doing anything useful. Reaction pills DO scale too, but via
// their own inline --emoji-scale in MessageList.tsx, not by inheriting
// this one -- a pill isn't a descendant of this wrapper div.
const emojiScale = EMOJI_SCALE_MULTIPLIER[user?.emoji_scale ?? 'normal']
return (
<div style={{ '--emoji-scale': emojiScale } as CSSProperties}>
<Markdown options={createMarkdownOptions(headingIds)}>{preserveLineBreaks(text)}</Markdown>
</div>
)
}