summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/files-app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js134
1 files changed, 122 insertions, 12 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index 195c385..4947f8c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -26,7 +26,7 @@ import {
function FilesPanel({
groupId, transportRef, gekRef, status,
entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
- isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview,
+ isNodeAdmin, operatorPaired, userId, setError, onPreview,
showGroup, readOnly, getTransport, onRefreshIndex, showRefresh,
}) {
const [selected, setSelected] = useState(() => new Set());
@@ -75,6 +75,12 @@ function FilesPanel({
e.target.value = '';
const transport = transportRef.current;
if (!files.length || !transport || !transport.connected) return;
+ // The folder on screen is the destination — not its root, and not a
+ // subdirectory of the node's invention. Somebody dropping a file into the
+ // folder they are looking at expects it to be in that folder.
+ const uploadDir = currentPath;
+ if (!uploadDir) return;
+ const uploadRoot = uploadDir.split('/')[0];
setError('');
for (const file of files) {
@@ -85,6 +91,8 @@ function FilesPanel({
// Bytes the node acknowledged, not bytes read locally.
onProgress: (sent) => onProgress(sent, file.size),
signal,
+ root: uploadRoot,
+ dir: uploadDir,
});
// The node re-indexes on a filesystem event, so there is nothing to
// wait on but the clock. Refreshing here means the file appears in
@@ -94,21 +102,49 @@ function FilesPanel({
},
});
}
- }, [applyIndex]);
+ }, [applyIndex, currentPath]);
+
+ // `null` while not creating one; a draft string while the field is open.
+ const [newDirName, setNewDirName] = useState(null);
+ const [creatingDir, setCreatingDir] = useState(false);
+
+ /**
+ * Create a folder in the directory being browsed.
+ *
+ * The name comes from a field in the toolbar rather than `window.prompt`,
+ * which **throws** in Electron — "prompt() is not supported" — and threw
+ * outside this function's try, so clicking the button did nothing at all:
+ * no folder, no error, nothing in the interface to react to. `confirm()` and
+ * `alert()` do work there and are used elsewhere; `prompt` is the one
+ * Chromium leaves to the embedder and Electron declines to implement.
+ *
+ * An inline field is better anyway — it can show the refusal next to the
+ * input instead of after the dialog has closed.
+ */
+ useEffect(() => { setNewDirName(null); }, [currentPath]);
- const makeDirectory = useCallback(async () => {
+ const makeDirectory = useCallback(async (rawName) => {
const transport = transportRef.current;
- if (!transport || !transport.connected) return;
- const name = prompt(t('group.mkdir_prompt'));
- if (!name || !name.trim()) return;
+ const name = (rawName || '').trim();
+ if (!name) return;
+ if (!transport || !transport.connected) {
+ setError(t('group.mkdir_offline'));
+ return;
+ }
+ setCreatingDir(true);
try {
- await transport.createDirectory(currentPath, name.trim());
+ await transport.createDirectory(currentPath, name);
+ // `list_dirs` walks the filesystem rather than the index, so a folder
+ // with nothing in it is here on this very fetch.
const indexMsg = await transport.fetchIndex();
if (indexMsg.entries) setEntries(indexMsg.entries);
if (indexMsg.dirs) setNodeDirs(indexMsg.dirs);
if (indexMsg.roots) setNodeRoots(indexMsg.roots);
+ setNewDirName(null);
} catch (err) {
setError(err.message);
+ } finally {
+ setCreatingDir(false);
}
}, [currentPath]);
@@ -212,11 +248,27 @@ function FilesPanel({
const unavailableHere = currentPath
? []
: subdirs.filter(d => rootState.get(d) && rootState.get(d).available === false);
+ const currentRootName = currentPath ? currentPath.split('/')[0] : '';
+ const currentRoot = currentRootName ? rootState.get(currentRootName) : null;
+ // `upload` is the same answer under the name a node speaking MNP 1.0 uses;
+ // reading only `writable` there means the Upload button disappears on every
+ // node that has not been upgraded yet, which is most of them on the day the
+ // page ships.
+ const currentRootWritable = currentRoot
+ ? (currentRoot.writable !== undefined ? currentRoot.writable
+ : Boolean(currentRoot.upload))
+ : false;
// A member cannot create a folder at the top of a group: that level is the
// set of roots, which is the operator's configuration and not a directory on
// anyone's disk. The node refuses it, so offering it would only produce an
// error nobody can act on.
- const canCreateDir = Boolean(currentPath) && isNodeAdmin;
+ //
+ // Otherwise the rule is the same as the Upload button's, and for the same
+ // reason the node gives: "making a directory is not a privileged act — a
+ // member who can add a file can organise where it goes". It used to require
+ // `isNodeAdmin`, which contradicted the node and hid the control from
+ // everyone who could actually use it.
+ const canCreateDir = Boolean(currentPath) && currentRootWritable && !readOnly;
const breadcrumbs = currentPath ? currentPath.split('/') : [];
@@ -338,13 +390,47 @@ function FilesPanel({
${status === 'connected' && html`
<div class="file-toolbar">
<div class="toolbar-group">
- ${mayUpload && html`
+ ${currentPath && currentRootWritable && html`
<label class="tb-btn primary">
<${Icon} name="upload" /> ${t('group.upload')}
<input type="file" multiple style="display:none"
onChange=${uploadFile} />
</label>
`}
+ ${/* Icon only: the toolbar already carries a labelled primary
+ action, and a second one beside it competes with it for the
+ width a breadcrumb trail needs. The name lives in the tooltip
+ and in aria-label, so it is not lost to anyone reading with
+ something other than their eyes. */''}
+ ${canCreateDir && newDirName === null && html`
+ <button class="tb-btn tb-btn-icon" onClick=${() => setNewDirName('')}
+ title=${t('group.mkdir')} aria-label=${t('group.mkdir')}>
+ <${Icon} name="folder-plus" />
+ </button>
+ `}
+ ${canCreateDir && newDirName !== null && html`
+ <span class="tb-mkdir">
+ <input type="text" class="tb-mkdir-input" autofocus
+ value=${newDirName} disabled=${creatingDir}
+ placeholder=${t('group.mkdir_prompt')}
+ aria-label=${t('group.mkdir')}
+ onInput=${(e) => setNewDirName(e.target.value)}
+ onKeyDown=${(e) => {
+ if (e.key === 'Enter') makeDirectory(newDirName);
+ if (e.key === 'Escape') setNewDirName(null);
+ }} />
+ <button class="tb-btn tb-btn-icon" title=${t('group.mkdir')}
+ disabled=${creatingDir || !newDirName.trim()}
+ onClick=${() => makeDirectory(newDirName)}>
+ <${Icon} name="check" />
+ </button>
+ <button class="tb-btn tb-btn-icon" title=${t('settings.cancel')}
+ disabled=${creatingDir}
+ onClick=${() => setNewDirName(null)}>
+ <${Icon} name="close" />
+ </button>
+ </span>
+ `}
</div>
<div class="breadcrumbs">
@@ -419,16 +505,40 @@ function FilesPanel({
const full = currentPath ? currentPath + '/' + d : d;
const inside = entriesUnder(entries, full);
const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0);
+ const rs = rootState.get(d);
+ const isEjected = rs && rs.ejected;
+ const isUnavail = unavailableHere.includes(d);
+ const isRemovable = rs && rs.removable;
return html`
- <tr class="file-row dir-row" key=${full} onClick=${() => setCurrentPath(full)}>
+ <tr class="file-row dir-row${isEjected ? ' root-ejected' : ''}" key=${full}
+ onClick=${() => { if (!isEjected) setCurrentPath(full); }}>
<td class="sel-cell">
<input type="checkbox" checked=${selected.has(dirKey(d))}
onClick=${(ev) => ev.stopPropagation()}
onChange=${() => toggle(dirKey(d))} />
</td>
- <td>${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'}</td>
- <td>${d}${unavailableHere.includes(d) ? html`
+ <td>${isEjected ? '\u{23CF}' : isUnavail ? '\u{26A0}' : '\u{1F4C1}'}</td>
+ <td>${d}${isEjected ? html`
+ <span class="root-offline"> ${t('group.root_ejected')}</span>
+ ` : isUnavail ? html`
<span class="root-offline"> ${t('group.root_unavailable')}</span>
+ ` : ''}${rs && rs.writable && !isEjected ? html`
+ <span class="root-rw" title="${t('group.root_writable')}" style="margin-left:8px;opacity:0.5;font-size:0.9em">✎</span>
+ ` : ''}${isRemovable && isNodeAdmin && operatorPaired ? html`
+ <button class="btn-small root-eject-btn" title=${isEjected ? t('group.root_plug') : t('group.root_eject')}
+ onClick=${(ev) => {
+ ev.stopPropagation();
+ const transport = transportRef.current;
+ if (!transport) return;
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ const fn = isEjected
+ ? () => transport.plugRoot(groupId, d, signFn)
+ : () => transport.ejectRoot(groupId, d, signFn);
+ fn().catch((err) => setError(err.message));
+ }}>${isEjected ? '\u{1F50C}' : '\u{23CF}'}</button>
` : ''}</td>
<td class="file-size">${inside.length ? formatSize(bytes) : ''}</td>
${showGroup && html`<td></td>`}