mirror of
https://github.com/Such-Software/smirk-website.git
synced 2026-10-17 05:51:28 +02:00
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:
+137
-40
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user