diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-11 16:12:16 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-11 16:12:16 +0200 |
| commit | b50750466622dd6cda0fc084d39dfce000ad0081 (patch) | |
| tree | eb888909a77a88d4f824f569301e02e65faef0f3 /packages | |
| parent | 8b02ae3a8d64dedb198284ac743378dd38ca31c4 (diff) | |
| download | meshbay-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')
7 files changed, 179 insertions, 26 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 8da3ea0..4095215 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -33,6 +33,8 @@ class MNP: GEK_RESPONSE = "gek_resp" # node delivers GEK over secure channel FILE_UPLOAD = "file_upload" # client pushes file chunk to node FILE_UPLOAD_ACK = "file_upload_ack" # node acknowledges chunk receipt + FILE_DELETE = "file_delete" # client requests file deletion + FILE_DELETE_ACK = "file_delete_ack" # node confirms deletion EPHEMERAL_STREAM = "ephemeral_stream" # reserved — mobile live push 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', diff --git a/packages/meshbay-node/src/meshbay_node/chat/store.py b/packages/meshbay-node/src/meshbay_node/chat/store.py index 1dbcc2b..a5b2d7d 100644 --- a/packages/meshbay-node/src/meshbay_node/chat/store.py +++ b/packages/meshbay-node/src/meshbay_node/chat/store.py @@ -17,17 +17,22 @@ log = logging.getLogger(__name__) _SCHEMA = """ CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - sender_id TEXT NOT NULL, - iteration INTEGER NOT NULL, - payload BLOB NOT NULL, - timestamp REAL NOT NULL, - thread_id TEXT DEFAULT NULL + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id TEXT NOT NULL, + iteration INTEGER NOT NULL, + payload BLOB NOT NULL, + timestamp REAL NOT NULL, + thread_id TEXT DEFAULT NULL, + sender_name TEXT DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(timestamp); CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id); """ +_MIGRATE_SENDER_NAME = ( + "ALTER TABLE messages ADD COLUMN sender_name TEXT DEFAULT ''" +) + @dataclass class StoredMessage: @@ -37,6 +42,7 @@ class StoredMessage: payload: bytes timestamp: float thread_id: str | None + sender_name: str = "" class ChatStore: @@ -50,6 +56,10 @@ class ChatStore: self._db_path.parent.mkdir(parents=True, exist_ok=True) self._db = await aiosqlite.connect(str(self._db_path)) await self._db.executescript(_SCHEMA) + try: + await self._db.execute(_MIGRATE_SENDER_NAME) + except Exception: + pass await self._db.commit() async def close(self) -> None: @@ -70,13 +80,14 @@ class ChatStore: iteration: int, payload: bytes, thread_id: str | None = None, + sender_name: str = "", ) -> int: """Store a message. Returns the row id.""" ts = time.time() cursor = await self._db.execute( - "INSERT INTO messages (sender_id, iteration, payload, timestamp, thread_id) " - "VALUES (?, ?, ?, ?, ?)", - (sender_id, iteration, payload, ts, thread_id), + "INSERT INTO messages (sender_id, iteration, payload, timestamp, thread_id, sender_name) " + "VALUES (?, ?, ?, ?, ?, ?)", + (sender_id, iteration, payload, ts, thread_id, sender_name), ) await self._db.commit() return cursor.lastrowid @@ -88,28 +99,30 @@ class ChatStore: ) -> list[StoredMessage]: """Get messages after a timestamp, most recent last.""" cursor = await self._db.execute( - "SELECT id, sender_id, iteration, payload, timestamp, thread_id " + "SELECT id, sender_id, iteration, payload, timestamp, thread_id, sender_name " "FROM messages WHERE timestamp > ? ORDER BY timestamp ASC LIMIT ?", (since, limit), ) rows = await cursor.fetchall() return [ StoredMessage(id=r[0], sender_id=r[1], iteration=r[2], - payload=r[3], timestamp=r[4], thread_id=r[5]) + payload=r[3], timestamp=r[4], thread_id=r[5], + sender_name=r[6] or "") for r in rows ] async def get_thread(self, thread_id: str, limit: int = 100) -> list[StoredMessage]: """Get messages in a thread.""" cursor = await self._db.execute( - "SELECT id, sender_id, iteration, payload, timestamp, thread_id " + "SELECT id, sender_id, iteration, payload, timestamp, thread_id, sender_name " "FROM messages WHERE thread_id = ? ORDER BY timestamp ASC LIMIT ?", (thread_id, limit), ) rows = await cursor.fetchall() return [ StoredMessage(id=r[0], sender_id=r[1], iteration=r[2], - payload=r[3], timestamp=r[4], thread_id=r[5]) + payload=r[3], timestamp=r[4], thread_id=r[5], + sender_name=r[6] or "") for r in rows ] diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 6231310..15c8f66 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -116,6 +116,8 @@ class WebRTCPeerSession: self._do_chat_history(msg) elif mtype == MNP.FILE_UPLOAD: self._do_file_upload(msg) + elif mtype == MNP.FILE_DELETE: + self._do_file_delete(msg) else: log.warning("Unknown MNP message type on DataChannel: %s", mtype) except Exception as e: @@ -276,6 +278,7 @@ class WebRTCPeerSession: iteration=msg.get("iteration", 0), payload=raw, thread_id=msg.get("thread_id"), + sender_name=sender_name, )) peers = self._ctx.get("_peers", {}) @@ -321,7 +324,7 @@ class WebRTCPeerSession: { "id": m.id, "sender_id": m.sender_id, - "sender_name": names.get(m.sender_id, ""), + "sender_name": m.sender_name or names.get(m.sender_id, ""), "payload": m.payload.decode("utf-8", errors="replace") if isinstance(m.payload, bytes) else m.payload, "timestamp": m.timestamp, @@ -373,6 +376,35 @@ class WebRTCPeerSession: tmp_path.rename(final_path) log.info("Upload complete: %s (%d chunks)", safe_name, total_chunks) + def _do_file_delete(self, msg: dict) -> None: + ctx = self._group_ctx() + file_id = msg.get("file_id", "") + if not file_id: + self._send({"type": "error", "detail": "Missing file_id"}) + return + + node_user_id = self._ctx.get("node_user_id") + if node_user_id and self._user_id != node_user_id: + self._send({"type": "error", "detail": "Only node admin can delete files"}) + return + + entry = ctx["index"].get_entry(file_id) + if not entry: + self._send({"type": "error", "detail": "File not found"}) + return + + file_path = ctx["shared_root"] / entry.path / entry.name + if file_path.exists(): + file_path.unlink() + log.info("File deleted: %s", entry.name) + + ctx["index"].remove_entry(file_id) + self._send({ + "type": MNP.FILE_DELETE_ACK, + "v": MNP_VERSION, + "file_id": file_id, + }) + def _send(self, obj: dict) -> None: if self._channel and self._channel.readyState == "open": self._channel.send(_pack(obj)) |