From 76f008d635dce4f7c54b5affc3ef7bd1593190c8 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 09:51:18 +0300 Subject: [PATCH] feat(reactions): add message reactions API and web quick reactions --- alembic/versions/0013_message_reactions.py | 45 ++++++++++++++++++++ app/database/models.py | 3 +- app/messages/models.py | 13 ++++++ app/messages/repository.py | 45 +++++++++++++++++++- app/messages/router.py | 31 +++++++++++++- app/messages/schemas.py | 14 +++++++ app/messages/service.py | 49 +++++++++++++++++++++- web/src/api/chats.ts | 12 +++++- web/src/chat/types.ts | 6 +++ web/src/components/MessageList.tsx | 46 +++++++++++++++++++- 10 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 alembic/versions/0013_message_reactions.py diff --git a/alembic/versions/0013_message_reactions.py b/alembic/versions/0013_message_reactions.py new file mode 100644 index 0000000..91f0332 --- /dev/null +++ b/alembic/versions/0013_message_reactions.py @@ -0,0 +1,45 @@ +"""add message reactions + +Revision ID: 0013_msg_reactions +Revises: 0012_user_pm_privacy +Create Date: 2026-03-08 18:40:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0013_msg_reactions" +down_revision: Union[str, Sequence[str], None] = "0012_user_pm_privacy" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "message_reactions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("message_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("emoji", sa.String(length=16), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["message_id"], ["messages.id"], name=op.f("fk_message_reactions_message_id_messages"), ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name=op.f("fk_message_reactions_user_id_users"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_message_reactions")), + sa.UniqueConstraint("message_id", "user_id", name="uq_message_reactions_message_user"), + ) + op.create_index(op.f("ix_message_reactions_id"), "message_reactions", ["id"], unique=False) + op.create_index(op.f("ix_message_reactions_message_id"), "message_reactions", ["message_id"], unique=False) + op.create_index(op.f("ix_message_reactions_user_id"), "message_reactions", ["user_id"], unique=False) + op.create_index(op.f("ix_message_reactions_emoji"), "message_reactions", ["emoji"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_message_reactions_emoji"), table_name="message_reactions") + op.drop_index(op.f("ix_message_reactions_user_id"), table_name="message_reactions") + op.drop_index(op.f("ix_message_reactions_message_id"), table_name="message_reactions") + op.drop_index(op.f("ix_message_reactions_id"), table_name="message_reactions") + op.drop_table("message_reactions") + diff --git a/app/database/models.py b/app/database/models.py index ea9403d..aa698a5 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -2,7 +2,7 @@ from app.auth.models import EmailVerificationToken, PasswordResetToken from app.chats.models import Chat, ChatMember from app.email.models import EmailLog from app.media.models import Attachment -from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReceipt +from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReaction, MessageReceipt from app.notifications.models import NotificationLog from app.users.models import User @@ -14,6 +14,7 @@ __all__ = [ "EmailVerificationToken", "Message", "MessageIdempotencyKey", + "MessageReaction", "MessageReceipt", "NotificationLog", "PasswordResetToken", diff --git a/app/messages/models.py b/app/messages/models.py index 5438d88..8cc62ea 100644 --- a/app/messages/models.py +++ b/app/messages/models.py @@ -93,3 +93,16 @@ class MessageHidden(Base): message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"), nullable=False, index=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + +class MessageReaction(Base): + __tablename__ = "message_reactions" + __table_args__ = ( + UniqueConstraint("message_id", "user_id", name="uq_message_reactions_message_user"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + message_id: Mapped[int] = mapped_column(ForeignKey("messages.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + emoji: Mapped[str] = mapped_column(String(16), nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/app/messages/repository.py b/app/messages/repository.py index e7c4ab9..b02a047 100644 --- a/app/messages/repository.py +++ b/app/messages/repository.py @@ -1,8 +1,8 @@ -from sqlalchemy import delete, select +from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.chats.models import ChatMember -from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReceipt, MessageType +from app.messages.models import Message, MessageHidden, MessageIdempotencyKey, MessageReaction, MessageReceipt, MessageType async def create_message( @@ -178,3 +178,44 @@ async def create_message_receipt( db.add(receipt) await db.flush() return receipt + + +async def get_message_reaction(db: AsyncSession, *, message_id: int, user_id: int) -> MessageReaction | None: + result = await db.execute( + select(MessageReaction) + .where(MessageReaction.message_id == message_id, MessageReaction.user_id == user_id) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def list_message_reactions(db: AsyncSession, *, message_id: int) -> list[tuple[str, int]]: + result = await db.execute( + select(MessageReaction.emoji, func.count(MessageReaction.id)) + .where(MessageReaction.message_id == message_id) + .group_by(MessageReaction.emoji) + .order_by(func.count(MessageReaction.id).desc(), MessageReaction.emoji.asc()) + ) + return [(str(emoji), int(count)) for emoji, count in result.all()] + + +async def upsert_message_reaction( + db: AsyncSession, + *, + message_id: int, + user_id: int, + emoji: str, +) -> tuple[MessageReaction | None, str]: + existing = await get_message_reaction(db, message_id=message_id, user_id=user_id) + if existing and existing.emoji == emoji: + await db.delete(existing) + await db.flush() + return None, "removed" + if existing: + existing.emoji = emoji + await db.flush() + return existing, "updated" + reaction = MessageReaction(message_id=message_id, user_id=user_id, emoji=emoji) + db.add(reaction) + await db.flush() + return reaction, "added" diff --git a/app/messages/router.py b/app/messages/router.py index 341a2dc..705f104 100644 --- a/app/messages/router.py +++ b/app/messages/router.py @@ -3,14 +3,24 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth.service import get_current_user from app.database.session import get_db -from app.messages.schemas import MessageCreateRequest, MessageForwardRequest, MessageRead, MessageStatusUpdateRequest, MessageUpdateRequest +from app.messages.schemas import ( + MessageCreateRequest, + MessageForwardRequest, + MessageReactionRead, + MessageReactionToggleRequest, + MessageRead, + MessageStatusUpdateRequest, + MessageUpdateRequest, +) from app.messages.service import ( create_chat_message, delete_message, delete_message_for_all, forward_message, get_messages, + list_message_reactions, search_messages, + toggle_message_reaction, update_message, ) from app.realtime.schemas import MessageStatusPayload @@ -104,3 +114,22 @@ async def forward_message_endpoint( message = await forward_message(db, source_message_id=message_id, sender_id=current_user.id, payload=payload) await realtime_gateway.publish_message_created(message=message, sender_id=current_user.id) return message + + +@router.get("/{message_id}/reactions", response_model=list[MessageReactionRead]) +async def list_reactions_endpoint( + message_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[MessageReactionRead]: + return await list_message_reactions(db, message_id=message_id, user_id=current_user.id) + + +@router.post("/{message_id}/reactions/toggle", response_model=list[MessageReactionRead]) +async def toggle_reaction_endpoint( + message_id: int, + payload: MessageReactionToggleRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[MessageReactionRead]: + return await toggle_message_reaction(db, message_id=message_id, user_id=current_user.id, payload=payload) diff --git a/app/messages/schemas.py b/app/messages/schemas.py index bb296fc..3ee970d 100644 --- a/app/messages/schemas.py +++ b/app/messages/schemas.py @@ -40,3 +40,17 @@ class MessageStatusUpdateRequest(BaseModel): class MessageForwardRequest(BaseModel): target_chat_id: int + + +class MessageForwardBulkRequest(BaseModel): + target_chat_ids: list[int] = Field(min_length=1, max_length=20) + + +class MessageReactionToggleRequest(BaseModel): + emoji: str = Field(min_length=1, max_length=16) + + +class MessageReactionRead(BaseModel): + emoji: str + count: int + reacted: bool = False diff --git a/app/messages/service.py b/app/messages/service.py index 6542223..9d329b5 100644 --- a/app/messages/service.py +++ b/app/messages/service.py @@ -8,7 +8,14 @@ from app.chats.service import ensure_chat_membership from app.messages import repository from app.messages.models import Message from app.messages.spam_guard import enforce_message_spam_policy -from app.messages.schemas import MessageCreateRequest, MessageForwardRequest, MessageStatusUpdateRequest, MessageUpdateRequest +from app.messages.schemas import ( + MessageCreateRequest, + MessageForwardRequest, + MessageReactionRead, + MessageReactionToggleRequest, + MessageStatusUpdateRequest, + MessageUpdateRequest, +) from app.notifications.service import dispatch_message_notifications from app.users.repository import has_block_relation_between_users from app.users.service import get_user_by_id @@ -267,3 +274,43 @@ async def forward_message( await db.commit() await db.refresh(forwarded) return forwarded + + +async def list_message_reactions( + db: AsyncSession, + *, + message_id: int, + user_id: int, +) -> list[MessageReactionRead]: + message = await repository.get_message_by_id(db, message_id) + if not message: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found") + await ensure_chat_membership(db, chat_id=message.chat_id, user_id=user_id) + counts = await repository.list_message_reactions(db, message_id=message_id) + mine = await repository.get_message_reaction(db, message_id=message_id, user_id=user_id) + mine_emoji = mine.emoji if mine else None + return [ + MessageReactionRead(emoji=emoji, count=count, reacted=(emoji == mine_emoji)) + for emoji, count in counts + ] + + +async def toggle_message_reaction( + db: AsyncSession, + *, + message_id: int, + user_id: int, + payload: MessageReactionToggleRequest, +) -> list[MessageReactionRead]: + message = await repository.get_message_by_id(db, message_id) + if not message: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found") + await ensure_chat_membership(db, chat_id=message.chat_id, user_id=user_id) + await repository.upsert_message_reaction( + db, + message_id=message_id, + user_id=user_id, + emoji=payload.emoji.strip(), + ) + await db.commit() + return await list_message_reactions(db, message_id=message_id, user_id=user_id) diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index cfec78f..1ac50ec 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -1,5 +1,5 @@ import { http } from "./http"; -import type { Chat, ChatDetail, ChatMember, ChatMemberRole, ChatType, DiscoverChat, Message, MessageType } from "../chat/types"; +import type { Chat, ChatDetail, ChatMember, ChatMemberRole, ChatType, DiscoverChat, Message, MessageReaction, MessageType } from "../chat/types"; import axios from "axios"; export interface ChatNotificationSettings { @@ -160,6 +160,16 @@ export async function forwardMessage(messageId: number, targetChatId: number): P return data; } +export async function listMessageReactions(messageId: number): Promise { + const { data } = await http.get(`/messages/${messageId}/reactions`); + return data; +} + +export async function toggleMessageReaction(messageId: number, emoji: string): Promise { + const { data } = await http.post(`/messages/${messageId}/reactions/toggle`, { emoji }); + return data; +} + export async function pinMessage(chatId: number, messageId: number | null): Promise { const { data } = await http.post(`/chats/${chatId}/pin`, { message_id: messageId diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 30a5283..0756a0c 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -58,6 +58,12 @@ export interface Message { is_pending?: boolean; } +export interface MessageReaction { + emoji: string; + count: number; + reacted: boolean; +} + export interface AuthUser { id: number; email: string; diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 8b6fb54..83c5db0 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; -import { deleteMessage, forwardMessage, pinMessage } from "../api/chats"; -import type { Message } from "../chat/types"; +import { deleteMessage, forwardMessage, listMessageReactions, pinMessage, toggleMessageReaction } from "../api/chats"; +import type { Message, MessageReaction } from "../chat/types"; import { useAuthStore } from "../store/authStore"; import { useChatStore } from "../store/chatStore"; import { formatTime } from "../utils/format"; @@ -45,6 +45,7 @@ export function MessageList() { const [selectedIds, setSelectedIds] = useState>(new Set()); const [pendingDelete, setPendingDelete] = useState(null); const [undoTick, setUndoTick] = useState(0); + const [reactionsByMessage, setReactionsByMessage] = useState>({}); const messages = useMemo(() => { if (!activeChatId) { @@ -97,6 +98,7 @@ export function MessageList() { setCtx(null); setDeleteMessageId(null); setForwardMessageId(null); + setReactionsByMessage({}); }, [activeChatId]); useEffect(() => { @@ -162,6 +164,27 @@ export function MessageList() { } } + async function ensureReactionsLoaded(messageId: number) { + if (reactionsByMessage[messageId]) { + return; + } + try { + const rows = await listMessageReactions(messageId); + setReactionsByMessage((state) => ({ ...state, [messageId]: rows })); + } catch { + return; + } + } + + async function handleToggleReaction(messageId: number, emoji: string) { + try { + const rows = await toggleMessageReaction(messageId, emoji); + setReactionsByMessage((state) => ({ ...state, [messageId]: rows })); + } catch { + return; + } + } + function toggleSelected(messageId: number) { setSelectedIds((prev) => { const next = new Set(prev); @@ -314,6 +337,7 @@ export function MessageList() { }} onContextMenu={(e) => { e.preventDefault(); + void ensureReactionsLoaded(message.id); const pos = getSafeContextPosition(e.clientX, e.clientY, 160, 108); setCtx({ x: pos.x, y: pos.y, messageId: message.id }); }} @@ -335,6 +359,24 @@ export function MessageList() { ) : null} {renderContent(message.type, message.text)} +
+ {["👍", "❤️", "🔥"].map((emoji) => { + const items = reactionsByMessage[message.id] ?? []; + const item = items.find((reaction) => reaction.emoji === emoji); + return ( + + ); + })} +

{formatTime(message.created_at)} {own ? {renderStatus(message.delivery_status)} : null}