diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 169 |
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> |