Private
Public Access
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>
81 lines
2.3 KiB
Python
81 lines
2.3 KiB
Python
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()
|