From c6f90d49fcc585c95cb3cf35bd84f380865a7a25 Mon Sep 17 00:00:00 2001
From: Keith Smith
Date: Fri, 14 Aug 2026 15:37:59 -0600
Subject: [PATCH] Add emoji picker and message reactions (Gitea issue #11)
Composer gains an emoji picker (static curated unicode list, insert at
cursor position) and messages gain Slack/Mattermost-style reactions:
react with any emoji, toggle off by reacting again, see who reacted via
a tooltip on each pill.
Backend: MessageReaction model (unique on message_id+user_id+emoji backs
toggle semantics), WS "reaction" envelope broadcasts the full recomputed
reaction list per message (same approach as message edits), REST message
list embeds reactions so a reload doesn't lose state that only arrived
over WS.
Frontend: shared EmojiPicker component (anchored popover, Escape/outside-
click dismiss via new useEscapeKey hook) used by both the composer and a
new hover-revealed reaction trigger on each message row.
---
backend/README.md | 39 ++-
.../1d355add7299_message_reactions.py | 44 ++++
backend/app/models/__init__.py | 2 +
backend/app/models/message_reaction.py | 28 +++
backend/app/routers/rooms.py | 4 +-
backend/app/schemas/message.py | 7 +
backend/app/services/message_events.py | 20 ++
backend/app/services/message_service.py | 54 ++++-
backend/app/ws/chat.py | 42 +++-
backend/tests/test_reactions.py | 229 ++++++++++++++++++
frontend/src/components/ChatPane.tsx | 17 +-
frontend/src/components/Composer.css | 29 +++
frontend/src/components/Composer.tsx | 39 +++
frontend/src/components/EmojiPicker.css | 65 +++++
frontend/src/components/EmojiPicker.tsx | 43 ++++
frontend/src/components/ImageLightbox.tsx | 10 +-
frontend/src/components/MessageList.css | 59 ++++-
frontend/src/components/MessageList.tsx | 71 +++++-
frontend/src/hooks/useEscapeKey.ts | 11 +
frontend/src/lib/emoji.ts | 51 ++++
frontend/src/types.ts | 16 ++
frontend/src/ws/useChatSocket.ts | 8 +-
22 files changed, 854 insertions(+), 34 deletions(-)
create mode 100644 backend/alembic/versions/1d355add7299_message_reactions.py
create mode 100644 backend/app/models/message_reaction.py
create mode 100644 backend/tests/test_reactions.py
create mode 100644 frontend/src/components/EmojiPicker.css
create mode 100644 frontend/src/components/EmojiPicker.tsx
create mode 100644 frontend/src/hooks/useEscapeKey.ts
create mode 100644 frontend/src/lib/emoji.ts
diff --git a/backend/README.md b/backend/README.md
index cd43c03..2806242 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1,4 +1,4 @@
-# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads)
+# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, emoji & reactions)
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
CRUD (open and private), room roles (owner/admin/member) and invites, a
@@ -6,8 +6,9 @@ WebSocket chat endpoint that fans out across multiple app-server instances
via Redis pub/sub, Web Push notifications for offline room members, a
site-admin portal (user/room/bot management + an audit log), a bot/
extension layer (scoped API tokens, live bot WebSocket access, incoming and
-outgoing webhooks, message editing), and image uploads in chat messages. See
-`../ARCHITECTURE.md` for the full system design and the phased build plan.
+outgoing webhooks, message editing), image uploads in chat messages, and
+emoji reactions on messages. See `../ARCHITECTURE.md` for the full system
+design and the phased build plan.
This is an **invite-only site**: there is no public registration endpoint.
Accounts are created by an operator on the app server โ see step 4 below.
@@ -119,9 +120,10 @@ app/
and on-disk save/read -- see Image uploads below
cli.py `python -m app.cli create-user` / `generate-vapid-keys`
models/ SQLAlchemy models (users, rooms, room_memberships,
- messages, message_images, room_invites,
- push_subscriptions, admin_audit_log, api_tokens,
- webhooks_incoming, event_subscriptions)
+ messages, message_images, message_reactions,
+ room_invites, push_subscriptions,
+ admin_audit_log, api_tokens, webhooks_incoming,
+ event_subscriptions)
schemas/ Pydantic request/response models
routers/ auth, rooms, invites, push, admin, bots, webhooks, health
services/ business logic called by routers
@@ -349,6 +351,31 @@ cleanup job for this yet. Not a security issue, since serving still goes
through the same room-membership gate as everything else; just an eventual
disk-space housekeeping item.
+## Emoji & reactions
+
+An emoji picker in the frontend composer is purely client-side (a static
+curated unicode list, no backend involvement). Message **reactions** are
+full-stack: `message_reactions` (`app/models/message_reaction.py`) has
+`message_id`, `user_id`, `emoji`, and a `UniqueConstraint` on all three
+backing toggle semantics โ the same user reacting with the same emoji on
+the same message twice removes it (Slack/Mattermost convention).
+`message_service.toggle_reaction` is a plain select-then-delete-or-insert,
+no upsert needed.
+
+WS `"reaction"` envelope (`room_id`, `message_id`, `emoji`) toggles a
+reaction; the server broadcasts the message's **full recomputed** reaction
+list (`{"type": "reaction_update", "id", "room_id", "reactions": [...]}`),
+not an add/remove delta โ same approach `message_update` already uses for
+edits, keeping client-side state a simple replace rather than a merge.
+`GET /{room_id}/messages` embeds each message's `reactions` too
+(`message_service.get_reactions_for_messages`, batched, not N+1), so a page
+reload doesn't lose reaction state that only ever arrived over WS.
+
+Scope cuts: no outgoing-webhook event type for reactions (`VALID_EVENT_TYPES`
+in `webhook_service.py` is unchanged โ same restraint as image uploads), no
+reaction-count limit or rate limiting, no custom/uploaded emoji (unicode
+only, curated client-side list in `frontend/src/lib/emoji.ts`).
+
## Notes / scope decisions
- Invite-only site registration: no `POST /api/auth/register`. Accounts are
diff --git a/backend/alembic/versions/1d355add7299_message_reactions.py b/backend/alembic/versions/1d355add7299_message_reactions.py
new file mode 100644
index 0000000..8254f8b
--- /dev/null
+++ b/backend/alembic/versions/1d355add7299_message_reactions.py
@@ -0,0 +1,44 @@
+"""message reactions
+
+Revision ID: 1d355add7299
+Revises: c610bdb04567
+Create Date: 2026-08-14 12:59:40.013407
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '1d355add7299'
+down_revision: Union[str, Sequence[str], None] = 'c610bdb04567'
+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('message_reactions',
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('message_id', sa.Uuid(), nullable=False),
+ sa.Column('user_id', sa.Uuid(), nullable=False),
+ sa.Column('emoji', sa.String(length=32), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.ForeignKeyConstraint(['message_id'], ['messages.id'], ),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('message_id', 'user_id', 'emoji', name='uq_message_reactions_message_user_emoji')
+ )
+ op.create_index(op.f('ix_message_reactions_message_id'), 'message_reactions', ['message_id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_message_reactions_message_id'), table_name='message_reactions')
+ op.drop_table('message_reactions')
+ # ### end Alembic commands ###
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 5279f36..edcc12b 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -6,6 +6,7 @@ from app.models.invite import InviteStatus, RoomInvite
from app.models.membership import RoomMembership, RoomRole
from app.models.message import Message
from app.models.message_image import MessageImage
+from app.models.message_reaction import MessageReaction
from app.models.push_subscription import PushSubscription
from app.models.room import Room
from app.models.user import User
@@ -19,6 +20,7 @@ __all__ = [
"RoomRole",
"Message",
"MessageImage",
+ "MessageReaction",
"RoomInvite",
"InviteStatus",
"PushSubscription",
diff --git a/backend/app/models/message_reaction.py b/backend/app/models/message_reaction.py
new file mode 100644
index 0000000..f9e5d4a
--- /dev/null
+++ b/backend/app/models/message_reaction.py
@@ -0,0 +1,28 @@
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.models.base import Base
+
+
+class MessageReaction(Base):
+ __tablename__ = "message_reactions"
+ __table_args__ = (
+ UniqueConstraint(
+ "message_id", "user_id", "emoji",
+ name="uq_message_reactions_message_user_emoji",
+ ),
+ )
+
+ id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
+ message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("messages.id"), index=True, nullable=False)
+ user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
+ emoji: Mapped[str] = mapped_column(String(32), nullable=False)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ message = relationship("Message")
+ user = relationship("User")
diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py
index 1451df8..dac82dd 100644
--- a/backend/app/routers/rooms.py
+++ b/backend/app/routers/rooms.py
@@ -42,7 +42,7 @@ from app.schemas.webhook import (
WebhookIncomingCreate,
WebhookIncomingRead,
)
-from app.services.message_service import list_recent_messages
+from app.services.message_service import get_reactions_for_messages, list_recent_messages
from app.services.room_service import (
CannotRemoveOwnerError,
DuplicateRoomError,
@@ -295,6 +295,7 @@ async def get_room_messages_endpoint(
require_scope(request, "read:messages")
await require_room_member(room_id, current_user, db)
messages = await list_recent_messages(db, room_id, limit)
+ reactions_by_message = await get_reactions_for_messages(db, [m.id for m in messages])
return [
MessageRead(
id=m.id,
@@ -303,6 +304,7 @@ async def get_room_messages_endpoint(
username=m.user.username,
content=m.content,
image_id=m.image_id,
+ reactions=reactions_by_message.get(m.id, []),
created_at=m.created_at,
edited_at=m.edited_at,
)
diff --git a/backend/app/schemas/message.py b/backend/app/schemas/message.py
index e9d8b8f..6b1fa3f 100644
--- a/backend/app/schemas/message.py
+++ b/backend/app/schemas/message.py
@@ -4,6 +4,12 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict
+class ReactionSummary(BaseModel):
+ emoji: str
+ count: int
+ user_ids: list[str]
+
+
class MessageRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -13,5 +19,6 @@ class MessageRead(BaseModel):
username: str
content: str | None
image_id: uuid.UUID | None
+ reactions: list[ReactionSummary]
created_at: datetime
edited_at: datetime | None
diff --git a/backend/app/services/message_events.py b/backend/app/services/message_events.py
index 890fe4a..aff3e75 100644
--- a/backend/app/services/message_events.py
+++ b/backend/app/services/message_events.py
@@ -4,6 +4,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Message, Room, RoomMembership, User
+from app.schemas.message import ReactionSummary
from app.services.push_service import send_push_to_user
from app.services.webhook_service import dispatch_event
from app.ws.broadcaster import RoomBroadcaster
@@ -49,6 +50,7 @@ def _message_payload(message: Message, username: str) -> dict:
"username": username,
"content": message.content,
"image_id": str(message.image_id) if message.image_id else None,
+ "reactions": [],
"created_at": message.created_at.isoformat(),
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
}
@@ -83,3 +85,21 @@ async def broadcast_message_update(
}
await broadcaster.publish(room_id, payload)
await dispatch_event(db, "message.updated", room_id, payload)
+
+
+async def broadcast_reaction_update(
+ broadcaster: RoomBroadcaster,
+ room_id: uuid.UUID,
+ message_id: uuid.UUID,
+ reactions: list[ReactionSummary],
+) -> None:
+ payload = {
+ "type": "reaction_update",
+ "id": str(message_id),
+ "room_id": str(room_id),
+ "reactions": [r.model_dump() for r in reactions],
+ }
+ await broadcaster.publish(room_id, payload)
+ # Deliberately no dispatch_event() call -- reactions don't get an
+ # outgoing-webhook event type, matching the same scope cut made for
+ # image uploads (see backend/README.md).
diff --git a/backend/app/services/message_service.py b/backend/app/services/message_service.py
index e7bd635..c55804a 100644
--- a/backend/app/services/message_service.py
+++ b/backend/app/services/message_service.py
@@ -1,11 +1,13 @@
import uuid
+from collections import defaultdict
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
-from app.models import Message
+from app.models import Message, MessageReaction
+from app.schemas.message import ReactionSummary
class MessageNotFoundError(Exception):
@@ -59,3 +61,53 @@ async def list_recent_messages(
messages = list(result.scalars().all())
messages.reverse()
return messages
+
+
+async def get_reactions_for_messages(
+ db: AsyncSession, message_ids: list[uuid.UUID]
+) -> dict[uuid.UUID, list[ReactionSummary]]:
+ if not message_ids:
+ return {}
+
+ result = await db.execute(
+ select(MessageReaction)
+ .where(MessageReaction.message_id.in_(message_ids))
+ .order_by(MessageReaction.created_at)
+ )
+ # Grouped in Python rather than a GROUP BY/array_agg query -- the row
+ # count per room-history page is small, and this keeps the ordering
+ # (first-reacted emoji first, first-reacted user first within it)
+ # trivial instead of relying on Postgres-specific aggregate ordering.
+ by_message: dict[uuid.UUID, dict[str, list[str]]] = defaultdict(dict)
+ for reaction in result.scalars().all():
+ emoji_map = by_message[reaction.message_id]
+ emoji_map.setdefault(reaction.emoji, []).append(str(reaction.user_id))
+
+ return {
+ message_id: [
+ ReactionSummary(emoji=emoji, count=len(user_ids), user_ids=user_ids)
+ for emoji, user_ids in emoji_map.items()
+ ]
+ for message_id, emoji_map in by_message.items()
+ }
+
+
+async def toggle_reaction(
+ db: AsyncSession, message_id: uuid.UUID, user_id: uuid.UUID, emoji: str
+) -> list[ReactionSummary]:
+ result = await db.execute(
+ select(MessageReaction).where(
+ MessageReaction.message_id == message_id,
+ MessageReaction.user_id == user_id,
+ MessageReaction.emoji == emoji,
+ )
+ )
+ existing = result.scalar_one_or_none()
+ if existing is not None:
+ await db.delete(existing)
+ else:
+ db.add(MessageReaction(message_id=message_id, user_id=user_id, emoji=emoji))
+ await db.commit()
+
+ reactions = await get_reactions_for_messages(db, [message_id])
+ return reactions.get(message_id, [])
diff --git a/backend/app/ws/chat.py b/backend/app/ws/chat.py
index 95c2420..ad1140d 100644
--- a/backend/app/ws/chat.py
+++ b/backend/app/ws/chat.py
@@ -6,14 +6,19 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
-from app.models import ApiToken, MessageImage, RoomMembership, User
+from app.models import ApiToken, Message, MessageImage, RoomMembership, User
from app.services.bot_service import resolve_token
-from app.services.message_events import broadcast_message_update, broadcast_new_message
+from app.services.message_events import (
+ broadcast_message_update,
+ broadcast_new_message,
+ broadcast_reaction_update,
+)
from app.services.message_service import (
MessageNotFoundError,
NotMessageAuthorError,
create_message,
edit_message,
+ toggle_reaction,
)
router = APIRouter(tags=["ws"])
@@ -27,6 +32,7 @@ class ClientEnvelope(BaseModel):
content: str | None = None
image_id: uuid.UUID | None = None
message_id: uuid.UUID | None = None
+ emoji: str | None = None
async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID) -> bool:
@@ -163,6 +169,38 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
continue
await broadcast_message_update(db, broadcaster, envelope.room_id, message)
+ elif envelope.type == "reaction":
+ if (
+ envelope.room_id is None
+ or envelope.message_id is None
+ or not envelope.emoji
+ or len(envelope.emoji) > 8
+ ):
+ await websocket.send_json(
+ {"type": "error", "detail": "room_id, message_id, and emoji required"}
+ )
+ continue
+ if _missing_scope(api_token, "write:messages"):
+ await websocket.send_json(
+ {"type": "error", "detail": "Token missing required scope: write:messages"}
+ )
+ continue
+ if envelope.room_id not in joined_rooms or not await _is_room_member(
+ db, envelope.room_id, user.id
+ ):
+ await websocket.send_json(
+ {"type": "error", "detail": "Not a member of this room"}
+ )
+ continue
+ target_message = await db.get(Message, envelope.message_id)
+ if target_message is None or target_message.room_id != envelope.room_id:
+ await websocket.send_json({"type": "error", "detail": "Message not found"})
+ continue
+ reactions = await toggle_reaction(db, envelope.message_id, user.id, envelope.emoji)
+ await broadcast_reaction_update(
+ broadcaster, envelope.room_id, envelope.message_id, reactions
+ )
+
else:
await websocket.send_json(
{"type": "error", "detail": f"Unknown message type: {envelope.type}"}
diff --git a/backend/tests/test_reactions.py b/backend/tests/test_reactions.py
new file mode 100644
index 0000000..13241de
--- /dev/null
+++ b/backend/tests/test_reactions.py
@@ -0,0 +1,229 @@
+import uuid
+
+from app.models import User
+from app.schemas.user import UserCreate
+from app.services.auth_service import register_user
+
+
+def _unique(prefix: str) -> str:
+ return f"{prefix}-{uuid.uuid4().hex[:8]}"
+
+
+def _register_ws(ws_client, username: str) -> dict:
+ 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 _make_admin_ws(ws_client, user_id: str) -> None:
+ async def _promote():
+ async with ws_client.session_factory() as session:
+ user = await session.get(User, uuid.UUID(user_id))
+ user.is_site_admin = True
+ await session.commit()
+
+ ws_client.portal.call(_promote)
+
+
+def test_ws_reaction_toggle_add(ws_client):
+ _register_ws(ws_client, _unique("alice"))
+ room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ with ws_client.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()
+ assert message["reactions"] == []
+
+ ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "๐"})
+ update = ws.receive_json()
+ assert update["type"] == "reaction_update"
+ assert update["id"] == message["id"]
+ assert len(update["reactions"]) == 1
+ assert update["reactions"][0]["emoji"] == "๐"
+ assert update["reactions"][0]["count"] == 1
+
+
+def test_ws_reaction_toggle_remove(ws_client):
+ alice = _register_ws(ws_client, _unique("alice"))
+ room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ with ws_client.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()
+
+ ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "๐"})
+ first = ws.receive_json()
+ assert len(first["reactions"]) == 1
+
+ ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "๐"})
+ second = ws.receive_json()
+ assert second["reactions"] == []
+
+
+def test_reaction_appears_in_rest_message_list(ws_client):
+ _register_ws(ws_client, _unique("alice"))
+ room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ with ws_client.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()
+ ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "๐ฅ"})
+ assert ws.receive_json()["type"] == "reaction_update"
+
+ history = ws_client.get(f"/api/rooms/{room['id']}/messages").json()
+ persisted = next(m for m in history if m["id"] == message["id"])
+ assert persisted["reactions"] == [{"emoji": "๐ฅ", "count": 1, "user_ids": [message["user_id"]]}]
+
+
+def test_reaction_broadcasts_to_other_room_members(ws_client):
+ alice = _register_ws(ws_client, _unique("alice"))
+ room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ bob = _register_ws(ws_client, _unique("bob"))
+ ws_client.post(f"/api/rooms/{room['id']}/join")
+
+ ws_client.post(
+ "/api/auth/login", json={"username_or_email": alice["username"], "password": "password123"}
+ )
+ with ws_client.websocket_connect("/ws/chat") as alice_ws:
+ alice_ws.send_json({"type": "join", "room_id": room["id"]})
+ assert alice_ws.receive_json()["type"] == "joined"
+ alice_ws.send_json({"type": "message", "room_id": room["id"], "content": "hi"})
+ message = alice_ws.receive_json()
+
+ ws_client.post(
+ "/api/auth/login", json={"username_or_email": bob["username"], "password": "password123"}
+ )
+ with ws_client.websocket_connect("/ws/chat") as bob_ws:
+ bob_ws.send_json({"type": "join", "room_id": room["id"]})
+ assert bob_ws.receive_json()["type"] == "joined"
+
+ ws_client.post(
+ "/api/auth/login",
+ json={"username_or_email": alice["username"], "password": "password123"},
+ )
+ alice_ws.send_json(
+ {"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "๐"}
+ )
+ assert alice_ws.receive_json()["type"] == "reaction_update"
+ update = bob_ws.receive_json()
+ assert update["type"] == "reaction_update"
+ assert update["reactions"][0]["emoji"] == "๐"
+
+
+def test_reaction_requires_room_membership(ws_client_factory):
+ owner_client = ws_client_factory()
+ outsider_client = ws_client_factory()
+
+ _register_ws(owner_client, _unique("alice"))
+ room = owner_client.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ with owner_client.websocket_connect("/ws/chat") as owner_ws:
+ owner_ws.send_json({"type": "join", "room_id": room["id"]})
+ assert owner_ws.receive_json()["type"] == "joined"
+ owner_ws.send_json({"type": "message", "room_id": room["id"], "content": "hello"})
+ message = owner_ws.receive_json()
+
+ _register_ws(outsider_client, _unique("mallory"))
+ with outsider_client.websocket_connect("/ws/chat") as outsider_ws:
+ outsider_ws.send_json(
+ {"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "๐"}
+ )
+ resp = outsider_ws.receive_json()
+ assert resp["type"] == "error"
+
+
+def test_bot_reaction_without_write_scope_rejected(ws_client_factory):
+ ws_client = ws_client_factory()
+ admin = _register_ws(ws_client, _unique("admin"))
+ _make_admin_ws(ws_client, admin["id"])
+ room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ with ws_client.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()
+
+ bot = ws_client.post("/api/admin/bots", json={"username": _unique("bot")}).json()
+ token = ws_client.post(
+ f"/api/admin/bots/{bot['id']}/tokens", json={"scopes": ["read:messages"]}
+ ).json()["token"]
+ ws_client.post(f"/api/rooms/{room['id']}/join", headers={"Authorization": f"Bearer {token}"})
+
+ with ws_client.websocket_connect("/ws/chat", headers={"Authorization": f"Bearer {token}"}) as bot_ws:
+ bot_ws.send_json({"type": "join", "room_id": room["id"]})
+ assert bot_ws.receive_json()["type"] == "joined"
+ bot_ws.send_json(
+ {"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": "๐"}
+ )
+ resp = bot_ws.receive_json()
+ assert resp["type"] == "error"
+ assert "write:messages" in resp["detail"]
+
+
+def test_reaction_empty_emoji_rejected(ws_client):
+ _register_ws(ws_client, _unique("alice"))
+ room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ with ws_client.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()
+
+ ws.send_json({"type": "reaction", "room_id": room["id"], "message_id": message["id"], "emoji": ""})
+ resp = ws.receive_json()
+ assert resp["type"] == "error"
+
+
+def test_reaction_invalid_message_id_rejected(ws_client):
+ _register_ws(ws_client, _unique("alice"))
+ room = ws_client.post("/api/rooms", json={"name": _unique("general")}).json()
+
+ with ws_client.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": "reaction", "room_id": room["id"], "message_id": str(uuid.uuid4()), "emoji": "๐"}
+ )
+ resp = ws.receive_json()
+ assert resp["type"] == "error"
+ assert "Message not found" in resp["detail"]
+
+
+def test_reaction_wrong_room_rejected(ws_client):
+ _register_ws(ws_client, _unique("alice"))
+ room_a = ws_client.post("/api/rooms", json={"name": _unique("room-a")}).json()
+ room_b = ws_client.post("/api/rooms", json={"name": _unique("room-b")}).json()
+
+ with ws_client.websocket_connect("/ws/chat") as ws:
+ ws.send_json({"type": "join", "room_id": room_a["id"]})
+ assert ws.receive_json()["type"] == "joined"
+ ws.send_json({"type": "message", "room_id": room_a["id"], "content": "hello"})
+ message = ws.receive_json()
+
+ ws.send_json({"type": "join", "room_id": room_b["id"]})
+ assert ws.receive_json()["type"] == "joined"
+ ws.send_json(
+ {"type": "reaction", "room_id": room_b["id"], "message_id": message["id"], "emoji": "๐"}
+ )
+ resp = ws.receive_json()
+ assert resp["type"] == "error"
+ assert "Message not found" in resp["detail"]
diff --git a/frontend/src/components/ChatPane.tsx b/frontend/src/components/ChatPane.tsx
index 3fbef82..7c1d5a2 100644
--- a/frontend/src/components/ChatPane.tsx
+++ b/frontend/src/components/ChatPane.tsx
@@ -54,6 +54,13 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
m.id === envelope.id ? { ...m, content: envelope.content, edited_at: envelope.edited_at } : m,
),
)
+ } else if (envelope.type === 'reaction_update') {
+ setHistory((prev) =>
+ prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)),
+ )
+ setLive((prev) =>
+ prev.map((m) => (m.id === envelope.id ? { ...m, reactions: envelope.reactions } : m)),
+ )
} else if (envelope.type === 'error') {
setWsError(envelope.detail)
}
@@ -61,7 +68,7 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
const onUnauthenticated = useCallback(() => navigate('/login'), [navigate])
- const { connected, send, sendEdit } = useChatSocket({ roomId: room.id, onMessage, onUnauthenticated })
+ const { connected, send, sendEdit, sendReaction } = useChatSocket({ roomId: room.id, onMessage, onUnauthenticated })
return (
@@ -99,7 +106,13 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
)}
-
+
)
diff --git a/frontend/src/components/Composer.css b/frontend/src/components/Composer.css
index acff6f4..c4fc33b 100644
--- a/frontend/src/components/Composer.css
+++ b/frontend/src/components/Composer.css
@@ -53,6 +53,35 @@
display: none;
}
+.composer-emoji-wrap {
+ position: relative;
+ flex: none;
+}
+
+.composer-emoji-trigger {
+ width: 36px;
+ height: 36px;
+ flex: none;
+ border-radius: var(--radius);
+ background: transparent;
+ border: 1px solid var(--ds-border);
+ font-size: 1rem;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.composer-emoji-trigger:hover:not(:disabled) {
+ border-color: var(--ds-accent);
+}
+
+.composer-emoji-trigger:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
.composer-attach {
width: 36px;
height: 36px;
diff --git a/frontend/src/components/Composer.tsx b/frontend/src/components/Composer.tsx
index 0457983..0e867ce 100644
--- a/frontend/src/components/Composer.tsx
+++ b/frontend/src/components/Composer.tsx
@@ -1,6 +1,7 @@
import { useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomImage } from '../api/rooms'
+import { EmojiPicker } from './EmojiPicker'
import './Composer.css'
interface ComposerProps {
@@ -15,6 +16,7 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
const [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState(null)
+ const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
const textareaRef = useRef(null)
const fileInputRef = useRef(null)
const online = useOnlineStatus()
@@ -69,6 +71,24 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
})
}
+ function insertEmoji(emoji: string) {
+ const el = textareaRef.current
+ setEmojiPickerOpen(false)
+ if (!el) {
+ setValue((v) => v + emoji)
+ return
+ }
+ const start = el.selectionStart ?? value.length
+ const end = el.selectionEnd ?? value.length
+ setValue(value.slice(0, start) + emoji + value.slice(end))
+ requestAnimationFrame(() => {
+ el.focus()
+ const cursor = start + emoji.length
+ el.setSelectionRange(cursor, cursor)
+ autoGrow()
+ })
+ }
+
return (
{pendingImage && (
@@ -116,6 +136,25 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
)}
+
+
+ {emojiPickerOpen && (
+ setEmojiPickerOpen(false)}
+ placement="above"
+ align="left"
+ />
+ )}
+
- {mine && !editing && (
-
+ {!editing && (
+
+
+
+ {reactingId === msg.id && (
+ {
+ onReact(msg.id, emoji)
+ setReactingId(null)
+ }}
+ onClose={() => setReactingId(null)}
+ placement="below"
+ align="right"
+ />
+ )}
+
+ {mine && (
+
+ )}
+
)}
)
diff --git a/frontend/src/hooks/useEscapeKey.ts b/frontend/src/hooks/useEscapeKey.ts
new file mode 100644
index 0000000..4bce0a1
--- /dev/null
+++ b/frontend/src/hooks/useEscapeKey.ts
@@ -0,0 +1,11 @@
+import { useEffect } from 'react'
+
+export function useEscapeKey(onEscape: () => void) {
+ useEffect(() => {
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Escape') onEscape()
+ }
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [onEscape])
+}
diff --git a/frontend/src/lib/emoji.ts b/frontend/src/lib/emoji.ts
new file mode 100644
index 0000000..7f0abbd
--- /dev/null
+++ b/frontend/src/lib/emoji.ts
@@ -0,0 +1,51 @@
+export interface EmojiCategory {
+ label: string
+ emoji: string[]
+}
+
+export const EMOJI_CATEGORIES: EmojiCategory[] = [
+ {
+ label: 'Smileys',
+ emoji: [
+ '๐', '๐', '๐', '๐', '๐', '๐
', '๐คฃ', '๐', '๐', '๐',
+ '๐', '๐', '๐', '๐ฅฐ', '๐', '๐คฉ', '๐', '๐', '๐', '๐',
+ '๐คช', '๐คจ', '๐ง', '๐ค', '๐', '๐ฅณ', '๐', '๐', '๐', '๐',
+ '๐ข', '๐ญ', '๐ค', '๐ ', '๐ก', '๐คฏ', '๐ณ', '๐ฅต', '๐ฅถ', '๐ฑ',
+ '๐จ', '๐ฐ', '๐ฅ', '๐', '๐ค', '๐ค', '๐คญ', '๐คซ', '๐คฅ', '๐ถ',
+ '๐', '๐', '๐ฌ', '๐', '๐ฏ', '๐ด', '๐คค', '๐ช', '๐ต', '๐คข',
+ ],
+ },
+ {
+ label: 'Gestures',
+ emoji: [
+ '๐', '๐', '๐', '๐', '๐', '๐ค', '๐', 'โ๏ธ', '๐ค', '๐ค',
+ '๐ค', '๐', '๐ค', 'โ๏ธ', '๐', '๐', '๐', '๐', '๐ช', '๐ซก',
+ '๐ซถ', '๐คฒ',
+ ],
+ },
+ {
+ label: 'Hearts',
+ emoji: [
+ 'โค๏ธ', '๐งก', '๐', '๐', '๐', '๐', '๐ค', '๐ค', '๐ค', '๐',
+ 'โค๏ธโ๐ฅ', '๐ฏ', 'โจ', '๐', '๐', '๐',
+ ],
+ },
+ {
+ label: 'Objects & Symbols',
+ emoji: [
+ '๐ฅ', '๐', '๐', '๐', '๐', '๐', 'โญ', '๐', 'โ
', 'โ',
+ 'โก', '๐ก', '๐', '๐', '๐', '๐', '๐', 'โ', '๐บ', '๐ฏ',
+ 'โฐ', '๐ฐ', '๐', '๐', '๐ข', 'โ', 'โ', '๐ฌ', '๐', '๐',
+ ],
+ },
+ {
+ label: 'Animals & Nature',
+ emoji: [
+ '๐ถ', '๐ฑ', '๐ญ', '๐น', '๐ฐ', '๐ฆ', '๐ป', '๐ผ', '๐จ', '๐ฏ',
+ '๐ฆ', '๐ธ', '๐ต', '๐ฆ', '๐', '๐ข', '๐', '๐', 'โ๏ธ', '๐',
+ 'โ
', '๐ง๏ธ', 'โ๏ธ', '๐ธ', '๐ต', '๐ด',
+ ],
+ },
+]
+
+export const ALL_EMOJI = EMOJI_CATEGORIES.flatMap((c) => c.emoji)
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 9957e3d..9f85bf4 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -51,6 +51,12 @@ export interface MyInvite extends Invite {
invited_by_username: string
}
+export interface ReactionSummary {
+ emoji: string
+ count: number
+ user_ids: string[]
+}
+
export interface Message {
id: string
room_id: string
@@ -58,6 +64,7 @@ export interface Message {
username: string
content: string | null
image_id: string | null
+ reactions: ReactionSummary[]
created_at: string
edited_at: string | null
}
@@ -70,6 +77,7 @@ export interface ChatMessageEnvelope {
username: string
content: string | null
image_id: string | null
+ reactions: ReactionSummary[]
created_at: string
edited_at: string | null
}
@@ -82,6 +90,13 @@ export interface ChatMessageUpdateEnvelope {
edited_at: string | null
}
+export interface ChatReactionUpdateEnvelope {
+ type: 'reaction_update'
+ id: string
+ room_id: string
+ reactions: ReactionSummary[]
+}
+
export interface ChatJoinedEnvelope {
type: 'joined'
room_id: string
@@ -95,6 +110,7 @@ export interface ChatErrorEnvelope {
export type ServerEnvelope =
| ChatMessageEnvelope
| ChatMessageUpdateEnvelope
+ | ChatReactionUpdateEnvelope
| ChatJoinedEnvelope
| ChatErrorEnvelope
diff --git a/frontend/src/ws/useChatSocket.ts b/frontend/src/ws/useChatSocket.ts
index 109987c..ac7dc80 100644
--- a/frontend/src/ws/useChatSocket.ts
+++ b/frontend/src/ws/useChatSocket.ts
@@ -96,5 +96,11 @@ export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatS
ws.send(JSON.stringify({ type: 'edit', room_id: roomId, message_id: messageId, content }))
}, [roomId])
- return { connected, send, sendEdit }
+ const sendReaction = useCallback((messageId: string, emoji: string) => {
+ const ws = socketRef.current
+ if (!ws || ws.readyState !== WebSocket.OPEN) return
+ ws.send(JSON.stringify({ type: 'reaction', room_id: roomId, message_id: messageId, emoji }))
+ }, [roomId])
+
+ return { connected, send, sendEdit, sendReaction }
}