import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { PostMetrics, postTypeLabel } from "../../components/studio/PostMetrics"; import { ReplyComposer, type ReplySelection } from "../../components/studio/ReplyComposer"; import { Badge, Button, Card, EmptyState, Textarea } from "../../components/ui"; import { useData, useRepos } from "../../data/DataContext"; import type { OwnPost, OwnPostReply, Persona, ThreadsAccount } from "../../domain/types"; import { useI18n } from "../../i18n/I18nContext"; import { useFormatApiError } from "../../lib/apiErrors"; import { buildStructureNotes, saveComposeMimicBridge } from "../../lib/composeBridge"; import { allowHttpUrl } from "../../lib/externalUrl"; import { isPersonaReady } from "../../lib/personaPrompt"; import { formatLocalDateTime } from "../../lib/time"; type Props = { /** 頂部預設帳號(貼文列表視角) */ accountId: string; /** 頂部預設人設 */ personaId: string; accounts: ThreadsAccount[]; personas: Persona[]; }; type ReplyFilter = "pending" | "replied" | "all"; function defaultSelection(accountId: string, personaId: string): ReplySelection { return { accountId, personaId }; } /** * 第一層來訊(別人留在貼文下,不是子留言)。 * Threads API 的 replied_to 常等於「根貼 media_id」——那仍算第一層,不能當巢狀濾掉。 */ function isTopLevelIncoming( r: OwnPostReply, myUsernames: Set, rootMediaId?: string, ): boolean { const parent = (r.parent_reply_id || "").trim(); if (parent && parent !== (rootMediaId || "").trim()) { // 真·巢狀回覆(parent 是另一則留言) return false; } if (r.is_mine) return false; if (r.username && myUsernames.has(r.username)) return false; return true; } function childrenOf(all: OwnPostReply[], parentId: string): OwnPostReply[] { return all .filter((r) => r.parent_reply_id === parentId) .slice() .sort((a, b) => a.created_at - b.created_at); } /** 底下是否有我的回覆(純留言樹判斷,不靠 LLM) */ function hasMyChildReply( all: OwnPostReply[], parentId: string, myUsernames: Set, ): boolean { return all.some( (r) => r.parent_reply_id === parentId && (r.is_mine || (r.username ? myUsernames.has(r.username) : false)), ); } /** * 未回覆:別人的第一層留言,且底下還沒有我的子回覆。 * 已回覆:底下已有 is_mine/我的帳號 username 的回覆,或後端 reply_status=replied。 */ function isReplyHidden(r: OwnPostReply): boolean { return (r.hide_status || "").toUpperCase() === "HIDDEN"; } function isPendingReply( r: OwnPostReply, all: OwnPostReply[], myUsernames: Set, ): boolean { if (hasMyChildReply(all, r.id, myUsernames)) return false; if (r.reply_status === "replied") return false; return true; } export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Props) { const repos = useRepos(); const { refresh, tick } = useData(); const { t } = useI18n(); const formatError = useFormatApiError(); const navigate = useNavigate(); const [posts, setPosts] = useState([]); const [syncedAt, setSyncedAt] = useState(null); const [expanded, setExpanded] = useState(""); /** 每筆回覆草稿 key = postId 或 postId:replyId */ const [draftByKey, setDraftByKey] = useState>({}); /** 每筆回覆的帳號/人設(預設吃頂部) */ const [selByKey, setSelByKey] = useState>({}); /** 哪些貼文已手動按過「分析結構」(才顯示 AI 分析結果) */ const [analyzedIds, setAnalyzedIds] = useState>({}); /** 哪些 key 已打開回覆編輯區 */ const [composeOpen, setComposeOpen] = useState>({}); /** 留言篩選:預設未回覆 */ const [replyFilter, setReplyFilter] = useState("pending"); const [busy, setBusy] = useState(""); const [message, setMessage] = useState(""); const [messageTone, setMessageTone] = useState<"ok" | "error">("ok"); const [verifyUrl, setVerifyUrl] = useState(""); /** 本頁已載過留言的 post id(避免每次展開重打) */ const [repliesLoaded, setRepliesLoaded] = useState>({}); const myUsernames = new Set(accounts.map((a) => a.username)); useEffect(() => { void (async () => { if (!accountId) { setPosts([]); return; } setPosts(await repos.ownPosts.list(accountId)); setSyncedAt(await repos.ownPosts.lastSyncedAt()); })(); }, [accountId, repos.ownPosts, tick]); function flashOk(text: string, url = "") { setMessageTone("ok"); setMessage(text); setVerifyUrl(url); } function flashErr(err: unknown, fallback: string) { setMessageTone("error"); setMessage(formatError(err, fallback)); setVerifyUrl(""); } function getSel(key: string): ReplySelection { return selByKey[key] || defaultSelection(accountId, personaId); } function setSel(key: string, next: ReplySelection) { setSelByKey((m) => ({ ...m, [key]: next })); } function openCompose(key: string) { setComposeOpen((m) => ({ ...m, [key]: true })); setSelByKey((m) => ({ ...m, [key]: m[key] || defaultSelection(accountId, personaId), })); if (!draftByKey[key]) { setDraftByKey((m) => ({ ...m, [key]: m[key] || "" })); } } async function sync() { if (!accountId) return; setBusy("sync"); setMessage(""); setMessageTone("ok"); setVerifyUrl(""); try { const list = await repos.ownPosts.sync(accountId); setPosts(list); setSyncedAt(await repos.ownPosts.lastSyncedAt()); // 重新同步後留言改點開再載 setRepliesLoaded({}); flashOk(t("posts.syncDone", { n: list.length })); refresh(); } catch (e) { flashErr(e, "posts.syncFail"); } finally { setBusy(""); } } async function toggleReplies(post: OwnPost) { if (expanded === post.id) { setExpanded(""); return; } setExpanded(post.id); // 已載過或本機已有留言:不再打 API if (repliesLoaded[post.id] || (post.replies?.length ?? 0) > 0) { return; } setBusy(`replies:${post.id}`); setMessage(""); try { const next = await repos.ownPosts.loadReplies(post.id); setPosts((list) => list.map((p) => (p.id === next.id ? next : p))); setRepliesLoaded((m) => ({ ...m, [post.id]: true })); } catch (e) { flashErr(e, "posts.loadRepliesFail"); } finally { setBusy(""); } } async function genReply(key: string, post: OwnPost, replyId?: string) { const sel = getSel(key); const persona = personas.find((p) => p.id === sel.personaId); if (!isPersonaReady(persona)) { flashErr(new Error(t("posts.needPersona")), "posts.needPersona"); return; } setBusy(key); setMessage(""); try { const text = await repos.ownPosts.generateReply({ postId: post.id, replyId, personaId: sel.personaId, }); setDraftByKey((m) => ({ ...m, [key]: text })); setComposeOpen((m) => ({ ...m, [key]: true })); } catch (e) { flashErr(e, "posts.genFail"); } finally { setBusy(""); } } async function send(key: string, post: OwnPost, replyId?: string) { const text = draftByKey[key]; const sel = getSel(key); if (!text?.trim()) { flashErr(new Error(t("posts.needText")), "posts.needText"); return; } if (!sel.accountId) { flashErr(new Error(t("posts.needAccount")), "posts.needAccount"); return; } setBusy(`send:${key}`); flashOk(t("posts.sending")); try { // 回覆留言只送文字,不附圖 const next = await repos.ownPosts.sendReply({ postId: post.id, replyId, text, accountId: sel.accountId, }); setPosts((list) => list.map((p) => (p.id === next.id ? next : p))); setRepliesLoaded((m) => ({ ...m, [post.id]: true })); const user = accounts.find((a) => a.id === sel.accountId)?.username || t("posts.accountFallback"); flashOk(t("posts.sent", { user }), allowHttpUrl(next.permalink || post.permalink) || ""); setComposeOpen((m) => ({ ...m, [key]: false })); setDraftByKey((m) => ({ ...m, [key]: "" })); setExpanded(post.id); refresh(); } catch (e) { flashErr(e, "posts.sendFail"); } finally { setBusy(""); } } async function manageReply(post: OwnPost, reply: OwnPostReply, hide: boolean) { setBusy(`hide:${reply.id}`); setMessage(""); setVerifyUrl(""); try { const next = await repos.ownPosts.manageReply({ postId: post.id, replyId: reply.id, hide, }); setPosts((list) => list.map((p) => (p.id === next.id ? next : p))); flashOk( hide ? t("posts.replyHidden") : t("posts.replyUnhidden"), allowHttpUrl(next.permalink || post.permalink) || "", ); } catch (e) { flashErr(e, "posts.hideFail"); } finally { setBusy(""); } } async function analyze(post: OwnPost) { setBusy(`an:${post.id}`); setMessage(""); setMessageTone("ok"); try { const next = await repos.ownPosts.analyzePost(post.id); setPosts((list) => list.map((p) => (p.id === next.id ? next : p))); setAnalyzedIds((m) => ({ ...m, [post.id]: true })); setExpanded(post.id); flashOk(t("posts.analyzeDone")); } catch (e) { flashErr(e, "posts.analyzeFail"); } finally { setBusy(""); } } function openComposeMimic(post: OwnPost) { // 長文 + 結構分析走 sessionStorage,避免 query 截斷/帶不過分析 const notes = buildStructureNotes({ insight: post.insight, formulaSummary: post.formula_summary, formulaDetail: post.formula_detail, }); saveComposeMimicBridge({ sourceText: post.text || "", structureNotes: notes || undefined, formulaSummary: post.formula_summary, insight: post.insight, fromPostId: post.id, }); navigate(`/app/studio?tab=compose&from=own-post&mimic=1`); } return (

{syncedAt ? t("posts.syncedAt", { time: formatLocalDateTime(syncedAt) }) : t("posts.notSynced")}

{message ? (

{message} {verifyUrl ? ( <> {" "} {t("posts.openThreads")} ) : null}

) : null} {posts.length === 0 ? ( ) : ( posts.map((post) => { const open = expanded === post.id; const rootKey = post.id; // 本 session 按過分析,或後端已存結果(重整後仍顯示) const showAnalysis = Boolean( analyzedIds[post.id] || post.formula_detail || post.formula_summary || post.insight, ); return (
{post.thumbnail_url || post.media_url ? ( ) : null}
{post.topic_tag ? {post.topic_tag} : null} {postTypeLabel(post, t)} {post.is_quote_post ? quote : null} {post.insights_status && post.insights_status !== "ok" ? ( {post.insights_status} ) : null} {showAnalysis ? {t("posts.analyzedBadge")} : null} {post.reply_control ? ( {t("posts.whoCanReply")}: {t(`posts.replyControl.${post.reply_control}`)} ) : null}

{post.text || t("posts.noText")}

{formatLocalDateTime(post.published_at)} {post.shortcode ? ` · ${post.shortcode}` : ""} {allowHttpUrl(post.permalink) ? ( <> {" · "} {t("posts.openThreads")} ) : null}

{open ? : null} {/* AI 結構分析:有結果就顯示;詳細區在展開時更清楚 */} {showAnalysis && post.insight ? (

{t("posts.insight", { text: post.insight })}

) : null} {showAnalysis && post.formula_summary ? (

{t("posts.review", { text: post.formula_summary })}

) : null} {showAnalysis && post.formula_detail ? (