feat: add reply/forward/pin message flow across backend and web
Some checks failed
CI / test (push) Failing after 24s
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:
64
alembic/versions/0004_reply_forward_pin.py
Normal file
64
alembic/versions/0004_reply_forward_pin.py
Normal 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")
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
+
|
+
|
||||||
|
|||||||
@@ -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,11 +63,30 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}))
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user