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 I’m 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. You’re 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 site’s 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 don’t brag in public. You’ll 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 doesn’t 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 & { 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 { if (typeof window === "undefined") return {}; try { const raw = localStorage.getItem(VOTE_KEY); if (!raw) return {}; const o = JSON.parse(raw) as Record; const out: Record = {}; 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): 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; }); }