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 (