patx/gitman

Track unread notifications and exclude unstarred forks

Commit 6bf6ab4 · patx · 2026-11-03T13:24:53-05:00

Changeset
6bf6ab4a85603a9a3865115d66bbcb65ab08a262
Parents
5a613ad7390953e0795167e67a60aebbb881313b

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index b3c6b0b..a95789a 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,15 @@ Issue close/reopen and pull request close history is recorded from this version
 Commit activity scans up to `GITMAN_ACTIVITY_REPO_SCAN_LIMIT` owned or starred repositories,
 prioritizing the most recently updated repositories.
 
+The bell shows an unread count (99+ for larger counts). Opening the feed does not
+mark it read. Use **Mark as read** on an entry or **Mark all as read** to clear the
+current activity; read entries stay in the feed. Read state is stored per account
+and shared across devices. New events remain unread, including replies posted in
+the same second as a bulk-read action and commits with backdated author dates.
+The count refreshes on page load, when returning to a tab, and once a minute while
+visible. Other people's forks of your repositories are excluded unless you star
+that specific fork, including mentions and followed issues in those forks.
+
 ## Configuration
 
 - `SECRET_KEY`: Bottle signed-cookie secret
diff --git a/app.py b/app.py
index 0531e79..f8e7f8b 100644
--- a/app.py
+++ b/app.py
@@ -358,6 +358,18 @@ def init_db():
                 created_at TEXT NOT NULL
             );
 
+            CREATE TABLE IF NOT EXISTS notification_reads (
+                user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+                event_key TEXT NOT NULL,
+                read_at TEXT NOT NULL,
+                PRIMARY KEY(user_id, event_key)
+            );
+
+            CREATE TABLE IF NOT EXISTS notification_read_state (
+                user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+                read_before TEXT NOT NULL
+            );
+
             CREATE TABLE IF NOT EXISTS repositories (
                 id INTEGER PRIMARY KEY AUTOINCREMENT,
                 owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -1341,6 +1353,31 @@ def activity_sort_key(action):
         parse_activity_time(action["occurred_at"]),
         action["kind"],
         action["target_url"],
+        action.get("event_key", ""),
+    )
+
+
+def notification_key(kind, event_id):
+    return hashlib.sha256(f"{kind}:{event_id}".encode()).hexdigest()
+
+
+def notification_signature(user_id, event_key):
+    return hmac.new(SECRET_KEY.encode(), f"notification:{user_id}:{event_key}".encode(), hashlib.sha256).hexdigest()
+
+
+def notification_read_state(user_id):
+    if user_id is None:
+        return None, set()
+    with db_connect() as conn:
+        state = conn.execute("SELECT read_before FROM notification_read_state WHERE user_id = ?", (user_id,)).fetchone()
+        keys = {row[0] for row in conn.execute("SELECT event_key FROM notification_reads WHERE user_id = ?", (user_id,))}
+    return state[0] if state else None, keys
+
+
+def notification_is_read(action, read_before, read_keys):
+    return action["event_key"] in read_keys or bool(
+        action.get("kind") != "commit_created" and read_before
+        and parse_activity_time(action["occurred_at"]) < parse_activity_time(read_before)
     )
 
 
@@ -1386,61 +1423,72 @@ def list_activity_repositories(limit=ACTIVITY_REPO_SCAN_LIMIT, user_id=None):
         ).fetchall()
 
 
-def list_commit_activity_actions(limit, user_id=None):
-    actions = []
+def iter_commit_activity_actions(limit, user_id=None, *, read_before=None, read_keys=None, unread_only=False, since=None):
     user = get_user_by_id(user_id) if user_id is not None else None
     own_names = {name.strip().casefold() for name in (user["username"], user["display_name"]) if name.strip()} if user else set()
     repositories = list_activity_repositories() if user_id is None else list_activity_repositories(user_id=user_id)
     for repo in repositories:
         path = repo_path(repo["owner_username"], repo["name"])
