From aa5dbc8088f9f5399e5728346ce6ceb4abafa2ca Mon Sep 17 00:00:00 2001
From: Cyber-preacher
Date: Wed, 15 Apr 2026 17:22:34 +0400
Subject: [PATCH 1/3] Add proposal deliberation UI
---
src/app/App.tsx | 4 +-
.../discussions/ThreadPrimitives.tsx | 398 ++++++++++++++++++
src/lib/apiClient.ts | 164 +++++---
src/pages/proposals/ProposalChamber.tsx | 3 +
src/pages/proposals/ProposalChamberVeto.tsx | 3 +
src/pages/proposals/ProposalCitizenVeto.tsx | 3 +
src/pages/proposals/ProposalDeliberation.tsx | 395 +++++++++++++++++
src/pages/proposals/ProposalFinished.tsx | 3 +
src/pages/proposals/ProposalFormation.tsx | 3 +
src/pages/proposals/ProposalPP.tsx | 3 +
src/pages/proposals/ProposalReferendum.tsx | 3 +
src/pages/proposals/Proposals.tsx | 18 +-
src/types/api.ts | 52 +++
13 files changed, 992 insertions(+), 60 deletions(-)
create mode 100644 src/components/discussions/ThreadPrimitives.tsx
create mode 100644 src/pages/proposals/ProposalDeliberation.tsx
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 00b5f74..4dacfef 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -4,12 +4,12 @@ import { AuthProvider } from "@/app/auth/AuthContext";
import AppRoutes from "./AppRoutes";
const ScrollToTopOnRouteChange: React.FC = () => {
- const { pathname, search } = useLocation();
+ const { pathname } = useLocation();
useEffect(() => {
document.title = "Vortex Sim";
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
- }, [pathname, search]);
+ }, [pathname]);
return null;
};
diff --git a/src/components/discussions/ThreadPrimitives.tsx b/src/components/discussions/ThreadPrimitives.tsx
new file mode 100644
index 0000000..ab87dd7
--- /dev/null
+++ b/src/components/discussions/ThreadPrimitives.tsx
@@ -0,0 +1,398 @@
+import type { FormEventHandler, ReactNode } from "react";
+
+import { AddressInline } from "@/components/AddressInline";
+import { Surface } from "@/components/Surface";
+import { Button } from "@/components/primitives/button";
+import { Input } from "@/components/primitives/input";
+import { Select } from "@/components/primitives/select";
+import { formatDateTime } from "@/lib/dateTime";
+
+type DiscussionOption = {
+ value: TValue;
+ label: string;
+};
+
+export type DiscussionStatusOption =
+ DiscussionOption;
+
+type DiscussionThreadPermissions = {
+ canReply?: boolean;
+ canTransition?: boolean;
+};
+
+type DiscussionThreadListItem<
+ TCategory extends string = string,
+ TStatus extends string = string,
+> = {
+ id: string;
+ category: TCategory;
+ status: TStatus;
+ title: string;
+ body: string;
+ replies: number;
+ updatedAt: string;
+ permissions: DiscussionThreadPermissions;
+};
+
+type DiscussionThreadDetailItem<
+ TCategory extends string = string,
+ TStatus extends string = string,
+> = DiscussionThreadListItem & {
+ authorAddress: string;
+ createdAt: string;
+};
+
+type DiscussionThreadMessage = {
+ id: string;
+ authorAddress: string;
+ body: string;
+ createdAt: string;
+};
+
+const textareaClassName =
+ "min-h-[96px] w-full resize-y rounded-xl border border-border bg-panel-alt px-3 py-2 text-sm text-text shadow-[var(--shadow-control)] focus-visible:ring-2 focus-visible:ring-[color:var(--primary-dim)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60";
+
+type ThreadCategoryFilterProps = {
+ options: Array>;
+ value: TValue;
+ onChange: (value: TValue) => void;
+};
+
+export function ThreadCategoryFilter({
+ options,
+ value,
+ onChange,
+}: ThreadCategoryFilterProps) {
+ return (
+
+ {options.map((option) => (
+
+ ))}
+
+ );
+}
+
+type ThreadComposerProps = {
+ categoryOptions: Array>;
+ categoryValue: TCategory;
+ onCategoryChange: (value: TCategory) => void;
+ title: string;
+ onTitleChange: (value: string) => void;
+ body: string;
+ onBodyChange: (value: string) => void;
+ onSubmit: FormEventHandler;
+ canCreate: boolean;
+ busy: boolean;
+ disabledMessage?: string | null;
+ titlePlaceholder?: string;
+ bodyPlaceholder?: string;
+ submitLabel?: string;
+ busyLabel?: string;
+};
+
+export function ThreadComposer({
+ categoryOptions,
+ categoryValue,
+ onCategoryChange,
+ title,
+ onTitleChange,
+ body,
+ onBodyChange,
+ onSubmit,
+ canCreate,
+ busy,
+ disabledMessage,
+ titlePlaceholder = "Thread title",
+ bodyPlaceholder = "Write the opening post",
+ submitLabel = "Start thread",
+ busyLabel = "Posting...",
+}: ThreadComposerProps) {
+ return (
+
+ );
+}
+
+type ThreadListProps<
+ TCategory extends string,
+ TStatus extends string,
+ TThread extends DiscussionThreadListItem,
+> = {
+ threads: TThread[];
+ selectedThreadId: string | null;
+ emptyMessage: string;
+ categoryLabel: (value: TCategory) => string;
+ statusLabel: (value: TStatus) => string;
+ onSelect: (threadId: string) => void;
+};
+
+export function ThreadList<
+ TCategory extends string,
+ TStatus extends string,
+ TThread extends DiscussionThreadListItem,
+>({
+ threads,
+ selectedThreadId,
+ emptyMessage,
+ categoryLabel,
+ statusLabel,
+ onSelect,
+}: ThreadListProps) {
+ if (threads.length === 0) {
+ return (
+
+ {emptyMessage}
+
+ );
+ }
+
+ return (
+ <>
+ {threads.map((thread) => (
+ onSelect(thread.id)}
+ onKeyDown={(event) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ onSelect(thread.id);
+ }}
+ >
+
+
+
+ {categoryLabel(thread.category)} · {statusLabel(thread.status)}
+
+
+ {thread.title}
+
+
+ {thread.body}
+
+
+ {thread.replies} replies · Updated{" "}
+ {formatDateTime(thread.updatedAt)}
+
+
+
+
+ ))}
+ >
+ );
+}
+
+type ThreadDetailProps<
+ TCategory extends string,
+ TStatus extends string,
+ TThread extends DiscussionThreadDetailItem,
+ TMessage extends DiscussionThreadMessage,
+> = {
+ detail: {
+ thread: TThread;
+ messages: TMessage[];
+ } | null;
+ errorText: string | null;
+ busy: boolean;
+ emptyMessage: string;
+ categoryLabel: (value: TCategory) => string;
+ statusLabel: (value: TStatus) => string;
+ statusOptions: Array>;
+ replyBody: string;
+ onReplyBodyChange: (value: string) => void;
+ onReply: FormEventHandler;
+ onTransition: (status: TStatus) => void | Promise;
+ replyPlaceholder?: (thread: TThread) => string;
+ renderAuthor?: (address: string) => ReactNode;
+};
+
+export function ThreadDetail<
+ TCategory extends string,
+ TStatus extends string,
+ TThread extends DiscussionThreadDetailItem,
+ TMessage extends DiscussionThreadMessage,
+>({
+ detail,
+ errorText,
+ busy,
+ emptyMessage,
+ categoryLabel,
+ statusLabel,
+ statusOptions,
+ replyBody,
+ onReplyBodyChange,
+ onReply,
+ onTransition,
+ replyPlaceholder = () => "Write a reply",
+ renderAuthor = (address) => (
+
+ ),
+}: ThreadDetailProps) {
+ return (
+
+ {errorText ? (
+ {errorText}
+ ) : !detail ? (
+ {emptyMessage}
+ ) : (
+
+
+
+ {categoryLabel(detail.thread.category)}
+ {statusLabel(detail.thread.status)}
+ {formatDateTime(detail.thread.createdAt)}
+
+
+ {detail.thread.title}
+
+ {renderAuthor(detail.thread.authorAddress)}
+
+ {detail.thread.body}
+
+
+
+ {detail.thread.permissions.canTransition ? (
+
+
+
+ ) : null}
+
+
+
Replies
+ {detail.messages.length === 0 ? (
+
No replies yet.
+ ) : (
+ detail.messages.map((message) => (
+
+
+
+ {renderAuthor(message.authorAddress)}
+
+ {formatDateTime(message.createdAt)}
+
+
+ {message.body}
+
+
+
+
+ ))
+ )}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts
index 84a6433..b8e4f3d 100644
--- a/src/lib/apiClient.ts
+++ b/src/lib/apiClient.ts
@@ -29,6 +29,10 @@ import type {
GetProposalTimelineResponse,
HumanNodeProfileDto,
ProposalDraftDetailDto,
+ ProposalThreadDetailDto,
+ ProposalThreadDto,
+ ProposalThreadListDto,
+ ProposalThreadMessageDto,
ProposalStatusDto,
PoolProposalPageDto,
} from "@/types/api";
@@ -283,6 +287,21 @@ export async function apiProposalTimeline(
);
}
+export async function apiProposalThreads(
+ id: string,
+): Promise {
+ return await apiGet(`/api/proposals/${id}/threads`);
+}
+
+export async function apiProposalThreadDetail(
+ proposalId: string,
+ threadId: string,
+): Promise {
+ return await apiGet(
+ `/api/proposals/${proposalId}/threads/${threadId}`,
+ );
+}
+
export async function apiProposalStatus(
id: string,
): Promise {
@@ -467,6 +486,94 @@ export async function apiChamberThreadReply(input: {
);
}
+export async function apiProposalThreadCreate(input: {
+ proposalId: string;
+ category?: ProposalThreadDto["category"];
+ title: string;
+ body: string;
+ idempotencyKey?: string;
+}): Promise<{
+ ok: true;
+ type: "proposal.thread.create";
+ proposalId: string;
+ thread: ProposalThreadDto;
+}> {
+ return await apiPost(
+ "/api/command",
+ {
+ type: "proposal.thread.create",
+ payload: {
+ proposalId: input.proposalId,
+ category: input.category ?? "general",
+ title: input.title,
+ body: input.body,
+ },
+ idempotencyKey: input.idempotencyKey,
+ },
+ input.idempotencyKey
+ ? { headers: { "idempotency-key": input.idempotencyKey } }
+ : undefined,
+ );
+}
+
+export async function apiProposalThreadReply(input: {
+ proposalId: string;
+ threadId: string;
+ body: string;
+ idempotencyKey?: string;
+}): Promise<{
+ ok: true;
+ type: "proposal.thread.reply";
+ proposalId: string;
+ threadId: string;
+ message: ProposalThreadMessageDto;
+ replies: number;
+}> {
+ return await apiPost(
+ "/api/command",
+ {
+ type: "proposal.thread.reply",
+ payload: {
+ proposalId: input.proposalId,
+ threadId: input.threadId,
+ body: input.body,
+ },
+ idempotencyKey: input.idempotencyKey,
+ },
+ input.idempotencyKey
+ ? { headers: { "idempotency-key": input.idempotencyKey } }
+ : undefined,
+ );
+}
+
+export async function apiProposalThreadTransition(input: {
+ proposalId: string;
+ threadId: string;
+ status: ProposalThreadDto["status"];
+ idempotencyKey?: string;
+}): Promise<{
+ ok: true;
+ type: "proposal.thread.transition";
+ proposalId: string;
+ thread: { id: string; status: ProposalThreadDto["status"] };
+}> {
+ return await apiPost(
+ "/api/command",
+ {
+ type: "proposal.thread.transition",
+ payload: {
+ proposalId: input.proposalId,
+ threadId: input.threadId,
+ status: input.status,
+ },
+ idempotencyKey: input.idempotencyKey,
+ },
+ input.idempotencyKey
+ ? { headers: { "idempotency-key": input.idempotencyKey } }
+ : undefined,
+ );
+}
+
export async function apiChamberChatPost(input: {
chamberId: string;
message: string;
@@ -1248,63 +1355,6 @@ export async function apiFactionThreadTransition(input: {
);
}
-export async function apiFactionThreadDelete(input: {
- factionId: string;
- threadId: string;
- idempotencyKey?: string;
-}): Promise<{
- ok: true;
- type: "faction.thread.delete";
- factionId: string;
- threadId: string;
- deleted: true;
-}> {
- return await apiPost(
- "/api/command",
- {
- type: "faction.thread.delete",
- payload: {
- factionId: input.factionId,
- threadId: input.threadId,
- },
- idempotencyKey: input.idempotencyKey,
- },
- input.idempotencyKey
- ? { headers: { "idempotency-key": input.idempotencyKey } }
- : undefined,
- );
-}
-
-export async function apiFactionThreadReplyDelete(input: {
- factionId: string;
- threadId: string;
- messageId: string;
- idempotencyKey?: string;
-}): Promise<{
- ok: true;
- type: "faction.thread.reply.delete";
- factionId: string;
- threadId: string;
- messageId: string;
- deleted: true;
-}> {
- return await apiPost(
- "/api/command",
- {
- type: "faction.thread.reply.delete",
- payload: {
- factionId: input.factionId,
- threadId: input.threadId,
- messageId: input.messageId,
- },
- idempotencyKey: input.idempotencyKey,
- },
- input.idempotencyKey
- ? { headers: { "idempotency-key": input.idempotencyKey } }
- : undefined,
- );
-}
-
export async function apiFactionInitiativeCreate(input: {
factionId: string;
title: string;
diff --git a/src/pages/proposals/ProposalChamber.tsx b/src/pages/proposals/ProposalChamber.tsx
index e4684c9..bd4d303 100644
--- a/src/pages/proposals/ProposalChamber.tsx
+++ b/src/pages/proposals/ProposalChamber.tsx
@@ -32,6 +32,7 @@ import {
} from "./useProposalStageSync";
import { useAuth } from "@/app/auth/AuthContext";
import { CitizenVetoActions } from "./CitizenVetoActions";
+import { ProposalDeliberation } from "./ProposalDeliberation";
const ProposalChamber: React.FC = () => {
const { id } = useParams();
@@ -622,6 +623,8 @@ const ProposalChamber: React.FC = () => {
/>
) : null}
+
+
{timelineError ? (
{
const { id } = useParams();
@@ -455,6 +456,8 @@ const ProposalChamberVeto: React.FC = () => {
showBudgetScope={proposal.formationEligible}
/>
+
+
{timelineError ? (
{
const { id } = useParams();
@@ -369,6 +370,8 @@ const ProposalCitizenVeto: React.FC = () => {
showBudgetScope={proposal.formationEligible}
/>
+
+
{timelineError ? (
= [
+ { value: "all", label: "All" },
+ { value: "question", label: "Questions" },
+ { value: "concern", label: "Concerns" },
+ { value: "amendment", label: "Amendments" },
+ { value: "support", label: "Support" },
+ { value: "execution", label: "Execution" },
+ { value: "general", label: "General" },
+];
+
+const CREATE_CATEGORY_OPTIONS = CATEGORY_OPTIONS.filter(
+ (item): item is { value: ProposalThreadCategoryDto; label: string } =>
+ item.value !== "all",
+);
+
+const THREAD_STATUS_OPTIONS: Array<
+ DiscussionStatusOption
+> = [
+ { value: "open", label: "Open" },
+ { value: "resolved", label: "Resolved" },
+ { value: "locked", label: "Locked" },
+];
+
+function categoryLabel(value: ProposalThreadCategoryDto): string {
+ return (
+ CREATE_CATEGORY_OPTIONS.find((item) => item.value === value)?.label ??
+ "General"
+ );
+}
+
+function statusLabel(value: ProposalThreadDto["status"]): string {
+ if (value === "resolved") return "Resolved";
+ if (value === "locked") return "Locked";
+ return "Open";
+}
+
+function canAttemptWrite(auth: ReturnType): boolean {
+ if (!auth.enabled) return true;
+ return auth.authenticated && auth.eligible;
+}
+
+type ProposalDeliberationProps = {
+ proposalId: string | undefined;
+};
+
+export const ProposalDeliberation: React.FC = ({
+ proposalId,
+}) => {
+ const auth = useAuth();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const selectedThreadId = searchParams.get("thread")?.trim() || null;
+ const [threadList, setThreadList] = useState(
+ null,
+ );
+ const [activeThread, setActiveThread] =
+ useState(null);
+ const [listError, setListError] = useState(null);
+ const [detailError, setDetailError] = useState(null);
+ const [actionError, setActionError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [filter, setFilter] = useState(
+ "all",
+ );
+ const [newCategory, setNewCategory] =
+ useState("question");
+ const [newTitle, setNewTitle] = useState("");
+ const [newBody, setNewBody] = useState("");
+ const [replyBody, setReplyBody] = useState("");
+ const detailRef = useRef(null);
+
+ const loadList = useCallback(async () => {
+ if (!proposalId) return;
+ try {
+ const next = await apiProposalThreads(proposalId);
+ setThreadList(next);
+ setListError(null);
+ } catch (error) {
+ setThreadList(null);
+ setListError((error as Error).message);
+ }
+ }, [proposalId]);
+
+ const loadDetail = useCallback(
+ async (threadId: string) => {
+ if (!proposalId || !threadId) return;
+ try {
+ const detail = await apiProposalThreadDetail(proposalId, threadId);
+ setActiveThread(detail);
+ setDetailError(null);
+ } catch (error) {
+ setActiveThread(null);
+ setDetailError((error as Error).message);
+ }
+ },
+ [proposalId],
+ );
+
+ useEffect(() => {
+ void loadList();
+ }, [loadList]);
+
+ useEffect(() => {
+ if (!selectedThreadId) {
+ setActiveThread(null);
+ setDetailError(null);
+ return;
+ }
+ void loadDetail(selectedThreadId);
+ }, [loadDetail, selectedThreadId]);
+
+ const writeAllowed = canAttemptWrite(auth);
+
+ const threadListForViewer = useMemo(() => {
+ if (!threadList) return null;
+ if (writeAllowed) return threadList;
+ return {
+ ...threadList,
+ permissions: {
+ ...threadList.permissions,
+ canCreate: false,
+ },
+ items: threadList.items.map((thread) => ({
+ ...thread,
+ permissions: {
+ ...thread.permissions,
+ canReply: false,
+ canTransition: false,
+ },
+ })),
+ };
+ }, [threadList, writeAllowed]);
+
+ const activeThreadForViewer = useMemo(() => {
+ if (!activeThread) return null;
+ if (writeAllowed) return activeThread;
+ return {
+ ...activeThread,
+ thread: {
+ ...activeThread.thread,
+ permissions: {
+ ...activeThread.thread.permissions,
+ canReply: false,
+ canTransition: false,
+ },
+ },
+ messages: activeThread.messages,
+ };
+ }, [activeThread, writeAllowed]);
+
+ const visibleThreads = useMemo(() => {
+ const items = threadListForViewer?.items ?? [];
+ if (filter === "all") return items;
+ return items.filter((thread) => thread.category === filter);
+ }, [filter, threadListForViewer?.items]);
+
+ const replyCount = useMemo(
+ () =>
+ (threadList?.items ?? []).reduce(
+ (sum, thread) => sum + thread.replies,
+ 0,
+ ),
+ [threadList?.items],
+ );
+
+ const canCreate = writeAllowed && Boolean(threadList?.permissions.canCreate);
+ const createDisabledMessage = !writeAllowed
+ ? "Sign in as an active human node to write."
+ : threadList && !threadList.permissions.canCreate
+ ? "Read-only until your session is active."
+ : null;
+
+ const scrollToDetail = useCallback(() => {
+ window.setTimeout(() => {
+ detailRef.current?.scrollIntoView({
+ behavior: "smooth",
+ block: "start",
+ });
+ }, 0);
+ }, []);
+
+ const selectThread = useCallback(
+ (threadId: string) => {
+ const next = new URLSearchParams(searchParams);
+ next.set("thread", threadId);
+ setSearchParams(next, { preventScrollReset: true, replace: true });
+ scrollToDetail();
+ },
+ [scrollToDetail, searchParams, setSearchParams],
+ );
+
+ const createThread = async (event: FormEvent) => {
+ event.preventDefault();
+ if (!proposalId || !canCreate) return;
+ if (!newTitle.trim() || !newBody.trim()) {
+ setActionError("Thread title and body are required.");
+ return;
+ }
+ setBusy(true);
+ setActionError(null);
+ try {
+ const res = await apiProposalThreadCreate({
+ proposalId,
+ category: newCategory,
+ title: newTitle.trim(),
+ body: newBody.trim(),
+ idempotencyKey: crypto.randomUUID(),
+ });
+ setNewTitle("");
+ setNewBody("");
+ await loadList();
+ selectThread(res.thread.id);
+ } catch (error) {
+ setActionError((error as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const replyToThread = async (event: FormEvent) => {
+ event.preventDefault();
+ if (!proposalId || !activeThread || !replyBody.trim()) return;
+ if (!writeAllowed || !activeThread.thread.permissions.canReply) return;
+ setBusy(true);
+ setActionError(null);
+ try {
+ await apiProposalThreadReply({
+ proposalId,
+ threadId: activeThread.thread.id,
+ body: replyBody.trim(),
+ idempotencyKey: crypto.randomUUID(),
+ });
+ setReplyBody("");
+ await Promise.all([loadList(), loadDetail(activeThread.thread.id)]);
+ } catch (error) {
+ setActionError((error as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const transitionThread = async (status: ProposalThreadDto["status"]) => {
+ if (!proposalId || !activeThread) return;
+ if (!writeAllowed || !activeThread.thread.permissions.canTransition) return;
+ setBusy(true);
+ setActionError(null);
+ try {
+ await apiProposalThreadTransition({
+ proposalId,
+ threadId: activeThread.thread.id,
+ status,
+ idempotencyKey: crypto.randomUUID(),
+ });
+ await Promise.all([loadList(), loadDetail(activeThread.thread.id)]);
+ } catch (error) {
+ setActionError((error as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ if (!proposalId) return null;
+
+ return (
+
+
+
+ {listError ? (
+
+ Deliberation unavailable: {formatLoadError(listError)}
+
+ ) : null}
+
+
+
+
+
+ {actionError ? (
+
+ {formatLoadError(actionError)}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ thread.status === "locked" ? "Thread is locked" : "Write a reply"
+ }
+ />
+
+
+
+ );
+};
diff --git a/src/pages/proposals/ProposalFinished.tsx b/src/pages/proposals/ProposalFinished.tsx
index 7ae480a..9e87410 100644
--- a/src/pages/proposals/ProposalFinished.tsx
+++ b/src/pages/proposals/ProposalFinished.tsx
@@ -19,6 +19,7 @@ import {
useProposalTransitionNotice,
} from "./useProposalStageSync";
import { formatLoadError } from "@/lib/errorFormatting";
+import { ProposalDeliberation } from "./ProposalDeliberation";
const ProposalFinished: React.FC = () => {
const { id } = useParams();
@@ -186,6 +187,8 @@ const ProposalFinished: React.FC = () => {
/>
) : null}
+
+
{timelineError ? (
{
const { id } = useParams();
@@ -332,6 +333,8 @@ const ProposalFormation: React.FC = () => {
milestonesDetail={project.milestonesDetail}
/>
+
+
{timelineError ? (
{
const { id } = useParams();
@@ -297,6 +298,8 @@ const ProposalPP: React.FC = () => {
/>
) : null}
+
+
{
const { id } = useParams();
@@ -407,6 +408,8 @@ const ProposalReferendum: React.FC = () => {
/>
) : null}
+
+
{timelineError ? (
{
const [proposalData, setProposalData] = useState<
ProposalListItemDto[] | null
@@ -503,7 +509,7 @@ const Proposals: React.FC = () => {
proposal.stage === "build" && formationPage
? getFormationProgress(formationPage)
: null;
- const keyStats =
+ const baseKeyStats =
proposal.stage === "pool" && poolPage && poolStats
? poolPage.formationEligible
? [
@@ -573,6 +579,16 @@ const Proposals: React.FC = () => {
...formationPage.stats,
]
: proposal.stats;
+ const deliberationStats = proposal.stats.filter((stat) =>
+ DELIBERATION_STAT_LABELS.has(stat.label),
+ );
+ const keyStats = [
+ ...baseKeyStats,
+ ...deliberationStats.filter(
+ (stat) =>
+ !baseKeyStats.some((item) => item.label === stat.label),
+ ),
+ ];
return (
Date: Wed, 15 Apr 2026 17:25:52 +0400
Subject: [PATCH 2/3] Restore faction thread delete API helpers
---
src/lib/apiClient.ts | 57 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 57 insertions(+)
diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts
index b8e4f3d..ee722bd 100644
--- a/src/lib/apiClient.ts
+++ b/src/lib/apiClient.ts
@@ -1355,6 +1355,63 @@ export async function apiFactionThreadTransition(input: {
);
}
+export async function apiFactionThreadDelete(input: {
+ factionId: string;
+ threadId: string;
+ idempotencyKey?: string;
+}): Promise<{
+ ok: true;
+ type: "faction.thread.delete";
+ factionId: string;
+ threadId: string;
+ deleted: true;
+}> {
+ return await apiPost(
+ "/api/command",
+ {
+ type: "faction.thread.delete",
+ payload: {
+ factionId: input.factionId,
+ threadId: input.threadId,
+ },
+ idempotencyKey: input.idempotencyKey,
+ },
+ input.idempotencyKey
+ ? { headers: { "idempotency-key": input.idempotencyKey } }
+ : undefined,
+ );
+}
+
+export async function apiFactionThreadReplyDelete(input: {
+ factionId: string;
+ threadId: string;
+ messageId: string;
+ idempotencyKey?: string;
+}): Promise<{
+ ok: true;
+ type: "faction.thread.reply.delete";
+ factionId: string;
+ threadId: string;
+ messageId: string;
+ deleted: true;
+}> {
+ return await apiPost(
+ "/api/command",
+ {
+ type: "faction.thread.reply.delete",
+ payload: {
+ factionId: input.factionId,
+ threadId: input.threadId,
+ messageId: input.messageId,
+ },
+ idempotencyKey: input.idempotencyKey,
+ },
+ input.idempotencyKey
+ ? { headers: { "idempotency-key": input.idempotencyKey } }
+ : undefined,
+ );
+}
+
export async function apiFactionInitiativeCreate(input: {
factionId: string;
title: string;
From 19d8b86072bbe19e20bc9cf3f325c84916eef9c9 Mon Sep 17 00:00:00 2001
From: Cyber-preacher
Date: Wed, 15 Apr 2026 19:20:39 +0400
Subject: [PATCH 3/3] Hide faction thread delete controls
---
src/pages/factions/FactionChannel.tsx | 83 ++++++---------------------
1 file changed, 17 insertions(+), 66 deletions(-)
diff --git a/src/pages/factions/FactionChannel.tsx b/src/pages/factions/FactionChannel.tsx
index 626687d..112c4aa 100644
--- a/src/pages/factions/FactionChannel.tsx
+++ b/src/pages/factions/FactionChannel.tsx
@@ -13,8 +13,6 @@ import { Input } from "@/components/primitives/input";
import { Select } from "@/components/primitives/select";
import {
apiFaction,
- apiFactionThreadDelete,
- apiFactionThreadReplyDelete,
apiFactionThreadReply,
apiFactionThreadTransition,
apiMe,
@@ -208,27 +206,6 @@ const FactionChannel: React.FC = () => {
{formatDateTime(thread.updatedAt)}
- {canModerate ||
- normalizeAddress(thread.authorAddress) ===
- normalizeAddress(viewerAddress ?? "") ? (
-
-
-
- ) : null}
))
@@ -269,50 +246,24 @@ const FactionChannel: React.FC = () => {
{(activeThread.messages ?? []).length === 0 ? (
No replies yet.
) : (
- (activeThread.messages ?? []).map((message) => {
- const canDeleteMessage =
- canModerate ||
- normalizeAddress(message.authorAddress) ===
- normalizeAddress(viewerAddress ?? "");
- return (
-
-
-
-
-
- {formatDateTime(message.createdAt)}
-
- {canDeleteMessage ? (
-
- ) : null}
-
-
-
{message.body}
+ (activeThread.messages ?? []).map((message) => (
+
+
+
+
+ {formatDateTime(message.createdAt)}
+
- );
- })
+
{message.body}
+
+ ))
)}