aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
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>