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" + /> + )} +