Add markdown :name: emoji shortcode support in messages (#27)

Typing a complete 😂/😉/etc. shortcode now renders as the emoji,
matching Slack/GitHub/Discord. Render-time only, alongside the existing
preserveLineBreaks preprocessing step -- stored/sent content keeps the
raw :name: text, same as markdown itself is never converted until
display. Fenced code blocks and inline code spans are skipped so
pasted code (a Ruby symbol, a dict key) isn't silently mangled.

frontend/src/lib/emojiShortcodes.ts is generated once from
emojibase-data's GitHub shortcode set (same one-time-generator approach
as #19's emojiNames.ts, never a runtime dependency) -- 928 of the
app's 936 emoji matched, 956 aliases, no collisions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 10:38:09 -06:00
co-authored by Claude Sonnet 5
parent 7dcc7104df
commit 1adb4fcbe0
2 changed files with 1001 additions and 1 deletions
+34 -1
View File
@@ -1,4 +1,5 @@
import Markdown from 'markdown-to-jsx'
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
interface MessageContentProps {
content: string
@@ -23,6 +24,38 @@ function MarkdownImageLink({ src, alt, title }: MarkdownImageLinkProps) {
)
}
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')
}
// 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
@@ -60,5 +93,5 @@ export const MARKDOWN_OPTIONS = {
}
export function MessageContent({ content }: MessageContentProps) {
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(content)}</Markdown>
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(content))}</Markdown>
}