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
@@ -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>
)
}