aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-12 23:29:24 -0700
committerAdam Soltys <[email protected]>2026-11-12 23:29:24 -0700
commit5dad3fed20be40eaaeb8e7373dd005f61b3e5c8b (patch)
tree4c356ca35093b33d3252b5b8897ca43f2b5aff72
parent5026227de10e424f590d2a042e5a8770266a6b5e (diff)
downloadcoinosv3-5dad3fed20be40eaaeb8e7373dd005f61b3e5c8b.tar.gz
coinosv3-5dad3fed20be40eaaeb8e7373dd005f61b3e5c8b.zip
Relay groups with nothing said wait at the bottom of Messages, and a dead relay says so. "They shouldn't be at the top if they have 0 members and no recent activity" (Adam, 2026-11-12), of four groups.0xchat.com groups that came in on his kind 10009 list (saved by Armada) and topped the phone's list. A group's place was its last message, else the moment this device first recorded it, so a list adoption counted as brand new. Now a relay group without messages sorts by when it was joined on this device (joinedAt, set by Join) and one that only arrived on the list sorts last. groups.0xchat.com no longer resolves: a group whose relay can't be reached and that has shown nothing reads "<host> isn't answering" on its row ("On <host>" while it is still quiet) instead of "0 members"; the mark clears with the relay's first event and on every start. nip29-group-test joins gone.invalid'deadgroup on the second device and reads the row
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6UKGJyuGyZFxddYRxS
-rw-r--r--src/features/messages.js6
-rw-r--r--src/i18n.js2
-rw-r--r--src/nip29-client.js9
-rw-r--r--tools/nip29-group-test.js9
4 files changed, 23 insertions, 3 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 85b44f3..d5c7943 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -17651,7 +17651,10 @@ export function messagesFeature(ctx) {
// one list, newest first: a group chat is a conversation like any other
const chatRows = [
...dmRows.map((x) => ({ at: x.last.rumor.created_at, dm: x })),
- ...wnGroups().filter((g) => !g.fresh).map((g) => ({ at: (wnFold(g).rows.at(-1) || {}).rumor?.created_at || g.at, g })),
+ // a relay group with nothing said yet sorts by when it was joined
+ // HERE: one that only arrived on the 10009 list (joined elsewhere,
+ // maybe years ago, maybe on a relay long gone) waits at the bottom
+ ...wnGroups().filter((g) => !g.fresh).map((g) => ({ at: (wnFold(g).rows.at(-1) || {}).rumor?.created_at || (g.nip29 ? g.joinedAt || 0 : g.at), g })),
].sort((a, b) => b.at - a.at);
const shownDms = ui.msgAllDms ? chatRows : chatRows.slice(0, LIST_PREVIEW);
kids.push(
@@ -18118,6 +18121,7 @@ export function messagesFeature(ctx) {
h('div', { class: 'muted small chat-preview' },
g.removed ? t('msgGroupGone')
: last ? (last.mine ? t('msgYouPrefix') : displayName(last.rumor.pubkey) + ':') + ' ' + (last.rumor.content || '📎 ' + t('msgGroupAttachment'))
+ : g.nip29 && !wn.accounts(g).length ? t(g.unreachable ? 'msgNip29Unreachable' : 'msgNip29Waiting', { host: g.relay.replace(/^wss?:\/\//, '').replace(/\/$/, '') })
: t('msgMembers', { n: wn.accounts(g).length }))),
unread ? h('i', { class: 'thread-dot' }) : null);
}
diff --git a/src/i18n.js b/src/i18n.js
index 194ce40..dde69c9 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -797,6 +797,8 @@ const STR = {
msgNip29JoinRefused: 'The relay didn\'t let you in yet ({why}). You can still read the group if it\'s open.',
msgNip29Info: 'Relay group {relay} · not encrypted · the relay keeps the members',
msgNip29CopyAddress: 'Copy group address',
+ msgNip29Unreachable: "{host} isn't answering",
+ msgNip29Waiting: 'On {host}',
msgCordnInfo: 'Cordn group · end-to-end encrypted · delivered by a coordinator',
msgCordnNoFiles: 'Attachments are not available in Cordn groups yet',
msgRouteTitle: 'Send messages by',
diff --git a/src/nip29-client.js b/src/nip29-client.js
index 4c27b64..cfbd171 100644
--- a/src/nip29-client.js
+++ b/src/nip29-client.js
@@ -125,7 +125,7 @@ export function nip29Client({ scope, pubkey, identity, on, listRelays, store = m
function load() {
if (!loaded)
loaded = store.all().then((all) => {
- for (const [k, v] of all) if (k.startsWith('g:') && v && v.relay && v.gid) groups.set(v.id, { ...v, log: v.log || [] });
+ for (const [k, v] of all) if (k.startsWith('g:') && v && v.relay && v.gid) groups.set(v.id, { ...v, log: v.log || [], unreachable: '' });
const l = all.get('list');
if (l && Array.isArray(l.tags)) list = l;
}).catch((e) => dlog('nip29: store', e));
@@ -171,8 +171,12 @@ export function nip29Client({ scope, pubkey, identity, on, listRelays, store = m
function subscribe(g) {
if (subs.has(g.id) || stopped) return;
const since = g.seen ? g.seen - 60 : undefined;
+ // a relay that can't be reached (groups.0xchat.com stopped resolving)
+ // is said on the group's row instead of "0 members"
+ const reached = () => { if (g.unreachable) { g.unreachable = ''; touch(g); emit('loaded', g); } };
const a = subscribeAuthed([g.relay], { kinds: CHAT_KINDS, '#h': [g.gid], limit: 300, ...(since ? { since } : {}) },
- (ev) => { if (ev.tags.some((x) => x[0] === 'h' && x[1] === g.gid)) take(g, ev); }, { onauth });
+ (ev) => { reached(); if (ev.tags.some((x) => x[0] === 'h' && x[1] === g.gid)) take(g, ev); },
+ { onauth, onclose: (why) => { if (!g.log.length && !g.members.length) { g.unreachable = String((why && why[0]) || 'unreachable'); touch(g); emit('loaded', g); } } });
// metadata: only what the group's own relay signed (the copies other
// relays carry are read nowhere)
const b = subscribeAuthed([g.relay], { kinds: META_KINDS, '#d': [g.gid] },
@@ -238,6 +242,7 @@ export function nip29Client({ scope, pubkey, identity, on, listRelays, store = m
await load();
leftNow.delete(keyOf(a.relay, a.gid));
const g = record(a.relay, a.gid);
+ g.joinedAt = now(); // sorts a group joined here, still quiet, as new
touch(g);
subscribe(g);
emit('joined', g);
diff --git a/tools/nip29-group-test.js b/tools/nip29-group-test.js
index 9096a47..de9b8e1 100644
--- a/tools/nip29-group-test.js
+++ b/tools/nip29-group-test.js
@@ -122,6 +122,15 @@ try {
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);
+ // a group whose relay is gone says so on its row, not "0 members"
+ await p2.evaluate(() => [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase() === 'new group')?.click()); await sleep(300);
+ await p2.evaluate(() => [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase() === 'relay group')?.click()); await sleep(200);
+ await p2.type('.nip29-address', "gone.invalid'deadgroup");
+ await p2.evaluate(() => [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase() === 'join')?.click()); await sleep(1500);
+ await p2.evaluate(() => document.querySelector('.chat-head button')?.click()); await sleep(500);
+ let dead = '';
+ for (let i = 0; i < 60 && !/answering/.test(dead); i++) { dead = await p2.evaluate(() => [...document.querySelectorAll('.chat-thread-row')].find((r) => r.textContent.includes('deadgroup'))?.textContent || ''); if (!/answering/.test(dead)) await sleep(250); }
+ check("a group on a relay that's gone says so", /gone\.invalid isn't answering/.test(dead), dead.slice(0, 80));
await ctx.close();
}