+        batch_limit = max(100, limit or 100) if own_names or unread_only or since or limit is None else limit
+        offset = 0
+        emitted = 0
         try:
-            batch_limit = max(100, limit) if own_names else limit
             commits = commit_log(path, limit=batch_limit)
-            if own_names:
-                visible = []
-                offset = 0
-                while commits:
-                    visible.extend(commit for commit in commits if commit["author"].strip().casefold() not in own_names)
-                    if len(visible) >= limit or len(commits) < batch_limit:
+            while commits:
+                for commit in commits:
+                    action = {
+                        "kind": "commit_created",
+                        "event_key": notification_key("commit_created", f"{repo['id']}:{commit['node']}"),
+                        "occurred_at": commit["date"],
+                        "actor_username": "",
+                        "actor_label": commit["author"],
+                        "actor_url": "",
+                        "repo_owner_username": repo["owner_username"],
+                        "repo_name": repo["name"],
+                        "target_url": f"/{repo['owner_username']}/{repo['name']}/commits/{commit['node']}",
+                        "target_label": commit["short_node"],
+                        "summary": "committed",
+                        "detail": text_preview(commit["summary"]),
+                    }
+                    if commit["author"].strip().casefold() in own_names:
+                        continue
+                    if since and parse_activity_time(commit["date"]) < parse_activity_time(since):
+                        continue
+                    if unread_only and notification_is_read(action, read_before, read_keys or set()):
+                        continue
+                    yield action
+                    emitted += 1
+                    if limit is not None and emitted >= limit:
                         break
-                    offset += len(commits)
-                    commits = commit_log(path, limit=batch_limit, offset=offset)
-                commits = visible[:limit]
+                if (limit is not None and emitted >= limit) or len(commits) < batch_limit:
+                    break
+                offset += len(commits)
+                commits = commit_log(path, limit=batch_limit, offset=offset)
         except (GitCommandError, OSError):
             continue
-        for commit in commits:
-            actions.append(
-                {
-                    "kind": "commit_created",
-                    "occurred_at": commit["date"],
-                    "actor_username": "",
-                    "actor_label": commit["author"],
-                    "actor_url": "",
-                    "repo_owner_username": repo["owner_username"],
-                    "repo_name": repo["name"],
-                    "target_url": f"/{repo['owner_username']}/{repo['name']}/commits/{commit['node']}",
-                    "target_label": commit["short_node"],
-                    "summary": "committed",
-                    "detail": text_preview(commit["summary"]),
-                }
-            )
-    return actions
 
 
-def list_recent_actions(limit=50, user_id=None, offset=0):
+def list_commit_activity_actions(limit, user_id=None, **filters):
+    return list(iter_commit_activity_actions(limit, user_id=user_id, **filters))
+
+
+def list_recent_actions(limit=50, user_id=None, offset=0, *, unread_only=False, since=None):
     limit = max(1, int(limit)) if user_id is not None else max(1, min(int(limit), 100))
     fetch_limit = offset + limit
+    read_before, read_keys = notification_read_state(user_id)
     with db_connect() as conn:
