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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|