T-HUB/apps/web/src/app/(main)/inbox/page.tsx
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

61 lines
9.1 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Avatar } from "../../../components/ui/primitives";
import { CONVERSATIONS, THREADS, ageLabel, type Conversation, type FeedItem } from "../../../lib/fixtures";
import { getPlatform, MOCK_COLUMN_DEFS, PLATFORMS } from "../../../lib/platforms";
import { Segmented } from "../../../components/mobile/segmented";
import { SwipeItem } from "../../../components/mobile/swipe-item";
import { ApprovalsSegment } from "../../../components/mobile/approvals-segment";
import { AmplifySheet } from "../../../components/mobile/amplify-sheet";
import { useApiResource } from "../../../lib/use-api";
import type { DraftRecord } from "../../../lib/api-types";
type Segment = "all" | "mentions" | "approvals" | "dms";
type Kind = "spike" | "mention" | "approval" | "reply" | "dm" | "health";
interface NoticeRow { id: string; kind: Kind; text: string; conversation?: Conversation; unread: boolean; }
const extra: NoticeRow[] = [
{ id: "spike", kind: "spike", text: "Sample spike alert · a post is getting attention", unread: true },
{ id: "approval-sample", kind: "approval", text: "Sample draft waiting for review", unread: true },
{ id: "health", kind: "health", text: "Facebook connection needs attention", unread: true },
];
const initial: NoticeRow[] = [...extra, ...CONVERSATIONS.map((conversation, i) => ({ id: `conversation-${conversation.id}`, kind: (i === 0 ? "mention" : i === 1 ? "dm" : "reply") as Kind, text: conversation.preview, conversation, unread: conversation.unread > 0 }))];
const identity = (value: DraftRecord[]): DraftRecord[] => value;
const empty: DraftRecord[] = [];
const spikePost: FeedItem = { id: -1, platform: MOCK_COLUMN_DEFS[0]?.platformId ?? "unknown", author: "Sample author", handle: "@sample", text: "Sample spike alert post", ageMinutes: 4, likes: 120, reposts: 30, replies: 12, hasMedia: false };
export default function InboxPage({ defaultSegment = "all" }: { defaultSegment?: "all" | "approvals" }): React.ReactElement {
const router = useRouter();
const [segment, setSegment] = useState<Segment>(defaultSegment);
const [rows, setRows] = useState(initial);
const [backup, setBackup] = useState<NoticeRow[] | null>(null);
const [activeId, setActiveId] = useState(CONVERSATIONS[0]?.id ?? 1);
const [amplify, setAmplify] = useState(false);
const drafts = useApiResource<DraftRecord[], DraftRecord[]>("/publishing/drafts", identity, empty, { state: "in_review", limit: 100 });
const approvals = drafts.data.length;
// Fixture rows are only shown while the API is unreachable (sample mode).
const sample = drafts.source === "sample";
useEffect(() => { const requested = new URLSearchParams(window.location.search).get("segment"); if (requested === "approvals" || requested === "mentions" || requested === "dms") setSegment(requested); }, []);
function change(value: Segment): void { setSegment(value); window.history.replaceState(window.history.state, "", value === "all" ? "/inbox" : `/inbox?segment=${value}`); }
function changeRows(update: (current: NoticeRow[]) => NoticeRow[]): void { setBackup(rows); setRows(update); }
const visible = sample ? rows.filter((row) => segment === "all" || (segment === "mentions" && row.kind === "mention") || (segment === "dms" && row.kind === "dm")) : [];
const selected = CONVERSATIONS.find((conversation) => conversation.id === activeId) ?? CONVERSATIONS[0];
return <div className="flex h-full min-w-0 flex-col lg:flex-row">
<div className="min-w-0 flex-1 lg:hidden">
<div className="flex items-center justify-between px-4 py-2"><h1 className="text-lg font-semibold">Inbox</h1>{sample && <button type="button" className="min-h-11 rounded-md px-3 text-xs text-accent" onClick={() => changeRows((current) => current.map((row) => ({ ...row, unread: false })))}>✓ Mark read</button>}</div>
{backup && <div role="status" className="flex min-h-11 items-center justify-between bg-surface-2 px-4 text-xs">Inbox updated<button type="button" className="min-h-11 min-w-11 text-accent" onClick={() => { setRows(backup); setBackup(null); }}>Undo</button></div>}
<div className="border-b border-border-subtle px-2 pb-2"><Segmented options={[{ value: "all", label: "All", count: visible.filter((row) => row.unread).length + approvals }, { value: "mentions", label: "Mentions", count: visible.filter((row) => row.unread && row.kind === "mention").length }, { value: "approvals", label: "Approvals", count: approvals }, { value: "dms", label: "DMs", count: visible.filter((row) => row.unread && row.kind === "dm").length }]} value={segment} onChange={change} /></div>
{segment === "approvals" ? <ApprovalsSegment /> : <div>{visible.map((row) => <SwipeItem key={row.id} leftActions={[{ label: "Dismiss", tone: "accent", onClick: () => changeRows((current) => current.filter((item) => item.id !== row.id)) }]} rightActions={row.kind === "mention" || row.kind === "dm" ? [{ label: "Reply", onClick: () => { if (row.conversation) router.push(`/inbox/${row.conversation.id}`); } }] : []}>
<div className={`flex min-h-20 items-start gap-3 border-b border-border-subtle px-4 py-3 ${row.kind === "spike" ? "border-l-4 border-l-accent" : row.kind === "health" ? "border-l-4 border-l-warn" : row.unread ? "border-l-4 border-l-accent/60" : ""}`}>
{row.conversation ? <Avatar handle={row.conversation.handle} /> : <span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${row.kind === "spike" || row.kind === "approval" ? "bg-accent" : "bg-warn/20 text-warn"}`}>{row.kind === "spike" ? "↗" : "!"}</span>}
<div className="min-w-0 flex-1"><div className="flex items-center gap-2"><span className="text-xs font-semibold capitalize">{row.kind}</span><span className="rounded border border-border px-1 text-2xs uppercase tracking-wide text-text-faint">sample</span>{row.conversation && <span className="truncate text-xs text-text-faint">{row.conversation.participant}</span>}{row.conversation && <span className="ml-auto text-2xs text-text-faint">{ageLabel(row.conversation.lastMinutes)}</span>}</div><p className="mt-1 text-xs text-text-dim">{row.text}</p><div className="mt-2 flex gap-2">{row.conversation && <button type="button" className="min-h-11 rounded-md border border-border px-3 text-xs" onClick={() => router.push(`/inbox/${row.conversation?.id}`)}>Open thread</button>}{row.kind === "spike" && <button type="button" className="min-h-11 rounded-md bg-accent px-3 text-xs text-white" onClick={() => setAmplify(true)}>Crosspost to…</button>}{row.kind === "approval" && <button type="button" className="min-h-11 rounded-md border border-accent px-3 text-xs text-accent" onClick={() => router.push("/approvals")}>Review</button>}{row.kind === "health" && <button type="button" className="min-h-11 rounded-md border border-warn px-3 text-xs text-warn" onClick={() => router.push("/connections")}>View connection</button>}</div></div>
</div></SwipeItem>)}
{!sample && segment === "all" && <p className="px-4 py-6 text-xs text-text-faint">No messages yet — direct messages arrive when connector messaging support lands.</p>}
</div>}
</div>
{segment === "approvals" ? <div className="hidden min-w-0 flex-1 lg:flex"><ApprovalsSegment /></div> : sample ? <><aside className="hidden w-80 shrink-0 overflow-y-auto border-r border-border-subtle lg:block"><div className="border-b border-border-subtle p-4 text-sm font-semibold">Inbox · sample messages</div>{CONVERSATIONS.map((conversation) => <button key={conversation.id} type="button" onClick={() => setActiveId(conversation.id)} className={`flex min-h-16 w-full items-start gap-3 border-b border-border-subtle p-3 text-left ${conversation.id === activeId ? "bg-accent-dim" : ""}`}><Avatar handle={conversation.handle} /><span className="min-w-0"><span className="block truncate text-sm font-semibold">{conversation.participant}</span><span className="block truncate text-xs text-text-faint">{conversation.preview}</span></span></button>)}</aside>
<section className="hidden min-w-0 flex-1 flex-col lg:flex"><div className="border-b border-border-subtle p-4 text-sm font-semibold">{selected?.participant} · {PLATFORMS[selected?.platform ?? ""]?.displayName}</div><div className="flex-1 overflow-y-auto p-4">{(THREADS[selected?.id ?? 1] ?? []).map((message, index) => <div key={index} className={`mb-3 flex ${message.mine ? "justify-end" : "justify-start"}`}><p className="max-w-[70%] rounded-lg border border-border-subtle bg-surface p-3 text-sm">{message.text}</p></div>)}</div><p className="border-t border-border-subtle p-4 text-xs text-text-faint">Sample thread · replies await connector messaging support.</p></section></> : <section className="hidden min-w-0 flex-1 flex-col lg:flex"><div className="border-b border-border-subtle p-4 text-sm font-semibold">Inbox</div><div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-text-faint">No messages yet — direct messages arrive when connector messaging support lands.</div></section>}
<AmplifySheet post={spikePost} platform={getPlatform(spikePost.platform)} open={amplify} onClose={() => setAmplify(false)} />
</div>;
}