From 609e5d77e7634eb2973b73a677c08f9f2b695951 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 5 Oct 2026 00:19:49 -0700 Subject: Show cached communities immediately and simplify discovery --- src/features/messages.js | 67 +++++++++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 26 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index a9b21b0..8001d39 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -50,6 +50,7 @@ import { qrSvg } from '../qr.js'; // The coinos community's join material lives in ../community.js — shared // with the public read-only chat page so the two can never drift. import { COMMUNITY, EPOCH } from '../community.js'; +import COMMUNITY_DIRECTORY from '../community-directory.json'; const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol']; const NOTIFIER = 'https://nwcpush.coinos.io'; const APP_BASE = 'https://v3.coinos.io'; @@ -1857,30 +1858,50 @@ export function messagesFeature(ctx) { scheduleRepaint(); } - // ---- community discovery: the Vector Hub's public listing as suggestions - // on Messages and under Browse. Fetched when Messages opens and kept - // for the session; every listing is a CORD-05 link, which is exactly what - // the paste box takes, so a tap previews it like a pasted invite. + // Public suggestions render immediately, including on a first/offline visit. + // The bundled snapshot is a fallback; the device cache refreshes once a day. + // Invite previews still check revocation and expiration on the relays. const HUB_URL = 'https://vectorapp.io/api/hub'; - const hub = { state: 'idle', list: [] }; + const HUB_CACHE_KEY = 'coinos-community-directory-v1'; + const HUB_REFRESH_MS = 86400_000; + function normalizeHub(list) { + return list.filter((c) => c && c.name && parseInviteLink(c.invite_url || '')) + .map((c) => ({ + name: String(c.name).slice(0, 64), description: String(c.description || '').slice(0, 200), + category: String(c.category || '').slice(0, 64), invite: c.invite_url, + members: Math.max(0, Number(c.member_count) || 0), + icon: /^data:image\//.test(c.icon_url || '') && c.icon_url.length <= 32_768 ? c.icon_url : null, + })) + .sort((a, b) => b.members - a.members); + } + const hub = { list: normalizeHub(COMMUNITY_DIRECTORY), at: 0, attempted: false, loading: false }; + try { + const cached = JSON.parse(localStorage.getItem(HUB_CACHE_KEY) || 'null'); + if (cached && Array.isArray(cached.list) && Number.isFinite(cached.at)) { + const list = normalizeHub(cached.list); + if (list.length || !cached.list.length) { hub.list = list; hub.at = cached.at; } + } + } catch {} async function loadHub() { - if (hub.state !== 'idle') return; - hub.state = 'loading'; scheduleRepaint(); + if (hub.loading || hub.attempted || (hub.at > 0 && Date.now() - hub.at < HUB_REFRESH_MS)) return; + hub.attempted = true; hub.loading = true; try { const res = await fetch(HUB_URL, { signal: AbortSignal.timeout(20_000) }); if (!res.ok) throw new Error('http ' + res.status); - const list = await res.json(); - hub.list = (Array.isArray(list) ? list : []) - .filter((c) => c && c.name && parseInviteLink(c.invite_url || '')) - .map((c) => ({ - name: String(c.name).slice(0, 64), description: String(c.description || '').slice(0, 200), - category: String(c.category || ''), invite: c.invite_url, members: Number(c.member_count) || 0, - icon: /^data:image\//.test(c.icon_url || '') ? c.icon_url : null, official: !!c.official, - })) - .sort((a, b) => b.members - a.members); - hub.state = 'ready'; - } catch { hub.state = 'error'; } - scheduleRepaint(); + const raw = await res.json(); + if (!Array.isArray(raw)) throw new Error('invalid directory'); + const list = normalizeHub(raw); + if (raw.length && !list.length) throw new Error('invalid invites'); + hub.list = list; hub.at = Date.now(); + try { + // Bound icon sizes so public suggestions do not crowd out wallet data. + const cached = list.map((c) => ({ name: c.name, description: c.description, category: c.category, + invite_url: c.invite, member_count: c.members, icon_url: c.icon })); + localStorage.setItem(HUB_CACHE_KEY, JSON.stringify({ at: hub.at, list: cached })); + } catch {} + scheduleRepaint(); + } catch { /* Keep the usable directory if refresh fails or we're offline. */ } + finally { hub.loading = false; } } function hubRows({ preview = false } = {}) { const q = preview ? '' : (ui.msgJoinText || '').trim().toLowerCase(); @@ -1908,13 +1929,7 @@ export function messagesFeature(ctx) { h('div', { class: 'row between', style: 'align-items:baseline' }, h('h3', { style: 'margin:0' }, t(preview ? 'msgPopularCommunities' : 'msgDiscoverTitle')), preview ? h('button', { class: 'btn-sm', onClick: (e) => { e.stopPropagation(); ui.msgJoinText = ''; ui.msgHomePanel = 'join'; render(); } }, t('msgBrowseCommunities')) : null), - h('a', { class: 'small muted', href: 'https://vectorapp.io/hub/', target: '_blank', rel: 'noopener noreferrer' }, t('msgHubCredit')), - hub.state === 'error' ? h('div', { class: 'row gap6', style: 'align-items:center' }, - h('span', { class: 'small muted grow' }, t('msgHubFailed')), - h('button', { class: 'btn-sm', onClick: (e) => { e.stopPropagation(); hub.state = 'idle'; loadHub().catch(() => {}); } }, t('retry'))) - : hub.state !== 'ready' ? h('div', { class: 'row gap6', style: 'align-items:center;padding:6px 0' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('msgHubLoading'))) - : h('div', { class: 'list' + (preview ? '' : ' hub-list') }, hubRows({ preview })), - h('div', { class: 'small muted' }, t('msgWhiteNoiseDiscovery'))); + h('div', { class: 'list' + (preview ? '' : ' hub-list') }, hubRows({ preview }))); } function joinFromText(text) { -- cgit v1.2.3