Files
dark-lord/lib/forumState.ts
drjones 78a071ba02 Harden onion boot flow and deepen site surfaces
Add persistent onion key backup and restore, improve startup resilience, and flesh out the major site verticals with richer navigation, search coverage, and operator documentation.

Made-with: Cursor
2026-04-07 21:35:52 -07:00

346 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { categoryToSlug } from "@/lib/forumRings";
import { SHOP_CHATTER_THREADS } from "@/lib/shopChatterThreads";
export type ForumReply = {
id: string;
author: string;
body: string;
ts: number;
};
export type ForumThread = {
id: string;
/** Ring / sub-board slug, e.g. opsec */
topicSlug: string;
/** @deprecated legacy display — prefer topicSlug + formatRingLabel */
category?: string;
title: string;
author: string;
body: string;
ts: number;
replies: ForumReply[];
/** Seeded score before user votes */
baseScore?: number;
};
const KEY = "cyberlux-forum-v2";
const LEGACY_KEY = "cyberlux-forum-v1";
const VOTE_KEY = "cyberlux-forum-votechoice-v1";
export type VoteChoice = -1 | 0 | 1;
function uid(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `t_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
function normalizeThread(raw: ForumThread): ForumThread {
const topicSlug = raw.topicSlug?.trim() || categoryToSlug(raw.category);
const baseScore = typeof raw.baseScore === "number" ? raw.baseScore : 1;
const { category, ...rest } = raw;
return {
...rest,
topicSlug,
baseScore,
...(category ? { category } : {}),
};
}
const SHOP_CHATTER_FORUM: ForumThread[] = SHOP_CHATTER_THREADS.map((t) =>
normalizeThread({
id: t.id,
topicSlug: t.topicSlug,
title: t.title,
author: t.author,
body: t.body,
ts: t.ts,
baseScore: t.baseScore,
replies: t.replies,
}),
);
const seedThreads: ForumThread[] = [
{
id: "seed-1",
topicSlug: "opsec",
category: "Security",
author: "GhostLayer",
title: "Tor browser hardening checklist",
body: "Post your best practices for compartmentalization and DNS leaks.",
ts: Date.now() - 86400000 * 3,
baseScore: 214,
replies: [
{
id: "r1",
author: "NodeOp",
body: "Never mix clearnet VPN identity with onion circuits on the same profile.",
ts: Date.now() - 86400000 * 2,
},
],
},
{
id: "seed-2",
topicSlug: "markets",
category: "Market",
author: "PlainVendor",
title: "Reputation threads — read before you buy",
body: "Link proof of prior deals. No PGP = no trust.",
ts: Date.now() - 86400000,
baseScore: 892,
replies: [],
},
];
const npcThreads: ForumThread[] = [
{
id: "npc-pinned-rules",
topicSlug: "lounge",
category: "Off-topic",
author: "STAFF",
title: "PINNED — Escrow rules & mirror verification",
body:
"All first-time vendor deals route through platform escrow. Do not finalize early.\n\n" +
"Before you load any .onion, cross-check the hostname against the signed mirror list on the main link. " +
"If the signature does not verify, assume phishing.\n\n" +
"No begging, no shill chains without proof.",
ts: Date.now() - 86400000 * 21,
baseScore: 4_021,
replies: [
{
id: "npc-pinned-rules-r1",
author: "ModQueue",
body: "Reporting impersonation: include the exact onion string and a signed message if you have one.",
ts: Date.now() - 86400000 * 20,
},
],
},
{
id: "npc-mirror-check",
topicSlug: "reputation",
category: "Reputation",
author: "watchtower_09",
title: "Mirror list still matching signed post from last week?",
body:
"Cross-posting my onion fingerprint here for sanity check — want to be sure Im not on a clone before I top up wallet credit.",
ts: Date.now() - 86400000 * 5,
baseScore: 1_876,
replies: [
{
id: "npc-mirror-check-r1",
author: "PGPOrBust",
body:
"Ran gpg --verify on the hub canary + mirror block. Three v3 hosts, checksums line up. Youre good.",
ts: Date.now() - 86400000 * 5 + 3600000,
},
{
id: "npc-mirror-check-r2",
author: "EU_buyer",
body:
"Been routing orders through the main hub for a couple months. Comms lag on weekends but escrow never ghosted me.",
ts: Date.now() - 86400000 * 4,
},
],
},
{
id: "npc-volume-banter",
topicSlug: "markets",
category: "Market",
author: "ledgerline",
title: "Anyone else seeing steady checkout traffic on the hub?",
body:
"Not asking for numbers — just weird how quiet the FUD threads are when the sites been up stable. Either the complainers aged out or liquidity actually picked up.",
ts: Date.now() - 86400000 * 2,
baseScore: 3_409,
replies: [
{
id: "npc-volume-banter-r1",
author: "quietBag",
body:
"Big spenders dont brag in public. Youll see it in voucher turnover and vendor queue times.",
ts: Date.now() - 86400000 * 2 + 7200000,
},
],
},
{
id: "npc-ui-thread",
topicSlug: "tech",
category: "Tech",
author: "saltfade",
title: "New neon skin on main link — performance feels tighter",
body:
"Old build used to hitch on the product grid. Whatever they shipped last cycle, Lighthouse numbers on my side look cleaner. Anyone benchmark on low-RAM tails?",
ts: Date.now() - 86400000,
baseScore: 667,
replies: [
{
id: "npc-ui-thread-r1",
author: "tab_hoarder",
body: "Same. Also the checkout step doesnt double-fire submit anymore — small but huge.",
ts: Date.now() - 86400000 + 1800000,
},
],
},
];
const READONLY_THREAD_IDS = new Set(
[...seedThreads, ...npcThreads, ...SHOP_CHATTER_FORUM].map((t) => t.id),
);
function mergeSystemThreads(userThreads: ForumThread[]): ForumThread[] {
const have = new Set(userThreads.map((t) => t.id));
const systemPools = [...npcThreads, ...SHOP_CHATTER_FORUM].map(normalizeThread);
const extra = systemPools.filter((t) => !have.has(t.id));
const merged = [...extra, ...userThreads.map(normalizeThread)];
merged.sort((a, b) => b.ts - a.ts);
return merged;
}
function migrateLegacyStorage(): ForumThread[] | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(LEGACY_KEY);
if (!raw) return null;
const v = JSON.parse(raw) as ForumThread[];
if (!Array.isArray(v)) return null;
localStorage.removeItem(LEGACY_KEY);
return v.map((t) => normalizeThread({ ...t, topicSlug: t.topicSlug || categoryToSlug(t.category) }));
} catch {
return null;
}
}
function loadRawUserThreads(): ForumThread[] {
if (typeof window === "undefined") return [];
const migrated = migrateLegacyStorage();
if (migrated?.length) {
const clean = migrated.filter((t) => t && typeof t.id === "string" && !READONLY_THREAD_IDS.has(t.id));
if (clean.length) localStorage.setItem(KEY, JSON.stringify(clean));
}
try {
const raw = localStorage.getItem(KEY);
if (!raw) return [];
const v = JSON.parse(raw) as ForumThread[];
if (!Array.isArray(v)) return [];
return v
.filter((t) => t && typeof t.id === "string" && !READONLY_THREAD_IDS.has(t.id))
.map(normalizeThread);
} catch {
return [];
}
}
export function loadForum(): ForumThread[] {
if (typeof window === "undefined") {
return mergeSystemThreads(seedThreads.map(normalizeThread));
}
const user = loadRawUserThreads();
const base = user.length ? user : seedThreads.map(normalizeThread);
return mergeSystemThreads(base);
}
export function saveForum(threads: ForumThread[]): void {
if (typeof window === "undefined") return;
const persist = threads.filter((t) => !READONLY_THREAD_IDS.has(t.id)).map(normalizeThread);
localStorage.setItem(KEY, JSON.stringify(persist));
}
export function addThread(
t: Omit<ForumThread, "id" | "ts" | "replies"> & { replies?: ForumReply[] },
): ForumThread {
const thread: ForumThread = normalizeThread({
id: uid(),
ts: Date.now(),
replies: t.replies ?? [],
topicSlug: t.topicSlug,
category: t.category,
title: t.title.trim(),
author: t.author.trim() || "Anonymous",
body: t.body.trim(),
baseScore: 1,
});
saveForum([thread, ...loadRawUserThreads()]);
return thread;
}
export function addReply(threadId: string, author: string, body: string): boolean {
if (READONLY_THREAD_IDS.has(threadId)) return false;
const trimmed = body.trim();
if (!trimmed) return false;
const user = loadRawUserThreads();
const i = user.findIndex((x) => x.id === threadId);
if (i < 0) return false;
const reply: ForumReply = {
id: uid(),
author: author.trim() || "Anonymous",
body: trimmed,
ts: Date.now(),
};
const next = [...user];
next[i] = { ...next[i], replies: [...next[i].replies, reply] };
saveForum(next);
return true;
}
export function getThread(id: string): ForumThread | undefined {
const hit = loadForum().find((t) => t.id === id);
return hit ? normalizeThread(hit) : undefined;
}
function loadVoteMap(): Record<string, VoteChoice> {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(VOTE_KEY);
if (!raw) return {};
const o = JSON.parse(raw) as Record<string, number>;
const out: Record<string, VoteChoice> = {};
for (const [k, v] of Object.entries(o)) {
if (v === -1 || v === 0 || v === 1) out[k] = v;
}
return out;
} catch {
return {};
}
}
function saveVoteMap(m: Record<string, VoteChoice>): void {
if (typeof window === "undefined") return;
localStorage.setItem(VOTE_KEY, JSON.stringify(m));
}
export function getVoteForThread(threadId: string): VoteChoice {
return loadVoteMap()[threadId] ?? 0;
}
/** Set user vote (-1 down, 0 clear, 1 up). Reddit-style toggle handled in UI. */
export function setVoteForThread(threadId: string, next: VoteChoice): void {
const m = loadVoteMap();
m[threadId] = next;
saveVoteMap(m);
}
export function displayScore(t: ForumThread): number {
const base = t.baseScore ?? 1;
const v = typeof window !== "undefined" ? getVoteForThread(t.id) : 0;
return base + v;
}
/** System-seeded threads — users cannot append replies (open a new thread instead). */
export function isReadOnlyThread(threadId: string): boolean {
return READONLY_THREAD_IDS.has(threadId);
}
const PINNED_THREAD_IDS = new Set(["npc-pinned-rules"]);
export function isPinnedThread(t: ForumThread): boolean {
return PINNED_THREAD_IDS.has(t.id) || /^PINNED\b/i.test(t.title.trim());
}
export function sortPinnedThreads(list: ForumThread[]): ForumThread[] {
return [...list].sort((a, b) => {
const pa = PINNED_THREAD_IDS.has(a.id) ? 0 : 1;
const pb = PINNED_THREAD_IDS.has(b.id) ? 0 : 1;
if (pa !== pb) return pa - pb;
return b.ts - a.ts;
});
}