feat(threads): support nested replies in thread API and panel
All checks were successful
CI / test (push) Successful in 31s
All checks were successful
CI / test (push) Successful in 31s
This commit is contained in:
@@ -152,6 +152,34 @@ async def list_message_thread(
|
|||||||
return list(result.scalars().all())
|
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:
|
async def delete_message(db: AsyncSession, message: Message) -> None:
|
||||||
await db.delete(message)
|
await db.delete(message)
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,29 @@ async def get_message_thread(
|
|||||||
if not root:
|
if not root:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found")
|
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)
|
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(
|
async def update_message(
|
||||||
|
|||||||
@@ -832,7 +832,7 @@ Response: `200` + `MessageRead[]`
|
|||||||
### GET `/api/v1/messages/{message_id}/thread?limit=100`
|
### GET `/api/v1/messages/{message_id}/thread?limit=100`
|
||||||
|
|
||||||
Auth required.
|
Auth required.
|
||||||
Returns root message + direct replies (basic thread view).
|
Returns root message + nested replies (thread subtree, BFS by reply links).
|
||||||
Response: `200` + `MessageRead[]`
|
Response: `200` + `MessageRead[]`
|
||||||
|
|
||||||
### PUT `/api/v1/messages/{message_id}`
|
### PUT `/api/v1/messages/{message_id}`
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Legend:
|
|||||||
7. Chat Creation - `DONE` (private/group/channel)
|
7. Chat Creation - `DONE` (private/group/channel)
|
||||||
8. Messages (base) - `DONE` (send/read/edit/delete/delete for all)
|
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)
|
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)
|
11. Forwarding - `PARTIAL` (single + bulk; "without author" missing)
|
||||||
12. Pinning - `DONE` (message/chat pin-unpin)
|
12. Pinning - `DONE` (message/chat pin-unpin)
|
||||||
13. Reactions - `DONE`
|
13. Reactions - `DONE`
|
||||||
|
|||||||
@@ -87,6 +87,30 @@ export function MessageList() {
|
|||||||
}, [activeChatId, messagesByChat]);
|
}, [activeChatId, messagesByChat]);
|
||||||
|
|
||||||
const messagesMap = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
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<number, number>();
|
||||||
|
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 activeChat = chats.find((chat) => chat.id === activeChatId);
|
||||||
const forwardTargets = useMemo(() => {
|
const forwardTargets = useMemo(() => {
|
||||||
const q = forwardQuery.trim().toLowerCase();
|
const q = forwardQuery.trim().toLowerCase();
|
||||||
@@ -800,16 +824,18 @@ export function MessageList() {
|
|||||||
{threadError ? <p className="text-xs text-red-400">{threadError}</p> : null}
|
{threadError ? <p className="text-xs text-red-400">{threadError}</p> : null}
|
||||||
{!threadLoading && !threadError && threadMessages.length === 0 ? <p className="text-xs text-slate-400">No replies yet</p> : null}
|
{!threadLoading && !threadError && threadMessages.length === 0 ? <p className="text-xs text-slate-400">No replies yet</p> : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{threadMessages.map((threadMessage) => {
|
{threadRows.map(({ message: threadMessage, depth }) => {
|
||||||
const own = threadMessage.sender_id === me?.id;
|
const own = threadMessage.sender_id === me?.id;
|
||||||
const isRoot = threadMessage.id === threadRootId;
|
const isRoot = threadMessage.id === threadRootId;
|
||||||
|
const indent = Math.min(6, depth) * 14;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-xl border px-3 py-2 ${isRoot ? "border-sky-400/60 bg-sky-500/10" : "border-slate-700/70 bg-slate-800/60"}`}
|
className={`rounded-xl border px-3 py-2 ${isRoot ? "border-sky-400/60 bg-sky-500/10" : "border-slate-700/70 bg-slate-800/60"}`}
|
||||||
key={`thread-${threadMessage.id}`}
|
key={`thread-${threadMessage.id}`}
|
||||||
|
style={{ marginLeft: `${indent}px` }}
|
||||||
>
|
>
|
||||||
<p className={`mb-1 text-[11px] ${own ? "text-sky-300" : "text-slate-400"}`}>
|
<p className={`mb-1 text-[11px] ${own ? "text-sky-300" : "text-slate-400"}`}>
|
||||||
{isRoot ? "Original message" : "Reply"} • {formatTime(threadMessage.created_at)}
|
{isRoot ? "Original message" : `Reply • level ${depth}`} • {formatTime(threadMessage.created_at)}
|
||||||
</p>
|
</p>
|
||||||
<div className={own ? "text-slate-100" : "text-slate-200"}>
|
<div className={own ? "text-slate-100" : "text-slate-200"}>
|
||||||
{renderMessageContent(threadMessage, {
|
{renderMessageContent(threadMessage, {
|
||||||
|
|||||||
Reference in New Issue
Block a user