thread-master/apps/web/src/pages/studio/OwnPostsPanel.tsx

566 lines
23 KiB
TypeScript
Raw Normal View History

2026-07-10 05:10:31 +00:00
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";
2026-07-13 01:15:30 +00:00
import { buildStructureNotes, saveComposeMimicBridge } from "../../lib/composeBridge";
2026-07-15 15:23:59 +00:00
import { allowHttpUrl } from "../../lib/externalUrl";
2026-07-10 05:10:31 +00:00
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 };
}
2026-07-13 01:15:30 +00:00
/**
*
* Threads API replied_to media_id
*/
function isTopLevelIncoming(
r: OwnPostReply,
myUsernames: Set<string>,
rootMediaId?: string,
): boolean {
const parent = (r.parent_reply_id || "").trim();
if (parent && parent !== (rootMediaId || "").trim()) {
// 真·巢狀回覆parent 是另一則留言)
return false;
}
2026-07-10 05:10:31 +00:00
if (r.is_mine) return false;
2026-07-13 01:15:30 +00:00
if (r.username && myUsernames.has(r.username)) return false;
return true;
2026-07-10 05:10:31 +00:00
}
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);
}
2026-07-13 01:15:30 +00:00
/** 底下是否有我的回覆(純留言樹判斷,不靠 LLM */
function hasMyChildReply(
all: OwnPostReply[],
parentId: string,
myUsernames: Set<string>,
): 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 isPendingReply(
r: OwnPostReply,
all: OwnPostReply[],
myUsernames: Set<string>,
): boolean {
if (hasMyChildReply(all, r.id, myUsernames)) return false;
if (r.reply_status === "replied") return false;
return true;
}
2026-07-10 05:10:31 +00:00
export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Props) {
const repos = useRepos();
const { refresh, tick } = useData();
const { t } = useI18n();
const navigate = useNavigate();
const [posts, setPosts] = useState<OwnPost[]>([]);
const [syncedAt, setSyncedAt] = useState<number | null>(null);
const [expanded, setExpanded] = useState("");
/** 每筆回覆草稿 key = postId 或 postId:replyId */
const [draftByKey, setDraftByKey] = useState<Record<string, string>>({});
/** 每筆回覆的帳號/人設(預設吃頂部) */
const [selByKey, setSelByKey] = useState<Record<string, ReplySelection>>({});
/** 哪些貼文已手動按過「分析結構」(才顯示 AI 分析結果) */
const [analyzedIds, setAnalyzedIds] = useState<Record<string, boolean>>({});
/** 哪些 key 已打開回覆編輯區 */
const [composeOpen, setComposeOpen] = useState<Record<string, boolean>>({});
/** 留言篩選:預設未回覆 */
const [replyFilter, setReplyFilter] = useState<ReplyFilter>("pending");
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
2026-07-13 01:15:30 +00:00
/** 本頁已載過留言的 post id避免每次展開重打 */
const [repliesLoaded, setRepliesLoaded] = useState<Record<string, boolean>>({});
2026-07-10 05:10:31 +00:00
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 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("");
try {
const list = await repos.ownPosts.sync(accountId);
setPosts(list);
setSyncedAt(await repos.ownPosts.lastSyncedAt());
2026-07-13 01:15:30 +00:00
// 重新同步後留言改點開再載
setRepliesLoaded({});
setMessage(t("posts.syncDone", { n: list.length }));
2026-07-10 05:10:31 +00:00
refresh();
2026-07-13 01:15:30 +00:00
} catch (e) {
setMessage(e instanceof Error ? e.message : t("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) {
setMessage(e instanceof Error ? e.message : t("posts.loadRepliesFail"));
2026-07-10 05:10:31 +00:00
} 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)) {
setMessage(t("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) {
setMessage(e instanceof Error ? e.message : t("posts.genFail"));
} finally {
setBusy("");
}
}
async function send(key: string, post: OwnPost, replyId?: string) {
const text = draftByKey[key];
const sel = getSel(key);
if (!text?.trim()) {
setMessage(t("posts.needText"));
return;
}
2026-07-13 01:15:30 +00:00
if (!sel.accountId) {
setMessage(t("posts.needAccount"));
return;
}
2026-07-10 05:10:31 +00:00
setBusy(`send:${key}`);
2026-07-13 01:15:30 +00:00
setMessage(t("posts.sending"));
2026-07-10 05:10:31 +00:00
try {
2026-07-13 01:15:30 +00:00
// 回覆留言只送文字,不附圖
2026-07-10 05:10:31 +00:00
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)));
2026-07-13 01:15:30 +00:00
setRepliesLoaded((m) => ({ ...m, [post.id]: true }));
2026-07-10 05:10:31 +00:00
const user =
accounts.find((a) => a.id === sel.accountId)?.username || t("posts.accountFallback");
2026-07-13 01:15:30 +00:00
setMessage(t("posts.sent", { user }));
2026-07-10 05:10:31 +00:00
setComposeOpen((m) => ({ ...m, [key]: false }));
setDraftByKey((m) => ({ ...m, [key]: "" }));
setExpanded(post.id);
refresh();
} catch (e) {
setMessage(e instanceof Error ? e.message : t("posts.sendFail"));
} finally {
setBusy("");
}
}
async function analyze(post: OwnPost) {
setBusy(`an:${post.id}`);
setMessage("");
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);
setMessage(t("posts.analyzeDone"));
refresh();
} catch (e) {
setMessage(e instanceof Error ? e.message : t("posts.analyzeFail"));
} finally {
setBusy("");
}
}
function openComposeMimic(post: OwnPost) {
2026-07-13 01:15:30 +00:00
// 長文 + 結構分析走 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`);
2026-07-10 05:10:31 +00:00
}
return (
<div className="hb-stack">
<div className="hb-toolbar">
<Button type="button" variant="ghost" onClick={() => void sync()} disabled={busy === "sync" || !accountId}>
{busy === "sync" ? t("posts.syncing") : t("posts.sync")}
</Button>
<p className="text-muted hb-toolbar__hint">
{syncedAt ? t("posts.syncedAt", { time: formatLocalDateTime(syncedAt) }) : t("posts.notSynced")}
</p>
</div>
{message ? (
<p className="hb-banner-ok" role="status">
{message}
</p>
) : null}
{posts.length === 0 ? (
<EmptyState title={t("posts.empty")} />
) : (
posts.map((post) => {
const open = expanded === post.id;
const rootKey = post.id;
2026-07-13 01:15:30 +00:00
// 本 session 按過分析,或後端已存結果(重整後仍顯示)
const showAnalysis = Boolean(
analyzedIds[post.id] ||
post.formula_detail ||
post.formula_summary ||
post.insight,
);
2026-07-10 05:10:31 +00:00
return (
<Card key={post.id}>
<div className="hb-post-head">
{post.thumbnail_url || post.media_url ? (
<img
className="hb-post-thumb"
src={post.thumbnail_url || post.media_url || ""}
alt=""
loading="lazy"
/>
) : null}
<div className="hb-post-head__body">
<div className="hb-inline-badges" style={{ marginBottom: "0.4rem" }}>
{post.topic_tag ? <Badge tone="brand">{post.topic_tag}</Badge> : null}
<Badge tone="neutral">{postTypeLabel(post, t)}</Badge>
{post.is_quote_post ? <Badge tone="brand">quote</Badge> : null}
{post.insights_status && post.insights_status !== "ok" ? (
<Badge tone="warning">{post.insights_status}</Badge>
) : null}
2026-07-13 01:15:30 +00:00
{showAnalysis ? <Badge tone="success">{t("posts.analyzedBadge")}</Badge> : null}
2026-07-10 05:10:31 +00:00
</div>
2026-07-13 01:15:30 +00:00
<p style={{ margin: "0 0 0.5rem" }}>{post.text || t("posts.noText")}</p>
2026-07-10 05:10:31 +00:00
<PostMetrics post={post} variant="compact" />
<p className="text-muted hb-compact-row__meta">
{formatLocalDateTime(post.published_at)}
{post.shortcode ? ` · ${post.shortcode}` : ""}
2026-07-15 15:23:59 +00:00
{allowHttpUrl(post.permalink) ? (
2026-07-10 05:10:31 +00:00
<>
{" · "}
2026-07-15 15:23:59 +00:00
<a href={allowHttpUrl(post.permalink) ?? undefined} target="_blank" rel="noreferrer">
2026-07-10 05:10:31 +00:00
{t("posts.openThreads")}
</a>
</>
) : null}
</p>
</div>
</div>
{open ? <PostMetrics post={post} variant="detail" /> : null}
2026-07-13 01:15:30 +00:00
{/* AI 結構分析:有結果就顯示;詳細區在展開時更清楚 */}
2026-07-10 05:10:31 +00:00
{showAnalysis && post.insight ? (
2026-07-30 01:25:34 +00:00
<p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
2026-07-10 05:10:31 +00:00
{t("posts.insight", { text: post.insight })}
</p>
) : null}
{showAnalysis && post.formula_summary ? (
2026-07-30 01:25:34 +00:00
<p className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
2026-07-10 05:10:31 +00:00
{t("posts.review", { text: post.formula_summary })}
</p>
) : null}
{showAnalysis && post.formula_detail ? (
2026-07-13 01:15:30 +00:00
<Textarea label={t("posts.formulaResult")} value={post.formula_detail} readOnly rows={8} />
2026-07-10 05:10:31 +00:00
) : null}
<div className="hb-wizard-actions">
2026-07-13 01:15:30 +00:00
<Button
type="button"
variant="ghost"
disabled={busy === `replies:${post.id}`}
onClick={() => void toggleReplies(post)}
>
2026-07-10 05:10:31 +00:00
{(() => {
2026-07-13 01:15:30 +00:00
if (busy === `replies:${post.id}`) return t("posts.loadingReplies");
const incoming = post.replies.filter((r) =>
isTopLevelIncoming(r, myUsernames, post.media_id),
);
const pendingN = incoming.filter((r) =>
isPendingReply(r, post.replies, myUsernames),
).length;
const total = Math.max(incoming.length, post.reply_count || 0);
2026-07-10 05:10:31 +00:00
return open
? t("posts.collapseReplies")
2026-07-13 01:15:30 +00:00
: t("posts.repliesBtn", { total, pending: pendingN });
2026-07-10 05:10:31 +00:00
})()}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => openCompose(rootKey)}
>
{t("posts.replyRoot")}
</Button>
<Button
type="button"
variant="ghost"
disabled={busy === `an:${post.id}`}
onClick={() => void analyze(post)}
>
{busy === `an:${post.id}`
? t("posts.analyzing")
: showAnalysis
? t("posts.reanalyze")
: t("posts.analyze")}
</Button>
<Button type="button" variant="ghost" onClick={() => openComposeMimic(post)}>
{t("posts.mimicThis")}
</Button>
</div>
{composeOpen[rootKey] ? (
<div style={{ marginTop: "0.65rem" }}>
<ReplyComposer
accounts={accounts}
personas={personas}
selection={getSel(rootKey)}
onSelectionChange={(s) => setSel(rootKey, s)}
text={draftByKey[rootKey] || ""}
onTextChange={(txt) => setDraftByKey((m) => ({ ...m, [rootKey]: txt }))}
onGenerate={() => void genReply(rootKey, post)}
onSend={() => void send(rootKey, post)}
generating={busy === rootKey}
sending={busy === `send:${rootKey}`}
label={t("posts.rootDraft")}
/>
</div>
) : null}
{open ? (
<div className="hb-stack" style={{ marginTop: "0.75rem" }}>
2026-07-13 01:15:30 +00:00
{busy === `replies:${post.id}` ? (
2026-07-30 01:25:34 +00:00
<p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
2026-07-13 01:15:30 +00:00
{t("posts.loadingReplies")}
</p>
) : null}
2026-07-10 05:10:31 +00:00
{(() => {
2026-07-13 01:15:30 +00:00
const tops = post.replies.filter((r) =>
isTopLevelIncoming(r, myUsernames, post.media_id),
);
const pendingN = tops.filter((r) =>
isPendingReply(r, post.replies, myUsernames),
).length;
const repliedN = tops.filter(
(r) => !isPendingReply(r, post.replies, myUsernames),
).length;
2026-07-10 05:10:31 +00:00
const filtered = tops.filter((r) => {
2026-07-13 01:15:30 +00:00
const pend = isPendingReply(r, post.replies, myUsernames);
if (replyFilter === "pending") return pend;
if (replyFilter === "replied") return !pend;
2026-07-10 05:10:31 +00:00
return true;
});
return (
<>
<div className="hb-tabs hb-tabs--sm" role="tablist">
<button
type="button"
className={`hb-tab ${replyFilter === "pending" ? "is-active" : ""}`}
onClick={() => setReplyFilter("pending")}
>
{t("posts.filter.pending", { n: pendingN })}
</button>
<button
type="button"
className={`hb-tab ${replyFilter === "replied" ? "is-active" : ""}`}
onClick={() => setReplyFilter("replied")}
>
{t("posts.filter.replied", { n: repliedN })}
</button>
<button
type="button"
className={`hb-tab ${replyFilter === "all" ? "is-active" : ""}`}
onClick={() => setReplyFilter("all")}
>
{t("posts.filter.all", { n: tops.length })}
</button>
</div>
{filtered.length === 0 ? (
<p className="text-muted">
{replyFilter === "pending"
? t("posts.noPending")
: replyFilter === "replied"
? t("posts.noReplied")
: t("posts.noReplies")}
</p>
) : (
filtered.map((r) => {
const key = `${post.id}:${r.id}`;
2026-07-13 01:15:30 +00:00
const pending = isPendingReply(r, post.replies, myUsernames);
2026-07-10 05:10:31 +00:00
const kids = childrenOf(post.replies, r.id);
return (
<div key={r.id} className="hb-script-row">
<div className="hb-inline-badges" style={{ marginBottom: "0.35rem" }}>
<Badge tone={pending ? "warning" : "success"}>
{pending ? t("posts.status.pending") : t("posts.status.replied")}
</Badge>
<strong>@{r.username}</strong>
{typeof r.like_count === "number" ? (
2026-07-30 01:25:34 +00:00
<span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
2026-07-10 05:10:31 +00:00
{t("posts.likesN", { n: r.like_count })}
</span>
) : null}
{kids.length > 0 ? (
2026-07-30 01:25:34 +00:00
<span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
2026-07-10 05:10:31 +00:00
{t("posts.childCount", { n: kids.length })}
</span>
) : null}
</div>
<p style={{ margin: "0.35rem 0" }}>{r.text}</p>
{/* 子留言:永遠顯示,不會因篩選或 is_mine 消失 */}
{kids.length > 0 ? (
<div className="hb-reply-thread">
{kids.map((c) => {
const mine = c.is_mine || myUsernames.has(c.username);
return (
<div
key={c.id}
className={`hb-reply-child${mine ? " is-mine" : ""}`}
>
<div className="hb-inline-badges">
{mine ? <Badge tone="brand">{t("posts.mine")}</Badge> : null}
<strong>@{c.username}</strong>
2026-07-30 01:25:34 +00:00
<span className="text-muted" style={{ fontSize: "var(--hb-text-2xs)" }}>
2026-07-10 05:10:31 +00:00
{formatLocalDateTime(c.created_at)}
</span>
</div>
<p style={{ margin: "0.25rem 0 0" }}>{c.text}</p>
</div>
);
})}
</div>
) : null}
{/* 未回/已回都可再回:串成子留言 */}
{!composeOpen[key] ? (
<div className="hb-wizard-actions" style={{ marginTop: "0.35rem" }}>
<Button type="button" variant="ghost" onClick={() => openCompose(key)}>
{pending ? t("posts.replyThis") : t("posts.replyAgain")}
</Button>
</div>
) : (
<ReplyComposer
accounts={accounts}
personas={personas}
selection={getSel(key)}
onSelectionChange={(s) => setSel(key, s)}
text={draftByKey[key] || ""}
onTextChange={(txt) => setDraftByKey((m) => ({ ...m, [key]: txt }))}
onGenerate={() => void genReply(key, post, r.id)}
onSend={() => void send(key, post, r.id)}
generating={busy === key}
sending={busy === `send:${key}`}
label={
pending
? t("posts.replyTo", { user: r.username })
: t("posts.replyAgainTo", { user: r.username })
}
/>
)}
</div>
);
})
)}
</>
);
})()}
</div>
) : null}
</Card>
);
})
)}
</div>
);
}