638 lines
26 KiB
TypeScript
638 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|