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.
This commit is contained in:
2026-08-14 15:37:59 -06:00
parent f2a59f798b
commit c6f90d49fc
22 changed files with 854 additions and 34 deletions
+33 -6
View File
@@ -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 FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
CRUD (open and private), room roles (owner/admin/member) and invites, a 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 via Redis pub/sub, Web Push notifications for offline room members, a
site-admin portal (user/room/bot management + an audit log), a bot/ site-admin portal (user/room/bot management + an audit log), a bot/
extension layer (scoped API tokens, live bot WebSocket access, incoming and extension layer (scoped API tokens, live bot WebSocket access, incoming and
outgoing webhooks, message editing), and image uploads in chat messages. See outgoing webhooks, message editing), image uploads in chat messages, and
`../ARCHITECTURE.md` for the full system design and the phased build plan. 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. 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. 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 and on-disk save/read -- see Image uploads below
cli.py `python -m app.cli create-user` / `generate-vapid-keys` cli.py `python -m app.cli create-user` / `generate-vapid-keys`
models/ SQLAlchemy models (users, rooms, room_memberships, models/ SQLAlchemy models (users, rooms, room_memberships,
messages, message_images, room_invites, messages, message_images, message_reactions,
push_subscriptions, admin_audit_log, api_tokens, room_invites, push_subscriptions,
webhooks_incoming, event_subscriptions) admin_audit_log, api_tokens, webhooks_incoming,
event_subscriptions)
schemas/ Pydantic request/response models schemas/ Pydantic request/response models
routers/ auth, rooms, invites, push, admin, bots, webhooks, health routers/ auth, rooms, invites, push, admin, bots, webhooks, health
services/ business logic called by routers 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 through the same room-membership gate as everything else; just an eventual
disk-space housekeeping item. 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 ## Notes / scope decisions
- Invite-only site registration: no `POST /api/auth/register`. Accounts are - Invite-only site registration: no `POST /api/auth/register`. Accounts are
@@ -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 ###
+2
View File
@@ -6,6 +6,7 @@ from app.models.invite import InviteStatus, RoomInvite
from app.models.membership import RoomMembership, RoomRole from app.models.membership import RoomMembership, RoomRole
from app.models.message import Message from app.models.message import Message
from app.models.message_image import MessageImage from app.models.message_image import MessageImage
from app.models.message_reaction import MessageReaction
from app.models.push_subscription import PushSubscription from app.models.push_subscription import PushSubscription
from app.models.room import Room from app.models.room import Room
from app.models.user import User from app.models.user import User
@@ -19,6 +20,7 @@ __all__ = [
"RoomRole", "RoomRole",
"Message", "Message",
"MessageImage", "MessageImage",
"MessageReaction",
"RoomInvite", "RoomInvite",
"InviteStatus", "InviteStatus",
"PushSubscription", "PushSubscription",
+28
View File
@@ -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")
+3 -1
View File
@@ -42,7 +42,7 @@ from app.schemas.webhook import (
WebhookIncomingCreate, WebhookIncomingCreate,
WebhookIncomingRead, 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 ( from app.services.room_service import (
CannotRemoveOwnerError, CannotRemoveOwnerError,
DuplicateRoomError, DuplicateRoomError,
@@ -295,6 +295,7 @@ async def get_room_messages_endpoint(
require_scope(request, "read:messages") require_scope(request, "read:messages")
await require_room_member(room_id, current_user, db) await require_room_member(room_id, current_user, db)
messages = await list_recent_messages(db, room_id, limit) 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 [ return [
MessageRead( MessageRead(
id=m.id, id=m.id,
@@ -303,6 +304,7 @@ async def get_room_messages_endpoint(
username=m.user.username, username=m.user.username,
content=m.content, content=m.content,
image_id=m.image_id, image_id=m.image_id,
reactions=reactions_by_message.get(m.id, []),
created_at=m.created_at, created_at=m.created_at,
edited_at=m.edited_at, edited_at=m.edited_at,
) )
+7
View File
@@ -4,6 +4,12 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
class ReactionSummary(BaseModel):
emoji: str
count: int
user_ids: list[str]
class MessageRead(BaseModel): class MessageRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -13,5 +19,6 @@ class MessageRead(BaseModel):
username: str username: str
content: str | None content: str | None
image_id: uuid.UUID | None image_id: uuid.UUID | None
reactions: list[ReactionSummary]
created_at: datetime created_at: datetime
edited_at: datetime | None edited_at: datetime | None
+20
View File
@@ -4,6 +4,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Message, Room, RoomMembership, User 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.push_service import send_push_to_user
from app.services.webhook_service import dispatch_event from app.services.webhook_service import dispatch_event
from app.ws.broadcaster import RoomBroadcaster from app.ws.broadcaster import RoomBroadcaster
@@ -49,6 +50,7 @@ def _message_payload(message: Message, username: str) -> dict:
"username": username, "username": username,
"content": message.content, "content": message.content,
"image_id": str(message.image_id) if message.image_id else None, "image_id": str(message.image_id) if message.image_id else None,
"reactions": [],
"created_at": message.created_at.isoformat(), "created_at": message.created_at.isoformat(),
"edited_at": message.edited_at.isoformat() if message.edited_at else None, "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 broadcaster.publish(room_id, payload)
await dispatch_event(db, "message.updated", 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).
+53 -1
View File
@@ -1,11 +1,13 @@
import uuid import uuid
from collections import defaultdict
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload 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): class MessageNotFoundError(Exception):
@@ -59,3 +61,53 @@ async def list_recent_messages(
messages = list(result.scalars().all()) messages = list(result.scalars().all())
messages.reverse() messages.reverse()
return messages 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, [])
+40 -2
View File
@@ -6,14 +6,19 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db 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.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 ( from app.services.message_service import (
MessageNotFoundError, MessageNotFoundError,
NotMessageAuthorError, NotMessageAuthorError,
create_message, create_message,
edit_message, edit_message,
toggle_reaction,
) )
router = APIRouter(tags=["ws"]) router = APIRouter(tags=["ws"])
@@ -27,6 +32,7 @@ class ClientEnvelope(BaseModel):
content: str | None = None content: str | None = None
image_id: uuid.UUID | None = None image_id: uuid.UUID | None = None
message_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: 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 continue
await broadcast_message_update(db, broadcaster, envelope.room_id, message) 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: else:
await websocket.send_json( await websocket.send_json(
{"type": "error", "detail": f"Unknown message type: {envelope.type}"} {"type": "error", "detail": f"Unknown message type: {envelope.type}"}
+229
View File
@@ -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"]
+15 -2
View File
@@ -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, 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') { } else if (envelope.type === 'error') {
setWsError(envelope.detail) setWsError(envelope.detail)
} }
@@ -61,7 +68,7 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
const onUnauthenticated = useCallback(() => navigate('/login'), [navigate]) 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 ( return (
<section className="chat-pane"> <section className="chat-pane">
@@ -99,7 +106,13 @@ export function ChatPane({ room, members, isMobile, onBack, onToggleInfo, infoOp
</p> </p>
)} )}
<MessageList roomId={room.id} messages={[...history, ...live]} members={members} onEdit={sendEdit} /> <MessageList
roomId={room.id}
messages={[...history, ...live]}
members={members}
onEdit={sendEdit}
onReact={sendReaction}
/>
<Composer roomId={room.id} roomName={room.name} disabled={!connected} onSend={send} /> <Composer roomId={room.id} roomName={room.name} disabled={!connected} onSend={send} />
</section> </section>
) )
+29
View File
@@ -53,6 +53,35 @@
display: none; 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 { .composer-attach {
width: 36px; width: 36px;
height: 36px; height: 36px;
+39
View File
@@ -1,6 +1,7 @@
import { useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react' import { useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { useOnlineStatus } from '../hooks/useOnlineStatus' import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomImage } from '../api/rooms' import { uploadRoomImage } from '../api/rooms'
import { EmojiPicker } from './EmojiPicker'
import './Composer.css' import './Composer.css'
interface ComposerProps { 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 [pendingImage, setPendingImage] = useState<{ id: string; previewUrl: string } | null>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null) const [uploadError, setUploadError] = useState<string | null>(null)
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const online = useOnlineStatus() 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 ( return (
<div className="composer"> <div className="composer">
{pendingImage && ( {pendingImage && (
@@ -116,6 +136,25 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
</svg> </svg>
)} )}
</button> </button>
<div className="composer-emoji-wrap">
<button
type="button"
className="composer-emoji-trigger"
onClick={() => setEmojiPickerOpen((v) => !v)}
disabled={disabled}
aria-label="Insert an emoji"
>
🙂
</button>
{emojiPickerOpen && (
<EmojiPicker
onPick={insertEmoji}
onClose={() => setEmojiPickerOpen(false)}
placement="above"
align="left"
/>
)}
</div>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
rows={1} rows={1}
+65
View File
@@ -0,0 +1,65 @@
.emoji-picker-scrim {
position: fixed;
inset: 0;
z-index: 30;
}
.emoji-picker {
position: absolute;
z-index: 31;
width: 280px;
max-height: 320px;
overflow-y: auto;
background: var(--card-bg);
border: 1px solid var(--ds-border);
border-radius: var(--radius);
padding: var(--sp-2);
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.emoji-picker-below {
top: calc(100% + 8px);
}
.emoji-picker-above {
bottom: calc(100% + 8px);
}
.emoji-picker-left {
left: 0;
}
.emoji-picker-right {
right: 0;
}
.emoji-picker-category-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ds-muted);
padding: 4px 4px 2px;
}
.emoji-picker-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 2px;
}
.emoji-picker-item {
background: transparent;
border: none;
border-radius: 6px;
font-size: 1.1rem;
line-height: 1;
padding: 6px 0;
cursor: pointer;
}
.emoji-picker-item:hover {
background: var(--ds-surface-2);
}
+43
View File
@@ -0,0 +1,43 @@
import { useEscapeKey } from '../hooks/useEscapeKey'
import { EMOJI_CATEGORIES } from '../lib/emoji'
import './EmojiPicker.css'
interface EmojiPickerProps {
onPick: (emoji: string) => void
onClose: () => void
placement?: 'above' | 'below'
align?: 'left' | 'right'
}
export function EmojiPicker({ onPick, onClose, placement = 'below', align = 'left' }: EmojiPickerProps) {
useEscapeKey(onClose)
return (
<>
<div className="emoji-picker-scrim" onClick={onClose} />
<div
className={`emoji-picker emoji-picker-${placement} emoji-picker-${align}`}
role="menu"
>
{EMOJI_CATEGORIES.map((category) => (
<div key={category.label} className="emoji-picker-category">
<div className="emoji-picker-category-label">{category.label}</div>
<div className="emoji-picker-grid">
{category.emoji.map((emoji) => (
<button
key={emoji}
type="button"
role="menuitem"
className="emoji-picker-item"
onClick={() => onPick(emoji)}
>
{emoji}
</button>
))}
</div>
</div>
))}
</div>
</>
)
}
+2 -8
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react' import { useEscapeKey } from '../hooks/useEscapeKey'
import './ImageLightbox.css' import './ImageLightbox.css'
interface ImageLightboxProps { interface ImageLightboxProps {
@@ -7,13 +7,7 @@ interface ImageLightboxProps {
} }
export function ImageLightbox({ src, onClose }: ImageLightboxProps) { export function ImageLightbox({ src, onClose }: ImageLightboxProps) {
useEffect(() => { useEscapeKey(onClose)
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose])
return ( return (
<div className="image-lightbox" onClick={onClose}> <div className="image-lightbox" onClick={onClose}>
+56 -3
View File
@@ -76,11 +76,20 @@
font-size: 0.78rem; font-size: 0.78rem;
} }
.message-edit-link { .message-row-actions {
display: none; display: none;
position: absolute; position: absolute;
top: 2px; top: 2px;
right: var(--sp-2); right: var(--sp-2);
align-items: center;
gap: 4px;
}
.message-row:hover .message-row-actions {
display: flex;
}
.message-edit-link {
background: var(--ds-surface-2); background: var(--ds-surface-2);
border: 1px solid var(--ds-border); border: 1px solid var(--ds-border);
color: var(--ds-muted); color: var(--ds-muted);
@@ -95,8 +104,52 @@
border-color: var(--ds-accent); border-color: var(--ds-accent);
} }
.message-row:hover .message-edit-link { .message-reaction-wrap {
display: inline; position: relative;
}
.message-reaction-trigger {
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
color: var(--ds-muted);
font-size: 0.78rem;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
}
.message-reaction-trigger:hover {
border-color: var(--ds-accent);
}
.message-reaction-pills {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.message-reaction-pill {
display: flex;
align-items: center;
gap: 4px;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: var(--radius-pill);
color: var(--ds-muted);
font-size: 0.76rem;
padding: 2px 8px;
cursor: pointer;
}
.message-reaction-pill:hover {
border-color: var(--ds-accent);
}
.message-reaction-pill-mine {
border-color: var(--ds-accent);
color: var(--ds-text);
} }
.message-edit-input { .message-edit-input {
+61 -10
View File
@@ -3,6 +3,7 @@ import { getRoomImageUrl } from '../api/rooms'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { senderColorIndex } from '../lib/messageGrouping' import { senderColorIndex } from '../lib/messageGrouping'
import type { ChatMessageEnvelope, Message, RoomMember } from '../types' import type { ChatMessageEnvelope, Message, RoomMember } from '../types'
import { EmojiPicker } from './EmojiPicker'
import { ImageLightbox } from './ImageLightbox' import { ImageLightbox } from './ImageLightbox'
import { UserAvatar } from './UserAvatar' import { UserAvatar } from './UserAvatar'
import './MessageList.css' import './MessageList.css'
@@ -12,14 +13,20 @@ interface MessageListProps {
messages: (Message | ChatMessageEnvelope)[] messages: (Message | ChatMessageEnvelope)[]
members: RoomMember[] members: RoomMember[]
onEdit: (messageId: string, content: string) => void onEdit: (messageId: string, content: string) => void
onReact: (messageId: string, emoji: string) => void
} }
export function MessageList({ roomId, messages, members, onEdit }: MessageListProps) { export function MessageList({ roomId, messages, members, onEdit, onReact }: MessageListProps) {
const { user } = useAuth() const { user } = useAuth()
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [draft, setDraft] = useState('') const [draft, setDraft] = useState('')
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null) const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
const [reactingId, setReactingId] = useState<string | null>(null)
function usernameFor(userId: string): string {
return members.find((m) => m.user_id === userId)?.username ?? 'someone'
}
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ block: 'end' }) bottomRef.current?.scrollIntoView({ block: 'end' })
@@ -92,18 +99,62 @@ export function MessageList({ roomId, messages, members, onEdit }: MessageListPr
{msg.edited_at && <span className="message-edited"> (edited)</span>} {msg.edited_at && <span className="message-edited"> (edited)</span>}
</div> </div>
)} )}
{msg.reactions.length > 0 && (
<div className="message-reaction-pills">
{msg.reactions.map((r) => {
const mineReaction = !!user && r.user_ids.includes(user.id)
return (
<button
key={r.emoji}
type="button"
className={`message-reaction-pill${mineReaction ? ' message-reaction-pill-mine' : ''}`}
title={r.user_ids.map(usernameFor).join(', ')}
onClick={() => onReact(msg.id, r.emoji)}
>
<span>{r.emoji}</span>
<span>{r.count}</span>
</button>
)
})}
</div>
)}
</> </>
)} )}
</div> </div>
{mine && !editing && ( {!editing && (
<button <div className="message-row-actions">
type="button" <div className="message-reaction-wrap">
className="message-edit-link" <button
onClick={() => startEdit(msg)} type="button"
aria-label="Edit message" className="message-reaction-trigger"
> onClick={() => setReactingId(reactingId === msg.id ? null : msg.id)}
Edit aria-label="Add reaction"
</button> >
🙂
</button>
{reactingId === msg.id && (
<EmojiPicker
onPick={(emoji) => {
onReact(msg.id, emoji)
setReactingId(null)
}}
onClose={() => setReactingId(null)}
placement="below"
align="right"
/>
)}
</div>
{mine && (
<button
type="button"
className="message-edit-link"
onClick={() => startEdit(msg)}
aria-label="Edit message"
>
Edit
</button>
)}
</div>
)} )}
</div> </div>
) )
+11
View File
@@ -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])
}
+51
View File
@@ -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)
+16
View File
@@ -51,6 +51,12 @@ export interface MyInvite extends Invite {
invited_by_username: string invited_by_username: string
} }
export interface ReactionSummary {
emoji: string
count: number
user_ids: string[]
}
export interface Message { export interface Message {
id: string id: string
room_id: string room_id: string
@@ -58,6 +64,7 @@ export interface Message {
username: string username: string
content: string | null content: string | null
image_id: string | null image_id: string | null
reactions: ReactionSummary[]
created_at: string created_at: string
edited_at: string | null edited_at: string | null
} }
@@ -70,6 +77,7 @@ export interface ChatMessageEnvelope {
username: string username: string
content: string | null content: string | null
image_id: string | null image_id: string | null
reactions: ReactionSummary[]
created_at: string created_at: string
edited_at: string | null edited_at: string | null
} }
@@ -82,6 +90,13 @@ export interface ChatMessageUpdateEnvelope {
edited_at: string | null edited_at: string | null
} }
export interface ChatReactionUpdateEnvelope {
type: 'reaction_update'
id: string
room_id: string
reactions: ReactionSummary[]
}
export interface ChatJoinedEnvelope { export interface ChatJoinedEnvelope {
type: 'joined' type: 'joined'
room_id: string room_id: string
@@ -95,6 +110,7 @@ export interface ChatErrorEnvelope {
export type ServerEnvelope = export type ServerEnvelope =
| ChatMessageEnvelope | ChatMessageEnvelope
| ChatMessageUpdateEnvelope | ChatMessageUpdateEnvelope
| ChatReactionUpdateEnvelope
| ChatJoinedEnvelope | ChatJoinedEnvelope
| ChatErrorEnvelope | ChatErrorEnvelope
+7 -1
View File
@@ -96,5 +96,11 @@ export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatS
ws.send(JSON.stringify({ type: 'edit', room_id: roomId, message_id: messageId, content })) ws.send(JSON.stringify({ type: 'edit', room_id: roomId, message_id: messageId, content }))
}, [roomId]) }, [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 }
} }