From 60e24e456e296e6fbb122171b673b12b0494a50e Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 3 Jul 2026 07:41:57 -0400 Subject: [PATCH] feat(feed): read-only Nostr feed at /feed + a "Join us" Nostr link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package-lock.json | 107 ++++++++++++++++++++++++++++++++- package.json | 1 + src/app/feed/page.tsx | 133 ++++++++++++++++++++++++++++++++++++++++++ src/app/page.tsx | 10 ++++ 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/app/feed/page.tsx diff --git a/package-lock.json b/package-lock.json index 2142227..7ab117b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@such-software/smirk-dapp-api": "^0.3.0", "next": "15.3.6", + "nostr-tools": "^2.23.9", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -939,6 +940,45 @@ "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": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1001,6 +1041,42 @@ "dev": true, "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": { "version": "0.3.0", "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_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": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5924,7 +6029,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 81118f1..508ecfd 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@such-software/smirk-dapp-api": "^0.3.0", "next": "15.3.6", + "nostr-tools": "^2.23.9", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/src/app/feed/page.tsx b/src/app/feed/page.tsx new file mode 100644 index 0000000..d25a4b9 --- /dev/null +++ b/src/app/feed/page.tsx @@ -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([]); + const [status, setStatus] = useState<'connecting' | 'live' | 'empty'>('connecting'); + + 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)); + }, + }, + ); + 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 ( +
+
+
+ + + SMIRK FEED + + +

+ The self-sovereign Smirk square, on{' '} + relay.smirk.cash +

+
+ + {dot.label} +
+
+ + {status !== 'live' && events.length === 0 ? ( +
+

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

+

+ Posts from Smirk premium members appear here. +

+
+ ) : ( +
    + {events.map((ev) => ( +
  • +
    + + {shortNpub(ev.pubkey)} + + {relativeTime(ev.created_at)} +
    +

    + {ev.content} +

    +
  • + ))} +
+ )} + + +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 7c37f27..f003f2f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -228,6 +228,16 @@ export default function Home() { + + + + + + )}