462 lines
20 KiB
TypeScript
462 lines
20 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 type { AttachedImage } from "../../lib/attachImage";
|
|||
|
|
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 };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 第一層來訊(別人留在貼文下,不是子留言) */
|
|||
|
|
function isTopLevelIncoming(r: OwnPostReply, myUsernames: Set<string>): boolean {
|
|||
|
|
if (r.parent_reply_id) return false;
|
|||
|
|
if (r.is_mine) return false;
|
|||
|
|
return !myUsernames.has(r.username);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isPendingReply(r: OwnPostReply): boolean {
|
|||
|
|
return (r.reply_status || "pending") === "pending";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 [imagesByKey, setImagesByKey] = useState<Record<string, AttachedImage[]>>({});
|
|||
|
|
/** 每筆回覆的帳號/人設(預設吃頂部) */
|
|||
|
|
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 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());
|
|||
|
|
setMessage(t("posts.syncDone"));
|
|||
|
|
refresh();
|
|||
|
|
} 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);
|
|||
|
|
const imgs = imagesByKey[key] || [];
|
|||
|
|
if (!text?.trim()) {
|
|||
|
|
setMessage(t("posts.needText"));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setBusy(`send:${key}`);
|
|||
|
|
try {
|
|||
|
|
const next = await repos.ownPosts.sendReply({
|
|||
|
|
postId: post.id,
|
|||
|
|
replyId,
|
|||
|
|
text,
|
|||
|
|
accountId: sel.accountId,
|
|||
|
|
imageUrls: imgs.map((i) => i.url),
|
|||
|
|
});
|
|||
|
|
setPosts((list) => list.map((p) => (p.id === next.id ? next : p)));
|
|||
|
|
const n = imgs.length;
|
|||
|
|
const user =
|
|||
|
|
accounts.find((a) => a.id === sel.accountId)?.username || t("posts.accountFallback");
|
|||
|
|
setMessage(t("posts.sent", { user }) + (n ? t("posts.sentImages", { n }) : ""));
|
|||
|
|
setComposeOpen((m) => ({ ...m, [key]: false }));
|
|||
|
|
setDraftByKey((m) => ({ ...m, [key]: "" }));
|
|||
|
|
setImagesByKey((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) {
|
|||
|
|
navigate(`/app/studio?tab=compose&mimic=${encodeURIComponent(post.text)}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
const showAnalysis = Boolean(analyzedIds[post.id]);
|
|||
|
|
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}
|
|||
|
|
</div>
|
|||
|
|
<p style={{ margin: "0 0 0.5rem" }}>{post.text}</p>
|
|||
|
|
<PostMetrics post={post} variant="compact" />
|
|||
|
|
<p className="text-muted hb-compact-row__meta">
|
|||
|
|
{formatLocalDateTime(post.published_at)}
|
|||
|
|
{post.shortcode ? ` · ${post.shortcode}` : ""}
|
|||
|
|
{post.permalink ? (
|
|||
|
|
<>
|
|||
|
|
{" · "}
|
|||
|
|
<a href={post.permalink} 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: "0.875rem" }}>
|
|||
|
|
{t("posts.insight", { text: post.insight })}
|
|||
|
|
</p>
|
|||
|
|
) : null}
|
|||
|
|
{showAnalysis && post.formula_summary ? (
|
|||
|
|
<p className="text-muted" style={{ fontSize: "0.8rem" }}>
|
|||
|
|
{t("posts.review", { text: post.formula_summary })}
|
|||
|
|
</p>
|
|||
|
|
) : null}
|
|||
|
|
{showAnalysis && post.formula_detail ? (
|
|||
|
|
<Textarea label={t("posts.formulaResult")} value={post.formula_detail} readOnly rows={6} />
|
|||
|
|
) : null}
|
|||
|
|
|
|||
|
|
<div className="hb-wizard-actions">
|
|||
|
|
<Button type="button" variant="ghost" onClick={() => setExpanded(open ? "" : post.id)}>
|
|||
|
|
{(() => {
|
|||
|
|
const incoming = post.replies.filter((r) => isTopLevelIncoming(r, myUsernames));
|
|||
|
|
const pendingN = incoming.filter(isPendingReply).length;
|
|||
|
|
return open
|
|||
|
|
? t("posts.collapseReplies")
|
|||
|
|
: t("posts.repliesBtn", { total: incoming.length, 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 }))}
|
|||
|
|
images={imagesByKey[rootKey] || []}
|
|||
|
|
onImagesChange={(imgs) => setImagesByKey((m) => ({ ...m, [rootKey]: imgs }))}
|
|||
|
|
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" }}>
|
|||
|
|
{(() => {
|
|||
|
|
const tops = post.replies.filter((r) => isTopLevelIncoming(r, myUsernames));
|
|||
|
|
const pendingN = tops.filter(isPendingReply).length;
|
|||
|
|
const repliedN = tops.filter((r) => !isPendingReply(r)).length;
|
|||
|
|
const filtered = tops.filter((r) => {
|
|||
|
|
if (replyFilter === "pending") return isPendingReply(r);
|
|||
|
|
if (replyFilter === "replied") return !isPendingReply(r);
|
|||
|
|
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);
|
|||
|
|
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" ? (
|
|||
|
|
<span className="text-muted" style={{ fontSize: "0.8rem" }}>
|
|||
|
|
{t("posts.likesN", { n: r.like_count })}
|
|||
|
|
</span>
|
|||
|
|
) : null}
|
|||
|
|
{kids.length > 0 ? (
|
|||
|
|
<span className="text-muted" style={{ fontSize: "0.8rem" }}>
|
|||
|
|
{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: "0.75rem" }}>
|
|||
|
|
{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 }))}
|
|||
|
|
images={imagesByKey[key] || []}
|
|||
|
|
onImagesChange={(imgs) => setImagesByKey((m) => ({ ...m, [key]: imgs }))}
|
|||
|
|
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>
|
|||
|
|
);
|
|||
|
|
}
|