From c6e8b779b0d13b1c77014b543eb1c2e4005ec73c Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 13:37:53 +0300 Subject: [PATCH] feat(threads): add basic message thread API and web thread panel --- app/messages/repository.py | 28 +++++++++++ app/messages/router.py | 11 +++++ app/messages/service.py | 14 ++++++ docs/api-reference.md | 6 +++ docs/core-checklist-status.md | 2 +- web/src/api/chats.ts | 7 +++ web/src/components/MessageList.tsx | 78 ++++++++++++++++++++++++++++++ 7 files changed, 145 insertions(+), 1 deletion(-) diff --git a/app/messages/repository.py b/app/messages/repository.py index 4c4bdb4..c407fce 100644 --- a/app/messages/repository.py +++ b/app/messages/repository.py @@ -124,6 +124,34 @@ async def search_messages( return list(result.scalars().all()) +async def list_message_thread( + db: AsyncSession, + *, + root_message_id: int, + user_id: int, + limit: int = 100, +) -> list[Message]: + root = await get_message_by_id(db, root_message_id) + if not root: + return [] + stmt = ( + select(Message) + .outerjoin( + MessageHidden, + (MessageHidden.message_id == Message.id) & (MessageHidden.user_id == user_id), + ) + .where( + Message.chat_id == root.chat_id, + MessageHidden.id.is_(None), + (Message.id == root_message_id) | (Message.reply_to_message_id == root_message_id), + ) + .order_by(Message.id.asc()) + .limit(max(1, min(limit, 200))) + ) + result = await db.execute(stmt) + return list(result.scalars().all()) + + async def delete_message(db: AsyncSession, message: Message) -> None: await db.delete(message) diff --git a/app/messages/router.py b/app/messages/router.py index cd76d0c..6db0b22 100644 --- a/app/messages/router.py +++ b/app/messages/router.py @@ -20,6 +20,7 @@ from app.messages.service import ( delete_message_for_all, forward_message, forward_message_bulk, + get_message_thread, get_messages, list_message_reactions, search_messages, @@ -59,6 +60,16 @@ async def search_messages_endpoint( return await search_messages(db, user_id=current_user.id, query=query, chat_id=chat_id, limit=limit) +@router.get("/{message_id}/thread", response_model=list[MessageRead]) +async def message_thread( + message_id: int, + limit: int = 100, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> list[MessageRead]: + return await get_message_thread(db, message_id=message_id, user_id=current_user.id, limit=limit) + + @router.get("/{chat_id}", response_model=list[MessageRead]) async def list_messages( chat_id: int, diff --git a/app/messages/service.py b/app/messages/service.py index d0be81d..483dedb 100644 --- a/app/messages/service.py +++ b/app/messages/service.py @@ -177,6 +177,20 @@ async def search_messages( ) +async def get_message_thread( + db: AsyncSession, + *, + message_id: int, + user_id: int, + limit: int = 100, +) -> list[Message]: + root = await repository.get_message_by_id(db, message_id) + if not root: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found") + await ensure_chat_membership(db, chat_id=root.chat_id, user_id=user_id) + return await repository.list_message_thread(db, root_message_id=message_id, user_id=user_id, limit=limit) + + async def update_message( db: AsyncSession, *, diff --git a/docs/api-reference.md b/docs/api-reference.md index 7b6ef5a..85264a3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -829,6 +829,12 @@ Response: `200` + `MessageRead[]` Auth required. Response: `200` + `MessageRead[]` +### GET `/api/v1/messages/{message_id}/thread?limit=100` + +Auth required. +Returns root message + direct replies (basic thread view). +Response: `200` + `MessageRead[]` + ### PUT `/api/v1/messages/{message_id}` Auth required. diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index 088aabe..1cc313d 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -16,7 +16,7 @@ Legend: 7. Chat Creation - `DONE` (private/group/channel) 8. Messages (base) - `DONE` (send/read/edit/delete/delete for all) 9. Message Types - `PARTIAL` (text/photo/video/docs/audio/voice/circle; GIF/stickers via dedicated system missing) -10. Reply/Quote/Threads - `PARTIAL` (reply + quote-like UI, no full thread model) +10. Reply/Quote/Threads - `PARTIAL` (reply + quote-like UI + basic thread panel, no deep nested thread model) 11. Forwarding - `PARTIAL` (single + bulk; "without author" missing) 12. Pinning - `DONE` (message/chat pin-unpin) 13. Reactions - `DONE` diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index b227d23..a247fd7 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -76,6 +76,13 @@ export async function searchMessages(query: string, chatId?: number): Promise { + const { data } = await http.get(`/messages/${messageId}/thread`, { + params: { limit } + }); + return data; +} + export async function sendMessage(chatId: number, text: string, type: MessageType = "text"): Promise { const { data } = await http.post("/messages", { chat_id: chatId, text, type }); return data; diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index fbdbc92..8ede8f4 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -4,6 +4,7 @@ import { deleteMessage, forwardMessageBulk, getChatAttachments, + getMessageThread, listMessageReactions, pinMessage, toggleMessageReaction @@ -71,6 +72,10 @@ export function MessageList() { const [reactionsByMessage, setReactionsByMessage] = useState>({}); const [attachmentsByMessage, setAttachmentsByMessage] = useState>({}); const [mediaViewer, setMediaViewer] = useState(null); + const [threadRootId, setThreadRootId] = useState(null); + const [threadMessages, setThreadMessages] = useState([]); + const [threadLoading, setThreadLoading] = useState(false); + const [threadError, setThreadError] = useState(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const scrollContainerRef = useRef(null); @@ -115,6 +120,7 @@ export function MessageList() { setDeleteMessageId(null); setSelectedIds(new Set()); setMediaViewer(null); + setThreadRootId(null); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); @@ -129,6 +135,9 @@ export function MessageList() { if (activeChatId) { setEditingMessage(activeChatId, null); } + setThreadRootId(null); + setThreadMessages([]); + setThreadError(null); setReactionsByMessage({}); setAttachmentsByMessage({}); }, [activeChatId, setEditingMessage]); @@ -361,6 +370,21 @@ export function MessageList() { setPendingDelete(null); } + async function openThread(messageId: number) { + setThreadRootId(messageId); + setThreadLoading(true); + setThreadError(null); + try { + const rows = await getMessageThread(messageId, 150); + setThreadMessages(rows); + } catch { + setThreadMessages([]); + setThreadError("Failed to load thread"); + } finally { + setThreadLoading(false); + } + } + return (
{ setCtx(null); }}> {activeChat?.pinned_message_id ? ( @@ -618,6 +642,15 @@ export function MessageList() { > Forward + +
+ {threadLoading ?

Loading...

: null} + {threadError ?

{threadError}

: null} + {!threadLoading && !threadError && threadMessages.length === 0 ?

No replies yet

: null} +
+ {threadMessages.map((threadMessage) => { + const own = threadMessage.sender_id === me?.id; + const isRoot = threadMessage.id === threadRootId; + return ( +
+

+ {isRoot ? "Original message" : "Reply"} • {formatTime(threadMessage.created_at)} +

+
+ {renderMessageContent(threadMessage, { + attachments: attachmentsByMessage[threadMessage.id] ?? [], + onAttachmentContextMenu: () => {}, + onOpenMedia: (url, type) => { + const items = collectMediaItems(messages, attachmentsByMessage); + const idx = items.findIndex((i) => i.url === url && i.type === type); + if (items.length) { + setMediaViewer({ items, index: idx >= 0 ? idx : 0 }); + } + }, + })} +
+
+ ); + })} +
+ + + ) : null} + {deleteMessageId ? (
setDeleteMessageId(null)}>
event.stopPropagation()}>