summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-15 15:48:36 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-15 15:48:36 +0200
commit4066c754a613deb965472853fe69727d68be593e (patch)
treef93b7d7129576f7406a2c97ac7399a8d7e606897 /packages/meshbay-hub/src/meshbay_hub/static/app.js
parent8528bce49bb637f4e9fb653fff148b7ea45cfdac (diff)
downloadmeshbay-4066c754a613deb965472853fe69727d68be593e.tar.gz
fix(downloads): automatic really is automatic, and a selection downloads all of it
Two bugs in what shipped last, and both were mine. Automatic mode still opened Save As, because with no folder granted the code fell through to the file picker — while the documentation said it would use the browser's own download folder. It does that now. Over 512 MB it still asks, since getting there means holding the file in memory and a tab will not survive a 40 GB blob; Settings is where to stop it asking again. Selecting two files downloaded one. They were started without awaiting, so each asked the browser for a save dialog at once, and a browser allows exactly one — the rest were rejected and the errors went nowhere. They are awaited one at a time now, which serializes the dialogs and not the transfers: each call returns as soon as its transfer is registered. Then the adjustments. The transfers widget offers Open on a finished download that went into a granted folder — the bytes go to a new tab, and that is the whole of what a page can do: no browser lets one start a desktop application or show a file manager, so the folder half of that request cannot be built and the guide says so. The Files toolbar was four controls of three different heights in a row. It is three groups now — what you can add, where you are, what you can do with what is here — on one baseline, with icons from the set and a gap between the dots and the word Actions. Chat comes first among the tabs and is the one you land on. The three Discover entries in the sidebar have icons. And a link in a chat message becomes a link: built as an element and never as markup, http and https only, so `javascript:` is not one message away from running here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js169
1 files changed, 120 insertions, 49 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index b84b7b1..c8fb694 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -261,6 +261,13 @@ const ICON_PATHS = {
upload: ['M12 20.5v-12', 'M7.5 13l4.5-4.5 4.5 4.5', 'M4.5 4h15'],
transfer: ['M6.5 3.5v11', 'M3.5 11l3 3.5 3-3.5',
'M17.5 20.5v-11', 'M14.5 13l3-3.5 3 3.5'],
+ search: ['M11 4.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13', 'M15.8 15.8L20.5 20.5'],
+ dots: ['M12 5.6h.01', 'M12 12h.01', 'M12 18.4h.01'],
+ checkbox: ['M5.5 4h13a1.5 1.5 0 0 1 1.5 1.5v13a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5v-13A1.5 1.5 0 0 1 5.5 4z'],
+ home: ['M4 11.2L12 4.5l8 6.7', 'M6.2 9.8V19a1 1 0 0 0 1 1h9.6a1 1 0 0 0 1-1V9.8'],
+ 'folder-plus': ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z',
+ 'M12 11.4v5', 'M9.5 13.9h5'],
+ plus: ['M12 5v14', 'M5 12h14'],
clip: ['M20.5 11.8l-8.4 8.4a5.4 5.4 0 0 1-7.6-7.6l8.8-8.8a3.6 3.6 0 0 1 5.1 5.1l-8.8 8.8a1.8 1.8 0 0 1-2.5-2.5l8.1-8.1'],
pencil: ['M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3',
'M14.5 6.5l3 3'],
@@ -418,6 +425,11 @@ function TransferWidget() {
: it.status === 'cancelled' ? t('transfers.cancelled')
: it.error || t('transfers.failed')}
</span>
+ ${it.canOpen && html`
+ <button class="link-btn" onClick=${() => transfers.open(it.id)}>
+ ${t('transfers.open')}
+ </button>
+ `}
</div>
`}
</div>
@@ -475,11 +487,11 @@ function Sidebar({ groups, route, menuOpen, role }) {
<div class="sidebar-section">
<div class="sidebar-heading">${t('sidebar.discover')}</div>
<a class="sidebar-item ${route === '/explore' ? 'active' : ''}"
- href="#/explore">${t('sidebar.public_groups')}</a>
+ href="#/explore"><${Icon} name="globe" /> ${t('sidebar.public_groups')}</a>
<a class="sidebar-item ${route === '/search' ? 'active' : ''}"
- href="#/search">${t('sidebar.search')}</a>
+ href="#/search"><${Icon} name="search" /> ${t('sidebar.search')}</a>
<a class="sidebar-item ${route === '/create-group' ? 'active' : ''}"
- href="#/create-group">${t('sidebar.create_group')}</a>
+ href="#/create-group"><${Icon} name="plus" /> ${t('sidebar.create_group')}</a>
</div>
<div class="sidebar-section">
<div class="sidebar-heading">${t('sidebar.my_groups')}</div>
@@ -938,13 +950,21 @@ const PIPELINE_WINDOW = 8;
* it and hand the browser a blob", or false for "the person dismissed the
* dialog", which is not an error and must not start a transfer.
*/
-async function _openDownloadTarget(filename, pickerOpts = {}) {
+async function _openDownloadTarget(filename, size = 0, pickerOpts = {}) {
try {
const target = await downloads.openTarget(filename);
if (target) return target;
} catch (err) {
console.warn('[MeshBay] download folder unusable:', err.message);
}
+
+ // No granted folder. Saving automatically means not putting a dialog in the
+ // way, so anything that fits in memory goes to the browser's own download
+ // folder — which is what "automatic" meant to whoever chose the setting.
+ // Past that a blob would take the tab down with it, and one dialog is the
+ // lesser evil; Settings is where to stop it happening again.
+ if (downloads.getMode() === 'auto' && size < downloads.BLOB_LIMIT) return null;
+
if (!window.showSaveFilePicker) return null;
try {
const handle = await window.showSaveFilePicker({
@@ -1030,7 +1050,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const [currentPath, setCurrentPath] = useState('');
const [videoEntry, setVideoEntry] = useState(null);
const [previewEntry, setPreviewEntry] = useState(null);
- const [tab, setTab] = useState('files');
+ const [tab, setTab] = useState('chat');
// Directories are not index entries, so a new empty one needs a nudge
// to appear in the breadcrumb listing.
const [nodeDirs, setNodeDirs] = useState([]);
@@ -1201,12 +1221,12 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
// Both of these have to happen inside the click: a browser grants a file
// picker, and re-grants a folder, only from a user gesture.
- const target = await _openDownloadTarget(entry.name);
+ const target = await _openDownloadTarget(entry.name, entry.size);
if (target === false) return; // the picker was dismissed
transfers.start({
kind: 'download', name: (target && target.name) || entry.name,
- total: entry.size, transport,
+ total: entry.size, transport, open: target && target.open,
run: async ({ signal, onProgress }) => {
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
let done = 0;
@@ -1311,7 +1331,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0);
const suggested = (dir.split('/').pop() || 'files') + '.zip';
- const target = await _openDownloadTarget(suggested, {
+ const target = await _openDownloadTarget(suggested, totalBytes, {
types: [{ description: 'ZIP archive',
accept: { 'application/zip': ['.zip'] } }],
});
@@ -1325,7 +1345,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
transfers.start({
kind: 'download', name: (target && target.name) || suggested,
- total: totalBytes, transport,
+ total: totalBytes, transport, open: target && target.open,
run: async ({ signal, onProgress }) => {
const writable = target ? target.writable : null;
const parts = writable ? null : [];
@@ -1475,8 +1495,14 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
? selectedFiles[0] : null;
const deletableFiles = selectedFiles.filter(
e => isNodeAdmin || (userId && e.uploader_id === userId));
- const run = (fn) => { setActionsOpen(false); setSelecting(false);
- setSelected(new Set()); fn(); };
+ const run = (fn) => {
+ setActionsOpen(false);
+ setSelecting(false);
+ setSelected(new Set());
+ Promise.resolve().then(fn).catch(err => {
+ if (err && err.name !== 'AbortError') setError(err.message);
+ });
+ };
const actionItems = html`
${onlyFile && onlyFile.type === 'video' && html`
@@ -1490,18 +1516,21 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
</button>
`}
${selectedFiles.length > 0 && html`
- <button onClick=${() => run(() => {
- // Started together and left to run together: each is its own transfer,
- // and the widget by the bell is where they are watched from now.
- for (const e of selectedFiles) downloadFile(e);
+ <button onClick=${() => run(async () => {
+ // Awaited one at a time, and each returns as soon as its transfer is
+ // registered — so the transfers still run together. Firing them without
+ // awaiting meant every file asked the browser for a save dialog at
+ // once, and a browser allows one: the rest were rejected and only the
+ // first file ever downloaded.
+ for (const e of selectedFiles) await downloadFile(e);
})}>
<span class="fmi">${'\u{2B07}'}</span>
${t('group.download_n', { n: selectedFiles.length })}
</button>
`}
${selectedDirs.length > 0 && html`
- <button onClick=${() => run(() => {
- for (const d of selectedDirs) downloadDirectory(d);
+ <button onClick=${() => run(async () => {
+ for (const d of selectedDirs) await downloadDirectory(d);
})}>
<span class="fmi">${'\u{2B07}'}</span>
${t('group.download_zip_n', { n: selectedDirs.length })}
@@ -1594,25 +1623,31 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
`}
${status === 'connected' && html`
<div class="group-tabs">
- <button class="group-tab ${tab === 'files' ? 'active' : ''}"
- onClick=${() => setTab('files')}>${t('group.tab_files')}</button>
<button class="group-tab ${tab === 'chat' ? 'active' : ''}"
onClick=${() => setTab('chat')}>${t('group.tab_chat')}</button>
+ <button class="group-tab ${tab === 'files' ? 'active' : ''}"
+ onClick=${() => setTab('files')}>${t('group.tab_files')}</button>
<button class="group-tab ${tab === 'members' ? 'active' : ''}"
onClick=${() => setTab('members')}>${t('group.tab_members')}</button>
</div>
${tab === 'files' && html`
<div class="file-toolbar">
- <label class="admin-btn upload-btn" style="cursor:pointer;margin-right:8px">
- ${t('group.upload')}
- <input type="file" multiple style="display:none"
- onChange=${uploadFile} />
- </label>
- <button class="admin-btn" style="margin-right:8px"
- onClick=${makeDirectory}>${t('group.mkdir')}</button>
+ <div class="toolbar-group">
+ <label class="tb-btn primary">
+ <${Icon} name="upload" /> ${t('group.upload')}
+ <input type="file" multiple style="display:none"
+ onChange=${uploadFile} />
+ </label>
+ <button class="tb-btn" onClick=${makeDirectory}>
+ <${Icon} name="folder-plus" /> ${t('group.mkdir')}
+ </button>
+ </div>
+
<div class="breadcrumbs">
- <a class="crumb" onClick=${() => setCurrentPath('')}>/</a>
+ <a class="crumb" onClick=${() => setCurrentPath('')}>
+ <${Icon} name="home" />
+ </a>
${breadcrumbs.map((seg, i) => {
const path = breadcrumbs.slice(0, i + 1).join('/');
return html`
@@ -1621,28 +1656,33 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
`;
})}
</div>
- <input type="text" class="file-search" placeholder="${t('group.filter')}"
- value=${filter} onInput=${e => setFilter(e.target.value)} />
- <button class="admin-btn ${selecting ? 'active' : ''}"
- style="margin-left:8px"
- onClick=${() => {
- setSelecting(v => !v);
- setSelected(new Set());
- setActionsOpen(false);
- }}>
- ${selecting ? t('group.select_done') : t('group.select')}
- </button>
- ${selecting && html`
- <div class="actions-wrap">
- <button class="admin-btn" disabled=${selected.size === 0}
- onClick=${(ev) => { ev.stopPropagation(); setActionsOpen(o => !o); }}>
- ${'\u{22EE}'} ${t('group.actions', { n: selected.size })}
- </button>
- ${actionsOpen && html`
- <div class="file-menu">${actionItems}</div>
- `}
+
+ <div class="toolbar-group right">
+ <div class="tb-search">
+ <${Icon} name="search" />
+ <input type="text" placeholder="${t('group.filter')}"
+ value=${filter} onInput=${e => setFilter(e.target.value)} />
</div>
- `}
+ <button class="tb-btn ${selecting ? 'active' : ''}"
+ onClick=${() => {
+ setSelecting(v => !v);
+ setSelected(new Set());
+ setActionsOpen(false);
+ }}>
+ <${Icon} name=${selecting ? 'check' : 'checkbox'} />
+ ${selecting ? t('group.select_done') : t('group.select')}
+ </button>
+ ${selecting && html`
+ <div class="actions-wrap">
+ <button class="tb-btn" disabled=${selected.size === 0}
+ onClick=${(ev) => { ev.stopPropagation(); setActionsOpen(o => !o); }}>
+ <${Icon} name="dots" />
+ ${t('group.actions', { n: selected.size })}
+ </button>
+ ${actionsOpen && html`<div class="file-menu">${actionItems}</div>`}
+ </div>
+ `}
+ </div>
</div>
<table class="file-table">
<thead>
@@ -2067,6 +2107,34 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef,
// ── Chat Panel ──────────────────────────────────────────────────────────
+/**
+ * Message text with its links made clickable.
+ *
+ * Only http and https, and built as elements rather than markup: a message is
+ * something another member wrote, so it must never become HTML. `javascript:`
+ * and `data:` are not matched at all, and the anchors carry noopener so the new
+ * tab cannot reach back into this one.
+ */
+const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi;
+
+function linkify(text) {
+ const out = [];
+ let last = 0;
+ for (const m of String(text).matchAll(URL_RE)) {
+ if (m.index > last) out.push(text.slice(last, m.index));
+ // Trailing punctuation is almost never part of the address.
+ let url = m[0];
+ let tail = '';
+ while (/[.,;:!?)\]]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); }
+ out.push(html`<a href=${url} target="_blank" rel="noopener noreferrer"
+ class="chat-link">${url}</a>`);
+ if (tail) out.push(tail);
+ last = m.index + m[0].length;
+ }
+ if (last < text.length) out.push(text.slice(last));
+ return out;
+}
+
function formatTime(ts) {
const d = new Date(ts * 1000);
const now = new Date();
@@ -2258,7 +2326,10 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on
<div class="chat-att-size">${formatSize(att.size)}</div>
</div>
` : html`
- <span class="chat-text">${m.payload}</span>
+ <span class="chat-text">
+ ${linkify(parsed && typeof parsed.text === 'string'
+ ? parsed.text : m.payload)}
+ </span>
`}
<span class="chat-time">${formatTime(m.timestamp)}</span>
</div>