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:
+135
-38
@@ -3,15 +3,47 @@
|
|||||||
/**
|
/**
|
||||||
* feed.smirk.cash — a public, read-only window into the Smirk Nostr square.
|
* 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
|
* Curation is a BACKEND-OPERATOR knob: the page reads `/capabilities.feed` from
|
||||||
* (kind 1) come only from members who hold premium — the feed is self-curating.
|
* the default backend and shows posts accordingly (owner / premium members /
|
||||||
* This v1 is read-only; composing/replying arrives once the wallet's dapp-api
|
* a featured allowlist, across the relay + optional extra relays). When the
|
||||||
* exposes a Nostr sign scope (see the monorepo roadmap).
|
* 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';
|
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 {
|
function shortNpub(pubkeyHex: string): string {
|
||||||
try {
|
try {
|
||||||
@@ -30,43 +62,95 @@ function relativeTime(unixSeconds: number): string {
|
|||||||
return `${Math.floor(s / 86400)}d`;
|
return `${Math.floor(s / 86400)}d`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Status = 'loading' | 'connecting' | 'live' | 'empty' | 'off';
|
||||||
|
|
||||||
export default function FeedPage(): React.ReactElement {
|
export default function FeedPage(): React.ReactElement {
|
||||||
const [events, setEvents] = useState<NostrEvent[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
const pool = new SimplePool();
|
let cancelled = false;
|
||||||
const seen = new Set<string>();
|
fetch(`${BACKEND}/capabilities`, { headers: { accept: 'application/json' } })
|
||||||
const sub = pool.subscribeMany(
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
[RELAY],
|
.catch(() => null)
|
||||||
{ kinds: [1], limit: 100 },
|
.then((caps) => {
|
||||||
{
|
if (cancelled) return;
|
||||||
onevent(ev: NostrEvent) {
|
if (caps && caps.feed) {
|
||||||
if (seen.has(ev.id)) return;
|
setFeed(caps.feed as FeedConfig);
|
||||||
seen.add(ev.id);
|
} else if (caps && caps.features && caps.features.feed === false) {
|
||||||
setStatus('live');
|
setStatus('off');
|
||||||
setEvents((prev) =>
|
} else {
|
||||||
[...prev, ev]
|
setFeed(DEFAULT_FEED); // old binary / unreachable → sensible default
|
||||||
.sort((a, b) => b.created_at - a.created_at)
|
}
|
||||||
.slice(0, 200),
|
});
|
||||||
);
|
|
||||||
},
|
|
||||||
oneose() {
|
|
||||||
setStatus((s) => (s === 'connecting' ? 'empty' : s));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return () => {
|
return () => {
|
||||||
sub.close();
|
cancelled = true;
|
||||||
pool.close([RELAY]);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const dot = useMemo(() => {
|
// 2. Subscribe once the feed config is known.
|
||||||
if (status === 'live') return { color: '#4ade80', label: 'live' };
|
useEffect(() => {
|
||||||
if (status === 'empty') return { color: '#71717a', label: 'quiet' };
|
if (!feed) return;
|
||||||
return { color: '#fbeb0a', label: 'connecting' };
|
setStatus('connecting');
|
||||||
}, [status]);
|
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 (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-200">
|
<main className="min-h-screen bg-black text-zinc-200">
|
||||||
@@ -79,7 +163,9 @@ export default function FeedPage(): React.ReactElement {
|
|||||||
</a>
|
</a>
|
||||||
<p className="mt-2 text-sm text-zinc-500">
|
<p className="mt-2 text-sm text-zinc-500">
|
||||||
The self-sovereign Smirk square, on{' '}
|
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>
|
</p>
|
||||||
<div className="mt-3 inline-flex items-center gap-2 text-xs text-zinc-500">
|
<div className="mt-3 inline-flex items-center gap-2 text-xs text-zinc-500">
|
||||||
<span
|
<span
|
||||||
@@ -90,10 +176,14 @@ export default function FeedPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<div className="rounded-xl border border-zinc-800 bg-zinc-950 p-8 text-center">
|
||||||
<p className="text-zinc-400">
|
<p className="text-zinc-400">
|
||||||
{status === 'connecting'
|
{status === 'loading' || status === 'connecting'
|
||||||
? 'Connecting to the relay…'
|
? 'Connecting to the relay…'
|
||||||
: 'No posts yet.'}
|
: 'No posts yet.'}
|
||||||
</p>
|
</p>
|
||||||
@@ -109,9 +199,16 @@ export default function FeedPage(): React.ReactElement {
|
|||||||
className="rounded-xl border border-zinc-800 bg-zinc-950 p-4"
|
className="rounded-xl border border-zinc-800 bg-zinc-950 p-4"
|
||||||
>
|
>
|
||||||
<div className="mb-1 flex items-center justify-between text-xs">
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
<span className="font-mono text-[#fbeb0a]">
|
<span className="font-mono text-[#fbeb0a]">
|
||||||
{shortNpub(ev.pubkey)}
|
{shortNpub(ev.pubkey)}
|
||||||
</span>
|
</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>
|
<span className="text-zinc-600">{relativeTime(ev.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="whitespace-pre-wrap break-words text-sm text-zinc-200">
|
<p className="whitespace-pre-wrap break-words text-sm text-zinc-200">
|
||||||
|
|||||||
Reference in New Issue
Block a user