aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js169
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css83
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transfers.js20
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;