From 63b21d98e1649094802173e19f73a211f2f8c07c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 14 Sep 2026 21:39:07 -0700 Subject: Join suggests communities from the Vector Hub, searchable, beside the paste box. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Join panel used to be one empty box asking for a link nobody had. It now lists the Vector Hub's public communities under it — icon, name, member count, category, description, sorted by size, marked when you're already in one — fetched on demand and kept for the session. The same box takes a link or filters the list; a tapped suggestion previews exactly like a pasted invite, since every listing is a CORD-05 link. Co-Authored-By: Claude Fable 5.1 --- src/features/messages.js | 67 +++++++++++++++++++++++++++++++++++++++++++----- src/i18n.js | 7 ++++- src/style.css | 2 ++ 3 files changed, 68 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index 3b3857d..efddcc9 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1307,6 +1307,47 @@ export function messagesFeature(ctx) { scheduleRepaint(); } + // ---- community discovery: the Vector Hub's public listing as suggestions + // under Join. Fetched on demand (it's a megabyte, mostly icons) 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. + const HUB_URL = 'https://vectorapp.io/api/hub'; + const hub = { state: 'idle', list: [] }; + async function loadHub() { + if (hub.state === 'loading' || hub.state === 'ready') return; + hub.state = 'loading'; scheduleRepaint(); + 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(); + } + function hubRows() { + const q = (ui.msgJoinText || '').trim().toLowerCase(); + const have = new Set(communities().map((jm) => (rooms.get(jm.community_id)?.folded?.metadata?.name || jm.name || '').toLowerCase())); + const rows = hub.list.filter((c) => !q || [c.name, c.description, c.category].some((x) => x.toLowerCase().includes(q))); + return rows.map((c) => h('div', { + class: 'item chat-thread-row clickable', onClick: () => joinFromText(c.invite), + }, + c.icon ? h('img', { class: 'chat-avatar hub-icon', src: c.icon, alt: '' }) : h('div', { class: 'chat-avatar fallback' }, c.name.slice(0, 2)), + h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'chat-name', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, c.name), + have.has(c.name.toLowerCase()) ? h('span', { class: 'tag conf' }, t('msgJoined')) : null), + h('div', { class: 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, + (c.members ? t('msgMembers', { n: c.members }) + ' · ' : '') + c.category + (c.description ? ' · ' + c.description : ''))))); + } + function joinFromText(text) { const parsed = parseInviteLink(text); if (!parsed) { toast(t('msgBadInvite')); return; } @@ -5312,13 +5353,25 @@ export function messagesFeature(ctx) { h('button', { class: 'btn-sm', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'join' ? null : 'join'; render(); } }, t('msgJoin')), h('button', { class: 'btn-sm', onClick: () => { ui.msgHomePanel = ui.msgHomePanel === 'create' ? null : 'create'; render(); } }, t('msgCreate'))))); if (pendingLink && pendingLink.where === 'communities') kids.push(linkInviteCard()); - if (ui.msgHomePanel === 'join') - kids.push(h('div', { class: 'row gap6' }, - h('input', { - class: 'grow', type: 'text', placeholder: t('msgInvitePlaceholder'), - value: ui.msgJoinText || '', onInput: (e) => { ui.msgJoinText = e.target.value; }, - }), - h('button', { class: 'btn-sm', onClick: () => joinFromText(ui.msgJoinText) }, t('msgJoin')))); + if (ui.msgHomePanel === 'join') { + loadHub().catch(() => {}); + const isLink = !!parseInviteLink(ui.msgJoinText || ''); + kids.push(h('div', { class: 'col', style: 'gap:8px' }, + h('div', { class: 'row gap6' }, + h('input', { + class: 'grow', type: 'text', placeholder: t('msgInvitePlaceholder'), + value: ui.msgJoinText || '', onInput: (e) => { ui.msgJoinText = e.target.value; render(); }, + onKeydown: (e) => { if (e.key === 'Enter' && isLink) joinFromText(ui.msgJoinText); }, + }), + isLink ? h('button', { class: 'btn-sm btn-primary', onClick: () => joinFromText(ui.msgJoinText) }, t('msgJoin')) : null), + isLink ? null : h('div', { class: 'row between', style: 'align-items:baseline' }, + h('span', { class: 'small muted' }, t('msgDiscoverTitle')), + h('a', { class: 'small muted', href: 'https://vectorapp.io/hub/', target: '_blank', rel: 'noopener noreferrer' }, t('msgHubCredit'))), + isLink ? null + : hub.state === 'error' ? h('div', { class: 'small muted' }, t('msgHubFailed')) + : 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 hub-list' }, hubRows()))); + } if (ui.msgHomePanel === 'create') kids.push(h('div', { class: 'row gap6' }, h('input', { diff --git a/src/i18n.js b/src/i18n.js index 6712c4b..6cb847b 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -388,7 +388,12 @@ const STR = { msgSignerNoDm: 'This signer cannot encrypt DMs — log in again', msgJoin: 'Join', msgCreate: 'Create', - msgInvitePlaceholder: 'Paste an invite link', + msgInvitePlaceholder: 'Paste an invite link, or search', + msgDiscoverTitle: 'Suggested communities', + msgHubCredit: 'From the Vector Hub', + msgHubLoading: 'Finding communities…', + msgHubFailed: 'Could not load suggestions right now.', + msgJoined: 'joined', msgNamePlaceholder: 'Community name', msgBadInvite: 'Not a valid invite link', msgInviteLoading: 'Fetching invite…', diff --git a/src/style.css b/src/style.css index a5af604..4bb7c5b 100644 --- a/src/style.css +++ b/src/style.css @@ -1012,3 +1012,5 @@ body::after { .emoji-grid { display: flex; flex-wrap: wrap; gap: 2px; max-height: 260px; overflow-y: auto; } .emoji-grid button { width: 40px; height: 40px; font-size: 22px; line-height: 1; border: 1px solid transparent; border-radius: 8px; background: none; padding: 0; } .emoji-grid button.on { border-color: var(--accent); } +.hub-list { max-height: 360px; overflow-y: auto; } +.hub-icon { object-fit: cover; } -- cgit v1.2.3