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,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 ###
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
|
||||
@@ -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:"
|
||||
Reference in New Issue
Block a user