+        conn.create_function("notification_key", 2, notification_key, deterministic=True)
         conn.create_function("mentions_user", 2, text_mentions_user, deterministic=True)
         rows = conn.execute(
             """
-            SELECT *
+            SELECT *, notification_key(kind, event_id) AS event_key
             FROM (
                 SELECT
                     CASE
                         WHEN repositories.forked_from_repo_id IS NULL THEN 'repo_created'
                         ELSE 'repo_forked'
                     END AS kind,
+                    repositories.id AS event_id,
                     repositories.created_at AS occurred_at,
                     owner.username AS actor_username,
                     owner.username AS repo_owner_username,
@@ -1459,6 +1507,7 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
 
                 SELECT
                     'repo_starred' AS kind,
+                    repo_stars.repo_id || ':' || repo_stars.user_id || ':' || repo_stars.created_at AS event_id,
                     repo_stars.created_at AS occurred_at,
                     actor.username AS actor_username,
                     owner.username AS repo_owner_username,
@@ -1476,6 +1525,7 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
 
                 SELECT
                     'contributor_added' AS kind,
+                    repo_contributors.repo_id || ':' || repo_contributors.user_id || ':' || repo_contributors.created_at AS event_id,
                     repo_contributors.created_at AS occurred_at,
                     actor.username AS actor_username,
                     owner.username AS repo_owner_username,
@@ -1494,6 +1544,7 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
 
                 SELECT
                     'issue_opened' AS kind,
+                    issues.id AS event_id,
                     issues.created_at AS occurred_at,
                     actor.username AS actor_username,
                     owner.username AS repo_owner_username,
@@ -1511,6 +1562,7 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
 
                 SELECT
                     'issue_commented' AS kind,
+                    issue_comments.id AS event_id,
                     issue_comments.created_at AS occurred_at,
                     actor.username AS actor_username,
                     owner.username AS repo_owner_username,
@@ -1529,6 +1581,7 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
 
                 SELECT
                     'issue_' || issue_status_events.status AS kind,
+                    issue_status_events.id AS event_id,
                     issue_status_events.created_at AS occurred_at,
                     actor.username AS actor_username,
                     owner.username AS repo_owner_username,
@@ -1547,6 +1600,7 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
 
                 SELECT
                     'pull_request_opened' AS kind,
+                    pull_requests.id AS event_id,
                     pull_requests.created_at AS occurred_at,
                     actor.username AS actor_username,
                     owner.username AS repo_owner_username,
@@ -1564,6 +1618,7 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
 
                 SELECT
                     'pull_request_commented' AS kind,
+                    pull_request_comments.id AS event_id,
                     pull_request_comments.created_at AS occurred_at,
                     actor.username AS actor_username,
                     owner.username AS repo_owner_username,
@@ -1582,6 +1637,7 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
 
                 SELECT
                     'pull_request_closed' AS kind,
+                    pull_request_status_events.id AS event_id,
                     pull_request_status_events.created_at AS occurred_at,
                     actor.username AS actor_username,
                     owner.username AS repo_owner_username,
@@ -1600,6 +1656,7 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
 
                 SELECT
                     'pull_request_merged' AS kind,
+                    pull_requests.id AS event_id,
                     pull_requests.merged_at AS occurred_at,
                     actor.username AS actor_username,
                     owner.username AS repo_owner_username,
@@ -1618,6 +1675,7 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
 
                 SELECT
                     'commit_commented' AS kind,
+                    commit_comments.id AS event_id,
                     commit_comments.created_at AS occurred_at,
                     actor.username AS actor_username,
                     owner.username AS repo_owner_username,
@@ -1635,6 +1693,7 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
 
                 SELECT
                     'user_joined' AS kind,
+                    users.id AS event_id,
                     users.created_at AS occurred_at,
                     users.username AS actor_username,
                     '' AS repo_owner_username,
@@ -1646,6 +1705,22 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
                 FROM users
             )
             WHERE occurred_at IS NOT NULL
+                AND (:since IS NULL OR occurred_at >= :since)
+                AND (NOT :unread_only OR (
+                    (:read_before IS NULL OR occurred_at >= :read_before)
+                    AND NOT EXISTS (SELECT 1 FROM notification_reads
+                        WHERE notification_reads.user_id = :user_id
+                        AND notification_reads.event_key = notification_key(kind, event_id))
+                ))
+                AND (:user_id IS NULL OR NOT EXISTS (
+                    SELECT 1 FROM repositories AS fork
+                    JOIN users AS fork_owner ON fork_owner.id = fork.owner_id
+                    JOIN repositories AS source ON source.id = fork.forked_from_repo_id
+                    WHERE source.owner_id = :user_id AND fork.owner_id != :user_id
+                        AND fork_owner.username = repo_owner_username AND fork.name = repo_name
+                        AND NOT EXISTS (SELECT 1 FROM repo_stars
+                            WHERE repo_stars.repo_id = fork.id AND repo_stars.user_id = :user_id)
+                ))
                 AND (:user_id IS NULL OR actor_username != (
                     SELECT username FROM users WHERE id = :user_id
                 )) AND (
@@ -1677,15 +1752,22 @@ def list_recent_actions(limit=50, user_id=None, offset=0):
                     )
                 ))
             )
-            ORDER BY occurred_at DESC, kind DESC, target_url DESC
+            ORDER BY occurred_at DESC, kind DESC, target_url DESC, event_key DESC
             LIMIT :fetch_limit
             """,
-            {"user_id": user_id, "fetch_limit": fetch_limit},
+            {"user_id": user_id, "fetch_limit": fetch_limit, "unread_only": unread_only, "read_before": read_before, "since": since},
         ).fetchall()
 
     user = get_user_by_id(user_id) if user_id is not None else None
     actions = [normalize_activity_action(row, user["username"] if user else None) for row in rows]
