diff --git a/backend/alembic/versions/a318850726ee_add_custom_emoji.py b/backend/alembic/versions/a318850726ee_add_custom_emoji.py new file mode 100644 index 0000000..c4390fd --- /dev/null +++ b/backend/alembic/versions/a318850726ee_add_custom_emoji.py @@ -0,0 +1,43 @@ +"""add custom emoji + +Revision ID: a318850726ee +Revises: 319c30e24cd9 +Create Date: 2026-08-28 20:34:27.293658 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a318850726ee' +down_revision: Union[str, Sequence[str], None] = '319c30e24cd9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('custom_emoji', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('shortcode', sa.String(length=30), nullable=False), + sa.Column('storage_filename', sa.String(length=64), nullable=False), + sa.Column('content_type', sa.String(length=50), nullable=False), + sa.Column('uploaded_by', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_custom_emoji_shortcode'), 'custom_emoji', ['shortcode'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_custom_emoji_shortcode'), table_name='custom_emoji') + op.drop_table('custom_emoji') + # ### end Alembic commands ### diff --git a/backend/app/main.py b/backend/app/main.py index 4c9658a..b41af0e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,7 @@ from app.routers import ( admin, auth, bots, + custom_emoji, custom_themes, health, push, @@ -93,6 +94,7 @@ def create_app() -> FastAPI: app.include_router(users.router) app.include_router(push.router) app.include_router(custom_themes.router) + app.include_router(custom_emoji.router) app.include_router(uploads.router) app.include_router(admin.router) app.include_router(bots.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 3587601..0598c8d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,7 @@ from app.models.admin_audit_log import AdminAuditLog from app.models.api_token import ApiToken from app.models.base import Base +from app.models.custom_emoji import CustomEmoji from app.models.custom_theme import CustomTheme from app.models.event_subscription import EventSubscription from app.models.invite import InviteStatus @@ -46,5 +47,6 @@ __all__ = [ "WebhookIncoming", "EventSubscription", "CustomTheme", + "CustomEmoji", "LinkPreview", ] diff --git a/backend/app/models/custom_emoji.py b/backend/app/models/custom_emoji.py new file mode 100644 index 0000000..c05936d --- /dev/null +++ b/backend/app/models/custom_emoji.py @@ -0,0 +1,28 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base + + +class CustomEmoji(Base): + __tablename__ = "custom_emoji" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + # #18: site-wide, not room-scoped -- kept globally unique so a bare + # `:shortcode:` in any message/reaction is unambiguous without also + # knowing which room it was posted in. 30 chars, not 32 -- the stored + # *reference* in MessageReaction.emoji (String(32)) is the shortcode + # wrapped in colons, so this is sized to leave room for both without + # widening that column. + shortcode: Mapped[str] = mapped_column(String(30), unique=True, index=True, nullable=False) + storage_filename: Mapped[str] = mapped_column(String(64), nullable=False) + content_type: Mapped[str] = mapped_column(String(50), nullable=False) + uploaded_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + uploader = relationship("User") diff --git a/backend/app/routers/custom_emoji.py b/backend/app/routers/custom_emoji.py new file mode 100644 index 0000000..f581c72 --- /dev/null +++ b/backend/app/routers/custom_emoji.py @@ -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"}, + ) diff --git a/backend/app/schemas/custom_emoji.py b/backend/app/schemas/custom_emoji.py new file mode 100644 index 0000000..1bc9ba7 --- /dev/null +++ b/backend/app/schemas/custom_emoji.py @@ -0,0 +1,13 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class CustomEmojiRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + shortcode: str + uploaded_by: uuid.UUID + created_at: datetime diff --git a/backend/app/services/custom_emoji_service.py b/backend/app/services/custom_emoji_service.py new file mode 100644 index 0000000..1249005 --- /dev/null +++ b/backend/app/services/custom_emoji_service.py @@ -0,0 +1,80 @@ +import re +import uuid + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import CustomEmoji, User +from app.storage import delete_file + +# Deliberately stricter than the built-in Unicode shortcode set's charset +# (see frontend/src/lib/emojiShortcodes.ts, which also allows '+') -- this +# is validating a *new name being chosen*, not matching against an +# existing fixed list, so there's no reason to allow anything a person +# wouldn't naturally type. Max 30 chars matches CustomEmoji.shortcode's +# column width exactly (see that model's comment for why). +SHORTCODE_PATTERN = re.compile(r"^[a-z0-9_-]{2,30}$") + + +class InvalidShortcodeError(Exception): + pass + + +class DuplicateShortcodeError(Exception): + pass + + +class CustomEmojiNotFoundError(Exception): + pass + + +class NotEmojiOwnerError(Exception): + pass + + +async def create_custom_emoji( + db: AsyncSession, + uploaded_by: uuid.UUID, + shortcode: str, + storage_filename: str, + content_type: str, +) -> CustomEmoji: + if not SHORTCODE_PATTERN.match(shortcode): + raise InvalidShortcodeError() + + emoji = CustomEmoji( + shortcode=shortcode, + storage_filename=storage_filename, + content_type=content_type, + uploaded_by=uploaded_by, + ) + db.add(emoji) + try: + await db.commit() + except IntegrityError as exc: + await db.rollback() + raise DuplicateShortcodeError() from exc + await db.refresh(emoji) + return emoji + + +async def list_custom_emoji(db: AsyncSession) -> list[CustomEmoji]: + result = await db.execute(select(CustomEmoji).order_by(CustomEmoji.shortcode)) + return list(result.scalars().all()) + + +async def get_custom_emoji_by_shortcode(db: AsyncSession, shortcode: str) -> CustomEmoji | None: + result = await db.execute(select(CustomEmoji).where(CustomEmoji.shortcode == shortcode)) + return result.scalar_one_or_none() + + +async def delete_custom_emoji(db: AsyncSession, emoji_id: uuid.UUID, current_user: User) -> None: + emoji = await db.get(CustomEmoji, emoji_id) + if emoji is None: + raise CustomEmojiNotFoundError() + if emoji.uploaded_by != current_user.id and not current_user.is_site_admin: + raise NotEmojiOwnerError() + delete_file(emoji.storage_filename) + await db.delete(emoji) + await db.commit() diff --git a/backend/app/ws/chat.py b/backend/app/ws/chat.py index 854a3e4..d015b03 100644 --- a/backend/app/ws/chat.py +++ b/backend/app/ws/chat.py @@ -311,7 +311,11 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db) envelope.room_id is None or envelope.message_id is None or not envelope.emoji - or len(envelope.emoji) > 8 + # #18: a raw unicode glyph never gets close to this, + # but a custom emoji reaction is stored as its + # literal `:shortcode:` text (see MessageReaction.emoji's + # String(32) column, which this matches exactly). + or len(envelope.emoji) > 32 ): await websocket.send_json( {"type": "error", "detail": "room_id, message_id, and emoji required"} diff --git a/backend/tests/test_custom_emoji.py b/backend/tests/test_custom_emoji.py new file mode 100644 index 0000000..428b8cd --- /dev/null +++ b/backend/tests/test_custom_emoji.py @@ -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:" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6303422..1a66d9c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - - {routes} + + + {routes} + ) } diff --git a/frontend/src/api/customEmoji.ts b/frontend/src/api/customEmoji.ts new file mode 100644 index 0000000..5d0527f --- /dev/null +++ b/frontend/src/api/customEmoji.ts @@ -0,0 +1,47 @@ +import { apiFetch, ApiError, NetworkError } from './client' +import type { CustomEmoji } from '../types' + +export function listCustomEmoji(): Promise { + return apiFetch('/api/custom-emoji') +} + +export function getCustomEmojiUrl(shortcode: string): string { + return `/api/custom-emoji/${encodeURIComponent(shortcode)}/image` +} + +export function deleteCustomEmoji(id: string): Promise { + return apiFetch(`/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 { + 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 +} diff --git a/frontend/src/components/Composer.tsx b/frontend/src/components/Composer.tsx index 45194fe..bbfbdf4 100644 --- a/frontend/src/components/Composer.tsx +++ b/frontend/src/components/Composer.tsx @@ -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(null) const fileInputRef = useRef(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() }) diff --git a/frontend/src/components/ComposerAutocomplete.css b/frontend/src/components/ComposerAutocomplete.css index 77d552c..ed40266 100644 --- a/frontend/src/components/ComposerAutocomplete.css +++ b/frontend/src/components/ComposerAutocomplete.css @@ -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); diff --git a/frontend/src/components/CustomEmojiUploadModal.tsx b/frontend/src/components/CustomEmojiUploadModal.tsx new file mode 100644 index 0000000..d8a3c6f --- /dev/null +++ b/frontend/src/components/CustomEmojiUploadModal.tsx @@ -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(null) + const [previewUrl, setPreviewUrl] = useState(null) + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + function handleFileSelected(e: ChangeEvent) { + 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 ( +
+
e.stopPropagation()}> +
+

Add custom emoji

+ +
+
+
Shortcode
+ setShortcode(e.target.value)} + placeholder="party-parrot" + autoFocus + /> + {shortcode && !shortcodeValid && ( +

+ 2-30 characters: lowercase letters, numbers, hyphens, underscores +

+ )} + {collidesWithBuiltin && ( +

+ :{normalizedShortcode}: is already a built-in emoji -- typing it will always show that + one instead of yours +

+ )} +
Image
+ + {previewUrl && ( + Preview + )} + {error &&

{error}

} +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/EmojiPicker.css b/frontend/src/components/EmojiPicker.css index f60f9fe..b872a31 100644 --- a/frontend/src/components/EmojiPicker.css +++ b/frontend/src/components/EmojiPicker.css @@ -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 diff --git a/frontend/src/components/EmojiPicker.tsx b/frontend/src/components/EmojiPicker.tsx index 6ec692b..6e503a4 100644 --- a/frontend/src/components/EmojiPicker.tsx +++ b/frontend/src/components/EmojiPicker.tsx @@ -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() @@ -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(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 ( <>
@@ -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} + ))}
@@ -87,6 +122,48 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef ) ) : ( <> +
+
+
Custom
+ +
+ {customEmoji.length > 0 && ( +
+ {customEmoji.map((e) => { + const canDelete = user?.id === e.uploaded_by || user?.is_site_admin + return ( + + ) + })} +
+ )} +
{recent.length > 0 && (
Recently used
@@ -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} + ))}
@@ -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} + ))} @@ -128,6 +205,15 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef )} + {uploadOpen && ( + setUploadOpen(false)} + onUploaded={() => { + refreshCustomEmoji() + setUploadOpen(false) + }} + /> + )} ) } diff --git a/frontend/src/components/EmojiShortcodeAutocomplete.tsx b/frontend/src/components/EmojiShortcodeAutocomplete.tsx index 622236a..e763ffd 100644 --- a/frontend/src/components/EmojiShortcodeAutocomplete.tsx +++ b/frontend/src/components/EmojiShortcodeAutocomplete.tsx @@ -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)} > - {match.glyph} + + {match.glyph ?? ( + + )} + :{match.shortcode}: ))} diff --git a/frontend/src/components/MessageContent.css b/frontend/src/components/MessageContent.css new file mode 100644 index 0000000..2a806fb --- /dev/null +++ b/frontend/src/components/MessageContent.css @@ -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; +} diff --git a/frontend/src/components/MessageContent.tsx b/frontend/src/components/MessageContent.tsx index 2e70d0b..0531401 100644 --- a/frontend/src/components/MessageContent.tsx +++ b/frontend/src/components/MessageContent.tsx @@ -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 {children} } + if (href?.startsWith('emoji:')) { + const shortcode = href.slice('emoji:'.length) + return ( + {`:${shortcode}:`} + ) + } return ( {children} @@ -174,6 +188,68 @@ function extractHeadingIds(text: string): { text: string; headingIds: Map): 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 ( + {value} + ) + } + 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 {preserveLineBreaks(text)} } diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 3a8fcdd..3bf058a 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -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)} > - {r.emoji} + + + {r.count} ) diff --git a/frontend/src/components/Modal.css b/frontend/src/components/Modal.css index 4466e9b..c7d2cf6 100644 --- a/frontend/src/components/Modal.css +++ b/frontend/src/components/Modal.css @@ -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; diff --git a/frontend/src/context/CustomEmojiContext.tsx b/frontend/src/context/CustomEmojiContext.tsx new file mode 100644 index 0000000..ac7858e --- /dev/null +++ b/frontend/src/context/CustomEmojiContext.tsx @@ -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: -> 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 + 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 +} + +const CustomEmojiContext = createContext(undefined) + +export function CustomEmojiProvider({ children }: { children: ReactNode }) { + const [list, setList] = useState([]) + + 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 ( + + {children} + + ) +} + +export function useCustomEmoji(): CustomEmojiContextValue { + const ctx = useContext(CustomEmojiContext) + if (!ctx) throw new Error('useCustomEmoji must be used within a CustomEmojiProvider') + return ctx +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5020d12..08392ff 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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