diff --git a/app/messages/repository.py b/app/messages/repository.py index c407fce..f4d076f 100644 --- a/app/messages/repository.py +++ b/app/messages/repository.py @@ -152,6 +152,34 @@ async def list_message_thread( return list(result.scalars().all()) +async def list_messages_by_parent_ids( + db: AsyncSession, + *, + chat_id: int, + user_id: int, + parent_ids: list[int], + limit: int = 200, +) -> list[Message]: + if not parent_ids: + return [] + stmt = ( + select(Message) + .outerjoin( + MessageHidden, + (MessageHidden.message_id == Message.id) & (MessageHidden.user_id == user_id), + ) + .where( + Message.chat_id == chat_id, + MessageHidden.id.is_(None), + Message.reply_to_message_id.in_(parent_ids), + ) + .order_by(Message.id.asc()) + .limit(max(1, min(limit, 500))) + ) + 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/service.py b/app/messages/service.py index 483dedb..7ade969 100644 --- a/app/messages/service.py +++ b/app/messages/service.py @@ -188,7 +188,29 @@ async def get_message_thread( 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) + safe_limit = max(1, min(limit, 500)) + collected: dict[int, Message] = {root.id: root} + frontier: list[int] = [root.id] + remaining = safe_limit - 1 + while frontier and remaining > 0: + batch = await repository.list_messages_by_parent_ids( + db, + chat_id=root.chat_id, + user_id=user_id, + parent_ids=frontier, + limit=min(remaining, 200), + ) + next_frontier: list[int] = [] + for message in batch: + if message.id in collected: + continue + collected[message.id] = message + next_frontier.append(message.id) + remaining -= 1 + if remaining <= 0: + break + frontier = next_frontier + return sorted(collected.values(), key=lambda item: item.id) async def update_message( diff --git a/docs/api-reference.md b/docs/api-reference.md index 85264a3..b1d2b35 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -832,7 +832,7 @@ Response: `200` + `MessageRead[]` ### GET `/api/v1/messages/{message_id}/thread?limit=100` Auth required. -Returns root message + direct replies (basic thread view). +Returns root message + nested replies (thread subtree, BFS by reply links). Response: `200` + `MessageRead[]` ### PUT `/api/v1/messages/{message_id}` diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index 85ba201..8fc937d 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 + basic thread panel, no deep nested thread model) +10. Reply/Quote/Threads - `PARTIAL` (reply + quote-like UI + thread panel with nested replies, no dedicated full thread navigation yet) 11. Forwarding - `PARTIAL` (single + bulk; "without author" missing) 12. Pinning - `DONE` (message/chat pin-unpin) 13. Reactions - `DONE` diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 8ede8f4..8cb6f59 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -87,6 +87,30 @@ export function MessageList() { }, [activeChatId, messagesByChat]); const messagesMap = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]); + const threadRows = useMemo(() => { + if (!threadRootId || !threadMessages.length) return []; + const byId = new Map(threadMessages.map((message) => [message.id, message])); + const memoDepth = new Map(); + const calcDepth = (message: Message): number => { + if (memoDepth.has(message.id)) return memoDepth.get(message.id) ?? 0; + if (message.id === threadRootId) { + memoDepth.set(message.id, 0); + return 0; + } + const parentId = message.reply_to_message_id ?? null; + const parent = parentId ? byId.get(parentId) : undefined; + if (!parent) { + memoDepth.set(message.id, 1); + return 1; + } + const depth = Math.min(12, calcDepth(parent) + 1); + memoDepth.set(message.id, depth); + return depth; + }; + return threadMessages + .map((message) => ({ message, depth: calcDepth(message) })) + .sort((a, b) => a.message.id - b.message.id); + }, [threadMessages, threadRootId]); const activeChat = chats.find((chat) => chat.id === activeChatId); const forwardTargets = useMemo(() => { const q = forwardQuery.trim().toLowerCase(); @@ -800,16 +824,18 @@ export function MessageList() { {threadError ?

{threadError}

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

No replies yet

: null}
- {threadMessages.map((threadMessage) => { + {threadRows.map(({ message: threadMessage, depth }) => { const own = threadMessage.sender_id === me?.id; const isRoot = threadMessage.id === threadRootId; + const indent = Math.min(6, depth) * 14; return (

- {isRoot ? "Original message" : "Reply"} • {formatTime(threadMessage.created_at)} + {isRoot ? "Original message" : `Reply • level ${depth}`} • {formatTime(threadMessage.created_at)}

{renderMessageContent(threadMessage, {