-    actions.extend(list_commit_activity_actions(fetch_limit, user_id=user_id))
+    if user_id is None:
+        actions.extend(list_commit_activity_actions(fetch_limit))
+    else:
+        actions.extend(list_commit_activity_actions(fetch_limit, user_id=user_id,
+            read_before=read_before, read_keys=read_keys, unread_only=unread_only, since=since))
+        for action in actions:
+            action["is_read"] = notification_is_read(action, read_before, read_keys)
+            action["read_signature"] = notification_signature(user_id, action["event_key"])
     actions.sort(key=activity_sort_key, reverse=True)
     return actions[offset:fetch_limit]
 
@@ -4949,6 +5031,55 @@ def notifications():
     )
 
 
[email protected]("/-/notifications/unread")
+def unread_notifications():
+    user = require_login()
+    actions = list_recent_actions(100, user_id=user["id"], unread_only=True)
+    return HTTPResponse(
+        json.dumps({"unread_count": min(len(actions), 99), "has_more": len(actions) > 99}),
+        content_type="application/json", headers={"Cache-Control": "no-store"},
+    )
+
+
[email protected]("/-/notifications/read", method="POST")
+def read_notification():
+    user = require_login()
+    key = request.forms.get("notification_key", "")
+    signature = request.forms.get("signature", "")
+    if (not re.fullmatch(r"[a-f0-9]{64}", key) or not re.fullmatch(r"[a-f0-9]{64}", signature)
+            or not hmac.compare_digest(signature, notification_signature(user["id"], key))):
+        abort(403, "Invalid notification.")
+    with db_connect() as conn:
+        conn.execute("INSERT OR IGNORE INTO notification_reads (user_id, event_key, read_at) VALUES (?, ?, ?)",
+            (user["id"], key, utcnow()))
+    redirect("/-/notifications")
+
+
[email protected]("/-/notifications/read-all", method="POST")
+def read_all_notifications():
+    user = require_login()
+    cutoff = utcnow()
+    # Capture boundary events: later events within this same second stay unread.
+    boundary = []
+    offset = 0
+    while True:
+        batch = list_recent_actions(100, user_id=user["id"], offset=offset, since=cutoff)
+        boundary.extend(action["event_key"] for action in batch)
+        if len(batch) < 100:
+            break
+        offset += len(batch)
+    # Commit timestamps are author-controlled. Record observed hashes so a newly
+    # arriving, backdated commit is still unread after this snapshot.
+    boundary.extend(action["event_key"] for action in iter_commit_activity_actions(None, user_id=user["id"]))
+    with db_connect() as conn:
+        conn.execute("""INSERT INTO notification_read_state (user_id, read_before) VALUES (?, ?)
+            ON CONFLICT(user_id) DO UPDATE SET read_before = MAX(read_before, excluded.read_before)""", (user["id"], cutoff))
+        conn.execute("DELETE FROM notification_reads WHERE user_id = ? AND read_at < ?", (user["id"], cutoff))
+        conn.executemany("INSERT OR IGNORE INTO notification_reads (user_id, event_key, read_at) VALUES (?, ?, ?)",
+            [(user["id"], key, cutoff) for key in boundary])
+    redirect("/-/notifications")
+
+
 @app.route("/-/users/search")
 def mention_user_search():
     require_login()
