feat(feed): drive curation from /capabilities.feed (operator knobs)

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.
This commit is contained in:
jwinterm
2026-07-03 16:58:56 -04:00
parent 60e24e456e
commit c6ca19dd25
+137 -40
View File
@@ -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<NostrEvent[]>([]);
const [status, setStatus] = useState<'connecting' | 'live' | 'empty'>('connecting');
const [status, setStatus] = useState<Status>('loading');
const [feed, setFeed] = useState<FeedConfig | null>(null);
const [ownerHex, setOwnerHex] = useState<string | null>(null);
// 1. Resolve the operator's feed config (graceful default when unadvertised).
useEffect(() => {
const pool = new SimplePool();
const seen = new Set<string>();
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<string>();
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 (
<main className="min-h-screen bg-black text-zinc-200">
@@ -79,7 +163,9 @@ export default function FeedPage(): React.ReactElement {
</a>
<p className="mt-2 text-sm text-zinc-500">
The self-sovereign Smirk square, on{' '}
<span className="text-zinc-400">relay.smirk.cash</span>
<span className="text-zinc-400">
{(feed?.relay_url ?? DEFAULT_RELAY).replace('wss://', '')}
</span>
</p>
<div className="mt-3 inline-flex items-center gap-2 text-xs text-zinc-500">
<span
@@ -90,10 +176,14 @@ export default function FeedPage(): React.ReactElement {
</div>
</header>
{status !== 'live' && events.length === 0 ? (
{status === 'off' ? (
<div className="rounded-xl border border-zinc-800 bg-zinc-950 p-8 text-center">
<p className="text-zinc-400">The feed isn’t enabled on this instance.</p>
</div>
) : status !== 'live' && events.length === 0 ? (
<div className="rounded-xl border border-zinc-800 bg-zinc-950 p-8 text-center">
<p className="text-zinc-400">
{status === 'connecting'
{status === 'loading' || status === 'connecting'
? 'Connecting to the relay…'
: 'No posts yet.'}
</p>
@@ -109,8 +199,15 @@ export default function FeedPage(): React.ReactElement {
className="rounded-xl border border-zinc-800 bg-zinc-950 p-4"
>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="font-mono text-[#fbeb0a]">
{shortNpub(ev.pubkey)}
<span className="flex items-center gap-2">
<span className="font-mono text-[#fbeb0a]">
{shortNpub(ev.pubkey)}
</span>
{ownerHex && ev.pubkey === ownerHex && (
<span className="rounded bg-[#fbeb0a] px-1.5 py-0.5 text-[10px] font-semibold text-black">
owner
</span>
)}
</span>
<span className="text-zinc-600">{relativeTime(ev.created_at)}</span>
</div>