diff options
Diffstat (limited to 'packages')
10 files changed, 422 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" 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 483a6a7..d654894 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -1177,6 +1177,11 @@ class WebRTCPeerSession: "type": "chat_notify", "group_id": self._group_id, "sender_name": sender_name, + # Who actually wrote it, from the authenticated session. The + # hub used to fall back to this node's own token subject — + # the operator — so everyone was notified of their own + # messages and the operator was notified of nobody's. + "sender_user_id": self._user_id, }))) except Exception: pass |