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 | |
| 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')
9 files changed, 417 insertions, 27 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 000f3f7..43d72c3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -33,12 +33,17 @@ async def my_groups( .order_by(Group.name) ) groups = result.scalars().all() + muted_rows = await db.execute( + select(GroupMember.group_id, GroupMember.muted) + .where(GroupMember.user_id == current_user.id)) + muted_map = {gid: bool(m) for gid, m in muted_rows.all()} return { "groups": [ { "id": g.id, "name": g.name, "visibility": g.visibility, + "muted": muted_map.get(g.id, False), "join_policy": g.join_policy, "created_at": g.created_at.isoformat(), "is_admin": g.admin_id == current_user.id, @@ -301,12 +306,39 @@ async def add_group_member( db, target.id, "group_invite", f"You were added to {group.name}", link=f"#/group/{group_id}", + group_id=group_id, ) await db.commit() return {"status": "stored", "group_id": group_id, "username": username} +class MuteRequest(BaseModel): + muted: bool + + +@router.post("/{group_id}/mute") +async def set_group_mute( + group_id: str, + body: MuteRequest, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """ + Turn this group's notifications on or off, for this account. + + Server-side on purpose: it used to be a checkbox in the browser's + localStorage that nothing ever read, so turning notifications off for a group + had no effect anywhere. Now nothing is created in the first place. + """ + membership = await db.get(GroupMember, (group_id, current_user.id)) + if not membership: + raise HTTPException(status_code=404, detail="Not a member of this group") + membership.muted = body.muted + await db.commit() + return {"status": "ok", "group_id": group_id, "muted": body.muted} + + @router.delete("/{group_id}") async def delete_group( group_id: str, diff --git a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py index e7d36d2..ca56620 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py @@ -1,12 +1,14 @@ """Notification endpoints — /v1/notifications/*""" from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select, func, update +from datetime import datetime, timezone + +from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.api.deps import get_current_user from meshbay_hub.db.engine import get_db -from meshbay_hub.db.models import Notification, User +from meshbay_hub.db.models import GroupMember, Notification, User router = APIRouter(prefix="/v1/notifications", tags=["notifications"]) @@ -40,6 +42,7 @@ async def list_notifications( "title": n.title, "detail": n.detail, "link": n.link, + "group_id": n.group_id, "read": n.read, "created_at": n.created_at.isoformat(), } @@ -63,6 +66,24 @@ async def mark_read( return {"status": "ok"} +@router.delete("") +async def purge_notifications( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Throw them all away. + + These are signals, not a record: the group is still there, the message is + still in the chat, the invitation is still an invitation. Nothing is lost by + clearing the list, so it clears rather than marking a hundred rows read. + """ + result = await db.execute( + delete(Notification).where(Notification.user_id == current_user.id)) + await db.commit() + return {"status": "ok", "removed": result.rowcount} + + @router.post("/read-all") async def mark_all_read( current_user: User = Depends(get_current_user), @@ -84,9 +105,50 @@ async def create_notification( title: str, detail: str | None = None, link: str | None = None, -) -> Notification: + group_id: str | None = None, + aggregate: bool = False, +) -> Notification | None: + """ + Create a notification, or refresh the one already standing for this group. + + `aggregate` is for anything that repeats — chat, above all. One row per + (person, kind, group) whose date moves and whose read flag clears, so a busy + conversation is a single line saying when it last spoke rather than forty + saying that it spoke. + + Returns None when the person muted this group: the point of muting is that + nothing is created, not that something is created and hidden. + """ + if group_id is not None: + muted = await db.execute( + select(GroupMember.muted).where( + GroupMember.group_id == group_id, + GroupMember.user_id == user_id, + ) + ) + if muted.scalar() is True: + return None + + if aggregate and group_id is not None: + existing = (await db.execute( + select(Notification).where( + Notification.user_id == user_id, + Notification.kind == kind, + Notification.group_id == group_id, + ).order_by(Notification.created_at.desc()).limit(1) + )).scalar_one_or_none() + if existing is not None: + existing.title = title + existing.detail = detail + existing.link = link + existing.read = False + existing.created_at = datetime.now(timezone.utc) + await db.flush() + return existing + notif = Notification( user_id=user_id, kind=kind, title=title, detail=detail, link=link, + group_id=group_id, ) db.add(notif) await db.flush() diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index 2e3323c..58ebf50 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -122,6 +122,9 @@ async def _handle_chat_notify(group_id: str, sender_name: str, sender_user_id: s db, uid, "chat_message", f"{sender_name or 'Someone'} posted in {group.name}", link=f"#/group/{group_id}", + group_id=group_id, + # One line per conversation, moved to when it last spoke. + aggregate=True, ) await db.commit() except Exception as e: @@ -250,7 +253,12 @@ async def node_websocket(ws: WebSocket): asyncio.ensure_future(_handle_chat_notify( msg.get("group_id", ""), msg.get("sender_name", ""), - decoded.get("sub", ""), + # The author, as the node authenticated them — not + # decoded["sub"], which is the machine's own account and + # made this filter miss everyone except the operator. A node + # that lied here could only suppress one notification, which + # is the same power it has by not sending the message at all. + msg.get("sender_user_id", ""), )) except WebSocketDisconnect: diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b4d82e1c77a9_notification_groups_and_mute.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b4d82e1c77a9_notification_groups_and_mute.py new file mode 100644 index 0000000..d66a932 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b4d82e1c77a9_notification_groups_and_mute.py @@ -0,0 +1,41 @@ +"""notification_groups_and_mute + +Two columns, both so notifications behave the way people expect. + +`notifications.group_id` lets chat keep one row per group and move its date, +instead of one row per message — a busy conversation should be one line saying +when it last spoke. + +`group_members.muted` moves a setting that existed only in the browser's +localStorage, where nothing ever read it: turning notifications off for a group +did nothing at all. Muting now happens where the notification is created, so it +is not created. + +Revision ID: b4d82e1c77a9 +Revises: a7c31f9e40b2 +Create Date: 2026-08-14 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = 'b4d82e1c77a9' +down_revision: Union[str, Sequence[str], None] = 'a7c31f9e40b2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('notifications', + sa.Column('group_id', sa.String(36), sa.ForeignKey('groups.id'), + nullable=True)) + op.add_column('group_members', + sa.Column('muted', sa.Boolean(), nullable=False, + server_default=sa.false())) + + +def downgrade() -> None: + op.drop_column('group_members', 'muted') + op.drop_column('notifications', 'group_id') diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index a75217b..f749204 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -103,6 +103,10 @@ class GroupMember(Base): group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), primary_key=True) user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), primary_key=True) + # Set here rather than in the browser: a notification nobody wants should not + # be created at all. It used to be a checkbox in localStorage that nothing + # read, so muting a group did nothing whatsoever. + muted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) group: Mapped["Group"] = relationship(back_populates="members") @@ -184,6 +188,10 @@ class Notification(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) kind: Mapped[str] = mapped_column(String(32), nullable=False) + # Which group this is about, when it is about one. Chat keeps a single row per + # group and moves its date, so a busy conversation is one line that says when + # it last spoke — not forty lines saying it spoke. + group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id")) title: Mapped[str] = mapped_column(String(256), nullable=False) detail: Mapped[str | None] = mapped_column(String(512)) link: Mapped[str | None] = mapped_column(String(256)) 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; } diff --git a/packages/meshbay-hub/tests/test_notifications_behaviour.py b/packages/meshbay-hub/tests/test_notifications_behaviour.py new file mode 100644 index 0000000..376b9b0 --- /dev/null +++ b/packages/meshbay-hub/tests/test_notifications_behaviour.py @@ -0,0 +1,191 @@ +""" +How notifications behave, which is mostly about what they do NOT do. + +Three things were wrong and are pinned here: a busy chat produced one row per +message, muting a group did nothing whatsoever (the setting lived in the +browser's localStorage and nothing read it), and there was no way to clear the +list. +""" + +import hashlib +import base64 + +import pytest +from sqlalchemy import select + +from meshbay_hub.db.models import GroupMember, Notification, User + + +def _auth_key(password: str, username: str) -> str: + salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest() + return base64.b64encode( + hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode() + + +async def _user(client, username, password="a-long-enough-passphrase"): + await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@example.com", + "auth_key": _auth_key(password, username)}) + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": _auth_key(password, username)}) + return r.json()["access_token"] + + +@pytest.mark.asyncio +async def test_chat_keeps_one_notification_per_group(client, db_session): + """Forty messages are one line saying when the conversation last spoke.""" + from meshbay_hub.api.notifications import create_notification + + token = await _user(client, "listener") + owner = await _user(client, "talker") + g = await client.post("/v1/groups", json={"name": "busy"}, + headers={"Authorization": f"Bearer {owner}"}) + gid = g.json()["group_id"] + await client.post(f"/v1/groups/{gid}/members/listener", json={}, + headers={"Authorization": f"Bearer {owner}"}) + uid = (await db_session.execute( + select(User.id).where(User.username == "listener"))).scalar_one() + + for i in range(5): + await create_notification(db_session, uid, "chat_message", + f"talker posted in busy ({i})", + group_id=gid, aggregate=True) + await db_session.commit() + + rows = (await db_session.execute( + select(Notification).where(Notification.user_id == uid, + Notification.kind == "chat_message"))).scalars().all() + assert len(rows) == 1, f"{len(rows)} rows for one conversation" + assert rows[0].title.endswith("(4)"), "it should carry the latest message" + assert rows[0].read is False, "a new message makes it unread again" + + +@pytest.mark.asyncio +async def test_muting_a_group_stops_notifications_being_created(client, db_session): + """ + The point of muting is that nothing is created — not that something is + created and then hidden by whoever happens to be rendering it. + """ + from meshbay_hub.api.notifications import create_notification + + token = await _user(client, "quiet") + owner = await _user(client, "noisy") + g = await client.post("/v1/groups", json={"name": "loud"}, + headers={"Authorization": f"Bearer {owner}"}) + gid = g.json()["group_id"] + await client.post(f"/v1/groups/{gid}/members/quiet", json={}, + headers={"Authorization": f"Bearer {owner}"}) + + r = await client.post(f"/v1/groups/{gid}/mute", json={"muted": True}, + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200, r.text + + uid = (await db_session.execute( + select(User.id).where(User.username == "quiet"))).scalar_one() + await db_session.execute( + select(GroupMember).where(GroupMember.user_id == uid)) + + made = await create_notification(db_session, uid, "chat_message", + "noisy posted in loud", + group_id=gid, aggregate=True) + await db_session.commit() + assert made is None, "a muted group must not produce a notification" + + rows = (await db_session.execute( + select(Notification).where(Notification.user_id == uid, + Notification.kind == "chat_message"))).scalars().all() + assert rows == [] + + +@pytest.mark.asyncio +async def test_mute_is_reported_with_the_group_list(client): + """So a second browser shows the same state, which localStorage never did.""" + token = await _user(client, "settings-user") + g = await client.post("/v1/groups", json={"name": "mine"}, + headers={"Authorization": f"Bearer {token}"}) + gid = g.json()["group_id"] + + await client.post(f"/v1/groups/{gid}/mute", json={"muted": True}, + headers={"Authorization": f"Bearer {token}"}) + listing = await client.get("/v1/groups/mine", + headers={"Authorization": f"Bearer {token}"}) + assert listing.json()["groups"][0]["muted"] is True + + +@pytest.mark.asyncio +async def test_purge_clears_the_list(client, db_session): + from meshbay_hub.api.notifications import create_notification + + token = await _user(client, "cluttered") + uid = (await db_session.execute( + select(User.id).where(User.username == "cluttered"))).scalar_one() + for i in range(3): + await create_notification(db_session, uid, "system", f"thing {i}") + await db_session.commit() + + r = await client.delete("/v1/notifications", + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 + assert r.json()["removed"] == 3 + + after = await client.get("/v1/notifications", + headers={"Authorization": f"Bearer {token}"}) + assert after.json()["notifications"] == [] + assert after.json()["unread_count"] == 0 + + +@pytest.mark.asyncio +async def test_only_your_own_notifications_are_purged(client, db_session): + from meshbay_hub.api.notifications import create_notification + + mine = await _user(client, "self") + await _user(client, "other") + other_id = (await db_session.execute( + select(User.id).where(User.username == "other"))).scalar_one() + await create_notification(db_session, other_id, "system", "not yours") + await db_session.commit() + + await client.delete("/v1/notifications", + headers={"Authorization": f"Bearer {mine}"}) + + left = (await db_session.execute( + select(Notification).where(Notification.user_id == other_id))).scalars().all() + assert len(left) == 1, "purging one account emptied another" + + +@pytest.mark.asyncio +async def test_you_are_not_notified_of_your_own_message(client, db_session): + """ + The node reports who wrote the message. The hub used to substitute the + node's own token subject — the operator's account — so the filter matched + the operator and nobody else: everyone was told about their own messages, + and the operator was told about no one's. + """ + from meshbay_hub.api.revocation import _handle_chat_notify + + owner = await _user(client, "operator") + await _user(client, "chatty") + await _user(client, "quiet") + g = await client.post("/v1/groups", json={"name": "room"}, + headers={"Authorization": f"Bearer {owner}"}) + gid = g.json()["group_id"] + for name in ("chatty", "quiet"): + await client.post(f"/v1/groups/{gid}/members/{name}", json={}, + headers={"Authorization": f"Bearer {owner}"}) + + ids = {u.username: u.id for u in (await db_session.execute( + select(User).where(User.username.in_(["operator", "chatty", "quiet"])) + )).scalars().all()} + + await _handle_chat_notify(gid, "chatty", ids["chatty"]) + + async def chat_rows(uid): + return (await db_session.execute( + select(Notification).where(Notification.user_id == uid, + Notification.kind == "chat_message") + )).scalars().all() + + assert await chat_rows(ids["chatty"]) == [], "notified of their own message" + assert len(await chat_rows(ids["quiet"])) == 1 + assert len(await chat_rows(ids["operator"])) == 1, \ + "the operator reads the group too, and was the one being skipped" |