diff --git a/static/notifications.js b/static/notifications.js
new file mode 100644
index 0000000..c8756d4
--- /dev/null
+++ b/static/notifications.js
@@ -0,0 +1,30 @@
+(() => {
+  const bell = document.querySelector('[data-notification-bell]');
+  const count = document.querySelector('[data-notification-count]');
+  if (!bell || !count) return;
+  let loading = false;
+  let checkedAt = 0;
+  const refresh = async () => {
+    if (loading || document.hidden || Date.now() - checkedAt < 5000) return;
+    loading = true;
+    try {
+      const response = await fetch('/-/notifications/unread', { headers: { Accept: 'application/json' }, cache: 'no-store' });
+      if (!response.ok) return;
+      const data = await response.json();
+      const unread = Number(data.unread_count) || 0;
+      const label = data.has_more ? '99+' : String(unread);
+      count.textContent = label;
+      count.hidden = unread === 0;
+      bell.setAttribute('aria-label', unread ? `Notifications (${label} unread)` : 'Notifications');
+      checkedAt = Date.now();
+    } catch (_) {
+      // Keep the last successful count if the connection is interrupted.
+    } finally {
+      loading = false;
+    }
+  };
+  refresh();
+  setInterval(refresh, 60000);
+  window.addEventListener('focus', refresh);
+  document.addEventListener('visibilitychange', refresh);
+})();
diff --git a/static/styles.css b/static/styles.css
index 7c679c7..805fc90 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -422,3 +422,21 @@ button, .button {
   text-overflow: ellipsis;
   white-space: nowrap;
 }
+
+.notification-bell { position: relative; }
+.notification-count {
+  position: absolute;
+  top: -.55rem;
+  right: -.35rem;
+  min-width: 1.1rem;
+  padding: .1rem .22rem;
+  border: 1px solid var(--strong-border);
+  border-radius: .65rem;
+  color: var(--text);
+  background: var(--surface);
+  font-size: .65rem;
+  line-height: 1;
+  text-align: center;
+}
+.notification-unread .activity-title { font-weight: 700; }
+.notification-read-form { display: inline; margin: 0; font-size: .875rem; }
diff --git a/templates/activity_feed.tpl b/templates/activity_feed.tpl
index c2983c9..897eb40 100644
--- a/templates/activity_feed.tpl
+++ b/templates/activity_feed.tpl
@@ -2,7 +2,7 @@
       % for action in actions:
         % repo_url = "/" + action["repo_owner_username"] + "/" + action["repo_name"] if action["repo_owner_username"] and action["repo_name"] else ""
         % show_repo_context = repo_url and action["target_url"] != repo_url
-        <li>
+        <li class="{{'notification-unread' if get('notifications_feed', False) and not action['is_read'] else ''}}">
           <p class="activity-title">
             % if action["actor_url"]:
               <strong><a href="{{action['actor_url']}}">{{action["actor_label"]}}</a></strong>
@@ -18,6 +18,18 @@
             % end
             {{action["occurred_at"]}}
           </p>
+          % if get("notifications_feed", False):
+            % if action["is_read"]:
+              <small class="muted">Read</small>
+            % else:
+              <form class="notification-read-form" action="/-/notifications/read" method="post">
+                {{!csrf_field()}}
+                <input type="hidden" name="notification_key" value="{{action['event_key']}}">
+                <input type="hidden" name="signature" value="{{action['read_signature']}}">
+                <button class="link-button" type="submit">Mark as read</button>
+              </form>
+            % end
+          % end
           % if action["detail"]:
             <p class="activity-detail">{{!render_markdown_links(action["detail"])}}</p>
           % end
diff --git a/templates/base.tpl b/templates/base.tpl
index fe5774f..ea8e8b3 100644
--- a/templates/base.tpl
+++ b/templates/base.tpl
@@ -9,7 +9,7 @@
 <link rel="apple-touch-icon" sizes="180x180" href="/static/git.svg">
   <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
   <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)">
