summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py32
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/notifications.py68
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/b4d82e1c77a9_notification_groups_and_mute.py41
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js90
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css3
-rw-r--r--packages/meshbay-hub/tests/test_notifications_behaviour.py191
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py5
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