Private
Public Access
Add custom emoji support (#18)
Site-wide, any user can upload -- usable both as reactions and inline in message text via :shortcode:, alongside the existing built-in Unicode picker. A :shortcode: reference is stored/sent as literal text (same as the built-in shortcode convention) and resolved to an image at render time, so it degrades to plain text if the emoji is later deleted. Backend: new custom_emoji table (shortcode unique, sized to fit MessageReaction.emoji's existing column alongside its colons), upload/ list/delete endpoints (delete restricted to uploader or site admin). Frontend: a CustomEmojiProvider context feeds a new "Custom" category in the emoji picker (inline upload + hover-to-remove), extends the composer's shortcode autocomplete, and a shared EmojiGlyph resolver renders custom emoji wherever a value can appear -- message text, reaction pills, and the picker itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b26643527d
commit
2e84ca42b7
23 files changed
+1056
-28
No files matched your search
@@ -1,7 +1,10 @@
|
||||
import Markdown from 'markdown-to-jsx'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getCustomEmojiUrl } from '../api/customEmoji'
|
||||
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
||||
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
|
||||
import './MessageContent.css'
|
||||
|
||||
interface MessageContentProps {
|
||||
content: string
|
||||
@@ -71,6 +74,17 @@ function MarkdownLink({ href, children }: MarkdownLinkProps) {
|
||||
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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
@@ -174,6 +188,68 @@ function extractHeadingIds(text: string): { text: string; headingIds: Map<string
|
||||
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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <>{value}</>
|
||||
}
|
||||
|
||||
const MENTION_PATTERN = /@([a-zA-Z0-9_.-]+)/g
|
||||
|
||||
// Turns a validated @username into `[@username](mention:username)` --
|
||||
@@ -295,8 +371,13 @@ export function preprocessMarkdown(text: string): { text: string; headingIds: Ma
|
||||
}
|
||||
|
||||
export function MessageContent({ content, memberUsernames, myRooms }: MessageContentProps) {
|
||||
const { byShortcode } = useCustomEmoji()
|
||||
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
|
||||
const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions
|
||||
const { text, headingIds } = preprocessMarkdown(convertShortcodes(withRoomRefs))
|
||||
const withCustomEmoji = convertCustomEmojiShortcodes(
|
||||
convertShortcodes(withRoomRefs),
|
||||
new Set(byShortcode.keys()),
|
||||
)
|
||||
const { text, headingIds } = preprocessMarkdown(withCustomEmoji)
|
||||
return <Markdown options={createMarkdownOptions(headingIds)}>{preserveLineBreaks(text)}</Markdown>
|
||||
}
|
||||
Reference in new issue
Block a user