Private
Public Access
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:
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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}"}
|
||||
|
||||
Reference in New Issue
Block a user