diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-14 23:52:13 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-14 23:52:13 +0200 |
| commit | 0355167e02a710c0e40484592ac794810cde3922 (patch) | |
| tree | c57f258b57c7b2eac7aa29369fc42f0253e78fe2 /packages/meshbay-hub/src/meshbay_hub/static | |
| parent | 8d8f85b4bf976249266a89f692408027216711b7 (diff) | |
| download | meshbay-0355167e02a710c0e40484592ac794810cde3922.tar.gz | |
Notifications: one per conversation, none for your own messages
Four things were wrong, and they compounded: a busy chat produced one row
per message, muting a group did nothing at all, there was no way to clear
the list, and the one person guaranteed to know about a message — its
author — was told about it.
The author bug was a name mismatch across two processes. The node sent
chat_notify without saying who wrote the message, so the hub used the
node's own token subject, which is the operator's account. The skip
therefore matched the operator and no one else: everybody was notified of
their own messages, and the operator was notified of nobody's. The node
now names the author and the hub reads that field.
Muting lived in the browser's localStorage and nothing ever read it, so
the checkbox was decoration. It is a column on group_members now, checked
where the notification is created — a notification nobody wants is not
written at all.
Chat keeps a single row per (user, kind, group) whose date moves and whose
read flag clears, so a conversation is one line saying when it last spoke.
Clicking it opens the group and dismisses it; joining a group dismisses
its invitation; and DELETE /v1/notifications clears the lot.
The hub deploy now runs alembic. create_all() only creates missing tables,
so group_members.muted never arrived on the running hub and /v1/groups/mine
answered 500 — worth catching in the script rather than in a browser.
Verified end to end against the deployed hub and node: the author receives
nothing, the other member receives exactly one, carrying its group_id.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 90 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 1 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 3 |
3 files changed, 71 insertions, 23 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 7b1b5fc..3197c31 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -503,15 +503,22 @@ function RegisterPage() { // ── Home Page ──────────────────────────────────────────────────────────────── -function NotificationFeed({ notifications, onMarkRead }) { +function NotificationFeed({ notifications, onMarkRead, onPurge }) { if (!notifications.length) return null; return html` <div class="notif-feed"> - <h3>${t('notif.title')}</h3> + <h3> + ${t('notif.title')} + <button class="btn-secondary notif-purge" onClick=${onPurge}> + ${t('notif.purge')} + </button> + </h3> ${notifications.map(n => html` <div key=${n.id} class="notif-item ${n.read ? '' : 'notif-unread'}" onClick=${() => { - if (!n.read) onMarkRead(n.id); + // Reading it is the point of clicking it: it goes, here and in the + // count, rather than sitting there greyed out. + onMarkRead(n.id); if (n.link) navigate(n.link); }}> <span class="notif-kind">${n.kind}</span> @@ -523,12 +530,13 @@ function NotificationFeed({ notifications, onMarkRead }) { `; } -function HomePage({ groups, notifications, onMarkRead }) { +function HomePage({ groups, notifications, onMarkRead, onPurge }) { if (groups.length === 0) { return html` <div> <h2>${t('home.welcome')}</h2> - <${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} /> + <${NotificationFeed} notifications=${notifications} + onMarkRead=${onMarkRead} onPurge=${onPurge} /> <p class="page-message"> ${t('home.no_groups')} ${' '}${t('home.browse_prefix')}<a href="#/explore">${t('home.browse_link')}</a>${t('home.browse_suffix')} @@ -540,7 +548,8 @@ function HomePage({ groups, notifications, onMarkRead }) { return html` <div> <h2>${t('home.my_groups')}</h2> - <${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} /> + <${NotificationFeed} notifications=${notifications} + onMarkRead=${onMarkRead} onPurge=${onPurge} /> <div class="group-grid"> ${groups.map(g => html` <a key=${g.id} class="group-card" href="#/group/${g.id}"> @@ -809,7 +818,8 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk return results; } -function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { +function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, + onJoined }) { const [status, setStatus] = useState('idle'); const [entries, setEntries] = useState([]); const [cached, setCached] = useState(false); @@ -924,6 +934,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { cacheGroupIndex(groupId, group ? group.name : groupId, synced); }; + // We are in: an invitation to this group has served its purpose. + if (onJoined) onJoined(groupId); + const indexMsg = await transport.fetchIndex(); if (cancelled) return; const freshEntries = indexMsg.entries || []; @@ -2132,10 +2145,9 @@ const THEME_OPTIONS = ['light', 'dark', 'system']; function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) { const [locale, setLoc] = useState(getLocale); - const [muted, setMuted] = useState(() => { - try { return JSON.parse(localStorage.getItem('mb_muted') || '{}'); } - catch { return {}; } - }); + // 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 [nodeKey, setNodeKey] = useState(''); const [currentNodeKey, setCurrentNodeKey] = useState(null); const [nodeKeyStatus, setNodeKeyStatus] = useState(''); @@ -2192,13 +2204,20 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) { onThemeChange(e.target.value); }, [onThemeChange]); - const toggleMute = useCallback((gid) => { - setMuted(prev => { - const next = { ...prev, [gid]: !prev[gid] }; - localStorage.setItem('mb_muted', JSON.stringify(next)); - return next; - }); - }, []); + 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 { + await hubFetch(`/v1/groups/${gid}/mute`, { + method: 'POST', token: user.token, body: { muted: next }, + }); + } catch (err) { + setMuted(prev => ({ ...prev, [gid]: !next })); + } + }, [muted, user.token]); const submitNodeKey = useCallback(async () => { const key = nodeKey.trim(); @@ -2782,11 +2801,34 @@ function App() { const markRead = useCallback((id) => { if (!user) return; + // Drop it here and now. Waiting for the round trip leaves it on screen while + // the page navigates, which reads as "the click did nothing". + setNotifications(prev => prev.filter(n => n.id !== id)); + setUnreadCount(c => Math.max(0, c - 1)); hubFetch(`/v1/notifications/${id}/read`, { method: 'POST', token: user.token }) - .then(() => fetchNotifications()) - .catch(() => {}); + .catch(() => fetchNotifications()); + }, [user, fetchNotifications]); + + const purgeNotifications = useCallback(() => { + if (!user) return; + setNotifications([]); + setUnreadCount(0); + hubFetch('/v1/notifications', { method: 'DELETE', token: user.token }) + .catch(() => fetchNotifications()); }, [user, fetchNotifications]); + /** Clear the invitation for a group once its code has actually been redeemed. */ + const dismissGroupNotifications = useCallback((groupId) => { + if (!user) return; + setNotifications(prev => { + const gone = prev.filter(n => n.group_id === groupId && n.kind === 'group_invite'); + gone.forEach(n => hubFetch(`/v1/notifications/${n.id}/read`, + { method: 'POST', token: user.token }).catch(() => {})); + if (gone.length) setUnreadCount(c => Math.max(0, c - gone.length)); + return prev.filter(n => !gone.includes(n)); + }); + }, [user]); + useEffect(() => { setMenuOpen(false); }, [route]); const changeTheme = useCallback((val) => { @@ -2865,16 +2907,18 @@ function App() { page = html`<${GroupPage} groupId=${groupId} group=${group} token=${user.token} username=${user.username} userId=${user.userId} - onRefreshAuth=${refreshAuth} />`; + onRefreshAuth=${refreshAuth} onJoined=${dismissGroupNotifications} />`; } else if (route === '/admin') { page = (user.role === 'moderator' || user.role === 'admin') ? html`<${AdminPage} token=${user.token} />` - : html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`; + : html`<${HomePage} groups=${groups} notifications=${notifications} + onMarkRead=${markRead} onPurge=${purgeNotifications} />`; } else if (route === '/settings') { page = html`<${SettingsPage} user=${user} theme=${theme} onThemeChange=${setTheme} groups=${groups} onLogout=${authCtx.logout} />`; } else { - page = html`<${HomePage} groups=${groups} notifications=${notifications} onMarkRead=${markRead} />`; + page = html`<${HomePage} groups=${groups} notifications=${notifications} + onMarkRead=${markRead} onPurge=${purgeNotifications} />`; } return html` diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index 1b03375..6f27c32 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -280,6 +280,7 @@ const en = { + 'it here. After that this browser is recognised and you will not be asked again.', 'group.join_code_btn': 'Join', + 'notif.purge': 'Clear all', 'notif.title': 'Notifications', 'notif.empty': 'No notifications', 'notif.mark_all_read': 'Mark all read', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index d948dc2..da29027 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -1433,3 +1433,6 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } } .btn-danger:hover { filter: brightness(1.1); } .btn-danger:disabled { opacity: 0.5; cursor: not-allowed; } + +.notif-feed h3 { display: flex; align-items: center; justify-content: space-between; } +.notif-purge { font-size: 0.8em; padding: 4px 10px; } |