aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/nip29-client.js41
-rw-r--r--tools/nip29-group-test.js45
2 files changed, 81 insertions, 5 deletions
diff --git a/src/nip29-client.js b/src/nip29-client.js
index 040733c..4c27b64 100644
--- a/src/nip29-client.js
+++ b/src/nip29-client.js
@@ -197,18 +197,25 @@ export function nip29Client({ scope, pubkey, identity, on, listRelays, store = m
// Apply one change to the newest list the relays hold (never to a copy
// we only assume is current), then publish it.
async function writeList(change) {
- const cur = await readList(!list).catch(() => list);
+ const cur = await readList(true).catch(() => list);
const base = cur || { tags: [], content: '', at: 0 };
const tags = change(base.tags);
if (!tags) return;
const created_at = Math.max(now(), base.at + 1);
const ev = await sign({ kind: LIST, created_at, tags, content: base.content || '' });
- const ok = await publishOn(await listRelays(false), ev);
+ // everywhere a reader looks: a phone that asks only the relays a copy
+ // missed would never learn of a group joined on the laptop
+ const ok = await publishOn(await listRelays(true), ev);
if (ok) { list = { tags, content: base.content || '', at: created_at }; store.put('list', list).catch(() => {}); }
}
+ // The list as another device changed it: read at start, again whenever the
+ // app comes back (resubscribe), and followed live while it's open, so a
+ // group joined on the laptop shows up on the phone (Adam, 2026-11-12).
+ let adoptedAt = 0, listSub = null;
async function adoptList() {
+ adoptedAt = Date.now();
try {
- await readList(!list);
+ await readList(true);
const l = list; // the newest, a leave's own write included
if (!l || stopped) return;
for (const x of listGroups(l.tags)) {
@@ -299,15 +306,39 @@ export function nip29Client({ scope, pubkey, identity, on, listRelays, store = m
}).catch((e) => dlog('nip29: list write', e));
}
+ function adoptLive(ev) {
+ if (stopped || ev.pubkey !== pubkey || ev.kind !== LIST) return;
+ if (list && ev.created_at <= list.at) return;
+ list = { tags: ev.tags || [], content: ev.content || '', at: ev.created_at };
+ store.put('list', list).catch(() => {});
+ adoptList();
+ }
+ async function followList() {
+ if (listSub || stopped) return;
+ const relays = await listRelays(false);
+ if (stopped || listSub) return;
+ listSub = subscribeAuthed(relays, { kinds: [LIST], authors: [pubkey], limit: 1 }, adoptLive);
+ }
async function start() {
await load();
if (stopped) return;
for (const g of groups.values()) subscribe(g);
emit('loaded', null);
adoptList();
+ followList().catch(() => {});
+ }
+ function resubscribe() {
+ for (const g of groups.values()) { unsubscribe(g); subscribe(g); }
+ if (listSub) { try { listSub(); } catch {} listSub = null; }
+ followList().catch(() => {});
+ if (Date.now() - adoptedAt > 30_000) adoptList();
+ }
+ function stop() {
+ stopped = true;
+ for (const g of groups.values()) unsubscribe(g);
+ if (listSub) { try { listSub(); } catch {} listSub = null; }
+ flush();
}
- function resubscribe() { for (const g of groups.values()) { unsubscribe(g); subscribe(g); } }
- function stop() { stopped = true; for (const g of groups.values()) unsubscribe(g); flush(); }
const no = async () => { throw new Error('not supported'); };
return {
diff --git a/tools/nip29-group-test.js b/tools/nip29-group-test.js
index 2ff1192..9096a47 100644
--- a/tools/nip29-group-test.js
+++ b/tools/nip29-group-test.js
@@ -31,6 +31,7 @@ try {
await sleep(400);
await page.evaluate(() => [...document.querySelectorAll('button')].find((n) => /create a new wallet/i.test(n.textContent))?.click()); await sleep(600);
await seedWords(page);
+ const phrase = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
await skipSeedCheck(page);
await waitText('receive', 20000);
await page.click('[aria-label="Messages"]');
@@ -80,6 +81,50 @@ try {
for (let i = 0; i < 40 && !kept; i++) { kept = await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners'))); if (!kept) await sleep(250); }
check('...and still is after a reload', kept);
+ // the same wallet on another device, already open: the laptop's list
+ // arrives on its live 10009 subscription and the group appears there
+ // ("Not seeing the pyramid runners chat I joined … on my laptop appear on
+ // my phone", Adam, 2026-11-12)
+ {
+ const ctx = await browser.createBrowserContext();
+ const p2 = await ctx.newPage();
+ p2.on('console', (m) => { if (process.env.DEBUG && /nip29|chat|10009/.test(m.text())) console.log(' [p2]', m.text()); });
+ p2.on('pageerror', (e) => errs.push('p2: ' + e));
+ await p2.evaluateOnNewDocument(() => {
+ window.__subs = [];
+ const send = WebSocket.prototype.send;
+ WebSocket.prototype.send = function (d) {
+ try { const m = JSON.parse(d); if (m[0] === 'REQ') window.__subs.push({ ws: this, id: m[1], filters: m.slice(2) }); } catch {}
+ return send.call(this, d);
+ };
+ });
+ const t2 = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await p2.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+ await p2.setViewport({ width: 390, height: 844 });
+ await p2.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await p2.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await p2.reload({ waitUntil: 'domcontentloaded' });
+ await t2('sign in to an existing account');
+ await p2.evaluate(() => document.getElementById('onb-signin')?.click()); await sleep(500);
+ await p2.evaluate(() => document.getElementById('onb-restore')?.click()); await sleep(600);
+ await p2.type('textarea[placeholder*="seed words" i]', phrase);
+ await p2.evaluate(() => [...document.querySelectorAll('button')].find((n) => /open wallet/i.test(n.textContent))?.click());
+ await t2('receive');
+ await p2.click('[aria-label="Messages"]'); await sleep(500);
+ let subbed = false;
+ for (let i = 0; i < 60 && !subbed; i++) { subbed = await p2.evaluate(() => window.__subs.some((x) => x.ws.readyState === 1 && x.filters.some((f) => (f.kinds || []).includes(10009) && f.limit === 1))); if (!subbed) await sleep(250); }
+ check('another device follows the 10009 list live', subbed);
+ const before = await p2.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners')));
+ const fromLaptop = list; // the laptop's own signed list, from before its reload
+ await p2.evaluate((ev) => {
+ for (const x of window.__subs.filter((x) => x.ws.readyState === 1 && x.filters.some((f) => (f.kinds || []).includes(10009) && f.limit === 1)))
+ x.ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', x.id, ev]) }));
+ }, fromLaptop);
+ let shown = false;
+ for (let i = 0; i < 60 && !shown; i++) { shown = await p2.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].some((r) => r.textContent.includes('pyramid runners'))); if (!shown) await sleep(250); }
+ check('...and the group joined on the laptop appears there', !before && shown);
+ await ctx.close();
+ }
+
// leave: two taps, a 9022 to the relay, and off the list
await page.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].find((r) => r.textContent.includes('pyramid runners'))?.click()); await sleep(800);
await page.click('[aria-label="Leave group"]'); await sleep(200);