From 3ee7f817d532208df1d95ecd86def12e5bd53a18 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 11 Aug 2026 15:39:09 +0200 Subject: fix(ui): group creation error, chat UUIDs, file preview, action menu Bug fixes: - Group creation "[object Object]" error: removed dead pkcs8 import code that threw before GEK wrapping, added array detail handling in hubFetch - Chat shows usernames instead of UUIDs (sender_name passed through node) - Join button: navigate to group on "Already a member" instead of error UI improvements: - Loading spinner animation for async states (connecting, fetching) - File action menu (3-dot dropdown: View, Download, Play) - Click filename to preview inline (images, text/code files) - FilePreview overlay for images and text files - Chat file attachment button (upload to node + structured message) - Chat attachment display (icon, filename, size) - Member panel: "Group admin" badge instead of plain "Admin" text Co-Authored-By: Claude Opus 4.6 --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 319 +++++++++++++++++---- .../meshbay-hub/src/meshbay_hub/static/i18n.js | 4 + .../meshbay-hub/src/meshbay_hub/static/style.css | 149 ++++++++++ .../src/meshbay_hub/static/transport.js | 3 +- .../src/meshbay_node/transport/webrtc_server.py | 6 + 5 files changed, 428 insertions(+), 53 deletions(-) (limited to 'packages') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index dda4a09..92bf8f4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -110,7 +110,10 @@ async function hubFetch(path, { method = 'GET', body, token } = {}) { const r = await fetch(HUB + path, opts); if (!r.ok) { const err = await r.json().catch(() => ({ detail: r.statusText })); - throw new Error(err.detail || r.statusText); + const detail = Array.isArray(err.detail) + ? err.detail.map(e => e.msg || JSON.stringify(e)).join(', ') + : (err.detail || r.statusText); + throw new Error(String(detail)); } return r.json(); } @@ -417,9 +420,14 @@ function ExplorePage({ token, myGroupIds }) { setJoining(gid); try { await hubFetch(`/v1/groups/${gid}/join`, { method: 'POST', token }); - window.location.reload(); + navigate(`/group/${gid}`); + setTimeout(() => window.location.reload(), 100); } catch (err) { - alert(err.message); + if (err.message.includes('Already a member')) { + navigate(`/group/${gid}`); + } else { + alert(err.message); + } } finally { setJoining(null); } @@ -492,24 +500,19 @@ function CreateGroupPage({ token, onCreated }) { body: { name: name.trim(), visibility, join_policy: joinPolicy }, }); - if (window.MeshBayCrypto && window.MeshBayKeys && _sessionKeys) { - const gek = window.MeshBayCrypto.generateGEK(); - const skXB64 = _sessionKeys.skXB64; - const skXRaw = Uint8Array.from(atob(skXB64), c => c.charCodeAt(0)); - const skX = await crypto.subtle.importKey( - 'pkcs8', skXRaw, { name: 'X25519' }, true, ['deriveBits']); - const pkXRaw = new Uint8Array( - await crypto.subtle.exportKey('raw', - (await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits'])).publicKey)); - - const meResp = await hubFetch('/v1/users/me', { token }); - const pubkeys = await hubFetch(`/v1/users/${meResp.username}/pubkeys`); - const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0)); - - const bundle = await window.MeshBayCrypto.wrapGEK(gek, pkXBytes); - await hubFetch(`/v1/groups/${data.group_id}/members/${meResp.username}/gek`, { - method: 'POST', token, body: bundle, - }); + if (window.MeshBayCrypto) { + try { + const gek = window.MeshBayCrypto.generateGEK(); + const meResp = await hubFetch('/v1/users/me', { token }); + const pubkeys = await hubFetch(`/v1/users/${meResp.username}/pubkeys`); + const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0)); + const bundle = await window.MeshBayCrypto.wrapGEK(gek, pkXBytes); + await hubFetch(`/v1/groups/${data.group_id}/members/${meResp.username}/gek`, { + method: 'POST', token, body: bundle, + }); + } catch (e) { + console.warn('GEK wrap skipped:', e); + } } if (onCreated) onCreated(); @@ -621,8 +624,10 @@ function GroupPage({ groupId, group, token, username }) { const [currentPath, setCurrentPath] = useState(''); const [dlState, setDlState] = useState(null); const [videoEntry, setVideoEntry] = useState(null); + const [previewEntry, setPreviewEntry] = useState(null); const [tab, setTab] = useState('files'); const [uploading, setUploading] = useState(false); + const [menuOpen, setMenuOpen] = useState(null); const transportRef = useRef(null); const gekRef = useRef(null); @@ -817,7 +822,11 @@ function GroupPage({ groupId, group, token, username }) {

${group ? group.name : t('group.default_name')}

- ${statusLabel} + + ${(status === 'discovering' || status === 'connecting' || status === 'fetching') + && html`${' '}`} + ${statusLabel} +
${error && html`
${error}
`} ${dlState && html` @@ -890,27 +899,52 @@ function GroupPage({ groupId, group, token, username }) { `)} - ${sorted.map(e => html` - - ${FILE_ICONS[e.type] || FILE_ICONS.other} - ${e.name} - ${formatSize(e.size)} - ${e.type} - ${formatDate(e.added_at)} - - ${e.type === 'video' && html` - - `} - - - - `)} + ${sorted.map(e => { + const canPreview = ['image', 'video', 'document'].includes(e.type) + || e.name.match(/\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i); + return html` + + ${FILE_ICONS[e.type] || FILE_ICONS.other} + + ${canPreview + ? html` { + if (e.type === 'video') setVideoEntry(e); + else setPreviewEntry(e); + }}>${e.name}` + : e.name + } + + ${formatSize(e.size)} + ${e.type} + ${formatDate(e.added_at)} + + + ${menuOpen === e.id && html` +
+ ${canPreview && html` + + `} + + ${e.type === 'video' && html` + + `} +
+ `} + + + `; + })} ${sorted.length === 0 && subdirs.length === 0 && html` ${filter ? t('group.empty_filter') : t('group.empty_dir')} @@ -921,7 +955,8 @@ function GroupPage({ groupId, group, token, username }) { `} ${tab === 'chat' && html` - <${ChatPanel} transportRef=${transportRef} username=${username} /> + <${ChatPanel} transportRef=${transportRef} username=${username} + entries=${entries} /> `} ${tab === 'members' && html` @@ -937,6 +972,13 @@ function GroupPage({ groupId, group, token, username }) { ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`

${statusLabel}

`} + ${previewEntry && html` + <${FilePreview} + entry=${previewEntry} + transportRef=${transportRef} + gekRef=${gekRef} + onClose=${() => setPreviewEntry(null)} /> + `} ${videoEntry && html` <${VideoPlayer} entry=${videoEntry} @@ -948,6 +990,112 @@ function GroupPage({ groupId, group, token, username }) { `; } +// ── File Preview (text, images) ───────────────────────────────────────── + +const TEXT_EXTS = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i; +const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i; + +function FilePreview({ entry, transportRef, gekRef, onClose }) { + const [phase, setPhase] = useState('loading'); + const [progress, setProgress] = useState(0); + const [content, setContent] = useState(null); + const [error, setError] = useState(''); + const blobUrlRef = useRef(null); + + useEffect(() => { + let cancelled = false; + const load = async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) { + setError(t('video.err_transport')); + setPhase('error'); + 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); + let downloaded = 0; + const chunks = await pipelinedDownload( + transport, gekRef.current, entry.id, totalChunks, + (bytes) => { downloaded += bytes; setProgress(downloaded / entry.size); }, + ); + if (cancelled) return; + + if (entry.name.match(IMAGE_EXTS)) { + const ext = entry.name.split('.').pop().toLowerCase(); + const mime = ext === 'svg' ? 'image/svg+xml' + : ext === 'png' ? 'image/png' + : ext === 'gif' ? 'image/gif' + : ext === 'webp' ? 'image/webp' + : 'image/jpeg'; + const blob = new Blob(chunks, { type: mime }); + blobUrlRef.current = URL.createObjectURL(blob); + setContent({ type: 'image' }); + } else { + const decoder = new TextDecoder('utf-8', { fatal: false }); + const text = chunks.map(c => decoder.decode(c, { stream: true })).join(''); + setContent({ type: 'text', text: text.slice(0, 500000) }); + } + setPhase('ready'); + } catch (err) { + if (!cancelled) { setError(err.message); setPhase('error'); } + } + }; + load(); + return () => { cancelled = true; }; + }, [entry]); + + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + useEffect(() => { + return () => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, []); + + return html` +
{ + if (e.target.classList.contains('video-overlay')) onClose(); + }}> +
+ ${entry.name} (${formatSize(entry.size)}) + +
+ ${phase === 'loading' && html` +
+
${t('video.loading', { name: entry.name })}
+
+
+
+
+ `} + ${phase === 'ready' && content?.type === 'image' && html` +
+ ${entry.name} +
+ `} + ${phase === 'ready' && content?.type === 'text' && html` +
+
${content.text}
+
+ `} + ${phase === 'error' && html` +
${error}
+ `} +
+ `; +} + function _b64ToU8(b64) { const bin = atob(b64); const arr = new Uint8Array(bin.length); @@ -1027,14 +1175,19 @@ function MembersPanel({ groupId, group, token }) { ${t('admin.col_username')} - ${t('members.col_role')} + ${t('members.group_role')} ${members.map(m => html` ${m.username} - ${m.user_id === adminId ? t('members.admin') : t('members.member')} + + ${m.user_id === adminId + ? html`${t('members.group_admin')}` + : html`${t('members.member')}` + } + `)} @@ -1066,10 +1219,18 @@ function formatTime(ts) { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time; } -function ChatPanel({ transportRef, username }) { +function _parsePayload(raw) { + if (typeof raw === 'string' && raw.startsWith('{')) { + try { return JSON.parse(raw); } catch { /* not JSON */ } + } + return null; +} + +function ChatPanel({ transportRef, username, entries }) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); + const [attaching, setAttaching] = useState(false); const listRef = useRef(null); const bottomRef = useRef(null); const loadedRef = useRef(false); @@ -1088,6 +1249,7 @@ function ChatPanel({ transportRef, username }) { transport.onChat = (msg) => { setMessages(prev => [...prev, { sender_id: msg.sender_id, + sender_name: msg.sender_name || '', payload: msg.payload, timestamp: msg.timestamp || Date.now() / 1000, thread_id: msg.thread_id, @@ -1112,9 +1274,10 @@ function ChatPanel({ transportRef, username }) { setSending(true); setInput(''); try { - await transport.sendChat(text, 0, null); + await transport.sendChat(text, 0, null, username); setMessages(prev => [...prev, { sender_id: username, + sender_name: username, payload: text, timestamp: Date.now() / 1000, thread_id: null, @@ -1126,6 +1289,38 @@ function ChatPanel({ transportRef, username }) { } }, [input, username]); + const attachFile = useCallback(async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ''; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setAttaching(true); + try { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + for (let i = 0; i < totalChunks; i++) { + const slice = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE); + const buf = new Uint8Array(await slice.arrayBuffer()); + await transport.uploadChunk(file.name, i, totalChunks, buf); + } + 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'; + const structured = JSON.stringify({ + text: '', attachment: { filename: file.name, size: file.size, type: ftype }, + }); + await transport.sendChat(structured, 0, null, username); + setMessages(prev => [...prev, { + sender_id: username, sender_name: username, + payload: structured, timestamp: Date.now() / 1000, thread_id: null, + }]); + } catch (err) { + alert(err.message); + } finally { + setAttaching(false); + } + }, [username]); + const onKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -1140,15 +1335,31 @@ function ChatPanel({ transportRef, username }) {
${t('chat.empty')}
`} ${messages.map((m, i) => { - const isOwn = m.sender_id === username; - const showSender = !isOwn && (i === 0 || messages[i - 1].sender_id !== m.sender_id); + const isOwn = m.sender_name === username || m.sender_id === username; + const displayName = m.sender_name || m.sender_id?.slice(0, 8) || '?'; + 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); + const att = parsed && parsed.attachment; return html`
${showSender && html` -
${m.sender_id}
+
${displayName}
`}
- ${m.payload} + ${att ? html` +
+ ${att.type === 'image' + ? html`
${'\u{1F5BC}'} ${att.filename}
` + : att.type === 'video' + ? html`
${'\u{1F3AC}'} ${att.filename}
` + : html`
${'\u{1F4CE}'} ${att.filename}
` + } +
${formatSize(att.size)}
+
+ ` : html` + ${m.payload} + `} ${formatTime(m.timestamp)}
@@ -1157,6 +1368,10 @@ function ChatPanel({ transportRef, username }) {
+