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

638 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string>,
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<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 isReplyHidden(r: OwnPostReply): boolean {
return (r.hide_status || "").toUpperCase() === "HIDDEN";
}
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;
}
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<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("");
const [messageTone, setMessageTone] = useState<"ok" | "error">("ok");
const [verifyUrl, setVerifyUrl] = useState("");
/** 本頁已載過留言的 post id避免每次展開重打 */
const [repliesLoaded, setRepliesLoaded] = useState<Record<string, boolean>>({});
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 (
<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={messageTone === "error" ? "hb-banner-error" : "hb-banner-ok"} role={messageTone === "error" ? "alert" : "status"}>
{message}
{verifyUrl ? (
<>
{" "}
<a href={verifyUrl} target="_blank" rel="noreferrer">
{t("posts.openThreads")}
</a>
</>
) : null}
</p>
) : null}
{posts.length === 0 ? (
<EmptyState title={t("posts.empty")} />
) : (
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 (
<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}
{showAnalysis ? <Badge tone="success">{t("posts.analyzedBadge")}</Badge> : null}
{post.reply_control ? (
<Badge tone="neutral">
{t("posts.whoCanReply")}: {t(`posts.replyControl.${post.reply_control}`)}
</Badge>
) : null}
</div>
<p className="hb-post-body" style={{ margin: "0 0 0.5rem" }}>{post.text || t("posts.noText")}</p>
<PostMetrics post={post} variant="compact" />
<p className="text-muted hb-compact-row__meta">
{formatLocalDateTime(post.published_at)}
{post.shortcode ? ` · ${post.shortcode}` : ""}
{allowHttpUrl(post.permalink) ? (
<>
{" · "}
<a href={allowHttpUrl(post.permalink) ?? undefined} target="_blank" rel="noreferrer">
{t("posts.openThreads")}
</a>
</>
) : null}
</p>
</div>
</div>
{open ? <PostMetrics post={post} variant="detail" /> : null}
{/* AI 結構分析:有結果就顯示;詳細區在展開時更清楚 */}
{showAnalysis && post.insight ? (
<p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
{t("posts.insight", { text: post.insight })}
</p>
) : null}
{showAnalysis && post.formula_summary ? (
<p className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{t("posts.review", { text: post.formula_summary })}
</p>
) : null}
{showAnalysis && post.formula_detail ? (
<Textarea label={t("posts.formulaResult")} value={post.formula_detail} readOnly rows={8} />
) : null}
<div className="hb-wizard-actions">
<Button
type="button"
variant="ghost"
disabled={busy === `replies:${post.id}`}
onClick={() => void toggleReplies(post)}
>
{(() => {
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);
return open
? t("posts.collapseReplies")
: t("posts.repliesBtn", { total, pending: pendingN });
})()}
</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" }}>
{busy === `replies:${post.id}` ? (
<p className="text-muted" style={{ fontSize: "var(--hb-text-sm)" }}>
{t("posts.loadingReplies")}
</p>
) : null}
{(() => {
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;
const filtered = tops.filter((r) => {
const pend = isPendingReply(r, post.replies, myUsernames);
if (replyFilter === "pending") return pend;
if (replyFilter === "replied") return !pend;
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}`;
const pending = isPendingReply(r, post.replies, myUsernames);
const kids = childrenOf(post.replies, r.id);
return (
<div key={r.id} className={`hb-script-row${isReplyHidden(r) ? " is-hidden" : ""}`}>
<div className="hb-inline-badges" style={{ marginBottom: "0.35rem" }}>
<Badge tone={pending ? "warning" : "success"}>
{pending ? t("posts.status.pending") : t("posts.status.replied")}
</Badge>
{isReplyHidden(r) ? <Badge tone="neutral">{t("posts.hiddenBadge")}</Badge> : null}
<strong>@{r.username}</strong>
{typeof r.like_count === "number" ? (
<span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{t("posts.likesN", { n: r.like_count })}
</span>
) : null}
{kids.length > 0 ? (
<span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{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>
<span className="text-muted" style={{ fontSize: "var(--hb-text-2xs)" }}>
{formatLocalDateTime(c.created_at)}
</span>
</div>
<p style={{ margin: "0.25rem 0 0" }}>{c.text}</p>
</div>
);
})}
</div>
) : null}
{/* 未回/已回都可再回:串成子留言 */}
<div className="hb-wizard-actions" style={{ marginTop: "0.35rem" }}>
{!composeOpen[key] ? (
<Button type="button" variant="ghost" onClick={() => openCompose(key)}>
{pending ? t("posts.replyThis") : t("posts.replyAgain")}
</Button>
) : null}
<Button
type="button"
variant="ghost"
disabled={busy === `hide:${r.id}`}
onClick={() => void manageReply(post, r, !isReplyHidden(r))}
>
{busy === `hide:${r.id}`
? t("posts.hidingReply")
: isReplyHidden(r)
? t("posts.unhideReply")
: t("posts.hideReply")}
</Button>
</div>
{composeOpen[key] ? (
<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 })
}
/>
) : null}
</div>
);
})
)}
</>
);
})()}
</div>
) : null}
</Card>
);
})
)}
</div>
);
}