From 90c3c6a01f46c9b29c5d92702d7383f32e8951d7 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 19 Aug 2026 16:36:29 +0200 Subject: feat(ui): 11-point UI overhaul — tabs, transfers, settings, uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/meshbay-client/src/main.js | 9 + packages/meshbay-client/src/preload.js | 1 + packages/meshbay-hub/src/meshbay_hub/api/users.py | 122 ++++++++- .../versions/f1a2b3c4d5e6_add_user_preferences.py | 30 +++ packages/meshbay-hub/src/meshbay_hub/db/models.py | 9 + packages/meshbay-hub/src/meshbay_hub/static/app.js | 291 +++++++++++++++++---- .../src/meshbay_hub/static/locales/de.js | 19 +- .../src/meshbay_hub/static/locales/en.js | 17 +- .../src/meshbay_hub/static/locales/es.js | 19 +- .../src/meshbay_hub/static/locales/fr.js | 20 +- .../src/meshbay_hub/static/locales/it.js | 19 +- .../src/meshbay_hub/static/locales/ja.js | 20 +- .../src/meshbay_hub/static/locales/nl.js | 19 +- .../src/meshbay_hub/static/locales/pl.js | 19 +- .../src/meshbay_hub/static/locales/pt-BR.js | 19 +- .../src/meshbay_hub/static/locales/zh-CN.js | 17 +- .../meshbay-hub/src/meshbay_hub/static/platform.js | 1 + .../meshbay-hub/src/meshbay_hub/static/style.css | 47 ++-- packages/meshbay-node/src/meshbay_node/config.py | 12 + packages/meshbay-node/src/meshbay_node/daemon.py | 19 +- packages/meshbay-node/src/meshbay_node/ops.py | 28 +- packages/meshbay-node/src/meshbay_node/roots.py | 17 +- .../src/meshbay_node/transport/webrtc_server.py | 29 +- packages/meshbay-node/src/meshbay_node/ui/app.py | 1 + 24 files changed, 628 insertions(+), 176 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/f1a2b3c4d5e6_add_user_preferences.py (limited to 'packages') 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 }) { + `} ${error && html`
${error}
`} ${needsDevice && html` @@ -1969,15 +2006,18 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, chat still need the node and say so. */ group && html`
+ onClick=${() => setTab('chat')} title=${t('group.tab_chat')}> + <${Icon} name="chat" cls="tab-icon" /> + onClick=${() => setTab('files')} title=${t('group.tab_files')}> + <${Icon} name="folder" cls="tab-icon" /> + onClick=${() => setTab('settings')} title=${t('group.tab_settings')}> + <${Icon} name="gear" cls="tab-icon" />
${tab === 'files' && status !== 'connected' && html` -

${' '}${t('status.connecting')}

+

${' '}${t('status.connecting_short')}

`} ${tab === 'files' && status === 'connected' && html` @@ -2114,7 +2154,7 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, }} /> `} ${tab === 'chat' && status !== 'connected' && html` -

${' '}${t('status.connecting')}

+

${' '}${t('status.connecting_short')}

`} ${tab === 'settings' && html` @@ -2134,8 +2174,8 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, ${' '}${t('group.offline_hint')}

`} - ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html` -

${statusLabel}

+ ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && !group && html` +

${' '}${t('status.connecting_short')}

`} ${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 }) {
${entry.name} (${formatSize(entry.size)}) ${onDownload && html` - + `} @@ -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, `} -