aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-19 18:10:31 -0700
committerAdam Soltys <[email protected]>2026-10-19 18:10:31 -0700
commit1a85347b0a37886e550497eab5ece05410fce923 (patch)
treeaf4b5497eb89c5b6063ba772d8974f8057ec6b13
parent762bdcb558af94b540801a89cd16cd419d3a27d5 (diff)
downloadcoinosv3-1a85347b0a37886e550497eab5ece05410fce923.tar.gz
coinosv3-1a85347b0a37886e550497eab5ece05410fce923.zip
Posts: a relay named in a post (wss://…) is a link to what's on it — the raw firehose of that relay alone (/feed/all?r=…). Trailing punctuation stays outside the link.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
-rw-r--r--src/features/messages.js10
-rw-r--r--src/i18n.js1
-rw-r--r--tools/relay-link-test.js66
3 files changed, 76 insertions, 1 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 0974839..094de0d 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -7844,7 +7844,7 @@ export function messagesFeature(ctx) {
// A bare npub/nprofile — with or without an @ in front, no nostr: prefix —
// is how Vector (and people) write a mention; it resolves to the name too.
const BARE_KEY = '(?<![\\w/])@?(?:npub|nprofile)1[a-z0-9]{20,}(?![\\w])';
- const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi');
+ const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|wss?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi');
const MD_PARTS = new RegExp('^(!?)\\[([^\\]\\n]{0,300})\\]\\(\\s*<?(https?:\\/\\/[^\\s>)]+)>?[^)\\n]{0,300}\\)$', 'i');
// A YouTube link is a video, so show the video. All three shapes it comes
@@ -10377,6 +10377,14 @@ export function messagesFeature(ctx) {
} else if (/^https?:\/\//i.test(part)) {
const pack = PACK_LINK_RE.exec(part);
out.push(pack ? packLinkNode(part, pack[1]) : urlNode(part));
+ } else if (/^wss?:\/\//i.test(part)) {
+ // a relay named in a post opens what's on it (the raw firehose)
+ const [, addr, tail] = /^(.*?)([.,;:!?)\]'"]*)$/.exec(part);
+ const relay = normRelay(addr);
+ out.push(relay ? h('a', {
+ href: '/feed/all?r=' + encodeURIComponent(relay), title: t('relayLinkOpen'),
+ onClick: (e) => { e.preventDefault(); e.stopPropagation(); openFeedUrl('all', '?r=' + encodeURIComponent(relay)); },
+ }, addr) : addr, tail);
} else if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) {
const bare = part.replace(/^(nostr:|@)/i, '');
const ref = parseNostrRef(bare);
diff --git a/src/i18n.js b/src/i18n.js
index 9b921f1..d780d00 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -439,6 +439,7 @@ const STR = {
profLists: 'Lists',
searchTopicRow: 'Posts on this topic',
searchTopicFor: 'Posts tagged #{q}',
+ relayLinkOpen: 'See what’s posted on this relay',
alertsTitle: 'Notifications',
alertsSub: 'Zaps, reactions and replies to your posts',
alertsNew: '{n} new',
diff --git a/tools/relay-link-test.js b/tools/relay-link-test.js
new file mode 100644
index 0000000..52984ad
--- /dev/null
+++ b/tools/relay-link-test.js
@@ -0,0 +1,66 @@
+// A relay named in a post (wss://…) is a link: tapping it opens the raw
+// firehose of that relay (/feed/all?r=…), read from that relay alone.
+// Run: bun tools/relay-link-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
+import { buildHtml, buildVerifyWorker } from '../build.js';
+
+const sk = generateSecretKey(), now = Math.floor(Date.now() / 1000);
+const note = finalizeEvent({ kind: 1, created_at: now, tags: [], content: 'I run a good relay to read directly:\n\nwss://theforest.nostr1.com.\n\nAnyone can read from it.' }, sk);
+const forest = finalizeEvent({ kind: 1, created_at: now - 5, tags: [], content: 'Hello from the forest relay, one of its own posts.' }, generateSecretKey());
+const profile = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Relay Admin' }) }, sk);
+const html = await buildHtml({ minify: true, pwa: false });
+const worker = await buildVerifyWorker({ minify: true });
+const server = Bun.serve({ port: 0, fetch(req) {
+ const path = new URL(req.url).pathname;
+ if (path === '/api/feed') return Response.json({ version: 1, generatedAt: Date.now(), popUntil: now - 100, notes: [note], profiles: [profile] });
+ if (path === '/verify-worker.js') return new Response(worker, { headers: { 'Content-Type': 'text/javascript' } });
+ if (path.startsWith('/punks')) return new Response(null, { status: 404 });
+ return new Response(html, { headers: { 'Content-Type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const check = (name, ok, detail = '') => { assert(ok, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setRequestInterception(true);
+ page.on('request', (r) => (r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort()));
+ await page.evaluateOnNewDocument((forest) => {
+ window.asked = [];
+ class FakeSocket extends EventTarget {
+ static OPEN = 1; static CLOSED = 3;
+ readyState = 0;
+ constructor(url) { super(); this.url = url; setTimeout(() => { this.readyState = 1; this.onopen?.({}); this.dispatchEvent(new Event('open')); }, 0); }
+ emit(m) { const e = new MessageEvent('message', { data: JSON.stringify(m) }); this.onmessage?.(e); this.dispatchEvent(e); }
+ send(data) {
+ const m = JSON.parse(data);
+ if (m[0] !== 'REQ') return;
+ window.asked.push({ url: this.url, filter: m[2] });
+ setTimeout(() => {
+ if (/theforest/.test(this.url) && m[2].kinds?.includes(1) && !m[2].ids && !m[2].authors) this.emit(['EVENT', m[1], forest]);
+ this.emit(['EOSE', m[1]]);
+ }, 0);
+ }
+ close() { this.readyState = 3; this.onclose?.({}); }
+ }
+ window.WebSocket = FakeSocket;
+ }, forest);
+ await page.goto(server.url.origin + '/feed');
+ await page.waitForFunction(() => document.body.innerText.includes('I run a good relay'), { timeout: 5000 });
+ const link = await page.$$eval('.notes-feed a', (as) => as.filter((a) => /theforest/.test(a.textContent)).map((a) => ({ text: a.textContent, href: a.getAttribute('href') })));
+ check('the relay in the post is a link', link.length === 1, JSON.stringify(link));
+ check('...without the full stop after it', link[0].text === 'wss://theforest.nostr1.com' && /theforest\.nostr1\.com\./.test(await page.evaluate(() => document.querySelector('.notes-feed').innerText)));
+ check('...to that relay\'s firehose', link[0].href === '/feed/all?r=' + encodeURIComponent('wss://theforest.nostr1.com'), link[0].href);
+ await page.evaluate(() => [...document.querySelectorAll('.notes-feed a')].find((a) => /theforest/.test(a.textContent)).click());
+ check('tapping it shows the relay\'s own posts', await page.waitForFunction(() => document.body.innerText.includes('Hello from the forest relay'), { timeout: 8000 }).then(() => true, () => false));
+ const forestAsks = await page.evaluate(() => window.asked.filter((a) => /theforest/.test(a.url) && a.filter.kinds?.includes(1) && !a.filter.authors && !a.filter.ids));
+ check('...asked of that relay, for everything on it', forestAsks.length > 0);
+ check('...and the address bar says so', decodeURIComponent(await page.evaluate(() => location.pathname + location.search)) === '/feed/all?r=wss://theforest.nostr1.com', await page.evaluate(() => location.href));
+ check('no page errors', !errors.length, errors.join(' | '));
+ console.log('\n✅ relay links open the relay');
+} finally {
+ await browser.close();
+ server.stop(true);
+}