-  <link rel="stylesheet" href="/static/styles.css?v=8">
+  <link rel="stylesheet" href="/static/styles.css?v=9">
 </head>
 <body>
   <header class="site-header">
@@ -24,10 +24,11 @@
     </a>
     <nav class="nav">
       % if user:
-        <a class="nav-icons" href="/-/notifications" aria-label="Notifications" title="Notifications">
+        <a class="nav-icons notification-bell" data-notification-bell href="/-/notifications" aria-label="Notifications" title="Notifications">
           <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
             <path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2M8 1.918l-.797.161A4 4 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4 4 0 0 0-3.203-3.92zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5 5 0 0 1 13 6c0 .88.32 4.2 1.22 6"/>
           </svg>
+          <span class="notification-count" data-notification-count aria-live="polite" hidden></span>
         </a>
         <a class="nav-icons" href="/new" aria-label="New repository" title="New repository">
             <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-plus-square" viewBox="0 0 16 16" aria-hidden="true">
@@ -57,6 +58,9 @@
     {{!base}}
   </main>
   <script src="/static/mentions.js?v=1" defer></script>
+  % if user:
+    <script src="/static/notifications.js?v=1" defer></script>
+  % end
   <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
   <script>
     (() => {
diff --git a/templates/notifications.tpl b/templates/notifications.tpl
index f5ccf9f..3734ae7 100644
--- a/templates/notifications.tpl
+++ b/templates/notifications.tpl
@@ -6,10 +6,16 @@
       <h1>Notifications</h1>
       <p class="muted">Mentions and activity from other people in repositories you own or have starred, and on issues you follow.</p>
     </div>
+    % if actions:
+      <form class="inline-form" method="post" action="/-/notifications/read-all">
+        {{!csrf_field()}}
+        <button class="button secondary small" type="submit">Mark all as read</button>
+      </form>
+    % end
   </div>
 
   % if actions:
-    % include("activity_feed.tpl", actions=actions)
+    % include("activity_feed.tpl", actions=actions, notifications_feed=True)
   % else:
     <p class="empty">No notifications yet. Create or comment on an issue, star a repository, receive a pull request, or get @mentioned to see activity here.</p>
   % end
diff --git a/tests/test_app.py b/tests/test_app.py
index c4806ef..d34b34f 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -3026,3 +3026,84 @@ def test_mention_autocomplete_searches_public_names_without_exposing_account_dat
     for i in range(10):
         create_user(f'person{i}')
     assert len(json.loads(client.get('/-/users/search').text)['results']) == 8
+
+
+def notification_read_fields(response):
+    form = re.search(r'<form[^>]*action="/-/notifications/read"[^>]*>(.*?)</form>', response.text, re.S)
+    assert form, 'An unread entry must offer a read control'
+    return dict(re.findall(r'name="(notification_key|signature)" value="([^"]+)"', form.group(1)))
+
+
+def test_notification_read_state_is_per_user_persistent_and_handles_simultaneous_events(isolated_app, monkeypatch):
+    alice = create_user('alice')
+    create_user('bob')
+    isolated_app.create_repository(alice, 'demo', '')
+    a, b = WsgiClient(isolated_app.app), WsgiClient(isolated_app.app)
+    login_client(a, 'alice')
+    login_client(b, 'bob')
+    a.post('/alice/demo/issues/new', {'title': 'Discussion', 'body': ''})
+    monkeypatch.setattr(isolated_app, 'utcnow', lambda: '2026-11-03T00:00:00Z')
+    for body in ['First reply', 'Second reply']:
+        assert b.post('/alice/demo/issues/1', {'action': 'comment', 'body': body}).status_code == 303
+    unread = a.get('/-/notifications/unread')
+    assert unread.status_code == 200
+    assert json.loads(unread.text)['unread_count'] == 2
+    assert b.post('/-/notifications/read-all').status_code == 303
+    assert json.loads(a.get('/-/notifications/unread').text)['unread_count'] == 2
+    fields = notification_read_fields(a.get('/-/notifications?per_page=1'))
+    assert b.post('/-/notifications/read', fields).status_code == 403
+    assert a.post('/-/notifications/read', {**fields, 'signature': 'invalid'}).status_code == 403
+    no_csrf = WsgiClient(isolated_app.app)
+    no_csrf.cookies = {'session': a.cookies['session']}
+    assert no_csrf.post('/-/notifications/read', fields).status_code == 403
+    assert a.post('/-/notifications/read', fields).status_code == 303
+    assert json.loads(a.get('/-/notifications/unread').text)['unread_count'] == 1
+    assert 'Read' in a.get('/-/notifications').text
+    assert a.post('/-/notifications/read-all').status_code == 303
+    other_device = WsgiClient(isolated_app.app)
+    login_client(other_device, 'alice')
+    assert json.loads(other_device.get('/-/notifications/unread').text)['unread_count'] == 0
+    # A later row with the exact same timestamp/URL/kind is still new activity.
+    assert b.post('/alice/demo/issues/1', {'action': 'comment', 'body': 'New reply in same second'}).status_code == 303
+    assert json.loads(other_device.get('/-/notifications/unread').text)['unread_count'] == 1
+    feed = other_device.get('/-/notifications').text
+    assert 'First reply' in feed and 'Second reply' in feed and 'New reply in same second' in feed
+    assert WsgiClient(isolated_app.app).post('/-/notifications/read-all').status_code in [303,403]
+
+
+def test_notifications_from_other_peoples_forks_require_starring_the_fork(isolated_app):
+    alice = create_user('alice')
+    create_user('bob')
+    isolated_app.create_repository(alice, 'demo', '')
+    a, b = WsgiClient(isolated_app.app), WsgiClient(isolated_app.app)
+    login_client(a, 'alice')
+    login_client(b, 'bob')
+    assert b.post('/alice/demo/fork', {'name': 'forked', 'description': ''}).status_code == 303
+    assert b.post('/bob/forked/issues/new', {'title': 'Fork issue', 'body': 'Hello @alice'}).status_code == 303
+    assert 'Fork issue' not in a.get('/-/notifications').text
+    assert json.loads(a.get('/-/notifications/unread').text)['unread_count'] == 0
+    fork = isolated_app.get_repo('bob', 'forked')
+    isolated_app.star_repo(alice, fork)
+    assert 'Fork issue' in a.get('/-/notifications').text
+    assert json.loads(a.get('/-/notifications/unread').text)['unread_count'] > 0
+    isolated_app.unstar_repo(alice, fork)
+    assert 'Fork issue' not in a.get('/-/notifications').text
+
+
+def test_new_backdated_commits_are_unread_after_mark_all_read(isolated_app, monkeypatch):
+    alice = create_user('alice')
+    isolated_app.create_repository(alice, 'demo', '')
+    path = isolated_app.repo_path('alice', 'demo')
+    commit_file(path, 'old.txt', 'old', message='Existing commit', user='bob')
+    client = WsgiClient(isolated_app.app)
+    login_client(client, 'alice')
+    assert client.post('/-/notifications/read-all').status_code == 303
+    assert json.loads(client.get('/-/notifications/unread').text)['unread_count'] == 0
+    # Git authors can supply old dates; arrival/read state cannot depend on them.
+    monkeypatch.setenv('GIT_AUTHOR_DATE', '2001-01-01T00:00:00Z')
+    monkeypatch.setenv('GIT_COMMITTER_DATE', '2001-01-01T00:00:00Z')
+    commit_file(path, 'new.txt', 'new', message='New backdated commit', user='bob')
+    assert json.loads(client.get('/-/notifications/unread').text)['unread_count'] == 1
+    fields = notification_read_fields(client.get('/-/notifications'))
+    assert client.post('/-/notifications/read', fields).status_code == 303
+    assert json.loads(client.get('/-/notifications/unread').text)['unread_count'] == 0