summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-11 16:12:16 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-11 16:12:16 +0200
commitb50750466622dd6cda0fc084d39dfce000ad0081 (patch)
treeeb888909a77a88d4f824f569301e02e65faef0f3 /packages/meshbay-hub
parent8b02ae3a8d64dedb198284ac743378dd38ca31c4 (diff)
downloadmeshbay-b50750466622dd6cda0fc084d39dfce000ad0081.tar.gz
fix(ui): upload chunk size, cached file display, chat names, file delete, inline thumbnails
- Upload chunks capped at 64KB to avoid WebRTC DataChannel max-message-size - Show cached files immediately while WebRTC connects (tabs visible during connection) - Persist sender_name in chat store (SQLite) — no more UUID display in history - File delete action in menu (node admin only, enforced server-side) - FILE_DELETE / FILE_DELETE_ACK MNP message types - Inline image thumbnails in chat attachments (download+decrypt, Signal-style) - Member panel: "Owner" label instead of "Group admin" to avoid hub/group admin confusion - Create group page: hint about needing a node - Refresh index after chat file attachment upload Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js105
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js10
4 files changed, 118 insertions, 12 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 92bf8f4..34c2517 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -527,6 +527,9 @@ function CreateGroupPage({ token, onCreated }) {
return html`
<div class="page-center">
<h2>${t('create_group.title')}</h2>
+ <p class="page-message" style="max-width:380px;text-align:center;margin-bottom:12px">
+ ${t('create_group.hint')}
+ </p>
${error && html`<p class="error-msg">${error}</p>`}
<form class="login-form" onSubmit=${onSubmit}>
<input type="text" placeholder="${t('create_group.name')}"
@@ -574,6 +577,7 @@ function formatDate(ts) {
// ── Group Page ──────────────────────────────────────────────────────────────
const CHUNK_SIZE = 1024 * 1024;
+const UPLOAD_CHUNK_SIZE = 64 * 1024;
const PIPELINE_WINDOW = 8;
async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, writable) {
@@ -754,9 +758,9 @@ function GroupPage({ groupId, group, token, username }) {
setUploading(true);
setError('');
try {
- const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
+ const totalChunks = Math.ceil(file.size / UPLOAD_CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
- const slice = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
+ const slice = file.slice(i * UPLOAD_CHUNK_SIZE, (i + 1) * UPLOAD_CHUNK_SIZE);
const buf = new Uint8Array(await slice.arrayBuffer());
await transport.uploadChunk(file.name, i, totalChunks, buf);
}
@@ -769,6 +773,27 @@ function GroupPage({ groupId, group, token, username }) {
}
}, []);
+ const deleteFile = useCallback(async (entry) => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ try {
+ await transport.deleteFile(entry.id);
+ const indexMsg = await transport.fetchIndex();
+ setEntries(indexMsg.entries || []);
+ } catch (err) {
+ setError(err.message);
+ }
+ }, []);
+
+ const refreshIndex = useCallback(async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ try {
+ const indexMsg = await transport.fetchIndex();
+ setEntries(indexMsg.entries || []);
+ } catch {}
+ }, []);
+
const toggleSort = useCallback((key) => {
setSortAsc(prev => sortKey === key ? !prev : true);
setSortKey(key);
@@ -838,7 +863,7 @@ function GroupPage({ groupId, group, token, username }) {
<span class="dl-pct">${formatSize(dlState.progress)} / ${formatSize(dlState.total)}</span>
</div>
`}
- ${status === 'connected' && html`
+ ${(status === 'connected' || (cached && entries.length > 0)) && html`
<div class="group-tabs">
<button class="group-tab ${tab === 'files' ? 'active' : ''}"
onClick=${() => setTab('files')}>${t('group.tab_files')}</button>
@@ -939,6 +964,12 @@ function GroupPage({ groupId, group, token, username }) {
${t('group.play')}
</button>
`}
+ ${group && group.is_admin && status === 'connected' && html`
+ <button class="danger" onClick=${() => {
+ setMenuOpen(null);
+ if (confirm(t('group.delete_confirm', { name: e.name }))) deleteFile(e);
+ }}>${t('group.delete')}</button>
+ `}
</div>
`}
</td>
@@ -954,15 +985,23 @@ function GroupPage({ groupId, group, token, username }) {
</table>
`}
- ${tab === 'chat' && html`
+ ${tab === 'chat' && status === 'connected' && html`
<${ChatPanel} transportRef=${transportRef} username=${username}
- entries=${entries} />
+ entries=${entries} gekRef=${gekRef} onRefreshIndex=${refreshIndex} />
+ `}
+ ${tab === 'chat' && status !== 'connected' && html`
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p>
`}
${tab === 'members' && html`
<${MembersPanel} groupId=${groupId} group=${group} token=${token} />
`}
`}
+ ${cached && entries.length > 0 && status !== 'connected' && tab === 'files' && html`
+ <p class="page-message" style="margin-top:8px">
+ <span class="spinner"></span>${' '}${t('status.connecting')}
+ </p>
+ `}
${status === 'offline' && html`
<p class="page-message">
${t('group.offline_title')}
@@ -1184,7 +1223,7 @@ function MembersPanel({ groupId, group, token }) {
<td>${m.username}</td>
<td>
${m.user_id === adminId
- ? html`<span class="badge" style="background:var(--accent);color:var(--accent-text)">${t('members.group_admin')}</span>`
+ ? html`<span class="badge" style="background:var(--accent);color:var(--accent-text)">${t('members.owner')}</span>`
: html`<span class="badge">${t('members.member')}</span>`
}
</td>
@@ -1226,7 +1265,47 @@ function _parsePayload(raw) {
return null;
}
-function ChatPanel({ transportRef, username, entries }) {
+function ChatImage({ filename, entries, transportRef, gekRef }) {
+ const [blobUrl, setBlobUrl] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+ const load = async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) { setLoading(false); return; }
+ const entry = entries.find(e => e.name === filename);
+ if (!entry) { setLoading(false); return; }
+ try {
+ if (!gekRef.current && window.MeshBayCrypto) {
+ const gekB64 = await transport.fetchGEK();
+ gekRef.current = await window.MeshBayCrypto.importGEK(gekB64);
+ }
+ const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
+ const chunks = await pipelinedDownload(transport, gekRef.current, entry.id, totalChunks);
+ if (cancelled) return;
+ const ext = filename.split('.').pop().toLowerCase();
+ const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif'
+ : ext === 'webp' ? 'image/webp' : ext === 'svg' ? 'image/svg+xml' : 'image/jpeg';
+ const blob = new Blob(chunks, { type: mime });
+ setBlobUrl(URL.createObjectURL(blob));
+ } catch { /* ignore */ }
+ if (!cancelled) setLoading(false);
+ };
+ load();
+ return () => { cancelled = true; };
+ }, [filename]);
+
+ useEffect(() => {
+ return () => { if (blobUrl) URL.revokeObjectURL(blobUrl); };
+ }, [blobUrl]);
+
+ if (loading) return html`<div class="chat-att-thumb"><span class="spinner"></span></div>`;
+ if (!blobUrl) return html`<div class="chat-att-img">${'\u{1F5BC}'} ${filename}</div>`;
+ return html`<img class="chat-att-thumb" src=${blobUrl} alt=${filename} />`;
+}
+
+function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex }) {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
@@ -1297,12 +1376,13 @@ function ChatPanel({ transportRef, username, entries }) {
if (!transport || !transport.connected) return;
setAttaching(true);
try {
- const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
+ const totalChunks = Math.ceil(file.size / UPLOAD_CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
- const slice = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
+ const slice = file.slice(i * UPLOAD_CHUNK_SIZE, (i + 1) * UPLOAD_CHUNK_SIZE);
const buf = new Uint8Array(await slice.arrayBuffer());
await transport.uploadChunk(file.name, i, totalChunks, buf);
}
+ if (onRefreshIndex) await onRefreshIndex();
const ext = file.name.split('.').pop().toLowerCase();
const ftype = ['jpg','jpeg','png','gif','webp','svg'].includes(ext) ? 'image'
: ['mp4','webm','mkv','mov','avi'].includes(ext) ? 'video' : 'file';
@@ -1319,7 +1399,7 @@ function ChatPanel({ transportRef, username, entries }) {
} finally {
setAttaching(false);
}
- }, [username]);
+ }, [username, onRefreshIndex]);
const onKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -1336,7 +1416,7 @@ function ChatPanel({ transportRef, username, entries }) {
`}
${messages.map((m, i) => {
const isOwn = m.sender_name === username || m.sender_id === username;
- const displayName = m.sender_name || m.sender_id?.slice(0, 8) || '?';
+ const displayName = m.sender_name || '?';
const showSender = !isOwn && (i === 0 ||
(messages[i - 1].sender_name || messages[i - 1].sender_id) !== (m.sender_name || m.sender_id));
const parsed = _parsePayload(m.payload);
@@ -1350,7 +1430,8 @@ function ChatPanel({ transportRef, username, entries }) {
${att ? html`
<div class="chat-attachment">
${att.type === 'image'
- ? html`<div class="chat-att-img">${'\u{1F5BC}'} ${att.filename}</div>`
+ ? html`<${ChatImage} filename=${att.filename} entries=${entries}
+ transportRef=${transportRef} gekRef=${gekRef} />`
: att.type === 'video'
? html`<div class="chat-att-file">${'\u{1F3AC}'} ${att.filename}</div>`
: html`<div class="chat-att-file">${'\u{1F4CE}'} ${att.filename}</div>`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index 8ac35e7..8ea4600 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -87,6 +87,8 @@ const en = {
'group.upload': 'Upload',
'group.uploading': 'Uploading...',
'group.view': 'View',
+ 'group.delete': 'Delete',
+ 'group.delete_confirm': 'Delete {name}?',
'group.err_transport': 'Transport module not loaded',
// Status
@@ -202,6 +204,7 @@ const en = {
'create_group.invite': 'Invite only',
'create_group.open': 'Open (anyone can join)',
'create_group.submit': 'Create',
+ 'create_group.hint': 'A group needs a node to host files. You can create the group now and connect a node later.',
'create_group.creating': 'Creating...',
// Members
@@ -209,6 +212,7 @@ const en = {
'members.group_role': 'Group role',
'members.admin': 'Admin',
'members.group_admin': 'Group admin',
+ 'members.owner': 'Owner',
'members.member': 'Member',
'members.invite_title': 'Invite member',
'members.username_placeholder': 'Username',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 6e1eedd..542c672 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -674,12 +674,23 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
font-weight: 500;
font-size: 0.88em;
}
+.chat-att-thumb {
+ max-width: 200px;
+ max-height: 200px;
+ border-radius: 6px;
+ object-fit: contain;
+ display: block;
+ cursor: pointer;
+}
.chat-att-size {
font-size: 0.75em;
color: var(--text-dim);
}
.chat-bubble-own .chat-att-size { color: rgba(255, 255, 255, 0.6); }
+.file-menu button.danger { color: var(--error); }
+.file-menu button.danger:hover { background: var(--error-bg); }
+
/* ── Download button + progress ──────────────────────────────────────────── */
.dl-btn {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 6cbcc3c..011c33e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -176,6 +176,16 @@ class MeshBayTransport {
return msg;
}
+ async deleteFile(fileId) {
+ const msg = await this._sendAndWait({
+ type: 'file_delete',
+ v: '0.1',
+ file_id: fileId,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
async uploadChunk(filename, chunkIndex, totalChunks, data) {
const msg = await this._sendAndWait({
type: 'file_upload',