feat(reactions): add message reactions API and web quick reactions
This commit is contained in:
45
alembic/versions/0013_message_reactions.py
Normal file
45
alembic/versions/0013_message_reactions.py
Normal 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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user