feat(reactions): add message reactions API and web quick reactions

This commit is contained in:
2026-03-08 09:51:18 +03:00
parent 6adb8c24d7
commit 76f008d635
10 changed files with 256 additions and 8 deletions

View File

@@ -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")

View File

@@ -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",

View File

@@ -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)

View File

@@ -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"

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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<MessageReaction[]> {
const { data } = await http.get<MessageReaction[]>(`/messages/${messageId}/reactions`);
return data;
}
export async function toggleMessageReaction(messageId: number, emoji: string): Promise<MessageReaction[]> {
const { data } = await http.post<MessageReaction[]>(`/messages/${messageId}/reactions/toggle`, { emoji });
return data;
}
export async function pinMessage(chatId: number, messageId: number | null): Promise<Chat> {
const { data } = await http.post<Chat>(`/chats/${chatId}/pin`, {
message_id: messageId

View File

@@ -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;

View File

@@ -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<Set<number>>(new Set());
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
const [undoTick, setUndoTick] = useState(0);
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
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() {
</div>
) : null}
{renderContent(message.type, message.text)}
<div className="mt-1 flex flex-wrap gap-1">
{["👍", "❤️", "🔥"].map((emoji) => {
const items = reactionsByMessage[message.id] ?? [];
const item = items.find((reaction) => reaction.emoji === emoji);
return (
<button
className={`rounded-full border px-2 py-0.5 text-[11px] ${
item?.reacted ? "border-sky-300 bg-sky-500/30" : "border-slate-500/60 bg-slate-700/40"
}`}
key={`${message.id}-${emoji}`}
onClick={() => void handleToggleReaction(message.id, emoji)}
type="button"
>
{emoji}{item ? ` ${item.count}` : ""}
</button>
);
})}
</div>
<p className={`mt-1 flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<span>{formatTime(message.created_at)}</span>
{own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null}