feat: add reply/forward/pin message flow across backend and web
Some checks failed
CI / test (push) Failing after 24s

- add reply_to/forwarded_from message fields and chat pinned_message field

- add forward and pin APIs plus reply support in message create

- wire web actions: Reply, Fwd, Pin and reply composer state

- fix spam policy bug: allow repeated identical messages, keep rate limiting
This commit is contained in:
2026-03-08 00:28:43 +03:00
parent 4d704fc279
commit e1d0375392
18 changed files with 287 additions and 29 deletions

View File

@@ -0,0 +1,64 @@
"""reply forward pin
Revision ID: 0004_reply_forward_pin
Revises: 0003_search_indexes
Create Date: 2026-03-08 03:20:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0004_reply_forward_pin"
down_revision: Union[str, Sequence[str], None] = "0003_search_indexes"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("messages", sa.Column("reply_to_message_id", sa.Integer(), nullable=True))
op.add_column("messages", sa.Column("forwarded_from_message_id", sa.Integer(), nullable=True))
op.create_foreign_key(
op.f("fk_messages_reply_to_message_id_messages"),
"messages",
"messages",
["reply_to_message_id"],
["id"],
ondelete="SET NULL",
)
op.create_foreign_key(
op.f("fk_messages_forwarded_from_message_id_messages"),
"messages",
"messages",
["forwarded_from_message_id"],
["id"],
ondelete="SET NULL",
)
op.create_index(op.f("ix_messages_reply_to_message_id"), "messages", ["reply_to_message_id"], unique=False)
op.create_index(op.f("ix_messages_forwarded_from_message_id"), "messages", ["forwarded_from_message_id"], unique=False)
op.add_column("chats", sa.Column("pinned_message_id", sa.Integer(), nullable=True))
op.create_foreign_key(
op.f("fk_chats_pinned_message_id_messages"),
"chats",
"messages",
["pinned_message_id"],
["id"],
ondelete="SET NULL",
)
op.create_index(op.f("ix_chats_pinned_message_id"), "chats", ["pinned_message_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_chats_pinned_message_id"), table_name="chats")
op.drop_constraint(op.f("fk_chats_pinned_message_id_messages"), "chats", type_="foreignkey")
op.drop_column("chats", "pinned_message_id")
op.drop_index(op.f("ix_messages_forwarded_from_message_id"), table_name="messages")
op.drop_index(op.f("ix_messages_reply_to_message_id"), table_name="messages")
op.drop_constraint(op.f("fk_messages_forwarded_from_message_id_messages"), "messages", type_="foreignkey")
op.drop_constraint(op.f("fk_messages_reply_to_message_id_messages"), "messages", type_="foreignkey")
op.drop_column("messages", "forwarded_from_message_id")
op.drop_column("messages", "reply_to_message_id")

View File

@@ -30,6 +30,7 @@ class Chat(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
type: Mapped[ChatType] = mapped_column(SAEnum(ChatType), nullable=False, index=True) type: Mapped[ChatType] = mapped_column(SAEnum(ChatType), nullable=False, index=True)
title: Mapped[str | None] = mapped_column(String(255), nullable=True) title: Mapped[str | None] = mapped_column(String(255), nullable=True)
pinned_message_id: Mapped[int | None] = mapped_column(ForeignKey("messages.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
members: Mapped[list["ChatMember"]] = relationship(back_populates="chat", cascade="all, delete-orphan") members: Mapped[list["ChatMember"]] = relationship(back_populates="chat", cascade="all, delete-orphan")

View File

@@ -8,6 +8,7 @@ from app.chats.schemas import (
ChatMemberAddRequest, ChatMemberAddRequest,
ChatMemberRead, ChatMemberRead,
ChatMemberRoleUpdateRequest, ChatMemberRoleUpdateRequest,
ChatPinMessageRequest,
ChatRead, ChatRead,
ChatTitleUpdateRequest, ChatTitleUpdateRequest,
) )
@@ -17,6 +18,7 @@ from app.chats.service import (
get_chat_for_user, get_chat_for_user,
get_chats_for_user, get_chats_for_user,
leave_chat_for_user, leave_chat_for_user,
pin_chat_message_for_user,
remove_chat_member_for_user, remove_chat_member_for_user,
update_chat_member_role_for_user, update_chat_member_role_for_user,
update_chat_title_for_user, update_chat_title_for_user,
@@ -58,6 +60,7 @@ async def get_chat(
id=chat.id, id=chat.id,
type=chat.type, type=chat.type,
title=chat.title, title=chat.title,
pinned_message_id=chat.pinned_message_id,
created_at=chat.created_at, created_at=chat.created_at,
members=members, members=members,
) )
@@ -127,3 +130,13 @@ async def leave_chat(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
) -> None: ) -> None:
await leave_chat_for_user(db, chat_id=chat_id, user_id=current_user.id) await leave_chat_for_user(db, chat_id=chat_id, user_id=current_user.id)
@router.post("/{chat_id}/pin", response_model=ChatRead)
async def pin_chat_message(
chat_id: int,
payload: ChatPinMessageRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> ChatRead:
return await pin_chat_message_for_user(db, chat_id=chat_id, user_id=current_user.id, payload=payload)

View File

@@ -11,6 +11,7 @@ class ChatRead(BaseModel):
id: int id: int
type: ChatType type: ChatType
title: str | None = None title: str | None = None
pinned_message_id: int | None = None
created_at: datetime created_at: datetime
@@ -43,3 +44,7 @@ class ChatMemberRoleUpdateRequest(BaseModel):
class ChatTitleUpdateRequest(BaseModel): class ChatTitleUpdateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255) title: str = Field(min_length=1, max_length=255)
class ChatPinMessageRequest(BaseModel):
message_id: int | None = None

View File

@@ -4,7 +4,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.chats import repository from app.chats import repository
from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType from app.chats.models import Chat, ChatMember, ChatMemberRole, ChatType
from app.chats.schemas import ChatCreateRequest, ChatTitleUpdateRequest from app.chats.schemas import ChatCreateRequest, ChatPinMessageRequest, ChatTitleUpdateRequest
from app.messages.repository import get_message_by_id
from app.users.repository import get_user_by_id from app.users.repository import get_user_by_id
@@ -211,3 +212,28 @@ async def leave_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -
) )
await repository.delete_chat_member(db, membership) await repository.delete_chat_member(db, membership)
await db.commit() await db.commit()
async def pin_chat_message_for_user(
db: AsyncSession,
*,
chat_id: int,
user_id: int,
payload: ChatPinMessageRequest,
) -> Chat:
chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id)
_ensure_group_or_channel(chat.type)
_ensure_manage_permission(membership.role)
if payload.message_id is None:
chat.pinned_message_id = None
await db.commit()
await db.refresh(chat)
return chat
message = await get_message_by_id(db, payload.message_id)
if not message or message.chat_id != chat_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found in chat")
chat.pinned_message_id = message.id
await db.commit()
await db.refresh(chat)
return chat

View File

@@ -29,6 +29,16 @@ class Message(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True) chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True)
sender_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) sender_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
reply_to_message_id: Mapped[int | None] = mapped_column(
ForeignKey("messages.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
forwarded_from_message_id: Mapped[int | None] = mapped_column(
ForeignKey("messages.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
type: Mapped[MessageType] = mapped_column(SAEnum(MessageType), nullable=False, default=MessageType.TEXT) type: Mapped[MessageType] = mapped_column(SAEnum(MessageType), nullable=False, default=MessageType.TEXT)
text: Mapped[str | None] = mapped_column(Text, nullable=True) text: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -10,10 +10,19 @@ async def create_message(
*, *,
chat_id: int, chat_id: int,
sender_id: int, sender_id: int,
reply_to_message_id: int | None,
forwarded_from_message_id: int | None,
message_type: MessageType, message_type: MessageType,
text: str | None, text: str | None,
) -> Message: ) -> Message:
message = Message(chat_id=chat_id, sender_id=sender_id, type=message_type, text=text) message = Message(
chat_id=chat_id,
sender_id=sender_id,
reply_to_message_id=reply_to_message_id,
forwarded_from_message_id=forwarded_from_message_id,
type=message_type,
text=text,
)
db.add(message) db.add(message)
await db.flush() await db.flush()
return message return message

View File

@@ -3,8 +3,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.service import get_current_user from app.auth.service import get_current_user
from app.database.session import get_db from app.database.session import get_db
from app.messages.schemas import MessageCreateRequest, MessageRead, MessageStatusUpdateRequest, MessageUpdateRequest from app.messages.schemas import MessageCreateRequest, MessageForwardRequest, MessageRead, MessageStatusUpdateRequest, MessageUpdateRequest
from app.messages.service import create_chat_message, delete_message, get_messages, search_messages, update_message from app.messages.service import create_chat_message, delete_message, forward_message, get_messages, search_messages, update_message
from app.realtime.schemas import MessageStatusPayload from app.realtime.schemas import MessageStatusPayload
from app.realtime.service import realtime_gateway from app.realtime.service import realtime_gateway
from app.users.models import User from app.users.models import User
@@ -80,3 +80,15 @@ async def update_status(
payload=MessageStatusPayload(chat_id=payload.chat_id, message_id=payload.message_id), payload=MessageStatusPayload(chat_id=payload.chat_id, message_id=payload.message_id),
event=payload.status, event=payload.status,
) )
@router.post("/{message_id}/forward", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
async def forward_message_endpoint(
message_id: int,
payload: MessageForwardRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> MessageRead:
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

View File

@@ -12,6 +12,8 @@ class MessageRead(BaseModel):
id: int id: int
chat_id: int chat_id: int
sender_id: int sender_id: int
reply_to_message_id: int | None
forwarded_from_message_id: int | None
type: MessageType type: MessageType
text: str | None text: str | None
created_at: datetime created_at: datetime
@@ -23,6 +25,7 @@ class MessageCreateRequest(BaseModel):
type: MessageType = MessageType.TEXT type: MessageType = MessageType.TEXT
text: str | None = Field(default=None, max_length=4096) text: str | None = Field(default=None, max_length=4096)
client_message_id: str | None = Field(default=None, min_length=8, max_length=64) client_message_id: str | None = Field(default=None, min_length=8, max_length=64)
reply_to_message_id: int | None = None
class MessageUpdateRequest(BaseModel): class MessageUpdateRequest(BaseModel):
@@ -33,3 +36,7 @@ class MessageStatusUpdateRequest(BaseModel):
chat_id: int chat_id: int
message_id: int message_id: int
status: Literal["message_delivered", "message_read"] status: Literal["message_delivered", "message_read"]
class MessageForwardRequest(BaseModel):
target_chat_id: int

View File

@@ -6,12 +6,16 @@ from app.chats.service import ensure_chat_membership
from app.messages import repository from app.messages import repository
from app.messages.models import Message from app.messages.models import Message
from app.messages.spam_guard import enforce_message_spam_policy from app.messages.spam_guard import enforce_message_spam_policy
from app.messages.schemas import MessageCreateRequest, MessageStatusUpdateRequest, MessageUpdateRequest from app.messages.schemas import MessageCreateRequest, MessageForwardRequest, MessageStatusUpdateRequest, MessageUpdateRequest
from app.notifications.service import dispatch_message_notifications from app.notifications.service import dispatch_message_notifications
async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: MessageCreateRequest) -> Message: async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: MessageCreateRequest) -> Message:
await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=sender_id) await ensure_chat_membership(db, chat_id=payload.chat_id, user_id=sender_id)
if payload.reply_to_message_id is not None:
reply_to = await repository.get_message_by_id(db, payload.reply_to_message_id)
if not reply_to or reply_to.chat_id != payload.chat_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid reply target")
if payload.client_message_id: if payload.client_message_id:
existing = await repository.get_message_by_client_message_id( existing = await repository.get_message_by_client_message_id(
db, db,
@@ -30,6 +34,8 @@ async def create_chat_message(db: AsyncSession, *, sender_id: int, payload: Mess
db, db,
chat_id=payload.chat_id, chat_id=payload.chat_id,
sender_id=sender_id, sender_id=sender_id,
reply_to_message_id=payload.reply_to_message_id,
forwarded_from_message_id=None,
message_type=payload.type, message_type=payload.type,
text=payload.text, text=payload.text,
) )
@@ -167,3 +173,29 @@ async def mark_message_status(
"last_delivered_message_id": receipt.last_delivered_message_id or 0, "last_delivered_message_id": receipt.last_delivered_message_id or 0,
"last_read_message_id": receipt.last_read_message_id or 0, "last_read_message_id": receipt.last_read_message_id or 0,
} }
async def forward_message(
db: AsyncSession,
*,
source_message_id: int,
sender_id: int,
payload: MessageForwardRequest,
) -> Message:
source = await repository.get_message_by_id(db, source_message_id)
if not source:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Source message not found")
await ensure_chat_membership(db, chat_id=source.chat_id, user_id=sender_id)
await ensure_chat_membership(db, chat_id=payload.target_chat_id, user_id=sender_id)
forwarded = await repository.create_message(
db,
chat_id=payload.target_chat_id,
sender_id=sender_id,
reply_to_message_id=None,
forwarded_from_message_id=source.id,
message_type=source.type,
text=source.text,
)
await db.commit()
await db.refresh(forwarded)
return forwarded

View File

@@ -1,16 +1,9 @@
import hashlib
from fastapi import HTTPException, status from fastapi import HTTPException, status
from redis.exceptions import RedisError from redis.exceptions import RedisError
from app.config.settings import settings from app.config.settings import settings
from app.utils.redis_client import get_redis_client from app.utils.redis_client import get_redis_client
def _hash_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
async def enforce_message_spam_policy(*, user_id: int, chat_id: int, text: str | None) -> None: async def enforce_message_spam_policy(*, user_id: int, chat_id: int, text: str | None) -> None:
redis = get_redis_client() redis = get_redis_client()
rate_key = f"spam:msg_rate:{user_id}:{chat_id}" rate_key = f"spam:msg_rate:{user_id}:{chat_id}"
@@ -23,15 +16,5 @@ async def enforce_message_spam_policy(*, user_id: int, chat_id: int, text: str |
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Message rate limit exceeded for this chat.", detail="Message rate limit exceeded for this chat.",
) )
normalized = (text or "").strip()
if normalized:
dup_key = f"spam:dup:{user_id}:{chat_id}:{_hash_text(normalized)}"
if await redis.exists(dup_key):
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Duplicate message cooldown is active.",
)
await redis.set(dup_key, "1", ex=settings.duplicate_message_cooldown_seconds)
except RedisError: except RedisError:
return return

View File

@@ -25,6 +25,7 @@ class SendMessagePayload(BaseModel):
text: str | None = Field(default=None, max_length=4096) text: str | None = Field(default=None, max_length=4096)
temp_id: str | None = None temp_id: str | None = None
client_message_id: str | None = Field(default=None, min_length=8, max_length=64) client_message_id: str | None = Field(default=None, min_length=8, max_length=64)
reply_to_message_id: int | None = None
class ChatEventPayload(BaseModel): class ChatEventPayload(BaseModel):

View File

@@ -86,6 +86,7 @@ class RealtimeGateway:
type=payload.type, type=payload.type,
text=payload.text, text=payload.text,
client_message_id=payload.client_message_id or payload.temp_id, client_message_id=payload.client_message_id or payload.temp_id,
reply_to_message_id=payload.reply_to_message_id,
), ),
) )
await self.publish_message_created( await self.publish_message_created(

View File

@@ -51,13 +51,15 @@ export async function sendMessageWithClientId(
chatId: number, chatId: number,
text: string, text: string,
type: MessageType, type: MessageType,
clientMessageId: string clientMessageId: string,
replyToMessageId?: number
): Promise<Message> { ): Promise<Message> {
const { data } = await http.post<Message>("/messages", { const { data } = await http.post<Message>("/messages", {
chat_id: chatId, chat_id: chatId,
text, text,
type, type,
client_message_id: clientMessageId client_message_id: clientMessageId,
reply_to_message_id: replyToMessageId
}); });
return data; return data;
} }
@@ -116,3 +118,17 @@ export async function updateMessageStatus(
status status
}); });
} }
export async function forwardMessage(messageId: number, targetChatId: number): Promise<Message> {
const { data } = await http.post<Message>(`/messages/${messageId}/forward`, {
target_chat_id: targetChatId
});
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
});
return data;
}

View File

@@ -6,6 +6,7 @@ export interface Chat {
id: number; id: number;
type: ChatType; type: ChatType;
title: string | null; title: string | null;
pinned_message_id?: number | null;
created_at: string; created_at: string;
} }
@@ -15,6 +16,8 @@ export interface Message {
sender_id: number; sender_id: number;
type: MessageType; type: MessageType;
text: string | null; text: string | null;
reply_to_message_id?: number | null;
forwarded_from_message_id?: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
client_message_id?: string; client_message_id?: string;

View File

@@ -10,6 +10,8 @@ export function MessageComposer() {
const addOptimisticMessage = useChatStore((s) => s.addOptimisticMessage); const addOptimisticMessage = useChatStore((s) => s.addOptimisticMessage);
const confirmMessageByClientId = useChatStore((s) => s.confirmMessageByClientId); const confirmMessageByClientId = useChatStore((s) => s.confirmMessageByClientId);
const removeOptimisticMessage = useChatStore((s) => s.removeOptimisticMessage); const removeOptimisticMessage = useChatStore((s) => s.removeOptimisticMessage);
const replyToByChat = useChatStore((s) => s.replyToByChat);
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
const accessToken = useAuthStore((s) => s.accessToken); const accessToken = useAuthStore((s) => s.accessToken);
const [text, setText] = useState(""); const [text, setText] = useState("");
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@@ -56,11 +58,13 @@ export function MessageComposer() {
} }
const clientMessageId = makeClientMessageId(); const clientMessageId = makeClientMessageId();
const textValue = text.trim(); const textValue = text.trim();
const replyToMessageId = activeChatId ? (replyToByChat[activeChatId]?.id ?? undefined) : undefined;
addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "text", text: textValue, clientMessageId }); addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "text", text: textValue, clientMessageId });
try { try {
const message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId); const message = await sendMessageWithClientId(activeChatId, textValue, "text", clientMessageId, replyToMessageId);
confirmMessageByClientId(activeChatId, clientMessageId, message); confirmMessageByClientId(activeChatId, clientMessageId, message);
setText(""); setText("");
setReplyToMessage(activeChatId, null);
const ws = getWs(); const ws = getWs();
ws?.send(JSON.stringify({ event: "typing_stop", payload: { chat_id: activeChatId } })); ws?.send(JSON.stringify({ event: "typing_stop", payload: { chat_id: activeChatId } }));
} catch { } catch {
@@ -77,6 +81,7 @@ export function MessageComposer() {
setUploadProgress(0); setUploadProgress(0);
setUploadError(null); setUploadError(null);
const clientMessageId = makeClientMessageId(); const clientMessageId = makeClientMessageId();
const replyToMessageId = activeChatId ? (replyToByChat[activeChatId]?.id ?? undefined) : undefined;
try { try {
const upload = await requestUploadUrl(file); const upload = await requestUploadUrl(file);
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file, setUploadProgress); await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file, setUploadProgress);
@@ -87,9 +92,10 @@ export function MessageComposer() {
text: upload.file_url, text: upload.file_url,
clientMessageId clientMessageId
}); });
const message = await sendMessageWithClientId(activeChatId, upload.file_url, messageType, clientMessageId); const message = await sendMessageWithClientId(activeChatId, upload.file_url, messageType, clientMessageId, replyToMessageId);
await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size); await attachFile(message.id, upload.file_url, file.type || "application/octet-stream", file.size);
confirmMessageByClientId(activeChatId, clientMessageId, message); confirmMessageByClientId(activeChatId, clientMessageId, message);
setReplyToMessage(activeChatId, null);
} catch { } catch {
removeOptimisticMessage(activeChatId, clientMessageId); removeOptimisticMessage(activeChatId, clientMessageId);
setUploadError("Upload failed. Please try again."); setUploadError("Upload failed. Please try again.");
@@ -239,6 +245,17 @@ export function MessageComposer() {
return ( return (
<div className="border-t border-slate-700/50 bg-slate-900/55 p-3"> <div className="border-t border-slate-700/50 bg-slate-900/55 p-3">
{activeChatId && replyToByChat[activeChatId] ? (
<div className="mb-2 flex items-start justify-between rounded-lg border border-slate-700/80 bg-slate-800/70 px-3 py-2 text-xs">
<div className="min-w-0">
<p className="font-semibold text-sky-300">Replying</p>
<p className="truncate text-slate-300">{replyToByChat[activeChatId]?.text || "[media]"}</p>
</div>
<button className="ml-2 rounded bg-slate-700 px-2 py-1 text-[11px]" onClick={() => setReplyToMessage(activeChatId, null)}>
Cancel
</button>
</div>
) : null}
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<label className="cursor-pointer rounded-full bg-slate-700/80 px-3 py-2 text-xs font-semibold hover:bg-slate-700"> <label className="cursor-pointer rounded-full bg-slate-700/80 px-3 py-2 text-xs font-semibold hover:bg-slate-700">
+ +

View File

@@ -1,4 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { forwardMessage, pinMessage } from "../api/chats";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { formatTime } from "../utils/format"; import { formatTime } from "../utils/format";
@@ -8,6 +9,9 @@ export function MessageList() {
const activeChatId = useChatStore((s) => s.activeChatId); const activeChatId = useChatStore((s) => s.activeChatId);
const messagesByChat = useChatStore((s) => s.messagesByChat); const messagesByChat = useChatStore((s) => s.messagesByChat);
const typingByChat = useChatStore((s) => s.typingByChat); const typingByChat = useChatStore((s) => s.typingByChat);
const chats = useChatStore((s) => s.chats);
const setReplyToMessage = useChatStore((s) => s.setReplyToMessage);
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
const messages = useMemo(() => { const messages = useMemo(() => {
if (!activeChatId) { if (!activeChatId) {
@@ -15,13 +19,38 @@ export function MessageList() {
} }
return messagesByChat[activeChatId] ?? []; return messagesByChat[activeChatId] ?? [];
}, [activeChatId, messagesByChat]); }, [activeChatId, messagesByChat]);
const activeChat = chats.find((chat) => chat.id === activeChatId);
if (!activeChatId) { if (!activeChatId) {
return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>; return <div className="flex h-full items-center justify-center text-slate-300/80">Select a chat</div>;
} }
const chatId = activeChatId;
async function handleForward(messageId: number) {
const targetRaw = window.prompt("Forward to chat id:");
if (!targetRaw) {
return;
}
const targetId = Number(targetRaw);
if (!Number.isFinite(targetId) || targetId <= 0) {
return;
}
await forwardMessage(messageId, targetId);
}
async function handlePin(messageId: number) {
const nextPinned = activeChat?.pinned_message_id === messageId ? null : messageId;
const chat = await pinMessage(chatId, nextPinned);
updateChatPinnedMessage(chatId, chat.pinned_message_id ?? null);
}
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{activeChat?.pinned_message_id ? (
<div className="border-b border-slate-700/50 bg-slate-900/60 px-4 py-2 text-xs text-sky-300">
Pinned message ID: {activeChat.pinned_message_id}
</div>
) : null}
<div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6"> <div className="tg-scrollbar flex-1 overflow-auto px-3 py-4 md:px-6">
{messages.map((message) => { {messages.map((message) => {
const own = message.sender_id === me?.id; const own = message.sender_id === me?.id;
@@ -34,13 +63,32 @@ export function MessageList() {
: "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100" : "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
}`} }`}
> >
{message.forwarded_from_message_id ? (
<p className={`mb-1 text-[11px] font-semibold ${own ? "text-slate-900/75" : "text-sky-300"}`}>Forwarded</p>
) : null}
{message.reply_to_message_id ? (
<p className={`mb-1 text-[11px] ${own ? "text-slate-900/75" : "text-slate-300"}`}>Reply to #{message.reply_to_message_id}</p>
) : null}
{renderContent(message.type, message.text)} {renderContent(message.type, message.text)}
<p className={`mt-1 flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}> <div className="mt-1 flex items-center justify-between gap-2">
<div className="flex gap-1">
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => setReplyToMessage(chatId, message)}>
Reply
</button>
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => void handleForward(message.id)}>
Fwd
</button>
<button className={`rounded px-1.5 py-0.5 text-[10px] ${own ? "bg-slate-900/15" : "bg-slate-700/70"}`} onClick={() => void handlePin(message.id)}>
Pin
</button>
</div>
<p className={`flex items-center justify-end gap-1 text-[11px] ${own ? "text-slate-900/85" : "text-slate-400"}`}>
<span>{formatTime(message.created_at)}</span> <span>{formatTime(message.created_at)}</span>
{own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null} {own ? <span className={message.delivery_status === "read" ? "text-cyan-900" : ""}>{renderStatus(message.delivery_status)}</span> : null}
</p> </p>
</div> </div>
</div> </div>
</div>
); );
})} })}
</div> </div>

View File

@@ -7,6 +7,7 @@ interface ChatState {
activeChatId: number | null; activeChatId: number | null;
messagesByChat: Record<number, Message[]>; messagesByChat: Record<number, Message[]>;
typingByChat: Record<number, number[]>; typingByChat: Record<number, number[]>;
replyToByChat: Record<number, Message | null>;
loadChats: (query?: string) => Promise<void>; loadChats: (query?: string) => Promise<void>;
setActiveChatId: (chatId: number | null) => void; setActiveChatId: (chatId: number | null) => void;
loadMessages: (chatId: number) => Promise<void>; loadMessages: (chatId: number) => Promise<void>;
@@ -22,6 +23,8 @@ interface ChatState {
removeOptimisticMessage: (chatId: number, clientMessageId: string) => void; removeOptimisticMessage: (chatId: number, clientMessageId: string) => void;
setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void; setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void;
setTypingUsers: (chatId: number, userIds: number[]) => void; setTypingUsers: (chatId: number, userIds: number[]) => void;
setReplyToMessage: (chatId: number, message: Message | null) => void;
updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void;
} }
export const useChatStore = create<ChatState>((set, get) => ({ export const useChatStore = create<ChatState>((set, get) => ({
@@ -29,6 +32,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
activeChatId: null, activeChatId: null,
messagesByChat: {}, messagesByChat: {},
typingByChat: {}, typingByChat: {},
replyToByChat: {},
loadChats: async (query) => { loadChats: async (query) => {
const chats = await getChats(query); const chats = await getChats(query);
const currentActive = get().activeChatId; const currentActive = get().activeChatId;
@@ -134,5 +138,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
})); }));
}, },
setTypingUsers: (chatId, userIds) => setTypingUsers: (chatId, userIds) =>
set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } })) set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } })),
setReplyToMessage: (chatId, message) =>
set((state) => ({ replyToByChat: { ...state.replyToByChat, [chatId]: message } })),
updateChatPinnedMessage: (chatId, pinnedMessageId) =>
set((state) => ({
chats: state.chats.map((chat) => (chat.id === chatId ? { ...chat, pinned_message_id: pinnedMessageId } : chat))
}))
})); }));