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:
@@ -76,6 +76,13 @@ export async function searchMessages(query: string, chatId?: number): Promise<Me
|
||||
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> {
|
||||
const { data } = await http.post<Message>("/messages", { chat_id: chatId, text, type });
|
||||
return data;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
deleteMessage,
|
||||
forwardMessageBulk,
|
||||
getChatAttachments,
|
||||
getMessageThread,
|
||||
listMessageReactions,
|
||||
pinMessage,
|
||||
toggleMessageReaction
|
||||
@@ -71,6 +72,10 @@ export function MessageList() {
|
||||
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
|
||||
const [attachmentsByMessage, setAttachmentsByMessage] = useState<Record<number, ChatAttachment[]>>({});
|
||||
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 scrollContainerRef = useRef<HTMLDivElement | null>(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 (
|
||||
<div className="flex h-full flex-col" onClick={() => { setCtx(null); }}>
|
||||
{activeChat?.pinned_message_id ? (
|
||||
@@ -618,6 +642,15 @@ export function MessageList() {
|
||||
>
|
||||
Forward
|
||||
</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
|
||||
className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
@@ -754,6 +787,51 @@ export function MessageList() {
|
||||
/>
|
||||
) : 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 ? (
|
||||
<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()}>
|
||||
|
||||
Reference in New Issue
Block a user