diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 423 |
1 files changed, 414 insertions, 9 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 4ef334b..dda4a09 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -9,6 +9,59 @@ import { t, getLocale, setLocale, LOCALES } from './i18n.js'; const HUB = ''; const AUTH_KEY = 'mb_auth'; const THEME_KEY = 'mb_theme'; +const IDB_NAME = 'meshbay'; +const IDB_VERSION = 1; +const IDB_STORE = 'group_indexes'; + +// ── IndexedDB cache ───────────────────────────────────────────────────────── + +function openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(IDB_NAME, IDB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(IDB_STORE)) { + db.createObjectStore(IDB_STORE, { keyPath: 'groupId' }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function cacheGroupIndex(groupId, groupName, entries) { + try { + const db = await openDB(); + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).put({ + groupId, groupName, entries, cachedAt: Date.now(), + }); + await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; }); + db.close(); + } catch { /* best-effort */ } +} + +async function getCachedGroupIndex(groupId) { + try { + const db = await openDB(); + const tx = db.transaction(IDB_STORE, 'readonly'); + const req = tx.objectStore(IDB_STORE).get(groupId); + const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); + db.close(); + return result || null; + } catch { return null; } +} + +async function getAllCachedIndexes() { + try { + const db = await openDB(); + const tx = db.transaction(IDB_STORE, 'readonly'); + const req = tx.objectStore(IDB_STORE).getAll(); + const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); + db.close(); + return result || []; + } catch { return []; } +} // ── Auth persistence ───────────────────────────────────────────────────────── @@ -139,6 +192,10 @@ function Sidebar({ groups, route, menuOpen, role }) { <div class="sidebar-heading">${t('sidebar.discover')}</div> <a class="sidebar-item ${route === '/explore' ? 'active' : ''}" href="#/explore">${t('sidebar.public_groups')}</a> + <a class="sidebar-item ${route === '/search' ? 'active' : ''}" + href="#/search">${t('sidebar.search')}</a> + <a class="sidebar-item ${route === '/create-group' ? 'active' : ''}" + href="#/create-group">${t('sidebar.create_group')}</a> <a class="sidebar-item ${route === '/settings' ? 'active' : ''}" href="#/settings">${t('sidebar.settings')}</a> ${isStaff && html` @@ -333,10 +390,11 @@ function HomePage({ groups, notifications, onMarkRead }) { // ── Explore Page ───────────────────────────────────────────────────────────── -function ExplorePage({ token }) { +function ExplorePage({ token, myGroupIds }) { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); + const [joining, setJoining] = useState(null); const doSearch = useCallback((q) => { setLoading(true); @@ -355,9 +413,26 @@ function ExplorePage({ token }) { doSearch(q); }, [doSearch]); + const joinGroup = useCallback(async (gid) => { + setJoining(gid); + try { + await hubFetch(`/v1/groups/${gid}/join`, { method: 'POST', token }); + window.location.reload(); + } catch (err) { + alert(err.message); + } finally { + setJoining(null); + } + }, [token]); + + const isMember = (gid) => myGroupIds && myGroupIds.includes(gid); + return html` <div> - <h2>${t('explore.title')}</h2> + <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px"> + <h2 style="margin:0">${t('explore.title')}</h2> + <a class="admin-btn" href="#/create-group">${t('explore.create_group')}</a> + </div> <div class="file-toolbar" style="margin-bottom:16px"> <input type="text" class="admin-search" placeholder="${t('explore.search')}" value=${search} onInput=${onSearch} /> @@ -369,13 +444,26 @@ function ExplorePage({ token }) { : html` <div class="group-grid"> ${groups.map(g => html` - <a key=${g.id} class="group-card" href="#/group/${g.id}"> - <h3>${g.name}</h3> + <div key=${g.id} class="group-card"> + <a href="#/group/${g.id}" style="text-decoration:none;color:inherit"> + <h3>${g.name}</h3> + </a> <span class="badge">${g.join_policy}</span> ${g.source && g.source !== 'local' && html` ${' '}<span class="badge">${g.source}</span> `} - </a> + ${' '} + ${isMember(g.id) + ? html`<span class="badge">${t('explore.member')}</span>` + : g.join_policy === 'open' && html` + <button class="admin-btn" style="margin-top:8px" + disabled=${joining === g.id} + onClick=${() => joinGroup(g.id)}> + ${joining === g.id ? '...' : t('explore.join')} + </button> + ` + } + </div> `)} </div> ` @@ -384,6 +472,82 @@ function ExplorePage({ token }) { `; } +// ── Create Group Page ──────────────────────────────────────────────────────── + +function CreateGroupPage({ token, onCreated }) { + const [name, setName] = useState(''); + const [visibility, setVisibility] = useState('private'); + const [joinPolicy, setJoinPolicy] = useState('invite'); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const onSubmit = async (e) => { + e.preventDefault(); + if (!name.trim()) return; + setLoading(true); + setError(''); + try { + const data = await hubFetch('/v1/groups', { + method: 'POST', token, + 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 (onCreated) onCreated(); + navigate('/'); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return html` + <div class="page-center"> + <h2>${t('create_group.title')}</h2> + ${error && html`<p class="error-msg">${error}</p>`} + <form class="login-form" onSubmit=${onSubmit}> + <input type="text" placeholder="${t('create_group.name')}" + value=${name} onInput=${e => setName(e.target.value)} required /> + <label class="settings-label">${t('create_group.visibility')}</label> + <select class="settings-select" value=${visibility} + onChange=${e => setVisibility(e.target.value)}> + <option value="private">${t('create_group.private')}</option> + <option value="public">${t('create_group.public')}</option> + </select> + <label class="settings-label">${t('create_group.join_policy')}</label> + <select class="settings-select" value=${joinPolicy} + onChange=${e => setJoinPolicy(e.target.value)}> + <option value="invite">${t('create_group.invite')}</option> + <option value="open">${t('create_group.open')}</option> + </select> + <button type="submit" disabled=${loading}> + ${loading ? t('create_group.creating') : t('create_group.submit')} + </button> + </form> + </div> + `; +} + // ── Helpers ────────────────────────────────────────────────────────────────── const FILE_ICONS = { @@ -449,6 +613,7 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk function GroupPage({ groupId, group, token, username }) { const [status, setStatus] = useState('idle'); const [entries, setEntries] = useState([]); + const [cached, setCached] = useState(false); const [error, setError] = useState(''); const [sortKey, setSortKey] = useState('name'); const [sortAsc, setSortAsc] = useState(true); @@ -457,15 +622,23 @@ function GroupPage({ groupId, group, token, username }) { const [dlState, setDlState] = useState(null); const [videoEntry, setVideoEntry] = useState(null); const [tab, setTab] = useState('files'); + const [uploading, setUploading] = useState(false); const transportRef = useRef(null); const gekRef = useRef(null); useEffect(() => { let cancelled = false; + + getCachedGroupIndex(groupId).then(hit => { + if (hit && !cancelled) { + setEntries(hit.entries || []); + setCached(true); + } + }); + const connect = async () => { setStatus('discovering'); setError(''); - setEntries([]); gekRef.current = null; try { const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token }); @@ -486,8 +659,12 @@ function GroupPage({ groupId, group, token, username }) { const indexMsg = await transport.fetchIndex(); if (cancelled) return; - setEntries(indexMsg.entries || []); + const freshEntries = indexMsg.entries || []; + setEntries(freshEntries); + setCached(false); setStatus('connected'); + + cacheGroupIndex(groupId, group ? group.name : groupId, freshEntries); } catch (err) { if (!cancelled) { setError(err.message); @@ -563,6 +740,30 @@ function GroupPage({ groupId, group, token, username }) { } }, []); + const uploadFile = useCallback(async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ''; + const transport = transportRef.current; + if (!transport || !transport.connected) return; + setUploading(true); + setError(''); + 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 indexMsg = await transport.fetchIndex(); + setEntries(indexMsg.entries || []); + } catch (err) { + setError(err.message); + } finally { + setUploading(false); + } + }, []); + const toggleSort = useCallback((key) => { setSortAsc(prev => sortKey === key ? !prev : true); setSortKey(key); @@ -594,7 +795,7 @@ function GroupPage({ groupId, group, token, username }) { const subdirs = [...dirs].sort(); - const statusLabel = { + const baseLabel = { idle: t('status.idle'), discovering: t('status.discovering'), connecting: t('status.connecting'), @@ -603,6 +804,9 @@ function GroupPage({ groupId, group, token, username }) { offline: t('status.offline'), error: t('status.error'), }[status] || status; + const statusLabel = (cached && status !== 'connected') + ? `${baseLabel} (${t('status.cached', { n: entries.length })})` + : baseLabel; const statusClass = status === 'connected' ? 'status-ok' : status === 'error' || status === 'offline' ? 'status-err' : 'status-busy'; @@ -631,10 +835,17 @@ function GroupPage({ groupId, group, token, username }) { onClick=${() => setTab('files')}>${t('group.tab_files')}</button> <button class="group-tab ${tab === 'chat' ? 'active' : ''}" onClick=${() => setTab('chat')}>${t('group.tab_chat')}</button> + <button class="group-tab ${tab === 'members' ? 'active' : ''}" + onClick=${() => setTab('members')}>${t('group.tab_members')}</button> </div> ${tab === 'files' && html` <div class="file-toolbar"> + <label class="admin-btn upload-btn" style="cursor:pointer;margin-right:8px"> + ${uploading ? t('group.uploading') : t('group.upload')} + <input type="file" style="display:none" onChange=${uploadFile} + disabled=${uploading} /> + </label> <div class="breadcrumbs"> <a class="crumb" onClick=${() => setCurrentPath('')}>/</a> ${breadcrumbs.map((seg, i) => { @@ -712,6 +923,10 @@ function GroupPage({ groupId, group, token, username }) { ${tab === 'chat' && html` <${ChatPanel} transportRef=${transportRef} username=${username} /> `} + + ${tab === 'members' && html` + <${MembersPanel} groupId=${groupId} group=${group} token=${token} /> + `} `} ${status === 'offline' && html` <p class="page-message"> @@ -740,6 +955,107 @@ function _b64ToU8(b64) { return arr; } +// ── Members Panel ──────────────────────────────────────────────────────── + +function MembersPanel({ groupId, group, token }) { + const [members, setMembers] = useState([]); + const [adminId, setAdminId] = useState(''); + const [loading, setLoading] = useState(true); + const [inviteUser, setInviteUser] = useState(''); + const [inviting, setInviting] = useState(false); + const [error, setError] = useState(''); + + const loadMembers = useCallback(() => { + setLoading(true); + hubFetch(`/v1/groups/${groupId}/members`, { token }) + .then(data => { + setMembers(data.members || []); + setAdminId(data.admin_id || ''); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [groupId, token]); + + useEffect(() => { loadMembers(); }, [loadMembers]); + + const isAdmin = group && group.is_admin; + + const doInvite = useCallback(async (e) => { + e.preventDefault(); + if (!inviteUser.trim()) return; + setInviting(true); + setError(''); + try { + const pubkeys = await hubFetch(`/v1/users/${inviteUser.trim()}/pubkeys`); + const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0)); + + const gekB64 = await (async () => { + const transport = window._activeTransport; + if (transport && transport.connected) { + return await transport.fetchGEK(); + } + const bundleResp = await hubFetch(`/v1/groups/${groupId}/gek`, { token }); + if (!_sessionKeys) throw new Error('No session keys — log in via browser registration'); + const skXB64 = _sessionKeys.skXB64; + const skXRaw = Uint8Array.from(atob(skXB64), c => c.charCodeAt(0)); + const meResp = await hubFetch('/v1/users/me', { token }); + const myPubkeys = await hubFetch(`/v1/users/${meResp.username}/pubkeys`); + const myPkX = Uint8Array.from(atob(myPubkeys.pk_x25519), c => c.charCodeAt(0)); + const rawGek = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw, myPkX); + return btoa(String.fromCharCode(...rawGek)); + })(); + + const gekBytes = Uint8Array.from(atob(gekB64), c => c.charCodeAt(0)); + const bundle = await window.MeshBayCrypto.wrapGEK(gekBytes, pkXBytes); + await hubFetch(`/v1/groups/${groupId}/members/${inviteUser.trim()}/gek`, { + method: 'POST', token, body: bundle, + }); + setInviteUser(''); + loadMembers(); + } catch (err) { + setError(err.message); + } finally { + setInviting(false); + } + }, [groupId, token, inviteUser, loadMembers]); + + if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`; + + return html` + <div class="members-panel"> + <table class="admin-table"> + <thead> + <tr> + <th>${t('admin.col_username')}</th> + <th>${t('members.col_role')}</th> + </tr> + </thead> + <tbody> + ${members.map(m => html` + <tr key=${m.user_id}> + <td>${m.username}</td> + <td>${m.user_id === adminId ? t('members.admin') : t('members.member')}</td> + </tr> + `)} + </tbody> + </table> + ${isAdmin && html` + <form class="invite-form" onSubmit=${doInvite}> + <h4>${t('members.invite_title')}</h4> + ${error && html`<p class="error-msg">${error}</p>`} + <div style="display:flex;gap:8px"> + <input type="text" placeholder="${t('members.username_placeholder')}" + value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required /> + <button class="admin-btn" type="submit" disabled=${inviting}> + ${inviting ? '...' : t('members.invite_btn')} + </button> + </div> + </form> + `} + </div> + `; +} + // ── Chat Panel ────────────────────────────────────────────────────────── function formatTime(ts) { @@ -976,6 +1292,85 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { `; } +// ── Search Page (cross-group file search) ─────────────────────────────────── + +function SearchPage() { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [searched, setSearched] = useState(false); + + const doSearch = useCallback(async (q) => { + const term = q.trim().toLowerCase(); + if (!term) { setResults([]); setSearched(false); return; } + const indexes = await getAllCachedIndexes(); + const hits = []; + for (const idx of indexes) { + for (const e of (idx.entries || [])) { + if (e.name.toLowerCase().includes(term) || + (e.path && e.path.toLowerCase().includes(term))) { + hits.push({ ...e, groupId: idx.groupId, groupName: idx.groupName }); + } + } + } + setResults(hits); + setSearched(true); + }, []); + + const onInput = useCallback((e) => { + const q = e.target.value; + setQuery(q); + doSearch(q); + }, [doSearch]); + + return html` + <div> + <h2>${t('search.title')}</h2> + <div class="file-toolbar" style="margin-bottom:16px"> + <input type="text" class="admin-search" style="width:100%" + placeholder="${t('search.placeholder')}" + value=${query} onInput=${onInput} autofocus /> + </div> + ${searched && results.length === 0 && html` + <p class="page-message">${t('search.no_results')}</p> + `} + ${results.length > 0 && html` + <table class="file-table"> + <thead> + <tr> + <th></th> + <th>${t('group.col_name')}</th> + <th>${t('group.col_size')}</th> + <th>${t('search.col_group')}</th> + <th>${t('group.col_type')}</th> + </tr> + </thead> + <tbody> + ${results.map(r => html` + <tr class="file-row" key=${r.id + r.groupId}> + <td>${FILE_ICONS[r.type] || FILE_ICONS.other}</td> + <td class="file-name"> + <a href="#/group/${r.groupId}" style="color:inherit">${r.name}</a> + </td> + <td class="file-size">${formatSize(r.size)}</td> + <td> + <a href="#/group/${r.groupId}" class="badge">${r.groupName || r.groupId.slice(0, 8)}</a> + </td> + <td class="file-type">${r.type}</td> + </tr> + `)} + </tbody> + </table> + <p class="settings-value" style="margin-top:8px"> + ${t('search.result_count', { n: results.length })} + </p> + `} + ${!searched && html` + <p class="page-message">${t('search.hint')}</p> + `} + </div> + `; +} + // ── Settings Page ─────────────────────────────────────────────────────────── const THEME_OPTIONS = ['light', 'dark', 'system']; @@ -1491,8 +1886,18 @@ function App() { : html`<${LoginPage} />`; } else if (!user) { page = html`<${LoginPage} />`; + } else if (route === '/search') { + page = html`<${SearchPage} />`; } else if (route === '/explore') { - page = html`<${ExplorePage} token=${user.token} />`; + page = html`<${ExplorePage} token=${user.token} + myGroupIds=${groups.map(g => g.id)} />`; + } else if (route === '/create-group') { + page = html`<${CreateGroupPage} token=${user.token} + onCreated=${() => { + hubFetch('/v1/groups/mine', { token: user.token }) + .then(data => setGroups(data.groups || [])) + .catch(() => {}); + }} />`; } else if (route.startsWith('/group/')) { const groupId = route.slice(7); const group = groups.find(g => g.id === groupId); |