summaryrefslogtreecommitdiffstats
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.js134
1 files changed, 102 insertions, 32 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 03b76d5..b84b7b1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -5,6 +5,7 @@ import {
import { t, getLocale, setLocale, LOCALES } from './i18n.js';
import { ZipStream, entriesUnder } from './zipstream.js';
import { transfers, formatSpeed } from './transfers.js';
+import * as downloads from './downloads.js';
// ── Constants ────────────────────────────────────────────────────────────────
@@ -930,6 +931,32 @@ function canPreview(e) {
const CHUNK_SIZE = 1024 * 1024;
const PIPELINE_WINDOW = 8;
+/**
+ * Open somewhere to write, honouring the user's download setting.
+ *
+ * Returns a target ({writable, name}), null for "no stream available — collect
+ * 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 = {}) {
+ try {
+ const target = await downloads.openTarget(filename);
+ if (target) return target;
+ } catch (err) {
+ console.warn('[MeshBay] download folder unusable:', err.message);
+ }
+ if (!window.showSaveFilePicker) return null;
+ try {
+ const handle = await window.showSaveFilePicker({
+ suggestedName: filename, ...pickerOpts,
+ });
+ return { writable: await handle.createWritable(), name: handle.name || filename };
+ } catch (err) {
+ if (err.name === 'AbortError') return false;
+ throw err;
+ }
+}
+
/** The download of last resort, for browsers with no way to stream to disk. */
function _saveBlob(blob, filename) {
const url = URL.createObjectURL(blob);
@@ -1172,33 +1199,26 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
if (!transport || !transport.connected) return;
const gek = gekRef.current;
- // The file picker has to be opened here, in the click, before anything is
- // handed to the store: a browser only grants one from a user gesture.
- let handle = null;
- if (window.showSaveFilePicker) {
- try {
- handle = await window.showSaveFilePicker({ suggestedName: entry.name });
- } catch (err) {
- if (err.name === 'AbortError') return;
- throw err;
- }
- }
+ // 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);
+ if (target === false) return; // the picker was dismissed
transfers.start({
- kind: 'download', name: entry.name, total: entry.size, transport,
+ kind: 'download', name: (target && target.name) || entry.name,
+ total: entry.size, transport,
run: async ({ signal, onProgress }) => {
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
let done = 0;
const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); };
- if (handle) {
- const writable = await handle.createWritable();
+ if (target) {
try {
await pipelinedDownload(transport, gek, entry.id, totalChunks,
- onChunk, writable, signal);
- await writable.close();
+ onChunk, target.writable, signal);
+ await target.writable.close();
} catch (err) {
- await writable.abort().catch(() => {});
+ await target.writable.abort().catch(() => {});
throw err;
}
} else {
@@ -1291,19 +1311,12 @@ 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';
- let handle = null;
- if (window.showSaveFilePicker) {
- try {
- handle = await window.showSaveFilePicker({
- suggestedName: suggested,
- types: [{ description: 'ZIP archive',
- accept: { 'application/zip': ['.zip'] } }],
- });
- } catch (err) {
- if (err.name === 'AbortError') return;
- throw err;
- }
- } else if (!confirm(t('group.zip_no_stream', {
+ const target = await _openDownloadTarget(suggested, {
+ types: [{ description: 'ZIP archive',
+ accept: { 'application/zip': ['.zip'] } }],
+ });
+ if (target === false) return;
+ if (!target && !confirm(t('group.zip_no_stream', {
size: formatSize(totalBytes), name: suggested,
}))) {
return;
@@ -1311,9 +1324,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const gek = gekRef.current;
transfers.start({
- kind: 'download', name: suggested, total: totalBytes, transport,
+ kind: 'download', name: (target && target.name) || suggested,
+ total: totalBytes, transport,
run: async ({ signal, onProgress }) => {
- const writable = handle ? await handle.createWritable() : null;
+ const writable = target ? target.writable : null;
const parts = writable ? null : [];
let written = 0;
try {
@@ -2651,6 +2665,20 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) {
}
}, [muted, user.token]);
+ const [dlMode, setDlMode] = useState(() => downloads.getMode());
+ const [dlDir, setDlDir] = useState(null);
+
+ useEffect(() => { downloads.savedDirectory().then(setDlDir); }, []);
+
+ const pickFolder = useCallback(async () => {
+ try {
+ const handle = await downloads.chooseDirectory();
+ setDlDir(handle);
+ } catch (err) {
+ if (err.name !== 'AbortError') setNodeKeyStatus(err.message);
+ }
+ }, []);
+
const submitNodeKey = useCallback(async () => {
const key = nodeKey.trim();
if (!key) return;
@@ -2676,6 +2704,48 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) {
<h2>${t('settings.title')}</h2>
<div class="settings-section">
+ <h3 class="settings-heading">${t('settings.downloads')}</h3>
+ ${!downloads.SUPPORTED
+ ? html`<p class="settings-hint">${t('settings.dl_unsupported')}</p>`
+ : html`
+ <label class="settings-choice">
+ <input type="radio" name="dlmode" checked=${dlMode === 'auto'}
+ onChange=${() => { downloads.setMode('auto'); setDlMode('auto'); }} />
+ <span>
+ <strong>${t('settings.dl_auto')}</strong>
+ <span class="settings-hint">${t('settings.dl_auto_hint')}</span>
+ </span>
+ </label>
+ <label class="settings-choice">
+ <input type="radio" name="dlmode" checked=${dlMode === 'ask'}
+ onChange=${() => { downloads.setMode('ask'); setDlMode('ask'); }} />
+ <span>
+ <strong>${t('settings.dl_ask')}</strong>
+ <span class="settings-hint">${t('settings.dl_ask_hint')}</span>
+ </span>
+ </label>
+ <div class="settings-row" style="margin-top:10px">
+ <span class="settings-label">
+ ${dlDir ? t('settings.dl_folder', { name: dlDir.name })
+ : t('settings.dl_no_folder')}
+ </span>
+ <span>
+ <button class="admin-btn" onClick=${pickFolder}>
+ ${dlDir ? t('settings.dl_change') : t('settings.dl_choose')}
+ </button>
+ ${dlDir && html`
+ <button class="btn-secondary" onClick=${async () => {
+ await downloads.forgetDirectory();
+ setDlDir(null);
+ }}>${t('settings.dl_forget')}</button>
+ `}
+ </span>
+ </div>
+ <p class="settings-hint">${t('settings.dl_path_note')}</p>
+ `}
+ </div>
+
+ <div class="settings-section">
<h3 class="settings-heading">${t('settings.profile')}</h3>
<div class="settings-row">
<span class="settings-label">${t('settings.username')}</span>