From c6ca19dd25fa359a3f7c6e61c1182a056d20bca3 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 3 Jul 2026 16:58:56 -0400 Subject: [PATCH] feat(feed): drive curation from /capabilities.feed (operator knobs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feed now reads the backend's advertised feed config — show owner / premium members / a featured npub allowlist, across the relay + optional extra relays — and renders an owner badge. Falls back to a sensible default (the Smirk relay, premium + owner) when a backend doesn't advertise a feed config yet, and shows an "off" state when an operator disables it. --- src/app/feed/page.tsx | 177 ++++++++++++++++++++++++++++++++---------- 1 file changed, 137 insertions(+), 40 deletions(-) diff --git a/src/app/feed/page.tsx b/src/app/feed/page.tsx index d25a4b9..b17513f 100644 --- a/src/app/feed/page.tsx +++ b/src/app/feed/page.tsx @@ -3,15 +3,47 @@ /** * feed.smirk.cash — a public, read-only window into the Smirk Nostr square. * - * The relay (wss://relay.smirk.cash) is premium-post gated, so general notes - * (kind 1) come only from members who hold premium — the feed is self-curating. - * This v1 is read-only; composing/replying arrives once the wallet's dapp-api - * exposes a Nostr sign scope (see the monorepo roadmap). + * Curation is a BACKEND-OPERATOR knob: the page reads `/capabilities.feed` from + * the default backend and shows posts accordingly (owner / premium members / + * a featured allowlist, across the relay + optional extra relays). When the + * backend doesn't advertise a feed config yet, it falls back to a sensible + * default (the Smirk relay, premium + owner). Read-only for now; composing + * arrives with the wallet's Nostr dapp-api scope. */ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { SimplePool, nip19, type Event as NostrEvent } from 'nostr-tools'; -const RELAY = 'wss://relay.smirk.cash'; +const BACKEND = 'https://api.smirk.cash/api/v1'; +const DEFAULT_RELAY = 'wss://relay.smirk.cash'; + +interface FeedConfig { + relay_url: string; + show_owner: boolean; + show_premium: boolean; + owner_npub: string | null; + allowlist_npubs: string[]; + extra_relays: string[]; +} + +const DEFAULT_FEED: FeedConfig = { + relay_url: DEFAULT_RELAY, + show_owner: true, + show_premium: true, + owner_npub: null, + allowlist_npubs: [], + extra_relays: [], +}; + +function toHex(idOrNpub: string): string | null { + const s = idOrNpub.trim(); + if (/^[0-9a-f]{64}$/i.test(s)) return s.toLowerCase(); + try { + const d = nip19.decode(s); + return typeof d.data === 'string' ? d.data : null; + } catch { + return null; + } +} function shortNpub(pubkeyHex: string): string { try { @@ -30,43 +62,95 @@ function relativeTime(unixSeconds: number): string { return `${Math.floor(s / 86400)}d`; } +type Status = 'loading' | 'connecting' | 'live' | 'empty' | 'off'; + export default function FeedPage(): React.ReactElement { const [events, setEvents] = useState([]); - const [status, setStatus] = useState<'connecting' | 'live' | 'empty'>('connecting'); + const [status, setStatus] = useState('loading'); + const [feed, setFeed] = useState(null); + const [ownerHex, setOwnerHex] = useState(null); + // 1. Resolve the operator's feed config (graceful default when unadvertised). useEffect(() => { - const pool = new SimplePool(); - const seen = new Set(); - const sub = pool.subscribeMany( - [RELAY], - { kinds: [1], limit: 100 }, - { - onevent(ev: NostrEvent) { - if (seen.has(ev.id)) return; - seen.add(ev.id); - setStatus('live'); - setEvents((prev) => - [...prev, ev] - .sort((a, b) => b.created_at - a.created_at) - .slice(0, 200), - ); - }, - oneose() { - setStatus((s) => (s === 'connecting' ? 'empty' : s)); - }, - }, - ); + let cancelled = false; + fetch(`${BACKEND}/capabilities`, { headers: { accept: 'application/json' } }) + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null) + .then((caps) => { + if (cancelled) return; + if (caps && caps.feed) { + setFeed(caps.feed as FeedConfig); + } else if (caps && caps.features && caps.features.feed === false) { + setStatus('off'); + } else { + setFeed(DEFAULT_FEED); // old binary / unreachable → sensible default + } + }); return () => { - sub.close(); - pool.close([RELAY]); + cancelled = true; }; }, []); - const dot = useMemo(() => { - if (status === 'live') return { color: '#4ade80', label: 'live' }; - if (status === 'empty') return { color: '#71717a', label: 'quiet' }; - return { color: '#fbeb0a', label: 'connecting' }; - }, [status]); + // 2. Subscribe once the feed config is known. + useEffect(() => { + if (!feed) return; + setStatus('connecting'); + const owner = feed.owner_npub ? toHex(feed.owner_npub) : null; + setOwnerHex(owner); + const allow = new Set( + feed.allowlist_npubs.map(toHex).filter((h): h is string => !!h), + ); + const primary = feed.relay_url || DEFAULT_RELAY; + const pool = new SimplePool(); + const seen = new Set(); + + const accept = (ev: NostrEvent, fromPrimary: boolean): boolean => { + if (allow.has(ev.pubkey)) return true; + if (owner && ev.pubkey === owner) return feed.show_owner; + // Non-owner, non-allowlisted authors count as "premium" only on the + // premium-gated primary relay. + return fromPrimary && feed.show_premium; + }; + const push = (ev: NostrEvent, fromPrimary: boolean): void => { + if (seen.has(ev.id) || !accept(ev, fromPrimary)) return; + seen.add(ev.id); + setStatus('live'); + setEvents((prev) => + [...prev, ev].sort((a, b) => b.created_at - a.created_at).slice(0, 200), + ); + }; + + const subs: { close: () => void }[] = []; + subs.push( + pool.subscribeMany([primary], { kinds: [1], limit: 100 }, { + onevent: (ev: NostrEvent) => push(ev, true), + oneose: () => setStatus((s) => (s === 'connecting' ? 'empty' : s)), + }), + ); + const allowHex = [...allow]; + if (feed.extra_relays.length > 0 && allowHex.length > 0) { + subs.push( + pool.subscribeMany( + feed.extra_relays, + { kinds: [1], authors: allowHex, limit: 100 }, + { onevent: (ev: NostrEvent) => push(ev, false) }, + ), + ); + } + return () => { + subs.forEach((s) => s.close()); + pool.close([primary, ...feed.extra_relays]); + }; + }, [feed]); + + const dot = + status === 'live' + ? { color: '#4ade80', label: 'live' } + : status === 'empty' + ? { color: '#71717a', label: 'quiet' } + : status === 'off' + ? { color: '#71717a', label: 'off' } + : { color: '#fbeb0a', label: 'connecting' }; return (
@@ -79,7 +163,9 @@ export default function FeedPage(): React.ReactElement {

The self-sovereign Smirk square, on{' '} - relay.smirk.cash + + {(feed?.relay_url ?? DEFAULT_RELAY).replace('wss://', '')} +

- {status !== 'live' && events.length === 0 ? ( + {status === 'off' ? ( +
+

The feed isn’t enabled on this instance.

+
+ ) : status !== 'live' && events.length === 0 ? (

- {status === 'connecting' + {status === 'loading' || status === 'connecting' ? 'Connecting to the relay…' : 'No posts yet.'}

@@ -109,8 +199,15 @@ export default function FeedPage(): React.ReactElement { className="rounded-xl border border-zinc-800 bg-zinc-950 p-4" >
- - {shortNpub(ev.pubkey)} + + + {shortNpub(ev.pubkey)} + + {ownerHex && ev.pubkey === ownerHex && ( + + owner + + )} {relativeTime(ev.created_at)}