diff options
| author | Adam Soltys <[email protected]> | 2026-09-14 21:39:07 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-14 21:39:07 -0700 |
| commit | 63b21d98e1649094802173e19f73a211f2f8c07c (patch) | |
| tree | 8f9fe321afa9148cc230c482a68b0818feb360bb | |
| parent | 20673a4a7ae83f04ad843529c0c1ff1011dee93c (diff) | |
| download | coinosv3-63b21d98e1649094802173e19f73a211f2f8c07c.tar.gz coinosv3-63b21d98e1649094802173e19f73a211f2f8c07c.zip | |
Join suggests communities from the Vector Hub, searchable, beside the paste box.
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 <[email protected]>
| -rw-r--r-- | src/features/messages.js | 67 | ||||
| -rw-r--r-- | src/i18n.js | 7 | ||||
| -rw-r--r-- | src/style.css | 2 |
3 files changed, 68 insertions, 8 deletions
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; } |
