aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js319
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css149
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js3
4 files changed, 422 insertions, 53 deletions
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 }) {
<div>
<div class="group-header">
<h2>${group ? group.name : t('group.default_name')}</h2>
- <span class="status-badge ${statusClass}">${statusLabel}</span>
+ <span class="status-badge ${statusClass}">
+ ${(status === 'discovering' || status === 'connecting' || status === 'fetching')
+ && html`<span class="spinner"></span>${' '}`}
+ ${statusLabel}
+ </span>
</div>
${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
${dlState && html`
@@ -890,27 +899,52 @@ function GroupPage({ groupId, group, token, username }) {
<td></td>
</tr>
`)}
- ${sorted.map(e => html`
- <tr class="file-row" key=${e.id}>
- <td>${FILE_ICONS[e.type] || FILE_ICONS.other}</td>
- <td class="file-name">${e.name}</td>
- <td class="file-size">${formatSize(e.size)}</td>
- <td class="file-type td-type">${e.type}</td>
- <td class="file-date td-date">${formatDate(e.added_at)}</td>
- <td>
- ${e.type === 'video' && html`
- <button class="play-btn" onClick=${() => setVideoEntry(e)}
- disabled=${!!dlState || !!videoEntry} title="${t('group.play')}"
- \u{25B6}
- </button>
- `}
- <button class="dl-btn" onClick=${() => downloadFile(e)}
- disabled=${!!dlState} title="${t('group.download')}"
- \u{2B07}
- </button>
- </td>
- </tr>
- `)}
+ ${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`
+ <tr class="file-row" key=${e.id}>
+ <td>${FILE_ICONS[e.type] || FILE_ICONS.other}</td>
+ <td class="file-name">
+ ${canPreview
+ ? html`<a class="file-link" onClick=${() => {
+ if (e.type === 'video') setVideoEntry(e);
+ else setPreviewEntry(e);
+ }}>${e.name}</a>`
+ : e.name
+ }
+ </td>
+ <td class="file-size">${formatSize(e.size)}</td>
+ <td class="file-type td-type">${e.type}</td>
+ <td class="file-date td-date">${formatDate(e.added_at)}</td>
+ <td class="file-actions-cell">
+ <button class="file-menu-btn" onClick=${(ev) => {
+ ev.stopPropagation();
+ setMenuOpen(menuOpen === e.id ? null : e.id);
+ }}>${'\u{22EE}'}</button>
+ ${menuOpen === e.id && html`
+ <div class="file-menu">
+ ${canPreview && html`
+ <button onClick=${() => {
+ setMenuOpen(null);
+ if (e.type === 'video') setVideoEntry(e);
+ else setPreviewEntry(e);
+ }}>${t('group.view')}</button>
+ `}
+ <button onClick=${() => { setMenuOpen(null); downloadFile(e); }}>
+ ${t('group.download')}
+ </button>
+ ${e.type === 'video' && html`
+ <button onClick=${() => { setMenuOpen(null); setVideoEntry(e); }}>
+ ${t('group.play')}
+ </button>
+ `}
+ </div>
+ `}
+ </td>
+ </tr>
+ `;
+ })}
${sorted.length === 0 && subdirs.length === 0 && html`
<tr><td colspan="6" class="file-empty">
${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`
<p class="page-message">${statusLabel}</p>
`}
+ ${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`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onClose();
+ }}>
+ <div class="video-top-bar">
+ <span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
+ <button class="video-close" onClick=${onClose} title="${t('video.close')}">✕</button>
+ </div>
+ ${phase === 'loading' && html`
+ <div class="video-loading">
+ <div class="video-loading-label">${t('video.loading', { name: entry.name })}</div>
+ <div class="video-progress-bar">
+ <div class="video-progress-fill" style="width:${Math.round(progress * 100)}%"></div>
+ </div>
+ </div>
+ `}
+ ${phase === 'ready' && content?.type === 'image' && html`
+ <div class="preview-image-wrap">
+ <img class="preview-image" src=${blobUrlRef.current} alt=${entry.name} />
+ </div>
+ `}
+ ${phase === 'ready' && content?.type === 'text' && html`
+ <div class="preview-text-wrap">
+ <pre class="preview-text">${content.text}</pre>
+ </div>
+ `}
+ ${phase === 'error' && html`
+ <div class="video-error">${error}</div>
+ `}
+ </div>
+ `;
+}
+
function _b64ToU8(b64) {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
@@ -1027,14 +1175,19 @@ function MembersPanel({ groupId, group, token }) {
<thead>
<tr>
<th>${t('admin.col_username')}</th>
- <th>${t('members.col_role')}</th>
+ <th>${t('members.group_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>
+ <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">${t('members.member')}</span>`
+ }
+ </td>
</tr>
`)}
</tbody>
@@ -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 }) {
<div class="chat-empty">${t('chat.empty')}</div>
`}
${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`
<div key=${i} class="chat-msg ${isOwn ? 'chat-msg-own' : ''}">
${showSender && html`
- <div class="chat-sender">${m.sender_id}</div>
+ <div class="chat-sender">${displayName}</div>
`}
<div class="chat-bubble ${isOwn ? 'chat-bubble-own' : ''}">
- <span class="chat-text">${m.payload}</span>
+ ${att ? html`
+ <div class="chat-attachment">
+ ${att.type === 'image'
+ ? html`<div class="chat-att-img">${'\u{1F5BC}'} ${att.filename}</div>`
+ : 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>`
+ }
+ <div class="chat-att-size">${formatSize(att.size)}</div>
+ </div>
+ ` : html`
+ <span class="chat-text">${m.payload}</span>
+ `}
<span class="chat-time">${formatTime(m.timestamp)}</span>
</div>
</div>
@@ -1157,6 +1368,10 @@ function ChatPanel({ transportRef, username }) {
<div ref=${bottomRef} />
</div>
<div class="chat-input-row">
+ <label class="chat-attach" title="${t('chat.attach')}">
+ ${attaching ? html`<span class="spinner"></span>` : '\u{1F4CE}'}
+ <input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} />
+ </label>
<textarea class="chat-input" rows="1"
placeholder="${t('chat.placeholder')}"
value=${input}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index 0ec7d4a..8ac35e7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -86,6 +86,7 @@ const en = {
'group.offline_hint': 'Files will appear when a node hosting this group connects.',
'group.upload': 'Upload',
'group.uploading': 'Uploading...',
+ 'group.view': 'View',
'group.err_transport': 'Transport module not loaded',
// Status
@@ -101,6 +102,7 @@ const en = {
'chat.empty': 'No messages yet. Start the conversation!',
'chat.placeholder': 'Type a message...',
'chat.send': 'Send',
+ 'chat.attach': 'Attach file',
// Video player
'video.loading': 'Loading {name}...',
@@ -204,7 +206,9 @@ const en = {
// Members
'members.col_role': 'Role',
+ 'members.group_role': 'Group role',
'members.admin': 'Admin',
+ 'members.group_admin': 'Group admin',
'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 3aea648..6e1eedd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -531,6 +531,155 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
font-style: italic;
}
+/* ── Spinner ─────────────────────────────────────────────────────────────── */
+
+.spinner {
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ border: 2px solid var(--border);
+ border-top: 2px solid var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ vertical-align: middle;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+/* ── File action menu ───────────────────────────────────────────────────── */
+
+.file-actions-cell {
+ position: relative;
+ width: 40px;
+}
+
+.file-menu-btn {
+ background: none;
+ border: 1px solid var(--border);
+ color: var(--text-secondary);
+ width: 30px;
+ height: 30px;
+ border-radius: 6px;
+ font-size: 1.1em;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ line-height: 1;
+}
+.file-menu-btn:hover { background: var(--bg-raised); border-color: var(--accent); color: var(--accent); }
+
+.file-menu {
+ position: absolute;
+ right: 0;
+ top: 100%;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: var(--shadow-lg);
+ z-index: 30;
+ min-width: 130px;
+ padding: 4px 0;
+ overflow: hidden;
+}
+.file-menu button {
+ display: block;
+ width: 100%;
+ padding: 8px 14px;
+ background: none;
+ border: none;
+ color: var(--text);
+ font-size: 0.85em;
+ text-align: left;
+ cursor: pointer;
+ border-radius: 0;
+}
+.file-menu button:hover { background: var(--bg-raised); }
+
+/* ── File link (clickable name) ─────────────────────────────────────────── */
+
+.file-link {
+ color: var(--accent);
+ cursor: pointer;
+ text-decoration: none;
+}
+.file-link:hover { text-decoration: underline; }
+
+/* ── File preview overlay ───────────────────────────────────────────────── */
+
+.preview-image-wrap {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ max-width: 90vw;
+ max-height: calc(100vh - 100px);
+ padding: 40px 20px 20px;
+}
+
+.preview-image {
+ max-width: 100%;
+ max-height: calc(100vh - 120px);
+ object-fit: contain;
+ border-radius: 4px;
+}
+
+.preview-text-wrap {
+ width: min(90vw, 900px);
+ max-height: calc(100vh - 100px);
+ overflow: auto;
+ padding: 60px 20px 20px;
+}
+
+.preview-text {
+ background: var(--bg-surface);
+ color: var(--text);
+ padding: 20px;
+ border-radius: 8px;
+ font-size: 0.85em;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-x: auto;
+ margin: 0;
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
+ tab-size: 4;
+}
+
+/* ── Chat attachment ────────────────────────────────────────────────────── */
+
+.chat-attach {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 38px;
+ height: 38px;
+ font-size: 1.2em;
+ cursor: pointer;
+ border-radius: 8px;
+ flex-shrink: 0;
+ color: var(--text-secondary);
+}
+.chat-attach:hover { background: var(--bg-raised); color: var(--accent); }
+
+.chat-attachment {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.chat-att-img, .chat-att-file {
+ font-weight: 500;
+ font-size: 0.88em;
+}
+.chat-att-size {
+ font-size: 0.75em;
+ color: var(--text-dim);
+}
+.chat-bubble-own .chat-att-size { color: rgba(255, 255, 255, 0.6); }
+
/* ── 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 2f6680e..6cbcc3c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -164,13 +164,14 @@ class MeshBayTransport {
return msg.messages || [];
}
- async sendChat(payload, iteration, threadId) {
+ async sendChat(payload, iteration, threadId, senderName) {
const msg = await this._sendAndWait({
type: 'chat_msg',
v: '0.1',
payload: payload,
iteration: iteration || 0,
thread_id: threadId || null,
+ sender_name: senderName || null,
});
return msg;
}