Files
ds-chat/backend/app/services/message_service.py
T
ksmith c6f90d49fc 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.
2026-08-14 15:37:59 -06:00

114 lines
3.4 KiB
Python

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, MessageReaction
from app.schemas.message import ReactionSummary
class MessageNotFoundError(Exception):
pass
class NotMessageAuthorError(Exception):
pass
async def create_message(
db: AsyncSession,
room_id: uuid.UUID,
user_id: uuid.UUID,
content: str | None = None,
image_id: uuid.UUID | None = None,
) -> Message:
message = Message(room_id=room_id, user_id=user_id, content=content, image_id=image_id)
db.add(message)
await db.commit()
await db.refresh(message)
return message
async def edit_message(
db: AsyncSession, message_id: uuid.UUID, editor_id: uuid.UUID, content: str
) -> Message:
message = await db.get(Message, message_id)
if message is None:
raise MessageNotFoundError()
if message.user_id != editor_id:
raise NotMessageAuthorError()
message.content = content
message.edited_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(message)
return message
async def list_recent_messages(
db: AsyncSession, room_id: uuid.UUID, limit: int = 50
) -> list[Message]:
result = await db.execute(
select(Message)
.where(Message.room_id == room_id)
.options(selectinload(Message.user))
.order_by(Message.created_at.desc())
.limit(limit)
)
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, [])