feat(feed): read-only Nostr feed at /feed + a "Join us" Nostr link

The Smirk square, served from relay.smirk.cash. Because the relay is
premium-post gated, the feed is self-curating — general notes (kind 1) come
only from premium members. v1 is read-only (subscribe + render); composing
arrives with the wallet's Nostr dapp-api scope. Adds nostr-tools and a Nostr
icon beside Telegram/Discord that links to the feed.
This commit is contained in:
jwinterm
2026-07-03 07:41:57 -04:00
parent d25d54c942
commit 60e24e456e
4 changed files with 250 additions and 1 deletions
+106 -1
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@such-software/smirk-dapp-api": "^0.3.0", "@such-software/smirk-dapp-api": "^0.3.0",
"next": "15.3.6", "next": "15.3.6",
"nostr-tools": "^2.23.9",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
@@ -939,6 +940,45 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1001,6 +1041,42 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@scure/base": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
"license": "MIT",
"dependencies": {
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@such-software/smirk-dapp-api": { "node_modules/@such-software/smirk-dapp-api": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@such-software/smirk-dapp-api/-/smirk-dapp-api-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@such-software/smirk-dapp-api/-/smirk-dapp-api-0.3.0.tgz",
@@ -4705,6 +4781,35 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/nostr-tools": {
"version": "2.23.9",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.9.tgz",
"integrity": "sha512-PBN3TpGh+EszlTZWpQdvcqEMqhcKcY1vTw0Xkkccg+TyP7y/icu9/iXJw72aFZ/ETm35ehU0hneGTXLaTSkImw==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "2.1.1",
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0",
"@scure/bip32": "2.0.1",
"@scure/bip39": "2.0.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT"
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5924,7 +6029,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@such-software/smirk-dapp-api": "^0.3.0", "@such-software/smirk-dapp-api": "^0.3.0",
"next": "15.3.6", "next": "15.3.6",
"nostr-tools": "^2.23.9",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
+133
View File
@@ -0,0 +1,133 @@
'use client';
/**
* 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).
*/
import { useEffect, useMemo, useState } from 'react';
import { SimplePool, nip19, type Event as NostrEvent } from 'nostr-tools';
const RELAY = 'wss://relay.smirk.cash';
function shortNpub(pubkeyHex: string): string {
try {
const npub = nip19.npubEncode(pubkeyHex);
return `${npub.slice(0, 12)}…${npub.slice(-6)}`;
} catch {
return `${pubkeyHex.slice(0, 8)}…`;
}
}
function relativeTime(unixSeconds: number): string {
const s = Math.max(0, Math.floor(Date.now() / 1000) - unixSeconds);
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
return `${Math.floor(s / 86400)}d`;
}
export default function FeedPage(): React.ReactElement {
const [events, setEvents] = useState<NostrEvent[]>([]);
const [status, setStatus] = useState<'connecting' | 'live' | 'empty'>('connecting');
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));
},
},
);
return () => {
sub.close();
pool.close([RELAY]);
};
}, []);
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]);
return (
<main className="min-h-screen bg-black text-zinc-200">
<div className="mx-auto max-w-2xl px-4 py-10">
<header className="mb-8 text-center">
<a href="/" className="inline-block">
<span className="text-[#fbeb0a] font-bold text-2xl tracking-wide">
SMIRK FEED
</span>
</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>
</p>
<div className="mt-3 inline-flex items-center gap-2 text-xs text-zinc-500">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: dot.color }}
/>
{dot.label}
</div>
</header>
{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'
? 'Connecting to the relay…'
: 'No posts yet.'}
</p>
<p className="mt-2 text-sm text-zinc-600">
Posts from Smirk premium members appear here.
</p>
</div>
) : (
<ul className="space-y-3">
{events.map((ev) => (
<li
key={ev.id}
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>
<span className="text-zinc-600">{relativeTime(ev.created_at)}</span>
</div>
<p className="whitespace-pre-wrap break-words text-sm text-zinc-200">
{ev.content}
</p>
</li>
))}
</ul>
)}
<footer className="mt-10 text-center text-xs text-zinc-600">
<a href="/" className="hover:text-zinc-400">
← smirk.cash
</a>
</footer>
</div>
</main>
);
}
+10
View File
@@ -228,6 +228,16 @@ export default function Home() {
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/> <path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
</svg> </svg>
</a> </a>
<a
href="/feed"
className="text-zinc-500 hover:text-[#fbeb0a] transition-colors"
title="Nostr feed (self-sovereign)"
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none" />
<path d="M7.5 16.5a6 6 0 0 1 0-9M4.7 19.3a10 10 0 0 1 0-14.6M16.5 7.5a6 6 0 0 1 0 9M19.3 4.7a10 10 0 0 1 0 14.6" />
</svg>
</a>
</div> </div>
</div> </div>
)} )}