patx/gitman
Track unread notifications and exclude unstarred forks
Commit 6bf6ab4 · patx · 2026-11-03T13:24:53-05:00
Comments
No comments yet.
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