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,
|
admin,
|
||||||
auth,
|
auth,
|
||||||
bots,
|
bots,
|
||||||
|
custom_emoji,
|
||||||
custom_themes,
|
custom_themes,
|
||||||
health,
|
health,
|
||||||
push,
|
push,
|
||||||
@@ -93,6 +94,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(push.router)
|
app.include_router(push.router)
|
||||||
app.include_router(custom_themes.router)
|
app.include_router(custom_themes.router)
|
||||||
|
app.include_router(custom_emoji.router)
|
||||||
app.include_router(uploads.router)
|
app.include_router(uploads.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(bots.router)
|
app.include_router(bots.router)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from app.models.admin_audit_log import AdminAuditLog
|
from app.models.admin_audit_log import AdminAuditLog
|
||||||
from app.models.api_token import ApiToken
|
from app.models.api_token import ApiToken
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
|
from app.models.custom_emoji import CustomEmoji
|
||||||
from app.models.custom_theme import CustomTheme
|
from app.models.custom_theme import CustomTheme
|
||||||
from app.models.event_subscription import EventSubscription
|
from app.models.event_subscription import EventSubscription
|
||||||
from app.models.invite import InviteStatus
|
from app.models.invite import InviteStatus
|
||||||
@@ -46,5 +47,6 @@ __all__ = [
|
|||||||
"WebhookIncoming",
|
"WebhookIncoming",
|
||||||
"EventSubscription",
|
"EventSubscription",
|
||||||
"CustomTheme",
|
"CustomTheme",
|
||||||
|
"CustomEmoji",
|
||||||
"LinkPreview",
|
"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
|
envelope.room_id is None
|
||||||
or envelope.message_id is None
|
or envelope.message_id is None
|
||||||
or not envelope.emoji
|
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(
|
await websocket.send_json(
|
||||||
{"type": "error", "detail": "room_id, message_id, and emoji required"}
|
{"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:"
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
import { ChatSocketProvider } from './context/ChatSocketContext'
|
import { ChatSocketProvider } from './context/ChatSocketContext'
|
||||||
|
import { CustomEmojiProvider } from './context/CustomEmojiContext'
|
||||||
import { AdminRoute } from './components/AdminRoute'
|
import { AdminRoute } from './components/AdminRoute'
|
||||||
import { DesktopNotificationBridge } from './components/DesktopNotificationBridge'
|
import { DesktopNotificationBridge } from './components/DesktopNotificationBridge'
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||||
@@ -67,8 +68,10 @@ function AppRoutes() {
|
|||||||
if (!user) return routes
|
if (!user) return routes
|
||||||
return (
|
return (
|
||||||
<ChatSocketProvider key={user.id}>
|
<ChatSocketProvider key={user.id}>
|
||||||
|
<CustomEmojiProvider>
|
||||||
<DesktopNotificationBridge />
|
<DesktopNotificationBridge />
|
||||||
{routes}
|
{routes}
|
||||||
|
</CustomEmojiProvider>
|
||||||
</ChatSocketProvider>
|
</ChatSocketProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { apiFetch, ApiError, NetworkError } from './client'
|
||||||
|
import type { CustomEmoji } from '../types'
|
||||||
|
|
||||||
|
export function listCustomEmoji(): Promise<CustomEmoji[]> {
|
||||||
|
return apiFetch<CustomEmoji[]>('/api/custom-emoji')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomEmojiUrl(shortcode: string): string {
|
||||||
|
return `/api/custom-emoji/${encodeURIComponent(shortcode)}/image`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCustomEmoji(id: string): Promise<void> {
|
||||||
|
return apiFetch<void>(`/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<CustomEmoji> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { useEscapeKey } from '../hooks/useEscapeKey'
|
|||||||
import { useOnlineStatus } from '../hooks/useOnlineStatus'
|
import { useOnlineStatus } from '../hooks/useOnlineStatus'
|
||||||
import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
|
import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
|
||||||
import { getUploadLimit } from '../api/uploads'
|
import { getUploadLimit } from '../api/uploads'
|
||||||
|
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
||||||
import { EMOJI_SHORTCODES, SHORTCODE_BY_GLYPH } from '../lib/emojiShortcodes'
|
import { EMOJI_SHORTCODES, SHORTCODE_BY_GLYPH } from '../lib/emojiShortcodes'
|
||||||
import { formatFileSize } from '../lib/fileSize'
|
import { formatFileSize } from '../lib/fileSize'
|
||||||
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
|
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 [emojiActiveIndex, setEmojiActiveIndex] = useState(0)
|
||||||
const [dragActive, setDragActive] = useState(false)
|
const [dragActive, setDragActive] = useState(false)
|
||||||
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
|
const [attachMenuOpen, setAttachMenuOpen] = useState(false)
|
||||||
|
const { byShortcode: customEmojiByShortcode } = useCustomEmoji()
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
// #29: a separate input with an image/video accept hint, so mobile
|
// #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()
|
const q = emojiQuery.text.toLowerCase()
|
||||||
// A bare ":" with nothing typed yet -- suggest recently-used emoji
|
// A bare ":" with nothing typed yet -- suggest recently-used emoji
|
||||||
// (already capped to 8, see recentEmoji.ts) rather than an arbitrary
|
// (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) {
|
if (!q) {
|
||||||
return getRecentEmoji()
|
return getRecentEmoji()
|
||||||
.map((glyph) => {
|
.map((value) => {
|
||||||
const shortcode = SHORTCODE_BY_GLYPH[glyph]
|
const customMatch = /^:([a-z0-9_-]+):$/.exec(value)
|
||||||
return shortcode ? { shortcode, glyph } : null
|
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)
|
.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))
|
.filter((shortcode) => shortcode.startsWith(q))
|
||||||
.slice(0, 8)
|
|
||||||
.map((shortcode) => ({ shortcode, glyph: EMOJI_SHORTCODES[shortcode] }))
|
.map((shortcode) => ({ shortcode, glyph: EMOJI_SHORTCODES[shortcode] }))
|
||||||
}, [emojiQuery])
|
return [...customMatches, ...builtinMatches].slice(0, 8)
|
||||||
|
}, [emojiQuery, customEmojiByShortcode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getUploadLimit()
|
getUploadLimit()
|
||||||
@@ -248,20 +263,27 @@ export function Composer({ roomId, roomName, isDm, members, rooms, disabled, arc
|
|||||||
|
|
||||||
function selectEmojiShortcode(shortcode: string) {
|
function selectEmojiShortcode(shortcode: string) {
|
||||||
const query = emojiQuery
|
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]
|
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
|
// Matches EmojiPicker's own insertEmoji -- a shortcode-completed emoji
|
||||||
// counts as "used" the same as one picked from the picker, so it
|
// counts as "used" the same as one picked from the picker, so it
|
||||||
// shows up there too next time.
|
// shows up there too next time.
|
||||||
recordEmojiUsed(glyph)
|
recordEmojiUsed(inserted)
|
||||||
const el = textareaRef.current
|
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)
|
setValue(next)
|
||||||
setEmojiQuery(null)
|
setEmojiQuery(null)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!el) return
|
if (!el) return
|
||||||
el.focus()
|
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)
|
el.setSelectionRange(cursor, cursor)
|
||||||
autoGrow()
|
autoGrow()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,6 +45,13 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-autocomplete-custom-emoji {
|
||||||
|
display: block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.composer-autocomplete-secondary {
|
.composer-autocomplete-secondary {
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
color: var(--ds-muted);
|
color: var(--ds-muted);
|
||||||
|
|||||||
@@ -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<File | null>(null)
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="modal-scrim" onClick={handleClose}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Add custom emoji</h2>
|
||||||
|
<button type="button" className="modal-close" onClick={handleClose} aria-label="Close">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-field-label">Shortcode</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={shortcode}
|
||||||
|
onChange={(e) => setShortcode(e.target.value)}
|
||||||
|
placeholder="party-parrot"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{shortcode && !shortcodeValid && (
|
||||||
|
<p className="modal-error">
|
||||||
|
2-30 characters: lowercase letters, numbers, hyphens, underscores
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{collidesWithBuiltin && (
|
||||||
|
<p className="modal-error">
|
||||||
|
:{normalizedShortcode}: is already a built-in emoji -- typing it will always show that
|
||||||
|
one instead of yours
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="modal-field-label">Image</div>
|
||||||
|
<input type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleFileSelected} />
|
||||||
|
{previewUrl && (
|
||||||
|
<img src={previewUrl} alt="Preview" className="custom-emoji-upload-preview" />
|
||||||
|
)}
|
||||||
|
{error && <p className="modal-error">{error}</p>}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="btn-secondary" onClick={handleClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary" disabled={submitting || !file || !shortcodeValid}>
|
||||||
|
{submitting ? 'Uploading…' : 'Add emoji'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -69,6 +69,26 @@
|
|||||||
padding: 4px 4px 2px;
|
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 {
|
.emoji-picker-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(9, 1fr);
|
grid-template-columns: repeat(9, 1fr);
|
||||||
@@ -89,6 +109,32 @@
|
|||||||
background: var(--ds-surface-2);
|
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
|
/* 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
|
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
|
width runs off-screen (e.g. the composer's emoji trigger, near the left
|
||||||
|
|||||||
@@ -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 { useEscapeKey } from '../hooks/useEscapeKey'
|
||||||
import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji'
|
import { ALL_EMOJI, EMOJI_CATEGORIES } from '../lib/emoji'
|
||||||
import { EMOJI_NAMES } from '../lib/emojiNames'
|
import { EMOJI_NAMES } from '../lib/emojiNames'
|
||||||
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
|
import { getRecentEmoji, recordEmojiUsed } from '../lib/recentEmoji'
|
||||||
|
import { CustomEmojiUploadModal } from './CustomEmojiUploadModal'
|
||||||
|
import { EmojiGlyph } from './MessageContent'
|
||||||
import './EmojiPicker.css'
|
import './EmojiPicker.css'
|
||||||
|
|
||||||
interface EmojiPickerProps {
|
interface EmojiPickerProps {
|
||||||
@@ -17,7 +22,17 @@ interface EmojiPickerProps {
|
|||||||
// available viewport space) need this to know how much room to check for.
|
// available viewport space) need this to know how much room to check for.
|
||||||
export const EMOJI_PICKER_MAX_HEIGHT = 380
|
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()
|
const q = query.trim().toLowerCase()
|
||||||
if (!q) return []
|
if (!q) return []
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
@@ -32,13 +47,21 @@ function searchEmoji(query: string): string[] {
|
|||||||
results.push(emoji)
|
results.push(emoji)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const shortcode of customShortcodes) {
|
||||||
|
if (shortcode.toLowerCase().includes(q)) results.push(`:${shortcode}:`)
|
||||||
|
}
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) {
|
export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) {
|
||||||
useEscapeKey(onClose)
|
useEscapeKey(onClose)
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { list: customEmoji, refresh: refreshCustomEmoji } = useCustomEmoji()
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const searchResults = useMemo(() => searchEmoji(query), [query])
|
const [uploadOpen, setUploadOpen] = useState(false)
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||||
|
const customShortcodes = useMemo(() => customEmoji.map((e) => e.shortcode), [customEmoji])
|
||||||
|
const searchResults = useMemo(() => searchEmoji(query, customShortcodes), [query, customShortcodes])
|
||||||
const searching = query.trim().length > 0
|
const searching = query.trim().length > 0
|
||||||
// A snapshot taken once when the picker opens, not live-updating as picks
|
// A snapshot taken once when the picker opens, not live-updating as picks
|
||||||
// happen within this same session -- picking an emoji always closes the
|
// 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)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="emoji-picker-scrim" onClick={onClose} />
|
<div className="emoji-picker-scrim" onClick={onClose} />
|
||||||
@@ -75,10 +110,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className="emoji-picker-item"
|
className="emoji-picker-item"
|
||||||
title={EMOJI_NAMES[emoji]?.name}
|
title={titleFor(emoji)}
|
||||||
onClick={() => pick(emoji)}
|
onClick={() => pick(emoji)}
|
||||||
>
|
>
|
||||||
{emoji}
|
<EmojiGlyph value={emoji} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -87,6 +122,48 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div className="emoji-picker-category">
|
||||||
|
<div className="emoji-picker-category-label-row">
|
||||||
|
<div className="emoji-picker-category-label">Custom</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="emoji-picker-add-custom"
|
||||||
|
onClick={() => setUploadOpen(true)}
|
||||||
|
>
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{customEmoji.length > 0 && (
|
||||||
|
<div className="emoji-picker-grid">
|
||||||
|
{customEmoji.map((e) => {
|
||||||
|
const canDelete = user?.id === e.uploaded_by || user?.is_site_admin
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={e.id}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="emoji-picker-item emoji-picker-item-custom"
|
||||||
|
title={`:${e.shortcode}:`}
|
||||||
|
onClick={() => pick(`:${e.shortcode}:`)}
|
||||||
|
>
|
||||||
|
<EmojiGlyph value={`:${e.shortcode}:`} />
|
||||||
|
{canDelete && (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label={`Remove :${e.shortcode}:`}
|
||||||
|
className="emoji-picker-item-remove"
|
||||||
|
onClick={(ev) => handleDeleteCustomEmoji(ev, e.id)}
|
||||||
|
style={deletingId === e.id ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{recent.length > 0 && (
|
{recent.length > 0 && (
|
||||||
<div className="emoji-picker-category">
|
<div className="emoji-picker-category">
|
||||||
<div className="emoji-picker-category-label">Recently used</div>
|
<div className="emoji-picker-category-label">Recently used</div>
|
||||||
@@ -97,10 +174,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className="emoji-picker-item"
|
className="emoji-picker-item"
|
||||||
title={EMOJI_NAMES[emoji]?.name}
|
title={titleFor(emoji)}
|
||||||
onClick={() => pick(emoji)}
|
onClick={() => pick(emoji)}
|
||||||
>
|
>
|
||||||
{emoji}
|
<EmojiGlyph value={emoji} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,10 +193,10 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
|||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
className="emoji-picker-item"
|
className="emoji-picker-item"
|
||||||
title={EMOJI_NAMES[emoji]?.name}
|
title={titleFor(emoji)}
|
||||||
onClick={() => pick(emoji)}
|
onClick={() => pick(emoji)}
|
||||||
>
|
>
|
||||||
{emoji}
|
<EmojiGlyph value={emoji} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,6 +205,15 @@ export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'lef
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{uploadOpen && (
|
||||||
|
<CustomEmojiUploadModal
|
||||||
|
onClose={() => setUploadOpen(false)}
|
||||||
|
onUploaded={() => {
|
||||||
|
refreshCustomEmoji()
|
||||||
|
setUploadOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { getCustomEmojiUrl } from '../api/customEmoji'
|
||||||
import './ComposerAutocomplete.css'
|
import './ComposerAutocomplete.css'
|
||||||
|
|
||||||
export interface EmojiShortcodeMatch {
|
export interface EmojiShortcodeMatch {
|
||||||
shortcode: string
|
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 {
|
interface EmojiShortcodeAutocompleteProps {
|
||||||
@@ -34,7 +37,15 @@ export function EmojiShortcodeAutocomplete({
|
|||||||
onClick={() => onPick(match.shortcode)}
|
onClick={() => onPick(match.shortcode)}
|
||||||
onMouseEnter={() => onHover(i)}
|
onMouseEnter={() => onHover(i)}
|
||||||
>
|
>
|
||||||
<span className="composer-autocomplete-emoji-glyph">{match.glyph}</span>
|
<span className="composer-autocomplete-emoji-glyph">
|
||||||
|
{match.glyph ?? (
|
||||||
|
<img
|
||||||
|
src={getCustomEmojiUrl(match.shortcode)}
|
||||||
|
alt=""
|
||||||
|
className="composer-autocomplete-custom-emoji"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="composer-autocomplete-primary">:{match.shortcode}:</span>
|
<span className="composer-autocomplete-primary">:{match.shortcode}:</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import Markdown from 'markdown-to-jsx'
|
import Markdown from 'markdown-to-jsx'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { getCustomEmojiUrl } from '../api/customEmoji'
|
||||||
|
import { useCustomEmoji } from '../context/CustomEmojiContext'
|
||||||
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
|
import { EMOJI_SHORTCODES } from '../lib/emojiShortcodes'
|
||||||
|
import './MessageContent.css'
|
||||||
|
|
||||||
interface MessageContentProps {
|
interface MessageContentProps {
|
||||||
content: string
|
content: string
|
||||||
@@ -71,6 +74,17 @@ function MarkdownLink({ href, children }: MarkdownLinkProps) {
|
|||||||
if (href === 'sup:') {
|
if (href === 'sup:') {
|
||||||
return <sup>{children}</sup>
|
return <sup>{children}</sup>
|
||||||
}
|
}
|
||||||
|
if (href?.startsWith('emoji:')) {
|
||||||
|
const shortcode = href.slice('emoji:'.length)
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={getCustomEmojiUrl(shortcode)}
|
||||||
|
alt={`:${shortcode}:`}
|
||||||
|
title={`:${shortcode}:`}
|
||||||
|
className="message-custom-emoji"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||||
{children}
|
{children}
|
||||||
@@ -174,6 +188,68 @@ function extractHeadingIds(text: string): { text: string; headingIds: Map<string
|
|||||||
return { text: nextLines.join('\n'), headingIds }
|
return { text: nextLines.join('\n'), headingIds }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #18: a *complete* `:name:` that survived convertShortcodes above (it only
|
||||||
|
// replaces names it recognizes, so an unmatched one -- built-in or not --
|
||||||
|
// passes through untouched) and matches a shortcode this install actually
|
||||||
|
// has a custom emoji for. Turns it into `[:name:](emoji:name)`, the same
|
||||||
|
// link-trick MarkdownLink's other branches use -- deliberately reusing the
|
||||||
|
// exact fence/code-span-skip convention every other converter in this file
|
||||||
|
// follows, for the same reason (a pasted `:some_key:` in code shouldn't
|
||||||
|
// light up as an emoji any more than an unrelated one should).
|
||||||
|
const CUSTOM_EMOJI_PATTERN = /:([a-z0-9_-]+):/g
|
||||||
|
|
||||||
|
function convertCustomEmojiShortcodes(text: string, shortcodes: Set<string>): 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 (
|
||||||
|
<img
|
||||||
|
src={getCustomEmojiUrl(shortcode)}
|
||||||
|
alt={value}
|
||||||
|
title={value}
|
||||||
|
className="message-custom-emoji"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <>{value}</>
|
||||||
|
}
|
||||||
|
|
||||||
const MENTION_PATTERN = /@([a-zA-Z0-9_.-]+)/g
|
const MENTION_PATTERN = /@([a-zA-Z0-9_.-]+)/g
|
||||||
|
|
||||||
// Turns a validated @username into `[@username](mention:username)` --
|
// 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) {
|
export function MessageContent({ content, memberUsernames, myRooms }: MessageContentProps) {
|
||||||
|
const { byShortcode } = useCustomEmoji()
|
||||||
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
|
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
|
||||||
const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions
|
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 <Markdown options={createMarkdownOptions(headingIds)}>{preserveLineBreaks(text)}</Markdown>
|
return <Markdown options={createMarkdownOptions(headingIds)}>{preserveLineBreaks(text)}</Markdown>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { EMOJI_PICKER_MAX_HEIGHT, EmojiPicker } from './EmojiPicker'
|
|||||||
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
|
import { FilePreviewModal, getPreviewKind } from './FilePreviewModal'
|
||||||
import { ImageLightbox } from './ImageLightbox'
|
import { ImageLightbox } from './ImageLightbox'
|
||||||
import { LinkPreviewCard } from './LinkPreviewCard'
|
import { LinkPreviewCard } from './LinkPreviewCard'
|
||||||
import { MessageContent } from './MessageContent'
|
import { EmojiGlyph, MessageContent } from './MessageContent'
|
||||||
import { UserAvatar } from './UserAvatar'
|
import { UserAvatar } from './UserAvatar'
|
||||||
import { VideoLightbox } from './VideoLightbox'
|
import { VideoLightbox } from './VideoLightbox'
|
||||||
import './MessageList.css'
|
import './MessageList.css'
|
||||||
@@ -302,7 +302,9 @@ export function MessageList({
|
|||||||
title={r.user_ids.map(displayNameForUserId).join(', ')}
|
title={r.user_ids.map(displayNameForUserId).join(', ')}
|
||||||
onClick={() => onReact(msg.id, r.emoji)}
|
onClick={() => onReact(msg.id, r.emoji)}
|
||||||
>
|
>
|
||||||
<span>{r.emoji}</span>
|
<span>
|
||||||
|
<EmojiGlyph value={r.emoji} />
|
||||||
|
</span>
|
||||||
<span>{r.count}</span>
|
<span>{r.count}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -429,6 +429,17 @@
|
|||||||
color: var(--ds-muted);
|
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 {
|
.modal-list-row-action {
|
||||||
flex: none;
|
flex: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -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: -> <img> 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<string, CustomEmoji>
|
||||||
|
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<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomEmojiContext = createContext<CustomEmojiContextValue | undefined>(undefined)
|
||||||
|
|
||||||
|
export function CustomEmojiProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [list, setList] = useState<CustomEmoji[]>([])
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<CustomEmojiContext.Provider value={{ byShortcode, list, refresh }}>
|
||||||
|
{children}
|
||||||
|
</CustomEmojiContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCustomEmoji(): CustomEmojiContextValue {
|
||||||
|
const ctx = useContext(CustomEmojiContext)
|
||||||
|
if (!ctx) throw new Error('useCustomEmoji must be used within a CustomEmojiProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -26,6 +26,14 @@ export interface CustomTheme {
|
|||||||
created_at: string
|
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 {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
|
|||||||
Reference in New Issue
Block a user