feat(threads): add basic message thread API and web thread panel
All checks were successful
CI / test (push) Successful in 21s
All checks were successful
CI / test (push) Successful in 21s
This commit is contained in:
@@ -124,6 +124,34 @@ async def search_messages(
|
|||||||
return list(result.scalars().all())
|
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:
|
async def delete_message(db: AsyncSession, message: Message) -> None:
|
||||||
await db.delete(message)
|
await db.delete(message)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.messages.service import (
|
|||||||
delete_message_for_all,
|
delete_message_for_all,
|
||||||
forward_message,
|
forward_message,
|
||||||
forward_message_bulk,
|
forward_message_bulk,
|
||||||
|
get_message_thread,
|
||||||
get_messages,
|
get_messages,
|
||||||
list_message_reactions,
|
list_message_reactions,
|
||||||
search_messages,
|
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)
|
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])
|
@router.get("/{chat_id}", response_model=list[MessageRead])
|
||||||
async def list_messages(
|
async def list_messages(
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
|
|||||||
@@ -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(
|
async def update_message(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -829,6 +829,12 @@ Response: `200` + `MessageRead[]`
|
|||||||
Auth required.
|
Auth required.
|
||||||
Response: `200` + `MessageRead[]`
|
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}`
|
### PUT `/api/v1/messages/{message_id}`
|
||||||
|
|
||||||
Auth required.
|
Auth required.
|
||||||
|
|||||||
@@ -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, 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)
|
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`
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ export async function searchMessages(query: string, chatId?: number): Promise<Me
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMessageThread(messageId: number, limit = 100): Promise<Message[]> {
|
||||||
|
const { data } = await http.get<Message[]>(`/messages/${messageId}/thread`, {
|
||||||
|
params: { limit }
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendMessage(chatId: number, text: string, type: MessageType = "text"): Promise<Message> {
|
export async function sendMessage(chatId: number, text: string, type: MessageType = "text"): Promise<Message> {
|
||||||
const { data } = await http.post<Message>("/messages", { chat_id: chatId, text, type });
|
const { data } = await http.post<Message>("/messages", { chat_id: chatId, text, type });
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
deleteMessage,
|
deleteMessage,
|
||||||
forwardMessageBulk,
|
forwardMessageBulk,
|
||||||
getChatAttachments,
|
getChatAttachments,
|
||||||
|
getMessageThread,
|
||||||
listMessageReactions,
|
listMessageReactions,
|
||||||
pinMessage,
|
pinMessage,
|
||||||
toggleMessageReaction
|
toggleMessageReaction
|
||||||
@@ -71,6 +72,10 @@ export function MessageList() {
|
|||||||
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
|
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
|
||||||
const [attachmentsByMessage, setAttachmentsByMessage] = useState<Record<number, ChatAttachment[]>>({});
|
const [attachmentsByMessage, setAttachmentsByMessage] = useState<Record<number, ChatAttachment[]>>({});
|
||||||
const [mediaViewer, setMediaViewer] = useState<MediaViewerState>(null);
|
const [mediaViewer, setMediaViewer] = useState<MediaViewerState>(null);
|
||||||
|
const [threadRootId, setThreadRootId] = useState<number | null>(null);
|
||||||
|
const [threadMessages, setThreadMessages] = useState<Message[]>([]);
|
||||||
|
const [threadLoading, setThreadLoading] = useState(false);
|
||||||
|
const [threadError, setThreadError] = useState<string | null>(null);
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -115,6 +120,7 @@ export function MessageList() {
|
|||||||
setDeleteMessageId(null);
|
setDeleteMessageId(null);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setMediaViewer(null);
|
setMediaViewer(null);
|
||||||
|
setThreadRootId(null);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
@@ -129,6 +135,9 @@ export function MessageList() {
|
|||||||
if (activeChatId) {
|
if (activeChatId) {
|
||||||
setEditingMessage(activeChatId, null);
|
setEditingMessage(activeChatId, null);
|
||||||
}
|
}
|
||||||
|
setThreadRootId(null);
|
||||||
|
setThreadMessages([]);
|
||||||
|
setThreadError(null);
|
||||||
setReactionsByMessage({});
|
setReactionsByMessage({});
|
||||||
setAttachmentsByMessage({});
|
setAttachmentsByMessage({});
|
||||||
}, [activeChatId, setEditingMessage]);
|
}, [activeChatId, setEditingMessage]);
|
||||||
@@ -361,6 +370,21 @@ export function MessageList() {
|
|||||||
setPendingDelete(null);
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col" onClick={() => { setCtx(null); }}>
|
<div className="flex h-full flex-col" onClick={() => { setCtx(null); }}>
|
||||||
{activeChat?.pinned_message_id ? (
|
{activeChat?.pinned_message_id ? (
|
||||||
@@ -618,6 +642,15 @@ export function MessageList() {
|
|||||||
>
|
>
|
||||||
Forward
|
Forward
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||||
|
onClick={() => {
|
||||||
|
void openThread(ctx.messageId);
|
||||||
|
setCtx(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View thread
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -754,6 +787,51 @@ export function MessageList() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{threadRootId ? (
|
||||||
|
<div className="absolute inset-0 z-[120] flex items-end justify-center bg-slate-950/60 p-3" onClick={() => setThreadRootId(null)}>
|
||||||
|
<div className="tg-scrollbar max-h-[72vh] w-full overflow-auto rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold">Thread</p>
|
||||||
|
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={() => setThreadRootId(null)} type="button">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{threadLoading ? <p className="text-xs text-slate-400">Loading...</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}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{threadMessages.map((threadMessage) => {
|
||||||
|
const own = threadMessage.sender_id === me?.id;
|
||||||
|
const isRoot = threadMessage.id === threadRootId;
|
||||||
|
return (
|
||||||
|
<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"}`}
|
||||||
|
key={`thread-${threadMessage.id}`}
|
||||||
|
>
|
||||||
|
<p className={`mb-1 text-[11px] ${own ? "text-sky-300" : "text-slate-400"}`}>
|
||||||
|
{isRoot ? "Original message" : "Reply"} • {formatTime(threadMessage.created_at)}
|
||||||
|
</p>
|
||||||
|
<div className={own ? "text-slate-100" : "text-slate-200"}>
|
||||||
|
{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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{deleteMessageId ? (
|
{deleteMessageId ? (
|
||||||
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setDeleteMessageId(null)}>
|
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setDeleteMessageId(null)}>
|
||||||
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(event) => event.stopPropagation()}>
|
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(event) => event.stopPropagation()}>
|
||||||
|
|||||||
Reference in New Issue
Block a user