diff options
Diffstat (limited to 'packages/meshbay-hub/src')
5 files changed, 239 insertions, 63 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> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index 9c6ced1..a71f289 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -133,11 +133,11 @@ export async function freeName(name, exists) { } /** - * Where this download should be written. + * Where this download should be written, if a folder has been granted. * - * Returns a writable stream, or null meaning "there is nowhere to stream to — - * collect it and hand the browser a blob". The caller opens the picker itself - * in "ask" mode, because that one has to happen inside the click. + * Returns `{writable, name, open}` or null. Null does not mean failure: it + * means there is no granted folder, and the caller decides between handing the + * browser a blob and asking for a Save As. */ export async function openTarget(filename) { if (!SUPPORTED || getMode() === 'ask') return null; @@ -155,5 +155,24 @@ export async function openTarget(filename) { }; const name = await freeName(filename, exists); const handle = await dir.getFileHandle(name, { create: true }); - return { writable: await handle.createWritable(), name }; + return { + writable: await handle.createWritable(), + name, + // Reading it back is the only way a page can "open" a file it wrote: hand + // the bytes to a tab and let the browser decide what to do with them. No + // web page can start a desktop application, or show a file manager. + open: async () => { + const file = await handle.getFile(); + const url = URL.createObjectURL(file); + window.open(url, '_blank', 'noopener'); + setTimeout(() => URL.revokeObjectURL(url), 60000); + }, + }; } + +/** + * Below this, a download with no granted folder is collected in memory and + * handed to the browser, which saves it without asking. Above it that would + * mean holding gigabytes in a tab, so it is worth one Save As dialog instead. + */ +export const BLOB_LIMIT = 512 * 1024 * 1024; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index 25fbd77..a2d13c5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -298,6 +298,7 @@ const en = { 'transfers.title': 'Transfers', 'transfers.clear': 'Clear finished', 'transfers.cancel': 'Cancel', + 'transfers.open': 'Open', 'transfers.done': 'Finished', 'transfers.cancelled': 'Cancelled', 'transfers.failed': 'Failed', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 2296b2d..851c07e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -546,14 +546,77 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } /* ── File toolbar ────────────────────────────────────────────────────────── */ +/* One bar, three groups: what you can add on the left, where you are in the + middle, what you can do with what is here on the right. It used to be four + controls of three different heights sitting in a row. */ .file-toolbar { display: flex; align-items: center; - justify-content: space-between; - gap: 12px; + gap: 10px; margin-bottom: 12px; + padding: 8px; + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: 8px; flex-wrap: wrap; } +.toolbar-group { display: flex; align-items: center; gap: 6px; } +.toolbar-group.right { margin-left: auto; } + +.tb-btn { + display: inline-flex; + align-items: center; + gap: 6px; /* the icon and the word are not the same word */ + height: 32px; + padding: 0 11px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-surface); + color: var(--text); + font-size: 0.83em; + white-space: nowrap; + cursor: pointer; + transition: border-color 0.12s, color 0.12s, background 0.12s; +} +.tb-btn:hover { border-color: var(--accent); color: var(--accent); } +.tb-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.tb-btn:disabled:hover { border-color: var(--border); color: var(--text); } +.tb-btn .icon { width: 15px; height: 15px; } +.tb-btn.primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-text); +} +.tb-btn.primary:hover { background: var(--accent-hover); color: var(--accent-text); } +.tb-btn.active { + background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface)); + border-color: var(--accent); + color: var(--accent); +} + +/* The filter is a field, not a button, and should not pretend otherwise. */ +.tb-search { + display: flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-surface); + color: var(--text-dim); +} +.tb-search:focus-within { border-color: var(--border-focus); } +.tb-search .icon { width: 14px; height: 14px; flex-shrink: 0; } +.tb-search input { + border: none; + background: none; + outline: none; + padding: 0; + width: 150px; + font-size: 0.83em; + color: var(--text); +} .breadcrumbs { display: flex; @@ -564,6 +627,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } flex-wrap: wrap; } .crumb { + display: inline-flex; + align-items: center; cursor: pointer; color: var(--accent); padding: 2px 4px; @@ -572,11 +637,6 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .crumb:hover { background: var(--bg-raised); text-decoration: none; } .crumb-sep { color: var(--text-dim); } -.file-search { - max-width: 220px; - padding: 6px 10px !important; - font-size: 0.85em !important; -} /* ── File table ──────────────────────────────────────────────────────────── */ @@ -1190,6 +1250,15 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .settings-choice strong { display: block; font-size: 0.88em; color: var(--text); } .settings-choice .settings-hint { display: block; margin-top: 2px; } +/* A pasted address can be longer than the bubble; breaking it is better than + a message that scrolls sideways. */ +.chat-link { + color: var(--accent); + text-decoration: underline; + word-break: break-all; +} +.chat-bubble-own .chat-link { color: inherit; } + /* ── Transfers widget ────────────────────────────────────────────────────── */ .transfer-wrap { position: relative; display: flex; align-items: center; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js index 1ad1ec5..fa34c67 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js @@ -56,6 +56,9 @@ export class TransferStore { error: it.error || '', speed: this._speed(it), percent: it.total ? Math.min(100, Math.round(it.done / it.total * 100)) : 0, + // Only for a file written into a folder the browser granted us: that is + // the one case where the page can read its own download back. + canOpen: it.status === 'done' && typeof it.open === 'function', })); } @@ -79,10 +82,10 @@ export class TransferStore { * `run` receives `{ signal, onProgress }`. It must poll `signal.aborted` — a * cancel that only sets a flag nobody reads is a button that lies. */ - start({ kind, name, total = 0, transport = null, run }) { + start({ kind, name, total = 0, transport = null, run, open = null }) { const item = { id: _nextId++, - kind, name, total, transport, + kind, name, total, transport, open, done: 0, status: 'running', error: '', @@ -128,6 +131,19 @@ export class TransferStore { return item.id; } + /** + * Hand a finished download to the browser to display. + * + * As close to "open it" as a web page gets: the bytes go to a new tab and the + * browser decides what to do with them. A page cannot start a desktop + * application, and cannot show a file manager — there is no API for either, + * in any browser, by design. + */ + open(id) { + const item = this._items.find(it => it.id === id); + if (item && typeof item.open === 'function') return item.open(); + } + cancel(id) { const item = this._items.find(it => it.id === id); if (!item || item.status !== 'running') return; |