summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-19 16:36:29 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-19 16:36:29 +0200
commit90c3c6a01f46c9b29c5d92702d7383f32e8951d7 (patch)
tree779f5ee3557d5111e0a7f1fdc211a2845bc3bc69
parent9498ce0a34cc363e8cf9a42575c8fcf7dfe0fd1a (diff)
downloadmeshbay-90c3c6a01f46c9b29c5d92702d7383f32e8951d7.tar.gz
feat(ui): 11-point UI overhaul — tabs, transfers, settings, uploads
Hub/UI: - Icon-only group tabs (chat, files, settings) with per-group default tab - Transfer widget: filename becomes a clickable link to open completed downloads - Pulse animation on transfer icon (pale→dark green) while active - Download button feedback in FilePreview (spinner, auto-reset) - Group mute toggle persists across navigation - Login page autofocus, chat refocus after send - Theme toggle closes menu, status badge and duplicate connecting removed - Create-folder restricted to operators, download-path note removed - User preferences API (CRUD) with Alembic migration - Profile: email display/edit via PATCH /v1/users/me - Settings: "Defaults" section for default tab selector - All 10 locale files updated Node: - upload_dir in node.toml: separate filesystem path for uploads - Root.direct flag: uploads land at root path, no subdirectory - CLI --upload-dir flag on `group add` - Admin UI accepts upload_dir Client (Electron): - shell.openPath bridge for opening completed downloads - platform.js passes open callback from native save Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--packages/meshbay-client/src/main.js9
-rw-r--r--packages/meshbay-client/src/preload.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py122
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f1a2b3c4d5e6_add_user_preferences.py30
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js291
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css47
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py12
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py19
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py28
-rw-r--r--packages/meshbay-node/src/meshbay_node/roots.py17
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py29
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py1
24 files changed, 628 insertions, 176 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 6228189..0ed06d9 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -505,6 +505,7 @@ function registerBridge() {
// The renderer still never names a path. It asks; the user chooses once; the
// main process holds the handle and the renderer refers to it by an opaque id.
const sinks = new Map();
+ const completedPaths = new Map();
let sinkId = 0;
/** `name`, or the first "name (n).ext" that is not taken — never an overwrite. */
@@ -611,10 +612,18 @@ function registerBridge() {
const sink = sinks.get(String(id));
if (!sink) return false;
sinks.delete(String(id));
+ completedPaths.set(String(id), sink.path);
await new Promise((resolve) => sink.stream.end(resolve));
return true;
});
+ ipcMain.handle('save:open', async (_e, id) => {
+ const p = completedPaths.get(String(id));
+ if (!p) return false;
+ await shell.openPath(p);
+ return true;
+ });
+
ipcMain.handle('save:abort', async (_e, id) => {
const sink = sinks.get(String(id));
if (!sink) return false;
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
index caad523..3804ad3 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -86,6 +86,7 @@ contextBridge.exposeInMainWorld('meshbay', {
write: (chunk) => ipcRenderer.invoke('save:write', handle.id, chunk),
close: () => ipcRenderer.invoke('save:end', handle.id),
abort: () => ipcRenderer.invoke('save:abort', handle.id),
+ open: () => ipcRenderer.invoke('save:open', handle.id),
};
},
});
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index b5fa205..a50a2d7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.auth import (
current_pw_version,
decode_access_token,
+ decrypt_email,
encrypt_email,
generate_refresh_token,
hash_password,
@@ -29,7 +30,8 @@ from meshbay_hub.api.netutil import client_ip
from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import (
- Group, GroupMember, IPLog, Node, Notification, RefreshToken, User, UserDevice,
+ Group, GroupMember, IPLog, Node, Notification, RefreshToken, User,
+ UserDevice, UserPreference,
)
from meshbay_hub.api.deps import get_current_user, require_user_scope
@@ -474,14 +476,131 @@ async def token_refresh(
async def get_current_user_info(
current_user: User = Depends(get_current_user),
):
+ email = ""
+ try:
+ email = decrypt_email(current_user.email) if current_user.email else ""
+ except Exception:
+ pass
+ return {
+ "user_id": current_user.id,
+ "username": current_user.username,
+ "email": email,
+ "role": current_user.role,
+ "status": current_user.status,
+ }
+
+
+class UpdateProfileRequest(BaseModel):
+ email: str | None = None
+
+ @field_validator("email")
+ @classmethod
+ def email_valid(cls, v: str | None) -> str | None:
+ if v is None:
+ return v
+ v = v.strip()
+ local, sep, domain = v.partition("@")
+ if (not sep or not local or not domain
+ or "." not in domain
+ or len(v) > 254
+ or any(c.isspace() or ord(c) < 32 for c in v)):
+ raise ValueError("invalid email address")
+ return v
+
+
+@router.patch("/me")
+async def update_profile(
+ body: UpdateProfileRequest,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ if body.email is not None:
+ current_user.email = encrypt_email(body.email)
+ await db.commit()
+ email = ""
+ try:
+ email = decrypt_email(current_user.email) if current_user.email else ""
+ except Exception:
+ pass
return {
"user_id": current_user.id,
"username": current_user.username,
+ "email": email,
"role": current_user.role,
"status": current_user.status,
}
+# ── User preferences ────────────────────────────────────────────────────────
+
+ALLOWED_PREF_KEYS = frozenset([
+ "notifications_disabled",
+ "default_tab",
+])
+
+def _valid_pref_key(key: str) -> bool:
+ if key in ALLOWED_PREF_KEYS:
+ return True
+ if key.startswith("default_tab:"):
+ return True
+ return False
+
+
+@router.get("/me/preferences")
+async def get_preferences(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(UserPreference).where(UserPreference.user_id == current_user.id))
+ prefs = {p.key: p.value for p in result.scalars().all()}
+ return prefs
+
+
+class PrefValue(BaseModel):
+ value: str
+
+
+@router.put("/me/preferences/{key:path}")
+async def set_preference(
+ key: str,
+ body: PrefValue,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ if not _valid_pref_key(key):
+ raise HTTPException(status_code=400, detail=f"Unknown preference key: {key}")
+ result = await db.execute(
+ select(UserPreference).where(
+ UserPreference.user_id == current_user.id,
+ UserPreference.key == key))
+ pref = result.scalar_one_or_none()
+ if pref:
+ pref.value = body.value
+ else:
+ db.add(UserPreference(
+ user_id=current_user.id, key=key, value=body.value))
+ await db.commit()
+ return {"key": key, "value": body.value}
+
+
+@router.delete("/me/preferences/{key:path}")
+async def delete_preference(
+ key: str,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(UserPreference).where(
+ UserPreference.user_id == current_user.id,
+ UserPreference.key == key))
+ pref = result.scalar_one_or_none()
+ if pref:
+ await db.delete(pref)
+ await db.commit()
+ return {"status": "deleted", "key": key}
+
+
class NodeKeyRequest(BaseModel):
pk_node_ed25519: str # base64 raw 32B Ed25519 public key
@@ -541,6 +660,7 @@ async def erase_account(db: AsyncSession, user: User) -> dict:
"account would strand their members."),
)
+ await db.execute(delete(UserPreference).where(UserPreference.user_id == user.id))
await db.execute(delete(GroupMember).where(GroupMember.user_id == user.id))
await db.execute(delete(Notification).where(Notification.user_id == user.id))
await db.execute(delete(RefreshToken).where(RefreshToken.user_id == user.id))
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f1a2b3c4d5e6_add_user_preferences.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f1a2b3c4d5e6_add_user_preferences.py
new file mode 100644
index 0000000..e30a4da
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f1a2b3c4d5e6_add_user_preferences.py
@@ -0,0 +1,30 @@
+"""add_user_preferences
+
+Revision ID: f1a2b3c4d5e6
+Revises: d28b9caf9f07
+Create Date: 2026-08-19 12:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = 'f1a2b3c4d5e6'
+down_revision: Union[str, Sequence[str], None] = 'd28b9caf9f07'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table('user_preferences',
+ sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id'), nullable=False),
+ sa.Column('key', sa.String(64), nullable=False),
+ sa.Column('value', sa.Text(), nullable=False),
+ sa.PrimaryKeyConstraint('user_id', 'key'),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table('user_preferences')
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index 12507a6..f1f11e2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -288,6 +288,15 @@ class ContentBlocklist(Base):
added_by: Mapped[str | None] = mapped_column(String(64)) # "auto" or admin username
+class UserPreference(Base):
+ __tablename__ = "user_preferences"
+
+ user_id: Mapped[str] = mapped_column(
+ String(36), ForeignKey("users.id"), primary_key=True)
+ key: Mapped[str] = mapped_column(String(64), primary_key=True)
+ value: Mapped[str] = mapped_column(Text, nullable=False)
+
+
class IPLog(Base):
"""
Connection log for legal compliance.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 2e3c422..816c0ab 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -398,6 +398,11 @@ const ICON_PATHS = {
check: ['M4.5 12.5l5 5 10-11'],
chevron: ['M6 9.5l6 6 6-6'],
close: ['M6 6l12 12M18 6L6 18'],
+ chat: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'],
+ folder: ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z'],
+ 'bell-off': ['M18 9a6 6 0 0 0-12 0c0 6-2.5 7.5-2.5 7.5h17S18 15 18 9',
+ 'M10.3 20a2 2 0 0 0 3.4 0',
+ 'M4 4l16 16'],
};
// The M of the wordmark is a picture; the rest is text. Resolved from this
@@ -472,6 +477,7 @@ function UserMenu({ user, theme, onThemeChange, onLogout }) {
</button>
<button class="user-menu-item" onClick=${() => {
onThemeChange(resolved === 'dark' ? 'light' : 'dark');
+ setOpen(false);
}}>
<${Icon} name=${resolved === 'dark' ? 'sun' : 'moon'} cls="umi-icon" />
${' '}${resolved === 'dark' ? t('usermenu.theme_light') : t('usermenu.theme_dark')}
@@ -532,7 +538,10 @@ function TransferWidget() {
<span class="transfer-kind">
<${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} />
</span>
- <span class="transfer-name" title=${it.name}>${it.name}</span>
+ ${it.canOpen
+ ? html`<a class="transfer-name" href="#" title=${it.name}
+ onClick=${(e) => { e.preventDefault(); transfers.open(it.id); }}>${it.name}</a>`
+ : html`<span class="transfer-name" title=${it.name}>${it.name}</span>`}
${it.status === 'running' && html`
<button class="transfer-cancel" title=${t('transfers.cancel')}
onClick=${() => transfers.cancel(it.id)}>
@@ -738,7 +747,7 @@ function LoginPage() {
<form onSubmit=${onSubmit}>
<input type="text" placeholder="${t('login.username')}" value=${username}
onInput=${e => setUsername(e.target.value)}
- autocomplete="username" required />
+ autocomplete="username" required autofocus />
<input type="password" placeholder="${t('login.password')}" value=${password}
onInput=${e => setPassword(e.target.value)}
autocomplete="current-password" required />
@@ -1284,8 +1293,8 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
return results;
}
-function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
- onJoined, onGroupUpdated, onPresence, onLeft }) {
+function GroupPage({ groupId, group, token, username, userId, userPrefs,
+ onRefreshAuth, onJoined, onGroupUpdated, onPresence, onLeft }) {
const [status, setStatus] = useState('idle');
const [entries, setEntries] = useState([]);
@@ -1301,7 +1310,23 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const [currentPath, setCurrentPath] = useState('');
const [videoEntry, setVideoEntry] = useState(null);
const [previewEntry, setPreviewEntry] = useState(null);
- const [tab, setTab] = useState('chat');
+ const defaultTab = (userPrefs && (userPrefs[`default_tab:${groupId}`] || userPrefs['default_tab'])) || 'chat';
+ const [tab, setTab] = useState(defaultTab);
+ const [groupMuted, setGroupMuted] = useState(() => !!(group && group.muted));
+
+ const toggleGroupMute = useCallback(async () => {
+ const next = !groupMuted;
+ setGroupMuted(next);
+ try {
+ await hubFetch(`/v1/groups/${groupId}/mute`, {
+ method: 'POST', token, body: { muted: next },
+ });
+ if (onGroupUpdated) onGroupUpdated(groupId, { muted: next });
+ } catch (err) {
+ setGroupMuted(!next);
+ }
+ }, [groupMuted, groupId, token, onGroupUpdated]);
+
// Directories are not index entries, so a new empty one needs a nudge
// to appear in the breadcrumb listing.
const [nodeDirs, setNodeDirs] = useState([]);
@@ -1520,9 +1545,13 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const target = await _openDownloadTarget(entry.name, entry.size);
if (target === false) return; // the picker was dismissed
+ const openRef = { url: null };
transfers.start({
kind: 'download', name: (target && target.name) || entry.name,
- total: entry.size, transport, open: target && target.open,
+ total: entry.size, transport,
+ open: target
+ ? (target.open || null)
+ : () => { if (openRef.url) window.open(openRef.url, '_blank'); },
run: async ({ signal, onProgress }) => {
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
let done = 0;
@@ -1540,7 +1569,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
} else {
const chunks = await pipelinedDownload(
transport, gek, entry.id, totalChunks, onChunk, null, signal);
- _saveBlob(new Blob(chunks), entry.name);
+ const blob = new Blob(chunks);
+ _saveBlob(blob, entry.name);
+ openRef.url = URL.createObjectURL(blob);
}
},
});
@@ -1642,10 +1673,14 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
return;
}
const gek = gekRef.current;
+ const zipOpenRef = { url: null };
transfers.start({
kind: 'download', name: (target && target.name) || suggested,
- total: totalBytes, transport, open: target && target.open,
+ total: totalBytes, transport,
+ open: target
+ ? (target.open || null)
+ : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); },
run: async ({ signal, onProgress }) => {
const writable = target ? target.writable : null;
const parts = writable ? null : [];
@@ -1671,7 +1706,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
}
await zip.finish();
if (writable) await writable.close();
- else _saveBlob(new Blob(parts, { type: 'application/zip' }), suggested);
+ else {
+ const blob = new Blob(parts, { type: 'application/zip' });
+ _saveBlob(blob, suggested);
+ zipOpenRef.url = URL.createObjectURL(blob);
+ }
} catch (err) {
if (writable) await writable.abort().catch(() => {});
throw err;
@@ -1773,7 +1812,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
// set of roots, which is the operator's configuration and not a directory on
// anyone's disk. The node refuses it, so offering it would only produce an
// error nobody can act on.
- const canCreateDir = Boolean(currentPath);
+ const canCreateDir = Boolean(currentPath) && isNodeAdmin;
const baseLabel = {
idle: t('status.idle'),
@@ -1786,9 +1825,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
}[status] || status;
const statusLabel = baseLabel;
- const statusClass = status === 'connected' ? 'status-ok'
- : status === 'error' || status === 'offline' ? 'status-err' : 'status-busy';
-
const breadcrumbs = currentPath ? currentPath.split('/') : [];
// Selection is keyed globally — file ids, and 'dir:' plus a full path — so
@@ -1923,11 +1959,12 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
`}
`}
</div>
- <span class="status-badge ${statusClass}">
- ${(status === 'discovering' || status === 'connecting' || status === 'fetching')
- && html`<span class="spinner"></span>${' '}`}
- ${statusLabel}
- </span>
+ ${group && html`
+ <button class="group-mute-btn" onClick=${toggleGroupMute}
+ title=${groupMuted ? t('group.unmute') : t('group.mute')}>
+ <${Icon} name=${groupMuted ? 'bell-off' : 'bell'} />
+ </button>
+ `}
</div>
${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
${needsDevice && html`
@@ -1969,15 +2006,18 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
chat still need the node and say so. */ group && html`
<div class="group-tabs">
<button class="group-tab ${tab === 'chat' ? 'active' : ''}"
- onClick=${() => setTab('chat')}>${t('group.tab_chat')}</button>
+ onClick=${() => setTab('chat')} title=${t('group.tab_chat')}>
+ <${Icon} name="chat" cls="tab-icon" /></button>
<button class="group-tab ${tab === 'files' ? 'active' : ''}"
- onClick=${() => setTab('files')}>${t('group.tab_files')}</button>
+ onClick=${() => setTab('files')} title=${t('group.tab_files')}>
+ <${Icon} name="folder" cls="tab-icon" /></button>
<button class="group-tab ${tab === 'settings' ? 'active' : ''}"
- onClick=${() => setTab('settings')}>${t('group.tab_settings')}</button>
+ onClick=${() => setTab('settings')} title=${t('group.tab_settings')}>
+ <${Icon} name="gear" cls="tab-icon" /></button>
</div>
${tab === 'files' && status !== 'connected' && html`
- <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p>
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
`}
${tab === 'files' && status === 'connected' && html`
@@ -2114,7 +2154,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
}} />
`}
${tab === 'chat' && status !== 'connected' && html`
- <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p>
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
`}
${tab === 'settings' && html`
@@ -2134,8 +2174,8 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
${' '}${t('group.offline_hint')}
</p>
`}
- ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`
- <p class="page-message">${statusLabel}</p>
+ ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && !group && html`
+ <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting_short')}</p>
`}
${previewEntry && html`
<${FilePreview}
@@ -2167,6 +2207,7 @@ function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) {
const [progress, setProgress] = useState(0);
const [content, setContent] = useState(null);
const [error, setError] = useState('');
+ const [downloading, setDownloading] = useState(false);
const blobUrlRef = useRef(null);
useEffect(() => {
@@ -2239,9 +2280,18 @@ function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) {
<div class="video-top-bar">
<span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
${onDownload && html`
- <button class="video-close" onClick=${onDownload}
- title="${t('group.download')}">
- <${Icon} name="download" /></button>
+ <button class="video-close ${downloading ? 'dl-active' : ''}"
+ onClick=${() => {
+ if (!downloading) {
+ setDownloading(true);
+ onDownload();
+ setTimeout(() => setDownloading(false), 1500);
+ }
+ }}
+ title="${t('group.download')}" disabled=${downloading}>
+ ${downloading
+ ? html`<span class="spinner"></span>`
+ : html`<${Icon} name="download" />`}</button>
`}
<button class="video-close" onClick=${onClose} title="${t('video.close')}">
<${Icon} name="close" /></button>
@@ -2297,7 +2347,8 @@ function _b64ToU8(b64) {
*/
function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
isNodeAdmin, userId, operatorPaired, connected,
- memberUpload, onMemberUpload, onPaired, onLeft }) {
+ memberUpload, onMemberUpload,
+ onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
const [loading, setLoading] = useState(true);
@@ -2833,6 +2884,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
const [attaching, setAttaching] = useState(false);
const listRef = useRef(null);
const panelRef = useRef(null);
+ const inputRef = useRef(null);
const loadedRef = useRef(false);
// Set just before older messages are prepended; read once, after the DOM has
// them but before the browser paints.
@@ -2998,6 +3050,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
setInput(text);
} finally {
setSending(false);
+ setTimeout(() => { if (inputRef.current) inputRef.current.focus(); });
}
}, [input, username, jumpToBottom]);
@@ -3126,7 +3179,7 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex,
<input type="file" style="display:none" onChange=${attachFile} disabled=${attaching} />
</label>
`}
- <textarea class="chat-input" rows="1"
+ <textarea class="chat-input" rows="1" ref=${inputRef}
placeholder="${t('chat.placeholder')}"
value=${input}
onInput=${e => setInput(e.target.value)}
@@ -3224,6 +3277,7 @@ function purgeUnscopedResumePositions() {
purgeUnscopedResumePositions();
function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
+ const [dlBusy, setDlBusy] = useState(false);
const [phase, setPhase] = useState('loading');
const [error, setError] = useState('');
const videoRef = useRef(null);
@@ -3855,9 +3909,18 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
<div class="video-top-bar">
<span class="video-title">${entry.name} (${formatSize(entry.size)})</span>
${onDownload && html`
- <button class="video-close" onClick=${onDownload}
- title="${t('group.download')}">
- <${Icon} name="download" /></button>
+ <button class="video-close ${dlBusy ? 'dl-active' : ''}" disabled=${dlBusy}
+ onClick=${() => {
+ if (!dlBusy) {
+ setDlBusy(true);
+ onDownload();
+ setTimeout(() => setDlBusy(false), 1500);
+ }
+ }}
+ title="${t('group.download')}">
+ ${dlBusy
+ ? html`<span class="spinner"></span>`
+ : html`<${Icon} name="download" />`}</button>
`}
<button class="video-close" onClick=${onClose} title="${t('video.close')}">
<${Icon} name="close" /></button>
@@ -4019,6 +4082,11 @@ function ProfilePage({ user, onLogout }) {
const [currentNodeKey, setCurrentNodeKey] = useState(null);
const [nodeKeyStatus, setNodeKeyStatus] = useState('');
const [nodeKeyLoading, setNodeKeyLoading] = useState(false);
+ const [email, setEmail] = useState('');
+ const [emailDraft, setEmailDraft] = useState('');
+ const [emailEditing, setEmailEditing] = useState(false);
+ const [emailSaving, setEmailSaving] = useState(false);
+ const [emailStatus, setEmailStatus] = useState('');
const [pinCount, setPinCount] = useState(
() => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0));
const [delOpen, setDelOpen] = useState(false);
@@ -4031,8 +4099,6 @@ function ProfilePage({ user, onLogout }) {
setDeleting(true);
setDelError('');
try {
- // The passphrase is re-checked by the hub, not merely by this form: an
- // open session is not consent to something irreversible.
const authKey = await window.MeshBayKeys.deriveAuthKey(delPass, user.username);
await hubFetch('/v1/users/me', {
method: 'DELETE', token: user.token, body: { auth_key: authKey },
@@ -4045,14 +4111,17 @@ function ProfilePage({ user, onLogout }) {
}
}, [delPass, user]);
- // 11.5.8: node identity pins are refused strictly on change, so users need a
- // deliberate way to accept a legitimate rotation (operator reinstalled a node).
const clearPins = useCallback(() => {
window.MeshBayTransport?.clearNodePin?.();
setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0);
}, []);
useEffect(() => {
+ hubFetch('/v1/users/me', { token: user.token })
+ .then(data => {
+ if (data.email) { setEmail(data.email); setEmailDraft(data.email); }
+ })
+ .catch(() => {});
hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
.then(data => {
if (data.pk_node_ed25519) setCurrentNodeKey(data.pk_node_ed25519);
@@ -4060,6 +4129,26 @@ function ProfilePage({ user, onLogout }) {
.catch(() => {});
}, [user.username, user.token]);
+ const saveEmail = useCallback(async () => {
+ const val = emailDraft.trim();
+ if (!val || val === email) { setEmailEditing(false); return; }
+ setEmailSaving(true);
+ setEmailStatus('');
+ try {
+ await hubFetch('/v1/users/me', {
+ method: 'PATCH', token: user.token, body: { email: val },
+ });
+ setEmail(val);
+ setEmailEditing(false);
+ setEmailStatus(t('settings.email_saved'));
+ setTimeout(() => setEmailStatus(''), 3000);
+ } catch (e) {
+ setEmailStatus(e.message);
+ } finally {
+ setEmailSaving(false);
+ }
+ }, [emailDraft, email, user.token]);
+
const submitNodeKey = useCallback(async () => {
const key = nodeKey.trim();
if (!key) return;
@@ -4091,9 +4180,27 @@ function ProfilePage({ user, onLogout }) {
<span class="settings-value">${user.username}</span>
</div>
<div class="settings-row">
- <span class="settings-label">${t('settings.role')}</span>
- <span class="settings-value">${user.role || 'user'}</span>
+ <span class="settings-label">${t('settings.email')}</span>
+ ${emailEditing
+ ? html`<span style="display:flex;gap:8px;align-items:center">
+ <input type="email" value=${emailDraft}
+ onInput=${e => setEmailDraft(e.target.value)}
+ onKeyDown=${e => e.key === 'Enter' && saveEmail()}
+ style="font-size:0.9em;padding:4px 8px;border:1px solid var(--border);border-radius:4px" />
+ <button class="admin-btn" onClick=${saveEmail}
+ disabled=${emailSaving}>${t('settings.email_save')}</button>
+ <button class="btn-secondary" onClick=${() => {
+ setEmailEditing(false); setEmailDraft(email);
+ }}>${t('settings.cancel')}</button>
+ </span>`
+ : html`<span style="display:flex;gap:8px;align-items:center">
+ <span class="settings-value">${email || '—'}</span>
+ <button class="link-btn" onClick=${() => setEmailEditing(true)}>
+ <${Icon} name="pencil" /></button>
+ </span>`
+ }
</div>
+ ${emailStatus && html`<p class="settings-hint" style="margin-top:4px">${emailStatus}</p>`}
</div>
<div class="settings-section">
@@ -4164,11 +4271,13 @@ function ProfilePage({ user, onLogout }) {
`;
}
-function SettingsPage({ user, theme, onThemeChange, groups }) {
+function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange }) {
const [locale, setLoc] = useState(getLocale);
- // Comes from the hub with the group list, so it is the same on every device.
const [muted, setMuted] = useState(
() => Object.fromEntries((groups || []).map(g => [g.id, !!g.muted])));
+ const [globalMute, setGlobalMute] = useState(false);
+ const [defaultTab, setDefaultTab] = useState('chat');
+
const onLocaleChange = useCallback((e) => {
const code = e.target.value;
setLocale(code);
@@ -4180,10 +4289,30 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
onThemeChange(e.target.value);
}, [onThemeChange]);
+ useEffect(() => {
+ hubFetch('/v1/users/me/preferences', { token: user.token })
+ .then(prefs => {
+ if (prefs.notifications_disabled === 'true') setGlobalMute(true);
+ if (prefs.default_tab) setDefaultTab(prefs.default_tab);
+ })
+ .catch(() => {});
+ }, [user.token]);
+
+ const toggleGlobalMute = useCallback(async () => {
+ const next = !globalMute;
+ setGlobalMute(next);
+ try {
+ await hubFetch('/v1/users/me/preferences/notifications_disabled', {
+ method: 'PUT', token: user.token,
+ body: { value: next ? 'true' : 'false' },
+ });
+ if (onPrefsChange) onPrefsChange({ notifications_disabled: next });
+ } catch (err) {
+ setGlobalMute(!next);
+ }
+ }, [globalMute, user.token, onPrefsChange]);
+
const toggleMute = useCallback(async (gid) => {
- // Server-side: this used to write to localStorage, which nothing read, so
- // muting a group had no effect on anything. The hub now declines to create
- // the notification at all.
const next = !muted[gid];
setMuted(prev => ({ ...prev, [gid]: next }));
try {
@@ -4195,6 +4324,18 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
}
}, [muted, user.token]);
+ const changeDefaultTab = useCallback(async (e) => {
+ const val = e.target.value;
+ setDefaultTab(val);
+ try {
+ await hubFetch('/v1/users/me/preferences/default_tab', {
+ method: 'PUT', token: user.token,
+ body: { value: val },
+ });
+ if (onPrefsChange) onPrefsChange({ default_tab: val });
+ } catch { setDefaultTab(defaultTab); }
+ }, [defaultTab, user.token, onPrefsChange]);
+
const [dlMode, setDlMode] = useState(() => downloads.getMode());
const [dlDir, setDlDir] = useState(null);
const [dlError, setDlError] = useState('');
@@ -4250,9 +4391,7 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
<div class="settings-section">
<h3 class="settings-heading">${t('settings.downloads')}</h3>
- ${!(downloads.SUPPORTED || platform.folder.available)
- ? html`<p class="settings-hint">${t('settings.dl_unsupported')}</p>`
- : html`
+ ${(downloads.SUPPORTED || platform.folder.available) && html`
<label class="settings-choice">
<input type="radio" name="dlmode" checked=${dlMode === 'auto'}
onChange=${() => { downloads.setMode('auto'); setDlMode('auto'); }} />
@@ -4289,8 +4428,7 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
</span>
</div>
${dlError && html`<p class="error-msg">${dlError}</p>`}
- <p class="settings-hint">${t('settings.dl_path_note')}</p>
- `}
+ `}
</div>
<div class="settings-section">
@@ -4313,9 +4451,17 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
</div>
</div>
- ${groups.length > 0 && html`
- <div class="settings-section">
- <h3 class="settings-heading">${t('settings.groups')}</h3>
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.groups')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.notif_global_disable')}</span>
+ <label class="settings-value" style="cursor:pointer">
+ <input type="checkbox" checked=${globalMute}
+ onChange=${toggleGlobalMute} />
+ </label>
+ </div>
+ ${!globalMute && html`
+ <p class="settings-hint" style="margin-bottom:8px">${t('settings.notif_global_hint')}</p>
${groups.map(g => html`
<div class="settings-row" key=${g.id}>
<span class="settings-label">${g.name}</span>
@@ -4326,8 +4472,22 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
</label>
</div>
`)}
+ `}
+ </div>
+
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.defaults')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.default_tab')}</span>
+ <select class="settings-select" value=${defaultTab}
+ onChange=${changeDefaultTab}>
+ <option value="chat">${t('group.tab_chat')}</option>
+ <option value="files">${t('group.tab_files')}</option>
+ <option value="settings">${t('group.tab_settings')}</option>
+ </select>
</div>
- `}
+ <p class="settings-hint">${t('settings.default_tab_hint')}</p>
+ </div>
${platform.isNative && html`
<div class="settings-section">
@@ -4792,6 +4952,8 @@ function App() {
const [menuOpen, setMenuOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
+ const [notifDisabled, setNotifDisabled] = useState(false);
+ const [userPrefs, setUserPrefs] = useState({});
const resolved = resolveTheme(theme);
@@ -4828,20 +4990,28 @@ function App() {
}, [theme, resolved]);
const fetchNotifications = useCallback(() => {
- if (!user) return;
+ if (!user || notifDisabled) {
+ setNotifications([]); setUnreadCount(0); return;
+ }
hubFetch('/v1/notifications?limit=20', { token: user.token })
.then(data => {
setNotifications(data.notifications || []);
setUnreadCount(data.unread_count || 0);
})
.catch(() => {});
- }, [user]);
+ }, [user, notifDisabled]);
useEffect(() => {
if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); return; }
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => setGroups([]));
+ hubFetch('/v1/users/me/preferences', { token: user.token })
+ .then(prefs => {
+ setUserPrefs(prefs || {});
+ if (prefs.notifications_disabled === 'true') setNotifDisabled(true);
+ })
+ .catch(() => {});
fetchNotifications();
}, [user]);
@@ -5062,6 +5232,7 @@ function App() {
page = html`<${GroupPage}
groupId=${groupId} group=${group} token=${user.token}
username=${user.username} userId=${user.userId}
+ userPrefs=${userPrefs}
onRefreshAuth=${refreshAuth} onJoined=${dismissGroupNotifications}
onGroupUpdated=${updateGroup} onPresence=${notePresence}
onLeft=${handleLeftGroup} />`;
@@ -5072,7 +5243,15 @@ function App() {
onMarkRead=${markRead} onPurge=${purgeNotifications} />`;
} else if (route === '/settings') {
page = html`<${SettingsPage} user=${user} theme=${theme}
- onThemeChange=${setTheme} groups=${groups} />`;
+ onThemeChange=${setTheme} groups=${groups}
+ onPrefsChange=${(p) => {
+ if ('notifications_disabled' in p) {
+ setNotifDisabled(p.notifications_disabled);
+ if (p.notifications_disabled) { setNotifications([]); setUnreadCount(0); }
+ else fetchNotifications();
+ }
+ setUserPrefs(prev => ({ ...prev, ...p }));
+ }} />`;
} else if (route === '/profile') {
page = html`<${ProfilePage} user=${user} onLogout=${authCtx.logout} />`;
} else {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index df72382..ec56f03 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -127,6 +127,7 @@ export default {
'status.idle': 'Inaktiv',
'status.discovering': 'Nodes werden gesucht …',
'status.connecting': 'Verbindung über WebRTC …',
+ 'status.connecting_short': 'Verbindung wird hergestellt…',
'status.fetching': 'Index wird abgerufen …',
'status.files': {
one: '{n} Datei',
@@ -169,15 +170,17 @@ export default {
'settings.dl_choose': 'Ordner auswählen',
'settings.dl_change': 'Ändern',
'settings.dl_forget': 'Verwerfen',
- 'settings.dl_path_note': 'Einer Webseite lässt sich kein Pfad übergeben, hier ist '
- + 'also nichts einzutippen: Ihr Browser gewährt Zugriff auf den Ordner, den Sie '
- + 'auswählen, und MeshBay schreibt ausschließlich darin. Einmal pro Sitzung kann '
- + 'eine Bestätigung verlangt werden.',
- 'settings.dl_unsupported': 'Dieser Browser kann nicht in einen Ordner Ihrer Wahl '
- + 'schreiben (keine File System Access API), daher landen Downloads in seinem '
- + 'eigenen Download-Ordner. Chrome und Edge lassen die Wahl zu.',
+
'settings.profile': 'Profil',
'settings.username': 'Benutzername',
+ 'settings.email': 'Email',
+ 'settings.email_save': 'Save',
+ 'settings.email_saved': 'Email updated',
+ 'settings.notif_global_disable': 'Disable all notifications',
+ 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.defaults': 'Standardwerte',
+ 'settings.default_tab': 'Default tab',
+ 'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
'settings.node_pins': 'Node-Identitäten',
'settings.node_pins_hint': 'Der Identitätsschlüssel jedes Nodes wird bei der ersten '
+ 'Verbindung gemerkt. Ändert er sich, wird die Verbindung abgelehnt — das ist nur '
@@ -452,6 +455,8 @@ export default {
'chat.jump_new': 'Neue Nachrichten',
'group.leave': 'Gruppe verlassen',
'group.leave_confirm': '„{name}“ verlassen? Sie verlieren den Zugang zu ihren Dateien und zum Chat. Hochgeladene Dateien bleiben auf dem Node, und der Node behält die für Sie gemerkte Identität, bis sein Betreiber sie entfernt.',
+ 'group.mute': 'Mute notifications',
+ 'group.unmute': 'Unmute notifications',
'profile.title': 'Profil',
'usermenu.profile': 'Profil',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index ce14f0b..cb4b349 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -126,6 +126,7 @@ export default {
'status.idle': 'Idle',
'status.discovering': 'Finding nodes...',
'status.connecting': 'Connecting via WebRTC...',
+ 'status.connecting_short': 'Connecting...',
'status.fetching': 'Fetching index...',
'status.files': {
one: '{n} file',
@@ -167,14 +168,16 @@ export default {
'settings.dl_choose': 'Choose folder',
'settings.dl_change': 'Change',
'settings.dl_forget': 'Forget',
- 'settings.dl_path_note': 'A web page cannot be given a path, so there is nothing '
- + 'to type here: your browser grants access to the folder you pick, and MeshBay '
- + 'only ever writes inside it. You may be asked to confirm once per session.',
- 'settings.dl_unsupported': 'This browser cannot write into a folder of your '
- + 'choosing (no File System Access API), so downloads go to its own download '
- + 'folder. Chrome and Edge support choosing one.',
'settings.profile': 'Profile',
'settings.username': 'Username',
+ 'settings.email': 'Email',
+ 'settings.email_save': 'Save',
+ 'settings.email_saved': 'Email updated',
+ 'settings.notif_global_disable': 'Disable all notifications',
+ 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.defaults': 'Defaults',
+ 'settings.default_tab': 'Default tab',
+ 'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
'settings.node_pins': 'Node identities',
'settings.node_pins_hint': "Each node's identity key is remembered the first time you connect. If it changes, the connection is refused — that is expected only when an operator reinstalls a node. Verify with them before clearing.",
'settings.node_pins_count': {
@@ -437,6 +440,8 @@ export default {
'chat.jump_new': 'New messages',
'group.leave': 'Leave group',
'group.leave_confirm': 'Leave “{name}”? You will lose access to its files and chat. Files you uploaded stay on the node, and the node keeps the identity it pinned for you until its operator removes it.',
+ 'group.mute': 'Mute notifications',
+ 'group.unmute': 'Unmute notifications',
'profile.title': 'Profile',
'usermenu.profile': 'Profile',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 2d9084b..5afb3c3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -125,6 +125,7 @@ export default {
'status.idle': 'Inactivo',
'status.discovering': 'Buscando nodes...',
'status.connecting': 'Conectando por WebRTC...',
+ 'status.connecting_short': 'Conectando…',
'status.fetching': 'Obteniendo el índice...',
'status.files': {
one: '{n} archivo',
@@ -167,15 +168,17 @@ export default {
'settings.dl_choose': 'Elegir carpeta',
'settings.dl_change': 'Cambiar',
'settings.dl_forget': 'Olvidar',
- 'settings.dl_path_note': 'A una página web no se le puede indicar una ruta, así que '
- + 'aquí no hay nada que escribir: su navegador concede acceso a la carpeta que '
- + 'usted señale, y MeshBay solo escribe dentro de ella. Es posible que se le pida '
- + 'confirmación una vez por sesión.',
- 'settings.dl_unsupported': 'Este navegador no puede escribir en una carpeta de su '
- + 'elección (no tiene File System Access API), de modo que las descargas van a su '
- + 'propia carpeta de descargas. Chrome y Edge sí permiten elegir una.',
+
'settings.profile': 'Perfil',
'settings.username': 'Nombre de usuario',
+ 'settings.email': 'Email',
+ 'settings.email_save': 'Save',
+ 'settings.email_saved': 'Email updated',
+ 'settings.notif_global_disable': 'Disable all notifications',
+ 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.defaults': 'Valores predeterminados',
+ 'settings.default_tab': 'Default tab',
+ 'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
'settings.node_pins': 'Identidades de los nodes',
'settings.node_pins_hint': 'La clave de identidad de cada node se memoriza la '
+ 'primera vez que se conecta. Si cambia, la conexión se rechaza — algo esperable '
@@ -447,6 +450,8 @@ export default {
'chat.jump_new': 'Mensajes nuevos',
'group.leave': 'Salir del grupo',
'group.leave_confirm': '¿Salir de «{name}»? Perderá el acceso a sus archivos y a su chat. Los archivos que subió permanecen en el node, y este conserva la identidad que fijó para usted hasta que su operador la retire.',
+ 'group.mute': 'Mute notifications',
+ 'group.unmute': 'Unmute notifications',
'profile.title': 'Perfil',
'usermenu.profile': 'Perfil',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 9aa2857..fe6c666 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -126,6 +126,7 @@ export default {
'status.idle': 'Inactif',
'status.discovering': 'Recherche de nodes...',
'status.connecting': 'Connexion via WebRTC...',
+ 'status.connecting_short': 'Connexion…',
'status.fetching': "Récupération de l'index...",
'status.files': {
one: '{n} fichier',
@@ -168,16 +169,17 @@ export default {
'settings.dl_choose': 'Choisir un dossier',
'settings.dl_change': 'Changer',
'settings.dl_forget': 'Oublier',
- 'settings.dl_path_note': 'On ne peut pas indiquer un chemin à une page web, il n’y '
- + 'a donc rien à saisir ici : votre navigateur donne accès au dossier que vous '
- + 'désignez, et MeshBay n’écrit jamais ailleurs qu’à l’intérieur. Une '
- + 'confirmation peut vous être demandée une fois par session.',
- 'settings.dl_unsupported': 'Ce navigateur ne sait pas écrire dans un dossier de '
- + 'votre choix (pas de File System Access API) ; les téléchargements vont donc '
- + 'dans son propre dossier de téléchargement. Chrome et Edge permettent d’en '
- + 'choisir un.',
+
'settings.profile': 'Profil',
'settings.username': "Nom d'utilisateur",
+ 'settings.email': 'E-mail',
+ 'settings.email_save': 'Enregistrer',
+ 'settings.email_saved': 'E-mail mis à jour',
+ 'settings.notif_global_disable': 'Désactiver toutes les notifications',
+ 'settings.notif_global_hint': 'Quand activé, aucune notification n\x27est créée pour aucun groupe.',
+ 'settings.defaults': 'Valeurs par défaut',
+ 'settings.default_tab': 'Onglet par défaut',
+ 'settings.default_tab_hint': 'L\'onglet qui s\'ouvre en premier quand vous entrez dans un groupe.',
'settings.node_pins': 'Identités des nodes',
'settings.node_pins_hint': "La clé d'identité de chaque node est mémorisée lors de "
+ 'la première connexion. Si elle change, la connexion est refusée — ce qui n’est '
@@ -452,6 +454,8 @@ export default {
'chat.jump_new': 'Nouveaux messages',
'group.leave': 'Quitter le groupe',
'group.leave_confirm': 'Quitter « {name} » ? Vous perdrez l’accès à ses fichiers et à sa discussion. Les fichiers que vous avez envoyés restent sur le node, et celui-ci conserve l’identité qu’il a épinglée pour vous jusqu’à ce que son opérateur la retire.',
+ 'group.mute': 'Couper les notifications',
+ 'group.unmute': 'Réactiver les notifications',
'profile.title': 'Profil',
'usermenu.profile': 'Profil',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 379e7cb..5653ec0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -126,6 +126,7 @@ export default {
'status.idle': 'Inattivo',
'status.discovering': 'Ricerca dei node...',
'status.connecting': 'Connessione tramite WebRTC...',
+ 'status.connecting_short': 'Connessione…',
'status.fetching': "Recupero dell'indice...",
'status.files': {
one: '{n} file',
@@ -168,15 +169,17 @@ export default {
'settings.dl_choose': 'Scegli una cartella',
'settings.dl_change': 'Cambia',
'settings.dl_forget': 'Dimentica',
- 'settings.dl_path_note': 'A una pagina web non si può indicare un percorso, quindi '
- + 'qui non c’è nulla da digitare: è il browser a concedere l’accesso alla cartella '
- + 'che indica, e MeshBay scrive soltanto al suo interno. Potrebbe esserle chiesta '
- + 'una conferma una volta per sessione.',
- 'settings.dl_unsupported': 'Questo browser non sa scrivere in una cartella a sua '
- + 'scelta (manca la File System Access API), perciò i download finiscono nella sua '
- + 'cartella di download. Chrome ed Edge permettono di sceglierne una.',
+
'settings.profile': 'Profilo',
'settings.username': 'Nome utente',
+ 'settings.email': 'Email',
+ 'settings.email_save': 'Save',
+ 'settings.email_saved': 'Email updated',
+ 'settings.notif_global_disable': 'Disable all notifications',
+ 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.defaults': 'Valori predefiniti',
+ 'settings.default_tab': 'Default tab',
+ 'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
'settings.node_pins': 'Identità dei node',
'settings.node_pins_hint': "La chiave d'identità di ogni node viene memorizzata alla "
+ 'prima connessione. Se cambia, la connessione viene rifiutata — cosa che ci si '
@@ -449,6 +452,8 @@ export default {
'chat.jump_new': 'Nuovi messaggi',
'group.leave': 'Esci dal gruppo',
'group.leave_confirm': 'Uscire da «{name}»? Perderà l’accesso ai suoi file e alla chat. I file che ha caricato restano sul node, e il node mantiene l’identità che ha fissato per lei finché il suo operatore non la rimuove.',
+ 'group.mute': 'Mute notifications',
+ 'group.unmute': 'Unmute notifications',
'profile.title': 'Profilo',
'usermenu.profile': 'Profilo',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 45e7aa5..aa3899d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -124,6 +124,7 @@ export default {
'status.idle': '待機中',
'status.discovering': 'node を探しています…',
'status.connecting': 'WebRTC で接続しています…',
+ 'status.connecting_short': '接続中…',
'status.fetching': 'インデックスを取得しています…',
'status.files': {
other: '{n} 個のファイル',
@@ -165,16 +166,17 @@ export default {
'settings.dl_choose': 'フォルダーを選択',
'settings.dl_change': '変更',
'settings.dl_forget': '解除',
- 'settings.dl_path_note': 'ウェブページにパスを指定することはできないため、'
- + 'ここに入力するものはありません。お選びになったフォルダーへのアクセスは'
- + 'ブラウザーが許可し、MeshBay はその中にしか書き込みません。'
- + 'セッションごとに 1 回、確認を求められることがあります。',
- 'settings.dl_unsupported': 'このブラウザーはお好きなフォルダーへの書き込みに'
- + '対応していないため(File System Access API がありません)、'
- + 'ダウンロードはブラウザー自身のダウンロードフォルダーに保存されます。'
- + 'Chrome と Edge では選択できます。',
+
'settings.profile': 'プロフィール',
'settings.username': 'ユーザー名',
+ 'settings.email': 'Email',
+ 'settings.email_save': 'Save',
+ 'settings.email_saved': 'Email updated',
+ 'settings.notif_global_disable': 'Disable all notifications',
+ 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.defaults': 'デフォルト',
+ 'settings.default_tab': 'Default tab',
+ 'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
'settings.node_pins': 'node の識別情報',
'settings.node_pins_hint': '各 node の識別鍵は、最初に接続したときに記憶されます。'
+ 'それが変わった場合、接続は拒否されます。これが起こるのは、運営者が node を'
@@ -438,6 +440,8 @@ export default {
'chat.jump_new': '新しいメッセージ',
'group.leave': 'グループを退出',
'group.leave_confirm': '「{name}」を退出しますか?ファイルとチャットへのアクセスがなくなります。アップロードしたファイルは node に残り、node は固定した識別情報を運営者が削除するまで保持します。',
+ 'group.mute': 'Mute notifications',
+ 'group.unmute': 'Unmute notifications',
'profile.title': 'プロフィール',
'usermenu.profile': 'プロフィール',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 37d3e24..c5841dd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -127,6 +127,7 @@ export default {
'status.idle': 'Inactief',
'status.discovering': 'Nodes zoeken...',
'status.connecting': 'Verbinden via WebRTC...',
+ 'status.connecting_short': 'Verbinden…',
'status.fetching': 'Index ophalen...',
'status.files': {
one: '{n} bestand',
@@ -169,15 +170,17 @@ export default {
'settings.dl_choose': 'Map kiezen',
'settings.dl_change': 'Wijzigen',
'settings.dl_forget': 'Vergeten',
- 'settings.dl_path_note': 'Aan een webpagina kan geen pad worden doorgegeven, dus '
- + 'hier valt niets in te typen: uw browser verleent toegang tot de map die u '
- + 'aanwijst, en MeshBay schrijft uitsluitend daarbinnen. Mogelijk wordt u één keer '
- + 'per sessie om bevestiging gevraagd.',
- 'settings.dl_unsupported': 'Deze browser kan niet schrijven naar een map van uw keuze '
- + '(geen File System Access API), dus downloads gaan naar zijn eigen downloadmap. '
- + 'Chrome en Edge laten wel een keuze toe.',
+
'settings.profile': 'Profiel',
'settings.username': 'Gebruikersnaam',
+ 'settings.email': 'Email',
+ 'settings.email_save': 'Save',
+ 'settings.email_saved': 'Email updated',
+ 'settings.notif_global_disable': 'Disable all notifications',
+ 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.defaults': 'Standaardwaarden',
+ 'settings.default_tab': 'Default tab',
+ 'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
'settings.node_pins': 'Node-identiteiten',
'settings.node_pins_hint': 'De identiteitssleutel van elke node wordt bij de eerste '
+ 'verbinding onthouden. Verandert die, dan wordt de verbinding geweigerd — wat '
@@ -451,6 +454,8 @@ export default {
'chat.jump_new': 'Nieuwe berichten',
'group.leave': 'Groep verlaten',
'group.leave_confirm': '„{name}” verlaten? U verliest de toegang tot de bestanden en de chat ervan. Bestanden die u hebt geüpload blijven op de node, en de node houdt de voor u vastgezette identiteit tot zijn beheerder die weghaalt.',
+ 'group.mute': 'Mute notifications',
+ 'group.unmute': 'Unmute notifications',
'profile.title': 'Profiel',
'usermenu.profile': 'Profiel',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 6766515..eec144b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -130,6 +130,7 @@ export default {
'status.idle': 'Bezczynny',
'status.discovering': 'Szukanie nodes...',
'status.connecting': 'Łączenie przez WebRTC...',
+ 'status.connecting_short': 'Łączenie…',
'status.fetching': 'Pobieranie indeksu...',
'status.files': {
one: '{n} plik',
@@ -174,15 +175,17 @@ export default {
'settings.dl_choose': 'Wybierz folder',
'settings.dl_change': 'Zmień',
'settings.dl_forget': 'Zapomnij',
- 'settings.dl_path_note': 'Stronie internetowej nie da się podać ścieżki, więc nie ma '
- + 'tu czego wpisywać: to przeglądarka udziela dostępu do wskazanego folderu, a '
- + 'MeshBay zapisuje wyłącznie w jego wnętrzu. Raz na sesję może pojawić się prośba '
- + 'o potwierdzenie.',
- 'settings.dl_unsupported': 'Ta przeglądarka nie potrafi zapisywać w dowolnie '
- + 'wybranym folderze (brak File System Access API), więc pobrane pliki trafiają do '
- + 'jej własnego folderu pobierania. Chrome i Edge pozwalają wybrać folder.',
+
'settings.profile': 'Profil',
'settings.username': 'Nazwa użytkownika',
+ 'settings.email': 'Email',
+ 'settings.email_save': 'Save',
+ 'settings.email_saved': 'Email updated',
+ 'settings.notif_global_disable': 'Disable all notifications',
+ 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.defaults': 'Wartości domyślne',
+ 'settings.default_tab': 'Default tab',
+ 'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
'settings.node_pins': 'Tożsamości nodes',
'settings.node_pins_hint': 'Klucz tożsamości każdego node jest zapamiętywany przy '
+ 'pierwszym połączeniu. Jeśli się zmieni, połączenie zostanie odrzucone — czego '
@@ -464,6 +467,8 @@ export default {
'chat.jump_new': 'Nowe wiadomości',
'group.leave': 'Opuść grupę',
'group.leave_confirm': 'Opuścić grupę „{name}”? Utraci Pan(i) dostęp do jej plików i czatu. Wysłane pliki pozostaną na node, a node zachowa przypiętą dla Pana/Pani tożsamość do czasu, aż jego operator ją usunie.',
+ 'group.mute': 'Mute notifications',
+ 'group.unmute': 'Unmute notifications',
'profile.title': 'Profil',
'usermenu.profile': 'Profil',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index fa46887..ce2adf2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -127,6 +127,7 @@ export default {
'status.idle': 'Ocioso',
'status.discovering': 'Procurando nodes...',
'status.connecting': 'Conectando via WebRTC...',
+ 'status.connecting_short': 'Conectando…',
'status.fetching': 'Obtendo o índice...',
'status.files': {
one: '{n} arquivo',
@@ -169,15 +170,17 @@ export default {
'settings.dl_choose': 'Escolher pasta',
'settings.dl_change': 'Alterar',
'settings.dl_forget': 'Esquecer',
- 'settings.dl_path_note': 'Não é possível informar um caminho a uma página web, '
- + 'portanto não há nada a digitar aqui: o seu navegador concede acesso à pasta que '
- + 'você indicar, e o MeshBay só escreve dentro dela. Talvez seja pedida uma '
- + 'confirmação uma vez por sessão.',
- 'settings.dl_unsupported': 'Este navegador não consegue escrever em uma pasta de sua '
- + 'escolha (não tem a File System Access API), então os downloads vão para a pasta '
- + 'de downloads dele. Chrome e Edge permitem escolher uma.',
+
'settings.profile': 'Perfil',
'settings.username': 'Nome de usuário',
+ 'settings.email': 'Email',
+ 'settings.email_save': 'Save',
+ 'settings.email_saved': 'Email updated',
+ 'settings.notif_global_disable': 'Disable all notifications',
+ 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.defaults': 'Padrões',
+ 'settings.default_tab': 'Default tab',
+ 'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
'settings.node_pins': 'Identidades dos nodes',
'settings.node_pins_hint': 'A chave de identidade de cada node é memorizada na '
+ 'primeira conexão. Se ela mudar, a conexão é recusada — o que só é esperado '
@@ -448,6 +451,8 @@ export default {
'chat.jump_new': 'Mensagens novas',
'group.leave': 'Sair do grupo',
'group.leave_confirm': 'Sair de “{name}”? Você perderá o acesso aos arquivos e à conversa. Os arquivos que você enviou permanecem no node, e ele mantém a identidade que fixou para você até que o operador dele a remova.',
+ 'group.mute': 'Mute notifications',
+ 'group.unmute': 'Unmute notifications',
'profile.title': 'Perfil',
'usermenu.profile': 'Perfil',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 3276db8..f249835 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -122,6 +122,7 @@ export default {
'status.idle': '空闲',
'status.discovering': '正在查找 node…',
'status.connecting': '正在通过 WebRTC 连接…',
+ 'status.connecting_short': '正在连接…',
'status.fetching': '正在获取索引…',
'status.files': {
other: '{n} 个文件',
@@ -161,13 +162,17 @@ export default {
'settings.dl_choose': '选择文件夹',
'settings.dl_change': '更改',
'settings.dl_forget': '忘记',
- 'settings.dl_path_note': '网页无法被指定一个路径,因此这里没有什么需要输入:'
- + '由您的浏览器授予对所选文件夹的访问权限,而 MeshBay 只会写入该文件夹之内。'
- + '每个会话可能会要求您确认一次。',
- 'settings.dl_unsupported': '此浏览器无法写入您指定的文件夹(不支持 File System '
- + 'Access API),因此下载内容会进入它自己的下载文件夹。Chrome 和 Edge 支持自行选择。',
+
'settings.profile': '个人资料',
'settings.username': '用户名',
+ 'settings.email': 'Email',
+ 'settings.email_save': 'Save',
+ 'settings.email_saved': 'Email updated',
+ 'settings.notif_global_disable': 'Disable all notifications',
+ 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.',
+ 'settings.defaults': '默认值',
+ 'settings.default_tab': 'Default tab',
+ 'settings.default_tab_hint': 'Which tab opens first when you enter a group.',
'settings.node_pins': 'node 身份',
'settings.node_pins_hint': '每个 node 的身份密钥都会在您首次连接时被记住。'
+ '如果它发生变化,连接会被拒绝——只有当运营者重装 node 时才应如此。'
@@ -421,6 +426,8 @@ export default {
'chat.jump_new': '新消息',
'group.leave': '退出群组',
'group.leave_confirm': '退出“{name}”?您将失去其文件和聊天的访问权。您上传的文件仍留在 node 上,node 也会保留它为您固定的身份,直到其运营者将其移除。',
+ 'group.mute': 'Mute notifications',
+ 'group.unmute': 'Unmute notifications',
'profile.title': '个人资料',
'usermenu.profile': '个人资料',
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 372b666..6f8f654 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -189,6 +189,7 @@ export async function nativeSave(suggestedName, { auto = true } = {}) {
close: () => sink.close(),
abort: () => sink.abort(),
},
+ open: sink.open ? () => sink.open() : null,
};
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 238f33a..9ff5923 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -442,17 +442,6 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
}
.group-header h2 { margin-bottom: 0; }
-.status-badge {
- display: inline-block;
- padding: 3px 10px;
- border-radius: 12px;
- font-size: 0.75em;
- font-weight: 500;
-}
-.status-ok { background: #16a34a20; color: var(--success); }
-.status-err { background: var(--error-bg); color: var(--error); }
-.status-busy { background: var(--bg-raised); color: var(--text-secondary); }
-
/* ── Group tabs ──────────────────────────────────────────────────────────── */
.group-tabs {
@@ -467,19 +456,35 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
- padding: 8px 20px;
+ padding: 10px 18px;
color: var(--text-secondary);
- font-size: 0.9em;
- font-weight: 500;
cursor: pointer;
border-radius: 0;
transition: color 0.12s, border-color 0.12s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
}
.group-tab:hover { color: var(--text); background: none; }
.group-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
+.tab-icon { width: 22px; height: 22px; }
+
+.group-mute-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--text-dim);
+ padding: 4px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ margin-left: auto;
+}
+.group-mute-btn:hover { color: var(--text); }
+.group-mute-btn .icon { width: 20px; height: 20px; }
/* ── Chat panel ──────────────────────────────────────────────────────────── */
@@ -998,7 +1003,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
color: var(--text);
font-size: 0.9em;
cursor: pointer;
- min-width: 120px;
+ min-width: 180px;
}
.settings-select:focus { outline: none; border-color: var(--border-focus); }
@@ -1054,6 +1059,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
flex-shrink: 0;
}
.video-close:hover { background: rgba(255, 255, 255, 0.25); }
+.video-close.dl-active { background: rgba(34, 197, 94, 0.25); pointer-events: none; }
.video-container {
width: 100%;
@@ -1457,7 +1463,11 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Green while transfers are running: the count in the badge says how many, but
the colour is what is readable without looking at it. Back to the ordinary
nav colour the moment the last one finishes. */
-.transfer-btn.active .icon { color: var(--success); }
+@keyframes pulse-green {
+ 0%, 100% { color: #86efac; }
+ 50% { color: #15803d; }
+}
+.transfer-btn.active .icon { animation: pulse-green 2s ease-in-out infinite; }
.transfer-wrap { position: relative; display: flex; align-items: center; }
.transfer-btn {
@@ -1504,6 +1514,11 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
text-overflow: ellipsis;
white-space: nowrap;
}
+a.transfer-name {
+ color: var(--link);
+ text-decoration: underline;
+ cursor: pointer;
+}
.transfer-cancel {
background: none;
border: none;
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index c0326f0..8372e5c 100644
--- a/packages/meshbay-node/src/meshbay_node/config.py
+++ b/packages/meshbay-node/src/meshbay_node/config.py
@@ -70,6 +70,10 @@ quic_port = 19010
# and its files stay in the index,
# rather than looking deleted
+# upload_dir: a separate directory for uploads. Files land directly in it,
+# not in an "uploads" subdirectory. It appears as its own root in the index.
+# upload_dir = "/home/user/Incoming"
+
# The single-directory form still works and means the same thing — one root,
# named after the directory, receiving uploads.
[[groups]]
@@ -125,6 +129,7 @@ class RootSpec:
name: str = "" # empty → the directory's basename, derived at load
kind: str = "generic" # generic|video|audio|photo — a view hint, unused for now
upload: bool = False # exactly one root per group receives uploads
+ direct: bool = False # uploads land at root path, not in a subdirectory
@dataclass
@@ -137,6 +142,7 @@ class GroupConfig:
# unprefixed shape.
roots: list[RootSpec] = field(default_factory=list)
shared_dir: str = "" # legacy single-root form, migrated at load
+ upload_dir: str = "" # separate filesystem path for uploads
visibility: str = "private" # public|private — discoverability, not admission
# Admission. "invite" (default) means a newcomer needs a one-time pairing code
# before the node wraps the group key for them; "open" means the node pins
@@ -160,6 +166,11 @@ class GroupConfig:
"""
if not self.roots and self.shared_dir.strip():
self.roots = [RootSpec(path=self.shared_dir.strip(), upload=True)]
+ if self.upload_dir.strip():
+ for r in self.roots:
+ r.upload = False
+ self.roots.append(RootSpec(
+ path=self.upload_dir.strip(), upload=True, direct=True))
@dataclass
@@ -275,6 +286,7 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
# Ignored when roots are given explicitly (warned about in
# _read_roots); otherwise __post_init__ migrates it.
shared_dir="" if _read_roots(g) else g.get("shared_dir", ""),
+ upload_dir=g.get("upload_dir", ""),
visibility=g.get("visibility", "private"),
join_policy=g.get("join_policy", "invite"),
quic_port=g.get("quic_port", cfg.node.quic_port),
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 45aca7a..6d4f172 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -799,6 +799,8 @@ def main() -> None:
"denylist clear")
parser.add_argument("--dir", default=None,
help="shared directory, for group add")
+ parser.add_argument("--upload-dir", default=None,
+ help="separate upload directory, for group add")
parser.add_argument("--yes", action="store_true",
help="skip the confirmation for destructive commands")
parser.add_argument("--config", type=Path, default=None,
@@ -1147,7 +1149,9 @@ def main() -> None:
f"{g.get('peers', 0)} peer(s)")
print(f" {g['id']}")
for r in g.get("roots", []):
- flags = " (uploads)" if r.get("upload") else ""
+ flags = ""
+ if r.get("upload"):
+ flags = " (uploads, direct)" if r.get("direct") else " (uploads)"
live = "" if r.get("available", True) else " [UNAVAILABLE]"
print(f" root {r['name']}{flags}{live}")
if not g.get("has_gek"):
@@ -1156,20 +1160,25 @@ def main() -> None:
return
if args.subcommand != "add":
- print("usage: meshbay-node group list|add <name> --dir <path>")
+ print("usage: meshbay-node group list|add <name> --dir <path> [--upload-dir <path>]")
sys.exit(1)
if not args.target or not args.dir:
- print("usage: meshbay-node group add <name> --dir <path>")
+ print("usage: meshbay-node group add <name> --dir <path> [--upload-dir <path>]")
print()
print("The group must already exist on the hub and be yours. This")
print("only tells the node to host it, and picks the directory.")
+ print("--upload-dir sets a separate directory for uploaded files.")
sys.exit(1)
cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
- out = _daemon_api(cfg, "/api/groups/attach", method="POST",
- body={"name": args.target, "shared_dir": args.dir})
+ body = {"name": args.target, "shared_dir": args.dir}
+ if args.upload_dir:
+ body["upload_dir"] = args.upload_dir
+ out = _daemon_api(cfg, "/api/groups/attach", method="POST", body=body)
print(f"{out['name']} ({out['group_id'][:8]}) added to {out['config']}")
print(f" shared_dir {out['shared_dir']}")
+ if out.get("upload_dir"):
+ print(f" upload_dir {out['upload_dir']}")
print()
print("Tell the daemon to re-read its config, then give the group a key:")
print(" meshbay-node reload")
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index c111581..20345bb 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -315,7 +315,8 @@ async def list_groups(state: dict) -> dict:
return {"groups": out}
-async def attach_group(state: dict, name: str, shared_dir: str) -> dict:
+async def attach_group(state: dict, name: str, shared_dir: str,
+ upload_dir: str = "") -> dict:
"""
Write a new [[groups]] block into node.toml.
@@ -360,19 +361,30 @@ async def attach_group(state: dict, name: str, shared_dir: str) -> dict:
block = (f'\n[[groups]]\n'
f'id = "{group["id"]}"\n'
f'name = "{group["name"]}"\n'
- f'visibility = "{group.get("visibility", "private")}"\n'
- f'\n [[groups.roots]]\n'
- f' path = "{path}"\n'
- f' upload = true\n')
+ f'visibility = "{group.get("visibility", "private")}"\n')
+ if upload_dir:
+ upload_path = Path(upload_dir).expanduser()
+ try:
+ upload_path.mkdir(parents=True, exist_ok=True)
+ except OSError as e:
+ raise OpError(f"Cannot create {upload_path}: {e}") from e
+ block += f'upload_dir = "{upload_path}"\n'
+ block += (f'\n [[groups.roots]]\n'
+ f' path = "{path}"\n')
+ if not upload_dir:
+ block += f' upload = true\n'
try:
with conf_path.open("a") as f:
f.write(block)
except OSError as e:
raise OpError(f"Cannot write {conf_path}: {e}", status=500) from e
- return {"group_id": group["id"], "name": group["name"],
- "shared_dir": str(path), "config": str(conf_path),
- "note": "restart the node to pick it up"}
+ result = {"group_id": group["id"], "name": group["name"],
+ "shared_dir": str(path), "config": str(conf_path),
+ "note": "restart the node to pick it up"}
+ if upload_dir:
+ result["upload_dir"] = str(upload_path)
+ return result
async def add_root(state: dict, group_id: str, path: str, *,
diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py
index 8f999d7..bc27bf7 100644
--- a/packages/meshbay-node/src/meshbay_node/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/roots.py
@@ -51,6 +51,7 @@ class Root:
path: Path
kind: str = "generic"
upload: bool = False
+ direct: bool = False
# Runtime, not configuration: set by the indexer when the directory can no
# longer be read, and cleared when it comes back.
available: bool = True
@@ -139,7 +140,8 @@ class RootSet:
kind = "generic"
root = Root(name=name, path=path, kind=kind,
- upload=bool(spec.get("upload", False)))
+ upload=bool(spec.get("upload", False)),
+ direct=bool(spec.get("direct", False)))
_refuse_nesting(root, roots)
roots.append(root)
by_folded[root.folded] = root
@@ -277,11 +279,14 @@ class RootSet:
def describe(self) -> list[dict]:
"""Per-root state for the index payload and the admin UI."""
- return [
- {"name": r.name, "kind": r.kind, "available": r.available,
- "upload": r.upload}
- for r in self.roots
- ]
+ out = []
+ for r in self.roots:
+ d: dict = {"name": r.name, "kind": r.kind,
+ "available": r.available, "upload": r.upload}
+ if r.direct:
+ d["direct"] = True
+ out.append(d)
+ return out
def entry_abs_path(roots: RootSet, entry) -> Path | None:
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 22e5e15..f182ab2 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -2087,21 +2087,20 @@ class WebRTCPeerSession:
"filename": filename})
return
- # One destination, chosen by the operator and not by the client:
- # uploads/ inside the group's designated root. C5a is still honoured —
- # the name passed the allowlist above, and an existing file is never
- # replaced, which was the real defect (overwriting a file also made the
- # attacker its recorded uploader, and therefore able to delete it).
- rel_dir = f"{upload_root.name}/{UPLOAD_DIR_NAME}"
- target_dir = upload_root.path / UPLOAD_DIR_NAME
- try:
- target_dir.mkdir(parents=True, exist_ok=True)
- except OSError as e:
- log.warning("Cannot create upload folder in root %r: %s",
- upload_root.name, e)
- self._send({"type": "error", "detail": "Upload folder unavailable",
- "filename": filename})
- return
+ if upload_root.direct:
+ rel_dir = upload_root.name
+ target_dir = upload_root.path
+ else:
+ rel_dir = f"{upload_root.name}/{UPLOAD_DIR_NAME}"
+ target_dir = upload_root.path / UPLOAD_DIR_NAME
+ try:
+ target_dir.mkdir(parents=True, exist_ok=True)
+ except OSError as e:
+ log.warning("Cannot create upload folder in root %r: %s",
+ upload_root.name, e)
+ self._send({"type": "error", "detail": "Upload folder unavailable",
+ "filename": filename})
+ return
upload_key = f"{rel_dir}/{filename}"
state = self._uploads.get(upload_key)
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index 050829e..74a7c8a 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -136,6 +136,7 @@ def create_ui_app(state: dict) -> FastAPI:
state,
(payload.get("name") or "").strip(),
(payload.get("shared_dir") or "").strip(),
+ upload_dir=(payload.get("upload_dir") or "").strip(),
))
@app.delete("/api/groups/{group_id}/files/{file_id}")