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
+231
View File
@@ -0,0 +1,231 @@
import io
import uuid
from PIL import Image
from app.models import User
from tests.conftest import register_and_login
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _png_bytes(size: tuple[int, int] = (10, 10)) -> bytes:
buf = io.BytesIO()
Image.new("RGB", size, color=(255, 0, 0)).save(buf, format="PNG")
return buf.getvalue()
async def _make_admin(db_session, user_id: str) -> None:
user = await db_session.get(User, uuid.UUID(user_id))
user.is_site_admin = True
await db_session.commit()
async def test_upload_custom_emoji_succeeds(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.post(
"/api/custom-emoji",
data={"shortcode": "party-parrot"},
files={"file": ("parrot.png", _png_bytes(), "image/png")},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["shortcode"] == "party-parrot"
assert "id" in body
assert "created_at" in body
async def test_upload_normalizes_shortcode_case(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.post(
"/api/custom-emoji",
data={"shortcode": " PartyParrot "},
files={"file": ("parrot.png", _png_bytes(), "image/png")},
)
assert resp.status_code == 201, resp.text
assert resp.json()["shortcode"] == "partyparrot"
async def test_upload_rejects_invalid_shortcode(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.post(
"/api/custom-emoji",
data={"shortcode": "a"}, # too short
files={"file": ("x.png", _png_bytes(), "image/png")},
)
assert resp.status_code == 400
async def test_upload_rejects_duplicate_shortcode(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
first = await client.post(
"/api/custom-emoji",
data={"shortcode": "dupe-test"},
files={"file": ("a.png", _png_bytes(), "image/png")},
)
assert first.status_code == 201, first.text
second = await client.post(
"/api/custom-emoji",
data={"shortcode": "dupe-test"},
files={"file": ("b.png", _png_bytes(), "image/png")},
)
assert second.status_code == 409
async def test_upload_rejects_non_image(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.post(
"/api/custom-emoji",
data={"shortcode": "not-an-image"},
files={"file": ("x.txt", b"hello", "text/plain")},
)
assert resp.status_code == 400
async def test_upload_rejects_oversized(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
oversized = b"0" * (9 * 1024 * 1024)
resp = await client.post(
"/api/custom-emoji",
data={"shortcode": "too-big"},
files={"file": ("huge.png", oversized, "image/png")},
)
assert resp.status_code == 413
async def test_list_custom_emoji(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
await client.post(
"/api/custom-emoji",
data={"shortcode": _unique("listed")},
files={"file": ("a.png", _png_bytes(), "image/png")},
)
resp = await client.get("/api/custom-emoji")
assert resp.status_code == 200
assert len(resp.json()) >= 1
async def test_serve_custom_emoji_image_by_shortcode(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
shortcode = _unique("served")
upload = await client.post(
"/api/custom-emoji",
data={"shortcode": shortcode},
files={"file": ("a.png", _png_bytes(), "image/png")},
)
assert upload.status_code == 201
resp = await client.get(f"/api/custom-emoji/{shortcode}/image")
assert resp.status_code == 200
assert resp.headers["content-type"] == "image/png"
async def test_serve_unknown_shortcode_404s(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.get("/api/custom-emoji/no-such-emoji/image")
assert resp.status_code == 404
async def test_uploader_can_delete_own_emoji(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
upload = await client.post(
"/api/custom-emoji",
data={"shortcode": _unique("deleteme")},
files={"file": ("a.png", _png_bytes(), "image/png")},
)
emoji_id = upload.json()["id"]
resp = await client.delete(f"/api/custom-emoji/{emoji_id}")
assert resp.status_code == 204
listed = (await client.get("/api/custom-emoji")).json()
assert emoji_id not in [e["id"] for e in listed]
async def test_non_uploader_non_admin_cannot_delete(client, app, db_session):
from httpx import ASGITransport, AsyncClient
await register_and_login(client, db_session, username=_unique("alice"))
upload = await client.post(
"/api/custom-emoji",
data={"shortcode": _unique("guarded")},
files={"file": ("a.png", _png_bytes(), "image/png")},
)
emoji_id = upload.json()["id"]
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as bob_client:
await register_and_login(bob_client, db_session, username=_unique("bob"))
resp = await bob_client.delete(f"/api/custom-emoji/{emoji_id}")
assert resp.status_code == 403
listed = (await client.get("/api/custom-emoji")).json()
assert emoji_id in [e["id"] for e in listed]
async def test_site_admin_can_delete_others_emoji(client, app, db_session):
from httpx import ASGITransport, AsyncClient
alice = await register_and_login(client, db_session, username=_unique("alice"))
upload = await client.post(
"/api/custom-emoji",
data={"shortcode": _unique("admin-deletable")},
files={"file": ("a.png", _png_bytes(), "image/png")},
)
emoji_id = upload.json()["id"]
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as admin_client:
admin = await register_and_login(admin_client, db_session, username=_unique("admin"))
await _make_admin(db_session, admin["id"])
resp = await admin_client.delete(f"/api/custom-emoji/{emoji_id}")
assert resp.status_code == 204
assert alice["id"]
def _register_ws(ws_client, username: str) -> dict:
from app.schemas.user import UserCreate
from app.services.auth_service import register_user
async def _seed():
async with ws_client.session_factory() as session:
await register_user(
session,
UserCreate(username=username, email=f"{username}@example.com", password="password123"),
)
ws_client.portal.call(_seed)
resp = ws_client.post(
"/api/auth/login", json={"username_or_email": username, "password": "password123"}
)
assert resp.status_code == 200, resp.text
return resp.json()
def test_reaction_accepts_custom_emoji_shortcode_reference(ws_client_factory):
instance = ws_client_factory()
_register_ws(instance, _unique("alice"))
room = instance.post("/api/rooms", json={"name": _unique("general")}).json()
with instance.websocket_connect("/ws/chat") as ws:
ws.send_json({"type": "join", "room_id": room["id"]})
assert ws.receive_json()["type"] == "joined"
ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
message = ws.receive_json()
# A custom emoji reaction is stored as its literal `:shortcode:`
# text (14 chars here) -- well past the old 8-char cap that only
# ever needed to fit a raw unicode glyph.
ws.send_json(
{
"type": "reaction",
"room_id": room["id"],
"message_id": message["id"],
"emoji": ":party-parrot:",
}
)
reaction_update = ws.receive_json()
assert reaction_update["type"] == "reaction_update"
assert reaction_update["reactions"][0]["emoji"] == ":party-parrot:"