aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py2
-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
-rw-r--r--packages/meshbay-node/src/meshbay_node/chat/store.py39
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py34
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))