aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-20 23:20:41 -0700
committerAdam Soltys <[email protected]>2026-09-20 23:20:41 -0700
commit501da85c85e014eb48e6cd7df246875fadf27cb3 (patch)
treef24d15113f4d64f63853788b41dd6dad3531d197
parent19ac1c46091e969075d998af95d304d82cbba259 (diff)
downloadcoinosv3-501da85c85e014eb48e6cd7df246875fadf27cb3.tar.gz
coinosv3-501da85c85e014eb48e6cd7df246875fadf27cb3.zip
Feeds: packs are found by a few letters of their title, and by following.space's real link form.
NIP-50 search only knew the packs the search relay carried — 'Niche Accounts' lives on damus and nos.lol, not relay.nostr.band. So the editor now indexes the wide relays' packs once per session and matches titles and descriptions by substring. A following.space link is /d/<id>?p=<pubkey>, not an naddr; both are accepted. Co-Authored-By: Claude Fable 5.1 <[email protected]>
-rw-r--r--src/features/messages.js51
-rw-r--r--tools/feed-custom-test.js14
2 files changed, 49 insertions, 16 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index d4b6b00..10828d9 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -3625,13 +3625,39 @@ export function messagesFeature(ctx) {
return packMembers;
};
const packOf = (p) => packsNow()[packKey(p)] || null;
- // A pasted following.space link, a bare naddr, or nostr:naddr — the pack it names.
+ // A pasted following.space link (/d/<id>?p=<pubkey>), a bare naddr, or
+ // nostr:naddr — the pack it names.
function parsePackLink(input) {
- const m = /naddr1[a-z0-9]+/i.exec(String(input || ''));
- if (!m) return null;
- const ref = parseNostrRef(m[0].toLowerCase());
- if (!ref || ref.type !== 'addr' || ref.kind !== PACK_KIND) return null;
- return { pk: ref.pk, d: ref.d, relays: ref.relays || [] };
+ const s = String(input || '').trim();
+ const m = /naddr1[a-z0-9]+/i.exec(s);
+ if (m) {
+ const ref = parseNostrRef(m[0].toLowerCase());
+ return ref && ref.type === 'addr' && ref.kind === PACK_KIND ? { pk: ref.pk, d: ref.d, relays: ref.relays || [] } : null;
+ }
+ const link = /\/d\/([^/?#\s]+)[^?\s]*\?(?:[^#\s]*&)?p=([0-9a-f]{64})/i.exec(s);
+ if (link) return { pk: link[2].toLowerCase(), d: decodeURIComponent(link[1]), relays: [] };
+ return null;
+ }
+ // Every pack the wide relays hold, once per session: the way to find one
+ // by a few letters of its title — NIP-50 search only knows the packs the
+ // search relay happens to carry, and most live on damus / nos.lol.
+ let packIndex = null;
+ function loadPackIndex() {
+ if (packIndex) return packIndex;
+ packIndex = (async () => {
+ const relays = [...new Set([...PACK_RELAYS, 'wss://relay.damus.io', 'wss://nos.lol'])];
+ const all = await Promise.all(relays.map((r) => queryOn([r], { kinds: [PACK_KIND], limit: 500 }, 6000).catch(() => [])));
+ const newest = new Map();
+ for (const ev of all.flat()) {
+ const p = packFromEvent(ev);
+ if (!p.pks.length) continue;
+ const cur = newest.get(packKey(p));
+ if (!cur || p.at > cur.at) newest.set(packKey(p), { ...p, desc: (ev.tags.find((x) => x[0] === 'description') || [])[1] || '' });
+ }
+ return [...newest.values()];
+ })();
+ packIndex.catch(() => { packIndex = null; });
+ return packIndex;
}
const packFromEvent = (ev) => ({
pk: ev.pubkey, d: (ev.tags.find((x) => x[0] === 'd') || [])[1] || '',
@@ -5935,16 +5961,15 @@ export function messagesFeature(ctx) {
const got = await fetchPack(link).catch(() => null);
rows = got ? [{ ...link, title: got.title, n: got.pks.length }] : [];
} else {
- const evs = await queryOn(['wss://relay.nostr.band'], { kinds: [PACK_KIND], search: q, limit: 12 }, 4000).catch(() => []);
- const seen = new Set();
- for (const ev of (evs || []).sort((a, b) => b.created_at - a.created_at)) {
- const p = packFromEvent(ev);
- if (!p.pks.length || seen.has(packKey(p))) continue;
- seen.add(packKey(p));
+ const words = q.toLowerCase().split(/\s+/).filter(Boolean);
+ const index = await loadPackIndex().catch(() => []);
+ const hits = index.filter((p) => { const hay = (p.title + ' ' + p.desc).toLowerCase(); return words.every((w) => hay.includes(w)); })
+ .sort((a, b) => b.pks.length - a.pks.length).slice(0, 12);
+ for (const p of hits) {
packsNow()[packKey(p)] ||= { pks: p.pks, title: p.title, at: p.at };
rows.push({ pk: p.pk, d: p.d, relays: [], title: p.title, n: p.pks.length });
}
- try { wallet.saveFeatureState(PACK_CACHE, packsNow()); } catch {}
+ if (hits.length) { try { wallet.saveFeatureState(PACK_CACHE, packsNow()); } catch {} }
}
if (seq !== packSearchSeq || !ui.feedEdit) return;
ui.feedEdit.packRows = rows;
diff --git a/tools/feed-custom-test.js b/tools/feed-custom-test.js
index 8062a15..0fc7ab8 100644
--- a/tools/feed-custom-test.js
+++ b/tools/feed-custom-test.js
@@ -23,7 +23,8 @@ const seeded = [signed('hello from the feed, about #bitcoin today', 300), signed
const CURATOR_SK = generateSecretKey();
const MEMBER = getPublicKey(generateSecretKey());
const PACK = finalizeEvent({ kind: 39089, created_at: Math.floor(Date.now() / 1000) - 100, tags: [['d', 'test-pack'], ['title', 'Test pack'], ['p', MEMBER]], content: '' }, CURATOR_SK);
-const PACK_LINK = 'https://following.space/d/' + naddrEncode({ kind: 39089, pubkey: PACK.pubkey, identifier: 'test-pack', relays: [] });
+const PACK_LINK = 'https://following.space/d/test-pack?p=' + PACK.pubkey; // the form following.space shares
+const PACK_NADDR = naddrEncode({ kind: 39089, pubkey: PACK.pubkey, identifier: 'test-pack', relays: [] });
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5297, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
@@ -130,9 +131,16 @@ try {
console.log('\n[a follow pack from following.space]');
await page.click('.feed-chip.add'); await sleep(300);
await type('input[placeholder="e.g. Bitcoin builders"]', 'Pack feed');
+ await type('input[placeholder="Search packs, or paste a following.space link…"]', 'tes');
+ check('a few letters of the title find the pack', await waitText('test pack', 8000) && await waitText('1 members', 2000));
+ await type('input[placeholder="Search packs, or paste a following.space link…"]', 'zzzz');
+ await sleep(900);
+ check('...and letters that match nothing find nothing', !(await page.evaluate(() => document.body.innerText)).includes('Test pack'));
+ await type('input[placeholder="Search packs, or paste a following.space link…"]', PACK_NADDR);
+ check('a bare naddr finds it', await waitText('test pack', 6000));
await type('input[placeholder="Search packs, or paste a following.space link…"]', PACK_LINK);
- const found = await waitText('test pack', 6000);
- check('a pasted following.space link finds the pack', found && await waitText('1 members', 2000));
+ await sleep(900);
+ check('a pasted following.space link finds it', await waitText('test pack', 6000));
await page.evaluate(() => [...document.querySelectorAll('.row')].find((r) => /Test pack/.test(r.textContent) && !r.querySelector('button')).click()); await sleep(300);
check('...and adds it to the feed', await page.evaluate(() => [...document.querySelectorAll('.row')].some((r) => /Test pack/.test(r.textContent) && r.querySelector('button'))));
await page.evaluate(() => { window.__reqs = []; });