diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 70ac167..a1b8e37 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -401,6 +401,7 @@ textarea { resize: vertical; min-height: 120px; }
.update-ownership-actions button { min-width:0; width:100%; }
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
+.update-sheet-actions #mute-update-next { min-height:44px; width:100%; }
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-retry { min-height:44px; width:100%; margin-top:10px; }
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 068d193..0a06bed 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -719,6 +719,18 @@
return payload;
}
+ async function muteNotification(notificationId) {
+ const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
+ '/mute', { method: 'POST', headers: { Accept: 'application/json' } });
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ const error = new Error(payload.error || 'Muting future updates failed.');
+ error.muted = payload.muted === true;
+ throw error;
+ }
+ return payload;
+ }
+
async function markNotificationsRead(ids) {
const response = await fetch('api/v1/notifications/read', {
method: 'PATCH',
@@ -927,6 +939,7 @@
qs('#send-update-reply').disabled = false;
qs('#send-update-reply-read-next').disabled = false;
qs('#acknowledge-update-next').hidden = true;
+ qs('#mute-update-next').hidden = true;
qs('#update-ownership-action').hidden = true;
qs('#update-ownership-start').hidden = true;
qs('#create-update-follow-up').hidden = true;
@@ -942,6 +955,7 @@
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
+ qs('#mute-update-next').hidden = !detail.mute_supported;
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
if (offlineWorkMode) {
setOfflineUpdateControls(true);
@@ -2218,6 +2232,7 @@
qs('#mark-update-read-next').disabled = false;
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
qs('#acknowledge-update-next').disabled = offline;
+ qs('#mute-update-next').disabled = offline;
qs('#update-ownership-action').disabled = offline;
qs('#update-ownership-start').disabled = offline;
qs('#load-older-update-comments').disabled = offline;
@@ -5593,6 +5608,27 @@
button.disabled = offlineWorkMode;
}
});
+ qs('#mute-update-next').addEventListener('click', async () => {
+ if (!selectedUpdate || offlineWorkMode) return;
+ const button = qs('#mute-update-next');
+ const item = selectedUpdate;
+ button.disabled = true;
+ qs('#update-sheet-status').textContent = 'Muting future updates…';
+ try {
+ await muteNotification(item.notification_id);
+ const result = await notificationReader.acceptReadAndNext(lastMyWork, item);
+ if (result && updateTriage.active()) updateTriage.acceptCompleted();
+ } catch (error) {
+ qs('#update-sheet-status').textContent = error.muted ?
+ 'Future updates are muted; current item is still unread. Retry mark read & next.' : error.message;
+ if (error.muted) {
+ button.hidden = true;
+ qs('#mark-update-read-next').focus();
+ }
+ } finally {
+ button.disabled = offlineWorkMode;
+ }
+ });
qs('#undo-notification').addEventListener('click', async () => {
const button = qs('#undo-notification');
button.disabled = true;
diff --git a/frontend/index.html b/frontend/index.html
index 7bc9300..94f7c8f 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -794,6 +794,7 @@
+
Open in Gitea
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index 640b2ff..6c42ff9 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -4,7 +4,7 @@ import re
import shlex
from contextlib import asynccontextmanager
from typing import Any
-from urllib.parse import urljoin, urlsplit
+from urllib.parse import quote, urljoin, urlsplit
import httpx
@@ -916,6 +916,14 @@ def _gitea_api_path(value: Any) -> str:
return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "")
+async def _notification_subscription(repository: str, number: str) -> dict:
+ try:
+ value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
+ except Exception:
+ return {}
+ return value if isinstance(value, dict) else {}
+
+
async def notification_detail(thread_id: int) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
@@ -940,18 +948,20 @@ async def notification_detail(thread_id: int) -> dict:
)
if supported_conversation:
assert conversation_match is not None
- subject_detail, comment, conversation = await asyncio.gather(
+ subject_detail, comment, conversation, subscription = await asyncio.gather(
fetch(subject_path),
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
issue_conversation_page(
conversation_match.group(1), int(conversation_match.group(3))
),
+ _notification_subscription(conversation_match.group(1), conversation_match.group(3)),
)
else:
- subject_detail, comment, conversation = await asyncio.gather(
+ subject_detail, comment, conversation, subscription = await asyncio.gather(
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
+ asyncio.sleep(0, result={}),
)
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
comment = comment if isinstance(comment, dict) else {}
@@ -1012,6 +1022,12 @@ async def notification_detail(thread_id: int) -> dict:
and comment_match
and comment_match.group(1) == repository_name
),
+ "mute_supported": bool(
+ supported_conversation
+ and isinstance(subscription, dict)
+ and subscription.get("subscribed") is True
+ and subscription.get("ignored") is not True
+ ),
"conversation": conversation,
}
@@ -1058,6 +1074,21 @@ async def notification_conversation_target(thread_id: int) -> tuple[str, int]:
return match.group(1), int(match.group(3))
+async def mute_notification(thread_id: int) -> dict:
+ """Unsubscribe the current operator from the trusted notification conversation."""
+ repository, number = await notification_conversation_target(thread_id)
+ user = await fetch("user")
+ login = user.get("login") if isinstance(user, dict) else None
+ if not isinstance(login, str) or not login:
+ raise ValueError("Gitea did not identify the current operator")
+ response = await _get_client().delete(
+ f"/api/v1/repos/{repository}/issues/{number}/subscriptions/{quote(login, safe='')}",
+ headers=_auth(),
+ )
+ response.raise_for_status()
+ return {"id": thread_id, "repository": repository, "number": number, "muted": True}
+
+
async def reply_to_notification(thread_id: int, body: str) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
diff --git a/src/main.py b/src/main.py
index 6cdca0e..27ef9a3 100644
--- a/src/main.py
+++ b/src/main.py
@@ -3685,6 +3685,43 @@ async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
return JSONResponse({"id": thread_id, "status": "unread"})
+@app.post("/api/v1/notifications/{thread_id}/mute")
+async def mute_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
+ try:
+ await asyncio.wait_for(
+ gitea_proxy.mute_notification(thread_id),
+ timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
+ )
+ except TimeoutError:
+ return JSONResponse(
+ {"error": "Muting future updates timed out. Please retry."},
+ status_code=503,
+ headers={"Retry-After": "1"},
+ )
+ except Exception:
+ return JSONResponse(
+ {"error": "Future updates could not be muted. Please retry."},
+ status_code=503,
+ )
+ try:
+ await asyncio.wait_for(
+ mark_notification_read(thread_id),
+ timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
+ )
+ except Exception:
+ return JSONResponse(
+ {
+ "id": thread_id,
+ "muted": True,
+ "status": "unread",
+ "error": "Future updates are muted; current item is still unread. Retry mark read & next.",
+ },
+ status_code=409,
+ )
+ await _remove_notifications_from_live_snapshot([thread_id])
+ return JSONResponse({"id": thread_id, "muted": True, "status": "read"})
+
+
@app.patch("/api/v1/notifications/{thread_id}/later")
async def defer_notification(
payload: NotificationLaterRequest,
diff --git a/tests/test_gitea_notifications.py b/tests/test_gitea_notifications.py
index 74e9dd5..cf5316b 100644
--- a/tests/test_gitea_notifications.py
+++ b/tests/test_gitea_notifications.py
@@ -254,6 +254,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"number": 7, "state": "open", "assignees": [],
"body": "Deploy fails after **three** retries.",
})
+ if request.url.path.endswith("/issues/7/subscriptions/check"):
+ return httpx.Response(200, json={"subscribed": True, "ignored": False})
if request.url.path.endswith("/issues/comments/9"):
return httpx.Response(200, json={
"id": 9,
@@ -283,6 +285,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
+ "http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/subscriptions/check",
]
assert result == {
"id": 42,
@@ -300,6 +303,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
},
"issue": {"number": 7, "assignees": [], "claimable": True},
"acknowledge_supported": True,
+ "mute_supported": True,
"conversation": {
"comments": [{
"id": 9,
@@ -398,3 +402,37 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
"created_at": "2026-08-07T19:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
}
+
+
+@pytest.mark.anyio
+async def test_mute_notification_unsubscribes_current_user_from_trusted_thread_target():
+ requests = []
+
+ def upstream(request):
+ requests.append((request.method, request.url.path))
+ if request.url.path.endswith("/notifications/threads/42"):
+ return httpx.Response(200, json={
+ "repository": {"full_name": "stackchain/api"},
+ "subject": {
+ "type": "Pull",
+ "url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/pulls/7",
+ },
+ })
+ if request.url.path.endswith("/user"):
+ return httpx.Response(200, json={"login": "timmy"})
+ if request.method == "DELETE":
+ return httpx.Response(204)
+ return httpx.Response(404)
+
+ gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
+ try:
+ result = await gitea_proxy.mute_notification(42)
+ finally:
+ await gitea_proxy.stop_client()
+
+ assert result == {"id": 42, "repository": "stackchain/api", "number": 7, "muted": True}
+ assert requests == [
+ ("GET", "/api/v1/notifications/threads/42"),
+ ("GET", "/api/v1/user"),
+ ("DELETE", "/api/v1/repos/stackchain/api/issues/7/subscriptions/timmy"),
+ ]
diff --git a/tests/test_my_work.py b/tests/test_my_work.py
index 14e9b6a..39a0087 100644
--- a/tests/test_my_work.py
+++ b/tests/test_my_work.py
@@ -4958,6 +4958,21 @@ async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next(
assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html
+@pytest.mark.anyio
+async def test_mobile_update_sheet_mutes_future_updates_and_advances_only_on_read_success():
+ html = await dashboard()
+
+ assert 'id="mute-update-next" type="button" hidden' in html
+ assert ">Mute future updates & next" in html
+ assert "qs('#mute-update-next').hidden = !detail.mute_supported;" in html
+ assert "async function muteNotification(notificationId)" in html
+ assert "'/mute', { method: 'POST'" in html
+ assert "notificationReader.acceptReadAndNext(lastMyWork, item)" in html
+ assert "Future updates are muted; current item is still unread" in html
+ assert "qs('#mute-update-next').disabled = offline;" in html
+ assert ".update-sheet-actions #mute-update-next" in html
+
+
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
diff --git a/tests/test_notification_read.py b/tests/test_notification_read.py
index dda6152..fcf83c8 100644
--- a/tests/test_notification_read.py
+++ b/tests/test_notification_read.py
@@ -215,6 +215,34 @@ async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monke
assert restored == [42]
+@pytest.mark.anyio
+async def test_mute_notification_then_marks_read_and_reports_partial_success(monkeypatch):
+ calls = []
+
+ async def mute(thread_id):
+ calls.append(("mute", thread_id))
+ return {"id": thread_id, "muted": True}
+
+ async def fail_read(thread_id):
+ calls.append(("read", thread_id))
+ raise httpx.HTTPError("unavailable")
+
+ monkeypatch.setattr(main.gitea_proxy, "mute_notification", mute, raising=False)
+ monkeypatch.setattr(main, "mark_notification_read", fail_read)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
+ response = await client.post("/api/v1/notifications/42/mute")
+
+ assert response.status_code == 409
+ assert response.json() == {
+ "id": 42,
+ "muted": True,
+ "status": "unread",
+ "error": "Future updates are muted; current item is still unread. Retry mark read & next.",
+ }
+ assert calls == [("mute", 42), ("read", 42)]
+
+
@pytest.mark.anyio
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
calls = []