Private
Public Access
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:
@@ -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()
|
||||
Reference in New Issue
Block a user