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-hub/src/meshbay_hub/static/app.js | 291 +++++++++++++++++----
1 file changed, 235 insertions(+), 56 deletions(-)
(limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
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 }) {
`}
- ${(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,
`}
-
@@ -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 }) {
${t('settings.downloads')}
- ${!(downloads.SUPPORTED || platform.folder.available)
- ? html`
${t('settings.dl_unsupported')}
`
- : html`
+ ${(downloads.SUPPORTED || platform.folder.available) && html`
${dlError && html`
${dlError}
`}
-
${t('settings.dl_path_note')}
- `}
+ `}
@@ -4313,9 +4451,17 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
- ${groups.length > 0 && html`
-
-
${t('settings.groups')}
+
+
${t('settings.groups')}
+
+ ${t('settings.notif_global_disable')}
+
+
+ ${!globalMute && html`
+
${t('settings.notif_global_hint')}
${groups.map(g => html`
${g.name}
@@ -4326,8 +4472,22 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
`)}
+ `}
+
+
+
+
${t('settings.defaults')}
+
+ ${t('settings.default_tab')}
+
- `}
+
${t('settings.default_tab_hint')}
+
${platform.isNative && html`
@@ -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 {
--
cgit v1.2.3