"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Image from "next/image"; import { QRCodeSVG } from "qrcode.react"; import { nip19 } from "nostr-tools"; import { Navbar } from "@/components/public/Navbar"; import { Footer } from "@/components/public/Footer"; import { Button } from "@/components/ui/Button"; import { api } from "@/lib/api"; import { useAuth } from "@/hooks/useAuth"; import { formatDate } from "@/lib/utils"; import { cn } from "@/lib/utils"; import { shortenPubkey } from "@/lib/nostr"; import { Copy, Check, Heart, Zap } from "lucide-react"; type BoardMessage = { id: string; paymentHash: string; content: string; authorName: string; pubkey: string | null; profilePic: string | null; satsPaid: number; likeCount: number; createdAt: string; }; type PayPhase = "idle" | "polling" | "confirming" | "paid"; const MAX_LEN = 300; function BoardAvatar({ picture, name, size = 40, }: { picture?: string | null; name: string; size?: number; }) { const [err, setErr] = useState(false); const initial = name.slice(0, 1).toUpperCase() || "?"; if (picture && !err) { return ( setErr(true)} unoptimized /> ); } return (
{initial}
); } export default function BoardPage() { const { user } = useAuth(); const [config, setConfig] = useState<{ priceSats: number; bbeZapPubkey: string | null; bbeZapAddress: string | null; } | null>(null); const [messages, setMessages] = useState([]); const [content, setContent] = useState(""); const [guestName, setGuestName] = useState(""); const [postAsAnon, setPostAsAnon] = useState(false); const [payPhase, setPayPhase] = useState("idle"); const [payError, setPayError] = useState(""); const [invoice, setInvoice] = useState<{ pr: string; hash: string } | null>(null); const [copied, setCopied] = useState(false); const [localLikes, setLocalLikes] = useState>({}); const [highlightPaymentHash, setHighlightPaymentHash] = useState(null); const pollRef = useRef | null>(null); const loadMessages = useCallback(async () => { const list = await api.getBoardMessages(); setMessages(list as BoardMessage[]); }, []); const loadConfig = useCallback(async () => { const c = await api.getBoardConfig(); setConfig(c); }, []); useEffect(() => { loadConfig().catch(() => {}); }, [loadConfig]); useEffect(() => { loadMessages().catch(() => {}); const t = setInterval(() => { loadMessages().catch(() => {}); }, 12_000); return () => clearInterval(t); }, [loadMessages]); useEffect(() => { return () => { if (pollRef.current) clearInterval(pollRef.current); }; }, []); const displayName = user?.name || user?.displayName || ""; const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : ""; const shortNpub = npub.length > 20 ? `${npub.slice(0, 14)}…${npub.slice(-12)}` : npub; const handlePayAndPost = async () => { setPayError(""); const trimmed = content.trim(); if (!trimmed) { setPayError("Write a message first."); return; } if (trimmed.length > MAX_LEN) { setPayError(`Message must be ${MAX_LEN} characters or fewer.`); return; } try { const body: Parameters[0] = { content: trimmed }; if (user?.pubkey && !postAsAnon) { body.pubkey = user.pubkey; body.name = displayName || undefined; body.profilePic = user.picture; } else { const n = guestName.trim(); if (n) body.name = n; } body.postAsAnon = !!(user?.pubkey && postAsAnon); const inv = await api.createBoardInvoice(body); setHighlightPaymentHash(inv.payment_hash); setInvoice({ pr: inv.payment_request, hash: inv.payment_hash }); setPayPhase("polling"); if (pollRef.current) clearInterval(pollRef.current); pollRef.current = setInterval(async () => { try { const status = await api.getBoardPaymentStatus(inv.payment_hash); if (!status.paid) return; // Payment detected — try to ensure the message row exists setPayPhase("confirming"); setInvoice(null); if (!status.messageCreated) { // Webhook may not have fired yet; call confirm to create the row await api.confirmBoardPayment(inv.payment_hash).catch(() => {}); } // Poll until the message actually appears in the list (max ~30s) let found = false; for (let attempt = 0; attempt < 15; attempt++) { const list = await api.getBoardMessages(); setMessages(list as BoardMessage[]); if (list.some((m) => m.paymentHash === inv.payment_hash)) { found = true; break; } // Try confirm again if first attempt didn't work if (attempt === 2) { await api.confirmBoardPayment(inv.payment_hash).catch(() => {}); } await new Promise((r) => setTimeout(r, 2000)); } if (pollRef.current) clearInterval(pollRef.current); pollRef.current = null; if (found) { setPayPhase("paid"); setContent(""); setGuestName(""); setPostAsAnon(false); setTimeout(() => { setPayPhase("idle"); setHighlightPaymentHash(null); }, 4000); } else { setPayPhase("idle"); setPayError("Payment received but message is delayed — it will appear shortly."); } } catch { /* keep polling */ } }, 2000); } catch (e: unknown) { setPayPhase("idle"); setPayError(e instanceof Error ? e.message : "Could not create invoice."); } }; const copyPr = async () => { if (!invoice?.pr) return; await navigator.clipboard.writeText(invoice.pr); setCopied(true); setTimeout(() => setCopied(false), 2000); }; const bumpLike = async (msg: BoardMessage) => { if (!user) return; try { const { likeCount } = await api.likeBoardMessage(msg.id); setLocalLikes((prev) => ({ ...prev, [msg.id]: likeCount })); } catch (e: unknown) { setPayError(e instanceof Error ? e.message : "Like failed"); } }; const handleZap = async (msg: BoardMessage) => { const zapAddress = config?.bbeZapAddress; const fallbackPub = config?.bbeZapPubkey; try { const w = window as unknown as { nostr?: { zap?: (args: Record) => Promise }; }; if (msg.pubkey && w.nostr?.zap) { await w.nostr.zap({ pubkey: msg.pubkey, amount: 21, comment: "BBE board zap", }); return; } } catch { /* fall through */ } if (msg.pubkey) { window.open(`https://njump.me/${nip19.npubEncode(msg.pubkey)}`, "_blank", "noopener,noreferrer"); return; } if (zapAddress) { const addr = zapAddress.replace(/^lightning:/i, ""); window.location.href = `lightning:${addr}`; return; } if (fallbackPub) { try { window.open( `https://njump.me/${nip19.npubEncode(fallbackPub)}`, "_blank", "noopener,noreferrer" ); } catch { setPayError("Invalid BOARD_ZAP_PUBKEY on server (expected hex pubkey)."); } return; } setPayError("Zap: configure BOARD_ZAP_LN_ADDRESS or BOARD_ZAP_PUBKEY on the server."); }; const len = content.length; return ( <>

Message board

Pay {config?.priceSats ?? "…"} sats via Lightning to post. Messages are public and moderated.

Post a message

{user && !postAsAnon ? (

{displayName || "Nostr user"}

{shortNpub}

) : (
{user && ( )} {!user || postAsAnon ? (
setGuestName(e.target.value)} placeholder="Guest" maxLength={80} className="w-full rounded-lg border border-outline-variant bg-surface px-3 py-2 text-on-surface" />
) : null}
)}