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:
2026-08-28 20:47:49 -06:00
co-authored by Claude Sonnet 5
parent b26643527d
commit 2e84ca42b7
23 changed files with 1056 additions and 28 deletions
+5 -2
View File
@@ -1,6 +1,7 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { ChatSocketProvider } from './context/ChatSocketContext'
import { CustomEmojiProvider } from './context/CustomEmojiContext'
import { AdminRoute } from './components/AdminRoute'
import { DesktopNotificationBridge } from './components/DesktopNotificationBridge'
import { ProtectedRoute } from './components/ProtectedRoute'
@@ -67,8 +68,10 @@ function AppRoutes() {
if (!user) return routes
return (
<ChatSocketProvider key={user.id}>
<DesktopNotificationBridge />
{routes}
<CustomEmojiProvider>
<DesktopNotificationBridge />
{routes}
</CustomEmojiProvider>
</ChatSocketProvider>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { apiFetch, ApiError, NetworkError } from './client'
import type { CustomEmoji } from '../types'
export function listCustomEmoji(): Promise<CustomEmoji[]> {
return apiFetch<CustomEmoji[]>('/api/custom-emoji')
}
export function getCustomEmojiUrl(shortcode: string): string {
return `/api/custom-emoji/${encodeURIComponent(shortcode)}/image`
}
export function deleteCustomEmoji(id: string): Promise<void> {
return apiFetch<void>(`/api/custom-emoji/${id}`, { method: 'DELETE' })
}
// Raw fetch, not apiFetch -- same multipart-boundary reason as
// uploadAvatar/uploadRoomImage (a manually-set Content-Type header would
// omit the boundary the browser generates for FormData).
export async function uploadCustomEmoji(shortcode: string, file: File): Promise<CustomEmoji> {
const formData = new FormData()
formData.append('shortcode', shortcode)
formData.append('file', file)
let response: Response
try {
response = await fetch('/api/custom-emoji', {
method: 'POST',
credentials: 'include',
body: formData,
})
} catch {
throw new NetworkError()
}
if (!response.ok) {
let detail = response.statusText
try {
const body = await response.json()
detail = body.detail ?? detail
} catch {
// response had no JSON body
}
throw new ApiError(response.status, detail)
}
return (await response.json()) as CustomEmoji
}
+33 -11
View File
@@ -12,6 +12,7 @@ import { useEscapeKey } from '../hooks/useEscapeKey'
import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
import { getUploadLimit } from '../api/uploads'
import { useCustomEmoji } from '../context/CustomEmojiContext'
import { EMOJI_SHORTCODES, SHORTCODE_BY_GLYPH } from '../lib/emojiShortcodes'
import { formatFileSize } from '../lib/fileSize'
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
@@ -135,6 +136,7 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
const [emojiActiveIndex, setEmojiActiveIndex] = useState(0)
const [dragActive, setDragActive] = useState(false)
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
const { byShortcode: customEmojiByShortcode } = useCustomEmoji()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// #29: a separate input with an image/video accept hint, so mobile
@@ -170,20 +172,33 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
const q = emojiQuery.text.toLowerCase()
// A bare ":" with nothing typed yet -- suggest recently-used emoji
// (already capped to 8, see recentEmoji.ts) rather than an arbitrary
// slice of the ~950 known shortcodes.
// slice of the ~950 known shortcodes. A recent custom-emoji pick is
// stored as its literal `:shortcode:` (see recordEmojiUsed's call
// sites) -- resolved against the live registry the same way, so a
// since-deleted one just doesn't show up here.
if (!q) {
return getRecentEmoji()
.map((glyph) => {
const shortcode = SHORTCODE_BY_GLYPH[glyph]
return shortcode ? { shortcode, glyph } : null
.map((value) => {
const customMatch = /^:([a-z0-9_-]+):$/.exec(value)
if (customMatch && customEmojiByShortcode.has(customMatch[1])) {
return { shortcode: customMatch[1], glyph: null }
}
const shortcode = SHORTCODE_BY_GLYPH[value]
return shortcode ? { shortcode, glyph: value } : null
})
.filter((match): match is EmojiShortcodeMatch => match !== null)
}
return Object.keys(EMOJI_SHORTCODES)
// Custom emoji surface first -- a smaller, more specific set, and the
// whole reason this app has an upload feature at all is for them to be
// reachable as easily as the built-in set.
const customMatches: EmojiShortcodeMatch[] = [...customEmojiByShortcode.keys()]
.filter((shortcode) => shortcode.startsWith(q))
.map((shortcode) => ({ shortcode, glyph: null }))
const builtinMatches: EmojiShortcodeMatch[] = Object.keys(EMOJI_SHORTCODES)
.filter((shortcode) => shortcode.startsWith(q))
.slice(0, 8)
.map((shortcode) => ({ shortcode, glyph: EMOJI_SHORTCODES[shortcode] }))
}, [emojiQuery])
return [...customMatches, ...builtinMatches].slice(0, 8)
}, [emojiQuery, customEmojiByShortcode])
useEffect(() => {
getUploadLimit()
@@ -248,20 +263,27 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
function selectEmojiShortcode(shortcode: string) {
const query = emojiQuery
if (!query) return
// A custom emoji has no unicode glyph to substitute -- its literal
// `:shortcode:` text is what actually gets stored/rendered (see
// MessageContent.tsx's convertCustomEmojiShortcodes), so that's what
// goes in the textarea instead of a glyph.
const isCustom = customEmojiByShortcode.has(shortcode)
const glyph = EMOJI_SHORTCODES[shortcode]
if (!query || !glyph) return
if (!isCustom && !glyph) return
const inserted = isCustom ? `:${shortcode}:` : glyph
// Matches EmojiPicker's own insertEmoji -- a shortcode-completed emoji
// counts as "used" the same as one picked from the picker, so it
// shows up there too next time.
recordEmojiUsed(glyph)
recordEmojiUsed(inserted)
const el = textareaRef.current
const next = value.slice(0, query.start) + glyph + ' ' + value.slice(query.end)
const next = value.slice(0, query.start) + inserted + ' ' + value.slice(query.end)
setValue(next)
setEmojiQuery(null)
requestAnimationFrame(() => {
if (!el) return
el.focus()
const cursor = query.start + glyph.length + 1 // glyph + trailing space
const cursor = query.start + inserted.length + 1 // inserted text + trailing space
el.setSelectionRange(cursor, cursor)
autoGrow()
})
@@ -45,6 +45,13 @@
line-height: 1;
}
.composer-autocomplete-custom-emoji {
display: block;
width: 1rem;
height: 1rem;
object-fit: contain;
}
.composer-autocomplete-secondary {
font-size: 0.76rem;
color: var(--ds-muted);
@@ -0,0 +1,108 @@
import { useState, type ChangeEvent, type FormEvent } from 'react'
import { uploadCustomEmoji } from '../api/customEmoji'
import { ApiError } from '../api/client'
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
import './Modal.css'
interface CustomEmojiUploadModalProps {
onClose: () => void
onUploaded: () => void
}
// Mirrors the shortcode charset the backend actually enforces (see
// backend/app/services/custom_emoji_service.py's SHORTCODE_PATTERN) --
// checked here too so a bad name shows up immediately next to the field
// instead of only after a round trip.
const SHORTCODE_PATTERN = /^[a-z0-9_-]{2,30}$/
export function CustomEmojiUploadModal({ onClose, onUploaded }: CustomEmojiUploadModalProps) {
const [shortcode, setShortcode] = useState('')
const [file, setFile] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
const selected = e.target.files?.[0] ?? null
if (previewUrl) URL.revokeObjectURL(previewUrl)
setFile(selected)
setPreviewUrl(selected ? URL.createObjectURL(selected) : null)
}
function handleClose() {
if (previewUrl) URL.revokeObjectURL(previewUrl)
onClose()
}
const normalizedShortcode = shortcode.trim().toLowerCase()
const shortcodeValid = SHORTCODE_PATTERN.test(normalizedShortcode)
// A built-in shortcode always wins when :name: is typed in a message
// (see MessageContent.tsx's convertShortcodes, which runs first) -- a
// custom emoji uploaded under a colliding name would still upload fine,
// but could never actually be *reached* by typing its shortcode. Not a
// hard block (site-admin-free upload means no server-side authority to
// enforce this against ~950 names), just steered away from here.
const collidesWithBuiltin = shortcodeValid && normalizedShortcode in EMOJI_SHORTCODES
async function handleSubmit(e: FormEvent) {
e.preventDefault()
if (!file || !shortcodeValid) return
setSubmitting(true)
setError(null)
try {
await uploadCustomEmoji(normalizedShortcode, file)
onUploaded()
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err))
setSubmitting(false)
}
}
return (
<div className="modal-scrim" onClick={handleClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Add custom emoji</h2>
<button type="button" className="modal-close" onClick={handleClose} aria-label="Close">
&times;
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-field-label">Shortcode</div>
<input
type="text"
value={shortcode}
onChange={(e) => setShortcode(e.target.value)}
placeholder="party-parrot"
autoFocus
/>
{shortcode && !shortcodeValid && (
<p className="modal-error">
2-30 characters: lowercase letters, numbers, hyphens, underscores
</p>
)}
{collidesWithBuiltin && (
<p className="modal-error">
:{normalizedShortcode}: is already a built-in emoji -- typing it will always show that
one instead of yours
</p>
)}
<div className="modal-field-label">Image</div>
<input type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleFileSelected} />
{previewUrl && (
<img src={previewUrl} alt="Preview" className="custom-emoji-upload-preview" />
)}
{error && <p className="modal-error">{error}</p>}
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={handleClose}>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={submitting || !file || !shortcodeValid}>
{submitting ? 'Uploading…' : 'Add emoji'}
</button>
</div>
</form>
</div>
</div>
)
}
+46
View File
@@ -69,6 +69,26 @@
padding: 4px 4px 2px;
}
.emoji-picker-category-label-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.emoji-picker-add-custom {
background: transparent;
border: none;
color: var(--ds-accent);
font-size: 0.7rem;
font-weight: 600;
cursor: pointer;
padding: 2px 4px;
}
.emoji-picker-add-custom:hover {
text-decoration: underline;
}
.emoji-picker-grid {
display: grid;
grid-template-columns: repeat(9, 1fr);
@@ -89,6 +109,32 @@
background: var(--ds-surface-2);
}
.emoji-picker-item-custom {
position: relative;
}
.emoji-picker-item-remove {
position: absolute;
top: -2px;
right: -2px;
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--ds-danger);
color: white;
font-size: 0.65rem;
line-height: 1;
opacity: 0;
cursor: pointer;
}
.emoji-picker-item-custom:hover .emoji-picker-item-remove {
opacity: 1;
}
/* The picker is positioned absolutely relative to its trigger button, which
can sit close enough to a narrow viewport's edge that the full 320px
width runs off-screen (e.g. the composer's emoji trigger, near the left
+95 -9
View File
@@ -1,8 +1,13 @@
import { useMemo, useState } from 'react'
import { useMemo, useState, type MouseEvent } from 'react'
import { deleteCustomEmoji } from '../api/customEmoji'
import { useAuth } from '../context/AuthContext'
import { useCustomEmoji } from '../context/CustomEmojiContext'
import { useEscapeKey } from '../hooks/useEscapeKey'
import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji'
import { EMOJI_NAMES } from '../lib/emojiNames'
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
import { CustomEmojiUploadModal } from './CustomEmojiUploadModal'
import { EmojiGlyph } from './MessageContent'
import './EmojiPicker.css'
interface EmojiPickerProps {
@@ -17,7 +22,17 @@ interface EmojiPickerProps {
// available viewport space) need this to know how much room to check for.
export const EMOJI_PICKER_MAX_HEIGHT = 380
function searchEmoji(query: string): string[] {
// Every emoji this picker deals with -- built-in or custom -- is just a
// string from here on: a raw unicode glyph, or a custom emoji's literal
// `:shortcode:` reference (see EmojiGlyph in MessageContent.tsx, which
// resolves either into the right thing to render). Keeping both kinds in
// the same list/search/recent machinery means there's exactly one grid
// rendering path instead of a parallel one for custom emoji.
function titleFor(value: string): string {
return EMOJI_NAMES[value]?.name ?? value
}
function searchEmoji(query: string, customShortcodes: string[]): string[] {
const q = query.trim().toLowerCase()
if (!q) return []
const seen = new Set<string>()
@@ -32,13 +47,21 @@ function searchEmoji(query: string): string[] {
results.push(emoji)
}
}
for (const shortcode of customShortcodes) {
if (shortcode.toLowerCase().includes(q)) results.push(`:${shortcode}:`)
}
return results
}
export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) {
useEscapeKey(onClose)
const { user } = useAuth()
const { list: customEmoji, refresh: refreshCustomEmoji } = useCustomEmoji()
const [query, setQuery] = useState('')
const searchResults = useMemo(() => searchEmoji(query), [query])
const [uploadOpen, setUploadOpen] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const customShortcodes = useMemo(() => customEmoji.map((e) => e.shortcode), [customEmoji])
const searchResults = useMemo(() => searchEmoji(query, customShortcodes), [query, customShortcodes])
const searching = query.trim().length > 0
// A snapshot taken once when the picker opens, not live-updating as picks
// happen within this same session -- picking an emoji always closes the
@@ -51,6 +74,18 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
onPick(emoji)
}
async function handleDeleteCustomEmoji(e: MouseEvent, emojiId: string) {
// Delete, not pick -- must never bubble to the button's own onClick.
e.stopPropagation()
setDeletingId(emojiId)
try {
await deleteCustomEmoji(emojiId)
await refreshCustomEmoji()
} finally {
setDeletingId(null)
}
}
return (
<>
<div className="emoji-picker-scrim" onClick={onClose} />
@@ -75,10 +110,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
type="button"
role="menuitem"
className="emoji-picker-item"
title={EMOJI_NAMES[emoji]?.name}
title={titleFor(emoji)}
onClick={() => pick(emoji)}
>
{emoji}
<EmojiGlyph value={emoji} />
</button>
))}
</div>
@@ -87,6 +122,48 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
)
) : (
<>
<div className="emoji-picker-category">
<div className="emoji-picker-category-label-row">
<div className="emoji-picker-category-label">Custom</div>
<button
type="button"
className="emoji-picker-add-custom"
onClick={() => setUploadOpen(true)}
>
+ Add
</button>
</div>
{customEmoji.length > 0 && (
<div className="emoji-picker-grid">
{customEmoji.map((e) => {
const canDelete = user?.id === e.uploaded_by || user?.is_site_admin
return (
<button
key={e.id}
type="button"
role="menuitem"
className="emoji-picker-item emoji-picker-item-custom"
title={`:${e.shortcode}:`}
onClick={() => pick(`:${e.shortcode}:`)}
>
<EmojiGlyph value={`:${e.shortcode}:`} />
{canDelete && (
<span
role="button"
aria-label={`Remove :${e.shortcode}:`}
className="emoji-picker-item-remove"
onClick={(ev) => handleDeleteCustomEmoji(ev, e.id)}
style={deletingId === e.id ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
>
×
</span>
)}
</button>
)
})}
</div>
)}
</div>
{recent.length > 0 && (
<div className="emoji-picker-category">
<div className="emoji-picker-category-label">Recently used</div>
@@ -97,10 +174,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
type="button"
role="menuitem"
className="emoji-picker-item"
title={EMOJI_NAMES[emoji]?.name}
title={titleFor(emoji)}
onClick={() => pick(emoji)}
>
{emoji}
<EmojiGlyph value={emoji} />
</button>
))}
</div>
@@ -116,10 +193,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
type="button"
role="menuitem"
className="emoji-picker-item"
title={EMOJI_NAMES[emoji]?.name}
title={titleFor(emoji)}
onClick={() => pick(emoji)}
>
{emoji}
<EmojiGlyph value={emoji} />
</button>
))}
</div>
@@ -128,6 +205,15 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
</>
)}
</div>
{uploadOpen && (
<CustomEmojiUploadModal
onClose={() => setUploadOpen(false)}
onUploaded={() => {
refreshCustomEmoji()
setUploadOpen(false)
}}
/>
)}
</>
)
}
@@ -1,8 +1,11 @@
import { getCustomEmojiUrl } from '../api/customEmoji'
import './ComposerAutocomplete.css'
export interface EmojiShortcodeMatch {
shortcode: string
glyph: string
// null for a custom emoji -- there's no unicode glyph to show, so the
// row renders its uploaded image instead (see getCustomEmojiUrl below).
glyph: string | null
}
interface EmojiShortcodeAutocompleteProps {
@@ -34,7 +37,15 @@ export function EmojiShortcodeAutocomplete({
onClick={() => onPick(match.shortcode)}
onMouseEnter={() => onHover(i)}
>
<span className="composer-autocomplete-emoji-glyph">{match.glyph}</span>
<span className="composer-autocomplete-emoji-glyph">
{match.glyph ?? (
<img
src={getCustomEmojiUrl(match.shortcode)}
alt=""
className="composer-autocomplete-custom-emoji"
/>
)}
</span>
<span className="composer-autocomplete-primary">:{match.shortcode}:</span>
</button>
))}
@@ -0,0 +1,13 @@
/* #18: em-relative, deliberately -- renders correctly inline in message
text, inside a reaction pill, and inside the emoji picker's grid without
a separate override per context, since each of those already sets its
own font-size and this just tracks it. Kept in this file (imported
directly by MessageContent.tsx) rather than MessageList.css so it's
loaded wherever MessageContent renders -- FilePreviewModal and HelpPage
included, not just the message list. */
.message-custom-emoji {
height: 1.2em;
width: 1.2em;
object-fit: contain;
vertical-align: -0.25em;
}
+82 -1
View File
@@ -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>
}
+4 -2
View File
@@ -8,7 +8,7 @@ import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
import { ImageLightbox } from './ImageLightbox'
import { LinkPreviewCard } from './LinkPreviewCard'
import { MessageContent } from './MessageContent'
import { EmojiGlyph, MessageContent } from './MessageContent'
import { UserAvatar } from './UserAvatar'
import { VideoLightbox } from './VideoLightbox'
import './MessageList.css'
@@ -302,7 +302,9 @@ export function MessageList({
title={r.user_ids.map(displayNameForUserId).join(', ')}
onClick={() => onReact(msg.id, r.emoji)}
>
<span>{r.emoji}</span>
<span>
<EmojiGlyph value={r.emoji} />
</span>
<span>{r.count}</span>
</button>
)
+11
View File
@@ -429,6 +429,17 @@
color: var(--ds-muted);
}
.custom-emoji-upload-preview {
display: block;
width: 64px;
height: 64px;
object-fit: contain;
margin-top: var(--sp-2);
background: var(--ds-surface-2);
border-radius: var(--radius);
border: 1px solid var(--ds-border);
}
.modal-list-row-action {
flex: none;
background: transparent;
@@ -0,0 +1,49 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import { listCustomEmoji } from '../api/customEmoji'
import type { CustomEmoji } from '../types'
interface CustomEmojiContextValue {
// Every consumer needs one of two things: "does this shortcode exist"
// (MessageContent's :shortcode: -> <img> conversion, keyed by name) or
// "the full list to render" (EmojiPicker's Custom category) -- a Map
// serves both without a second data structure.
byShortcode: Map<string, CustomEmoji>
list: CustomEmoji[]
// Called after a successful upload/delete so every consumer (picker,
// already-rendered messages using a shortcode that didn't exist a
// moment ago) picks up the change without a full page reload.
refresh: () => Promise<void>
}
const CustomEmojiContext = createContext<CustomEmojiContextValue | undefined>(undefined)
export function CustomEmojiProvider({ children }: { children: ReactNode }) {
const [list, setList] = useState<CustomEmoji[]>([])
const refresh = useCallback(async () => {
try {
setList(await listCustomEmoji())
} catch {
// Non-critical -- the app works fine with an empty/stale custom-emoji
// set, same treatment as ProfileModal's custom-themes fetch.
}
}, [])
useEffect(() => {
refresh()
}, [refresh])
const byShortcode = useMemo(() => new Map(list.map((e) => [e.shortcode, e])), [list])
return (
<CustomEmojiContext.Provider value={{ byShortcode, list, refresh }}>
{children}
</CustomEmojiContext.Provider>
)
}
export function useCustomEmoji(): CustomEmojiContextValue {
const ctx = useContext(CustomEmojiContext)
if (!ctx) throw new Error('useCustomEmoji must be used within a CustomEmojiProvider')
return ctx
}
+8
View File
@@ -26,6 +26,14 @@ export interface CustomTheme {
created_at: string
}
// #18: site-wide, uploaded by any user -- see backend's app/models/custom_emoji.py.
export interface CustomEmoji {
id: string
shortcode: string
uploaded_by: string
created_at: string
}
export interface User {
id: string
username: string