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
+131
View File
@@ -0,0 +1,131 @@
import uuid
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user
from app.models import User
from app.schemas.custom_emoji import CustomEmojiRead
from app.services.custom_emoji_service import (
SHORTCODE_PATTERN,
CustomEmojiNotFoundError,
DuplicateShortcodeError,
InvalidShortcodeError,
NotEmojiOwnerError,
create_custom_emoji,
delete_custom_emoji,
get_custom_emoji_by_shortcode,
list_custom_emoji,
)
from app.services.upload_settings_service import format_mb, get_upload_settings
from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES,
UPLOADS_DIR,
InvalidImageError,
UploadTooLargeError,
process_image,
read_capped,
save_file,
)
# Small and square -- these render inline in message text/reaction pills at
# roughly text size, nowhere near message-image or avatar dimensions.
CUSTOM_EMOJI_MAX_DIMENSION = 128
router = APIRouter(prefix="/api/custom-emoji", tags=["custom-emoji"])
@router.post("", response_model=CustomEmojiRead, status_code=201)
async def upload_custom_emoji_endpoint(
shortcode: str = Form(...),
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
shortcode = shortcode.strip().lower()
# Checked here, before any file processing/saving, so the common
# rejection cases (bad format, name taken) never leave an orphaned
# file on disk -- create_custom_emoji below still re-checks both
# (the actual source of truth, and the only thing that closes the
# TOCTOU race on the uniqueness check).
if not SHORTCODE_PATTERN.match(shortcode):
raise HTTPException(
status_code=400,
detail="Shortcode must be 2-30 characters: lowercase letters, numbers, hyphens, underscores",
)
if await get_custom_emoji_by_shortcode(db, shortcode) is not None:
raise HTTPException(status_code=409, detail="An emoji with that shortcode already exists")
if file.content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
raise HTTPException(status_code=400, detail="Unsupported image type")
upload_settings = await get_upload_settings(db)
try:
data = await read_capped(file, cap=upload_settings.max_upload_bytes)
except UploadTooLargeError:
raise HTTPException(
status_code=413,
detail=f"Image exceeds {format_mb(upload_settings.max_upload_bytes)} limit",
)
try:
data, ext = process_image(
data, file.content_type, square=True, max_dimension=CUSTOM_EMOJI_MAX_DIMENSION
)
except InvalidImageError:
raise HTTPException(status_code=400, detail="File is not a valid image")
storage_filename = save_file(data, ext)
try:
return await create_custom_emoji(
db, current_user.id, shortcode, storage_filename, file.content_type
)
except (InvalidShortcodeError, DuplicateShortcodeError):
# Already checked above -- only reachable via the uniqueness
# check's TOCTOU race (two uploads of the same new shortcode at
# once), not the common case.
raise HTTPException(status_code=409, detail="An emoji with that shortcode already exists")
@router.get("", response_model=list[CustomEmojiRead])
async def list_custom_emoji_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_custom_emoji(db)
@router.delete("/{emoji_id}", status_code=204)
async def delete_custom_emoji_endpoint(
emoji_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
await delete_custom_emoji(db, emoji_id, current_user)
except CustomEmojiNotFoundError:
raise HTTPException(status_code=404, detail="Custom emoji not found")
except NotEmojiOwnerError:
raise HTTPException(status_code=403, detail="Only the uploader or a site admin can remove this")
@router.get("/{shortcode}/image")
async def get_custom_emoji_image_endpoint(
shortcode: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
emoji = await get_custom_emoji_by_shortcode(db, shortcode)
if emoji is None:
raise HTTPException(status_code=404, detail="Custom emoji not found")
return FileResponse(
UPLOADS_DIR / emoji.storage_filename,
media_type=emoji.content_type,
# Site-wide and rarely changed, but a shortcode can be deleted and
# re-uploaded with different image data -- short-cache like the
# avatar endpoint, not `immutable` like content-addressed message
# images.
headers={"Cache-Control": "private, max-age=300"},
)