aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-14 21:39:07 -0700
committerAdam Soltys <[email protected]>2026-09-14 21:39:07 -0700
commit63b21d98e1649094802173e19f73a211f2f8c07c (patch)
tree8f9fe321afa9148cc230c482a68b0818feb360bb /src
parent20673a4a7ae83f04ad843529c0c1ff1011dee93c (diff)
downloadcoinosv3-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]>
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js67
-rw-r--r--src/i18n.js7
-rw-r--r--src/style.css2
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; }