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
+2
View File
@@ -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",
+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,
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,
)
+7
View File
@@ -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
+20
View File
@@ -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).
+53 -1
View File
@@ -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, [])
+40 -2
View File
@@ -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}"}