diff --git a/README.md b/README.md
index 17d0e8c..42e11b6 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,13 @@ final item completes the Following phase. Failed or unconfirmed Gitea mutations
and current review position unchanged. Following counts never influence the recommended Work queue. Set
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
+Each device can explicitly opt in to **Notify me when Following changes**. Stackchain polls that
+channel independently, validates the device session immediately before delivery, and checkpoints
+the exact unseen revision set so unchanged work stays silent across workers. Lock-screen payloads
+contain only a bounded count and the `#/my-work/following` route—never repository names, titles,
+bodies, or comments. Disabling Following alerts leaves Updates, deadlines, and start-day reminders
+unchanged.
+
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
The mobile queue sheet begins with **Prepare Today**, a live briefing that totals Agenda, Attention, Updates, Filed,
and unseen Following activity, then opens the highest-priority non-empty review queue. Following refreshes only when
@@ -398,7 +405,8 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
# three individual alerts followed by one private digest that opens Updates. A later
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
-# that thread's updated_at revision; unchanged and older snapshots remain silent.
+# that thread's updated_at revision; unchanged and older snapshots remain silent. Opt-in
+# Following alerts use generic copy and deep-link to the changed-first Following review.
# Browser push services must resolve exclusively to public IP addresses. Stackchain
# validates endpoints at enrollment and again before delivery, rejects redirects,
# and removes legacy subscriptions that resolve to private or reserved networks.
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index e6df655..875a91d 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -8310,6 +8310,8 @@
startDayControl:qs('#push-start-day'),
startDayStatus:qs('#push-start-day-status'),
startDayHour:qs('#push-start-day-hour'),
+ followingControl:qs('#push-following'),
+ followingStatus:qs('#push-following-status'),
deadlineSnooze:qs('#deadline-snooze'),
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
diff --git a/frontend/index.html b/frontend/index.html
index 88f3bd6..390a4a5 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -221,6 +221,8 @@
+
+
diff --git a/frontend/push-notifications.js b/frontend/push-notifications.js
index 8f760b2..6024d23 100644
--- a/frontend/push-notifications.js
+++ b/frontend/push-notifications.js
@@ -4,6 +4,7 @@
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour,
+ followingControl, followingStatus,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
notification, serviceWorker, fetchJson,
}) {
@@ -129,12 +130,15 @@
if (testControl) testControl.hidden = true;
if (deadlineControl) deadlineControl.checked = false;
if (startDayControl) startDayControl.checked = false;
+ if (followingControl) followingControl.checked = false;
configuration.subscribed = false;
configuration.deadline_enabled = false;
configuration.start_day_enabled = false;
+ configuration.following_enabled = false;
pendingIntent = null;
status.textContent = 'New update notifications are off for this device.';
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
+ if (followingStatus) followingStatus.textContent = 'Following change alerts are off for this device.';
}
async function ensureSubscription() {
@@ -266,13 +270,45 @@
}
}
+ async function changeFollowing() {
+ followingControl.disabled = true;
+ try {
+ const registration = await serviceWorker.ready;
+ let subscription = await registration.pushManager.getSubscription();
+ if (followingControl.checked) pendingIntent = 'following';
+ if (followingControl.checked && !subscription) subscription = await ensureSubscription();
+ if (followingControl.checked && !subscription) {
+ followingControl.checked = false;
+ followingStatus.textContent = status.textContent;
+ return false;
+ }
+ await fetchJson('api/v1/push-subscription/following', {
+ method:'PUT',
+ headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({enabled:followingControl.checked}),
+ });
+ configuration.following_enabled = followingControl.checked;
+ pendingIntent = null;
+ followingStatus.textContent = followingControl.checked
+ ? 'Following change alerts enabled for this device.'
+ : 'Following change alerts are off for this device.';
+ return true;
+ } catch (_error) {
+ followingControl.checked = !followingControl.checked;
+ followingStatus.textContent = 'Could not change Following alerts. Check your connection and try again.';
+ return false;
+ } finally {
+ followingControl.disabled = false;
+ }
+ }
+
async function enableDeadline() {
deadlineControl.checked = true;
return changeDeadline();
}
async function recoverPermission(intent = null) {
- if (!pendingIntent && ['updates', 'deadline', 'start-day'].includes(intent)) pendingIntent = intent;
+ if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following'].includes(intent)) pendingIntent = intent;
if (!pendingIntent || notification.permission !== 'granted') return false;
if (recoveryPromise) return recoveryPromise;
recoveryPromise = (async () => {
@@ -284,6 +320,10 @@
startDayControl.checked = true;
return changeStartDay();
}
+ if (pendingIntent === 'following') {
+ followingControl.checked = true;
+ return changeFollowing();
+ }
return Boolean(await enable());
})();
try {
@@ -299,18 +339,21 @@
testControl?.addEventListener('click', testDelivery);
deadlineControl?.addEventListener('change', changeDeadline);
startDayControl?.addEventListener('change', changeStartDay);
+ followingControl?.addEventListener('change', changeFollowing);
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
configuration = await fetchJson('api/v1/push-subscription');
if (!configuration.available) {
control.disabled = true;
if (deadlineControl) deadlineControl.disabled = true;
if (startDayControl) startDayControl.disabled = true;
+ if (followingControl) followingControl.disabled = true;
status.textContent = 'New update notifications are not available on this server.';
return;
}
control.checked = Boolean(configuration.subscribed);
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
+ if (followingControl) followingControl.checked = Boolean(configuration.following_enabled);
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
@@ -321,8 +364,11 @@
if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled
? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.`
: 'Start-day reminders are off for this device.';
+ if (followingStatus) followingStatus.textContent = configuration.following_enabled
+ ? 'Following change alerts enabled for this device.'
+ : 'Following change alerts are off for this device.';
renderDeadlineSnooze();
}
- return {init, change, changeDeadline, changeStartDay, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
+ return {init, change, changeDeadline, changeStartDay, changeFollowing, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
});
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 363b1eb..905f9c8 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -649,6 +649,7 @@ self.addEventListener('push', event => {
const updateCount = Number(payload.update_count);
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
const deadlineCount = Number(payload.deadline_count);
+ const followingCount = Number(payload.following_count);
const planDate = String(payload.plan_date || '');
if (
route === '#/my-work/start-day'
@@ -685,6 +686,23 @@ self.addEventListener('push', event => {
));
return;
}
+ if (
+ route === '#/my-work/following'
+ && /^stackchain-following-[0-9a-f]{16}$/.test(tag)
+ && Number.isSafeInteger(followingCount)
+ && followingCount > 0
+ && followingCount <= 50
+ ) {
+ event.waitUntil(self.registration.showNotification(
+ followingCount + ' watched item' + (followingCount === 1 ? '' : 's') + ' changed',
+ {
+ body: 'Open Following to review the latest activity.',
+ tag,
+ data: {route},
+ }
+ ));
+ return;
+ }
if (
route === '#/my-work/updates'
&& tag === 'stackchain-update-digest'
diff --git a/src/main.py b/src/main.py
index 68902de..12409d1 100644
--- a/src/main.py
+++ b/src/main.py
@@ -55,6 +55,7 @@ from src.passkey_store import PasskeyStore
from src.push_notifications import (
PushConfiguration,
dispatch_deadline_reminders,
+ dispatch_following_changes,
dispatch_start_day_reminders,
dispatch_unread_updates,
send_web_push,
@@ -131,6 +132,10 @@ async def _start_day_plan_snapshot() -> dict:
return await asyncio.to_thread(_today_store().get_start_day_plan, login)
+async def _following_push_snapshot() -> dict:
+ return await get_following(Response())
+
+
async def _push_poll_loop() -> None:
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
deadline_interval = max(
@@ -173,6 +178,17 @@ async def _push_poll_loop() -> None:
max_concurrency=max_concurrency,
)
+ async def dispatch_following() -> None:
+ await dispatch_following_changes(
+ _push_subscription_store,
+ _push_configuration(),
+ _following_push_snapshot,
+ session_statuses=dashboard_auth.managed_session_statuses,
+ send_timeout_seconds=send_timeout,
+ lease_seconds=lease_seconds,
+ max_concurrency=max_concurrency,
+ )
+
async def dispatch_start_day() -> None:
await dispatch_start_day_reminders(
_push_subscription_store,
@@ -186,6 +202,7 @@ async def _push_poll_loop() -> None:
channel_tasks = (
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
+ asyncio.create_task(_push_channel_loop(dispatch_following, interval=interval)),
asyncio.create_task(
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
),
@@ -474,6 +491,10 @@ class StartDayReminderPayload(BaseModel):
return value
+class FollowingNotificationPayload(BaseModel):
+ enabled: bool
+
+
StepUpAction = Literal[
"merge_pull",
"submit_pull_review",
@@ -2322,6 +2343,9 @@ async def push_status(request: Request):
start_day_preferences = await asyncio.to_thread(
_push_subscription_store.start_day_preferences, device_id
)
+ following_preferences = await asyncio.to_thread(
+ _push_subscription_store.following_preferences, device_id
+ )
delivery_health = await asyncio.to_thread(
_push_subscription_store.delivery_health, device_id
)
@@ -2337,6 +2361,7 @@ async def push_status(request: Request):
"start_day_enabled": start_day_preferences["enabled"],
"start_day_timezone": start_day_preferences["timezone"],
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
+ "following_enabled": following_preferences["enabled"],
"delivery_health": delivery_health,
}
@@ -2485,6 +2510,25 @@ async def update_start_day_reminders(payload: StartDayReminderPayload, request:
}
+@app.put("/api/v1/push-subscription/following")
+async def update_following_notifications(
+ payload: FollowingNotificationPayload, request: Request
+):
+ device_id = await dashboard_auth.session_management_id(
+ request.state.dashboard_session
+ )
+ if payload.enabled and not await asyncio.to_thread(
+ _push_subscription_store.is_subscribed, device_id
+ ):
+ raise HTTPException(status_code=409, detail="Enable device notifications first")
+ await asyncio.to_thread(
+ _push_subscription_store.set_following_preferences,
+ device_id,
+ enabled=payload.enabled,
+ )
+ return {"following_enabled": payload.enabled}
+
+
@app.patch("/api/v1/push-subscription/deadlines/snooze")
async def snooze_deadline_reminder(request: Request):
device_id = await dashboard_auth.session_management_id(
diff --git a/src/push_notifications.py b/src/push_notifications.py
index c8ebabf..dcda660 100644
--- a/src/push_notifications.py
+++ b/src/push_notifications.py
@@ -1,4 +1,5 @@
import asyncio
+import hashlib
import json
import secrets
import time
@@ -99,6 +100,135 @@ async def send_web_push(
)
+async def dispatch_following_changes(
+ store: PushSubscriptionStore,
+ configuration: PushConfiguration,
+ following: Callable[[], Awaitable[dict]],
+ send: Callable[[dict, str], Awaitable[None]] | None = None,
+ *,
+ session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
+ lease_seconds: float = 60.0,
+ send_timeout_seconds: float = 10.0,
+ max_concurrency: int = 8,
+) -> int:
+ """Notify opted-in devices once for each privacy-safe Following change set."""
+ if not configuration.enabled:
+ return 0
+ owner = secrets.token_urlsafe(18)
+ acquired = await asyncio.to_thread(
+ store.acquire_dispatch_lease,
+ owner,
+ channel="following",
+ now=time.time(),
+ lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
+ )
+ if not acquired:
+ return 0
+ try:
+ devices = await asyncio.to_thread(store.following_notification_devices)
+ if not devices:
+ return 0
+ snapshot = await following()
+ if not isinstance(snapshot, dict) or snapshot.get("complete") is False:
+ return 0
+ changed = []
+ for item in snapshot.get("items", []):
+ if not isinstance(item, dict) or item.get("has_unseen_change") is not True:
+ continue
+ repository = item.get("repository")
+ kind = item.get("kind")
+ number = item.get("number")
+ updated_at = item.get("updated_at")
+ if (
+ isinstance(repository, str)
+ and kind in {"issue", "pull"}
+ and isinstance(number, int)
+ and not isinstance(number, bool)
+ and number > 0
+ and isinstance(updated_at, str)
+ and updated_at
+ ):
+ changed.append((repository.lower(), kind, number, updated_at))
+ changed.sort()
+ fingerprint = hashlib.sha256(
+ json.dumps(changed, separators=(",", ":")).encode()
+ ).hexdigest()
+ if not changed:
+ await asyncio.gather(*(
+ asyncio.to_thread(store.mark_following_delivered, device.session_id, fingerprint)
+ for device in devices
+ ))
+ return 0
+ pending = [device for device in devices if device.delivered_fingerprint != fingerprint]
+ if not pending:
+ return 0
+ if session_statuses is not None:
+ try:
+ statuses = await session_statuses([device.session_id for device in pending])
+ except Exception:
+ return 0
+ for device in pending:
+ if statuses.get(device.session_id) != "active":
+ await asyncio.to_thread(store.delete_session, device.session_id)
+ pending = [
+ device for device in pending if statuses.get(device.session_id) == "active"
+ ]
+ count = min(len(changed), 50)
+ payload = json.dumps({
+ "title": f"{count} watched item{'s' if count != 1 else ''} changed",
+ "body": "Open Following to review the latest activity.",
+ "route": "#/my-work/following",
+ "tag": f"stackchain-following-{fingerprint[:16]}",
+ "following_count": count,
+ }, separators=(",", ":"))
+ semaphore = asyncio.Semaphore(max(1, max_concurrency))
+
+ async def dispatch_device(device) -> int:
+ async with semaphore:
+ still_owner = await asyncio.to_thread(
+ store.acquire_dispatch_lease,
+ owner,
+ channel="following",
+ now=time.time(),
+ lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
+ )
+ if not still_owner:
+ return 0
+ try:
+ operation = (
+ send(device.subscription, payload)
+ if send is not None
+ else send_web_push(device.subscription, payload, configuration)
+ )
+ await asyncio.wait_for(operation, timeout=send_timeout_seconds)
+ except Exception as error:
+ status = getattr(getattr(error, "response", None), "status_code", None)
+ if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
+ await asyncio.to_thread(store.delete_session, device.session_id)
+ else:
+ await asyncio.to_thread(
+ store.mark_delivery_failed,
+ device.session_id,
+ "following",
+ _delivery_failure_reason(error),
+ )
+ return 0
+ await asyncio.to_thread(
+ store.mark_delivery_succeeded, device.session_id, "following"
+ )
+ await asyncio.to_thread(
+ store.mark_following_delivered, device.session_id, fingerprint
+ )
+ return 1
+
+ results = await asyncio.gather(
+ *(dispatch_device(device) for device in pending), return_exceptions=True
+ )
+ return sum(result for result in results if isinstance(result, int))
+ finally:
+ await asyncio.to_thread(store.release_dispatch_lease, owner, channel="following")
+
+
async def dispatch_unread_updates(
store: PushSubscriptionStore,
configuration: PushConfiguration,
diff --git a/src/push_subscription_store.py b/src/push_subscription_store.py
index 162595a..2de8479 100644
--- a/src/push_subscription_store.py
+++ b/src/push_subscription_store.py
@@ -52,6 +52,13 @@ class StartDayReminderDevice:
delivered_plan_date: str | None
+@dataclass(frozen=True)
+class FollowingNotificationDevice:
+ session_id: str
+ subscription: dict
+ delivered_fingerprint: str | None
+
+
class DisabledPushSubscriptionStore:
"""No-persistence store used when Web Push is not configured."""
@@ -81,6 +88,12 @@ class DisabledPushSubscriptionStore:
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
return []
+ def following_preferences(self, session_id: str) -> dict:
+ return {"enabled": False}
+
+ def following_notification_devices(self) -> list[FollowingNotificationDevice]:
+ return []
+
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
return []
@@ -117,6 +130,12 @@ class DisabledPushSubscriptionStore:
def mark_start_day_reminder_delivered(self, *args, **kwargs) -> None:
return None
+ def set_following_preferences(self, *args, **kwargs) -> None:
+ return None
+
+ def mark_following_delivered(self, *args, **kwargs) -> None:
+ return None
+
def reconcile_unread(self, *args, **kwargs) -> None:
return None
@@ -236,6 +255,13 @@ class PushSubscriptionStore:
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
+ CREATE TABLE IF NOT EXISTS push_following_preferences (
+ session_id TEXT PRIMARY KEY,
+ enabled INTEGER NOT NULL DEFAULT 0,
+ delivered_fingerprint TEXT,
+ FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
+ ON DELETE CASCADE
+ );
"""
)
delivery_columns = {
@@ -569,6 +595,46 @@ class PushSubscriptionStore:
(plan_date, session_id),
)
+ def set_following_preferences(self, session_id: str, *, enabled: bool) -> None:
+ with self._connect() as connection:
+ connection.execute(
+ """INSERT INTO push_following_preferences(session_id, enabled)
+ VALUES (?, ?)
+ ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""",
+ (session_id, int(enabled)),
+ )
+
+ def following_preferences(self, session_id: str) -> dict:
+ with self._connect() as connection:
+ row = connection.execute(
+ "SELECT enabled FROM push_following_preferences WHERE session_id = ?",
+ (session_id,),
+ ).fetchone()
+ return {"enabled": bool(row[0]) if row else False}
+
+ def following_notification_devices(self) -> list[FollowingNotificationDevice]:
+ with self._connect() as connection:
+ rows = connection.execute(
+ """SELECT s.session_id, s.subscription_json, p.delivered_fingerprint
+ FROM push_subscriptions s
+ JOIN push_following_preferences p ON p.session_id = s.session_id
+ WHERE p.enabled = 1 ORDER BY s.session_id"""
+ ).fetchall()
+ return [
+ FollowingNotificationDevice(
+ row[0], self._open_subscription(row[0], row[1]), row[2]
+ )
+ for row in rows
+ ]
+
+ def mark_following_delivered(self, session_id: str, fingerprint: str) -> None:
+ with self._connect() as connection:
+ connection.execute(
+ """UPDATE push_following_preferences SET delivered_fingerprint = ?
+ WHERE session_id = ? AND enabled = 1""",
+ (fingerprint, session_id),
+ )
+
def claim_unseen(
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
) -> list[PushDelivery]:
diff --git a/tests/e2e/test_mobile_following_release.py b/tests/e2e/test_mobile_following_release.py
index 7d56246..8ae7a3f 100644
--- a/tests/e2e/test_mobile_following_release.py
+++ b/tests/e2e/test_mobile_following_release.py
@@ -29,6 +29,12 @@ def test_following_queue_is_phone_usable_at_narrow_viewport(viewport):
row = page.locator('[data-mobile-queue="following"]')
expect(row).to_have_count(1)
expect(row).to_contain_text("Issues and pull requests you watch")
+ following_alert = page.locator('label[for="push-following"]')
+ expect(following_alert).to_have_count(1)
+ expect(following_alert).to_contain_text("Notify me when Following changes")
+ page.locator("#work-settings-toggle").click()
+ expect(following_alert).to_be_visible()
+ assert following_alert.bounding_box()["height"] >= 44
page.evaluate("""() => {
globalThis.fetch = async () => ({
ok:true,
diff --git a/tests/test_push_frontend.py b/tests/test_push_frontend.py
index 067cc9d..701dd3f 100644
--- a/tests/test_push_frontend.py
+++ b/tests/test_push_frontend.py
@@ -4,6 +4,8 @@ from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "push-notifications.js"
+INDEX = MODULE.parent / "index.html"
+DASHBOARD = MODULE.parent / "dashboard.js"
def run_scenario(script: str) -> dict:
@@ -27,6 +29,11 @@ const startDayControl = {
};
const startDayHour = {value:'9', disabled:false};
const startDayStatus = {set textContent(value) { state.startDayText = value; }, get textContent() { return state.startDayText; }};
+const followingControl = {
+ checked:false, disabled:false,
+ addEventListener:(_name, callback) => state.followingChange = callback,
+};
+const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }};
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback};
const deadlineSnooze = {hidden:true};
@@ -40,6 +47,7 @@ const registration = {pushManager:{
const feature = createPushNotifications({
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour,
+ followingControl, followingStatus,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
onReviewDeadlines:() => { state.reviewed = true; },
notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }},
@@ -77,6 +85,35 @@ process.stdout.write(JSON.stringify(state));
assert result["text"] == "New update notifications enabled for this device."
+def test_following_alert_toggle_is_opt_in_and_does_not_change_other_channels():
+ result = run_scenario("""
+state.server = {available:true,subscribed:true,following_enabled:false,deadline_enabled:true,start_day_enabled:true,public_key:'AQID'};
+state.current = existing;
+await feature.init();
+followingControl.checked = true;
+await state.followingChange();
+process.stdout.write(JSON.stringify({requests:state.requests, following:followingControl.checked, deadline:deadlineControl.checked, startDay:startDayControl.checked, text:state.followingText}));
+""")
+
+ assert result["following"] is True
+ assert result["deadline"] is True
+ assert result["startDay"] is True
+ assert result["requests"][-1][0:2] == ["api/v1/push-subscription/following", "PUT"]
+ assert json.loads(result["requests"][-1][2]) == {"enabled": True}
+ assert result["text"] == "Following change alerts enabled for this device."
+
+
+def test_device_settings_render_and_wire_the_following_alert_preference():
+ index = INDEX.read_text()
+ dashboard = DASHBOARD.read_text()
+
+ assert 'for="push-following"' in index
+ assert 'id="push-following" type="checkbox"' in index
+ assert 'id="push-following-status" role="status" aria-live="polite"' in index
+ assert "followingControl:qs('#push-following')" in dashboard
+ assert "followingStatus:qs('#push-following-status')" in dashboard
+
+
def test_degraded_device_can_run_a_test_notification_and_show_recovery():
result = run_scenario("""
state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}};
diff --git a/tests/test_push_notifications.py b/tests/test_push_notifications.py
index 1be7dcf..e17d784 100644
--- a/tests/test_push_notifications.py
+++ b/tests/test_push_notifications.py
@@ -12,6 +12,7 @@ import requests
from src import dashboard_auth, gitea_proxy, main
from src.push_notifications import (
PushConfiguration,
+ dispatch_following_changes,
dispatch_unread_updates,
send_web_push,
)
@@ -41,6 +42,98 @@ def test_dispatch_lease_is_exclusive_recoverable_and_owner_fenced(tmp_path):
assert second.release_dispatch_lease("worker-b") is True
+def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_path):
+ store = PushSubscriptionStore(tmp_path / "push.sqlite3")
+ for session_id in ("session-a", "session-b"):
+ store.upsert(session_id, {
+ "endpoint": f"https://push.example/{session_id}",
+ "keys": {"p256dh": "public-key", "auth": "auth-secret"},
+ })
+
+ assert store.following_preferences("session-a") == {"enabled": False}
+ store.set_following_preferences("session-a", enabled=True)
+
+ devices = store.following_notification_devices()
+ assert [(device.session_id, device.delivered_fingerprint) for device in devices] == [
+ ("session-a", None)
+ ]
+ store.mark_following_delivered("session-a", "revision-fingerprint")
+ assert store.following_notification_devices()[0].delivered_fingerprint == "revision-fingerprint"
+ assert store.following_preferences("session-b") == {"enabled": False}
+
+
+@pytest.mark.anyio
+async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_path):
+ store = PushSubscriptionStore(tmp_path / "push.sqlite3")
+ for session_id in ("active", "revoked", "disabled"):
+ store.upsert(session_id, {
+ "endpoint": f"https://push.example/{session_id}",
+ "keys": {"p256dh": "public-key", "auth": "auth-secret"},
+ })
+ store.set_following_preferences("active", enabled=True)
+ store.set_following_preferences("revoked", enabled=True)
+ sent = []
+
+ async def following():
+ return {"items": [{
+ "repository": "secret/project", "kind": "issue", "number": 42,
+ "title": "Sensitive acquisition", "updated_at": "2026-08-23T17:00:00Z",
+ "has_unseen_change": True,
+ }]}
+
+ async def send(subscription, payload):
+ sent.append((subscription["endpoint"], json.loads(payload)))
+
+ async def statuses(session_ids):
+ return {session_id: ("active" if session_id == "active" else "revoked") for session_id in session_ids}
+
+ configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
+ assert await dispatch_following_changes(
+ store, configuration, following, send, session_statuses=statuses
+ ) == 1
+ assert sent[0][0].endswith("/active")
+ assert sent[0][1] == {
+ "title": "1 watched item changed",
+ "body": "Open Following to review the latest activity.",
+ "route": "#/my-work/following",
+ "tag": sent[0][1]["tag"],
+ "following_count": 1,
+ }
+ assert sent[0][1]["tag"].startswith("stackchain-following-")
+ assert "secret" not in json.dumps(sent[0][1]).lower()
+ assert "acquisition" not in json.dumps(sent[0][1]).lower()
+ assert await dispatch_following_changes(
+ store, configuration, following, send, session_statuses=statuses
+ ) == 0
+ assert len(sent) == 1
+ assert store.subscription_for_session("revoked") is None
+
+
+@pytest.mark.anyio
+async def test_authenticated_device_controls_following_alerts_independently(tmp_path, monkeypatch):
+ store = PushSubscriptionStore(tmp_path / "push.sqlite3")
+ store.upsert("session-a", {
+ "endpoint": "https://push.example/session-a",
+ "keys": {"p256dh": "public-key", "auth": "auth-secret"},
+ })
+ monkeypatch.setattr(main, "_push_subscription_store", store)
+
+ async def management_id(_session):
+ return "session-a"
+
+ monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
+ request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
+
+ result = await main.update_following_notifications(
+ main.FollowingNotificationPayload(enabled=True), request
+ )
+
+ assert result == {"following_enabled": True}
+ assert (await main.push_status(request))["following_enabled"] is True
+ assert store.deadline_preferences("session-a")["enabled"] is False
+ assert store.start_day_preferences("session-a")["enabled"] is False
+
+
def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
state_dir = tmp_path / "push-state"
previous_umask = os.umask(0)
@@ -415,6 +508,7 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "4")
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
+ monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
monkeypatch.setattr(main, "dispatch_start_day_reminders", hold_dispatch)
@@ -437,8 +531,8 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
async def no_wait(_seconds):
nonlocal sleeps
sleeps += 1
- if sleeps <= 3:
- if sleeps == 3:
+ if sleeps <= 4:
+ if sleeps == 4:
first_tick.set()
await first_tick.wait()
else:
@@ -454,15 +548,19 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
async def dispatch_start_day(*_args, **_kwargs):
calls.append("start-day")
+ async def dispatch_following(*_args, **_kwargs):
+ calls.append("following")
+
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
+ monkeypatch.setattr(main, "dispatch_following_changes", dispatch_following)
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
with pytest.raises(asyncio.CancelledError):
await main._push_poll_loop()
- assert sorted(calls) == ["deadline", "start-day", "unread"]
+ assert sorted(calls) == ["deadline", "following", "start-day", "unread"]
@pytest.mark.anyio
@@ -487,6 +585,7 @@ async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(mon
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
+ monkeypatch.setattr(main, "dispatch_following_changes", dispatch_start_day)
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
@@ -513,7 +612,7 @@ async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch):
await main._push_poll_loop()
- assert sorted(intervals) == [30.0, 600.0, 600.0]
+ assert sorted(intervals) == [30.0, 30.0, 600.0, 600.0]
@pytest.mark.anyio
@@ -1282,6 +1381,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
"start_day_enabled": False,
"start_day_timezone": "UTC",
"start_day_reminder_hour": 9,
+ "following_enabled": False,
"delivery_health": {},
}
assert await main.subscribe_push(payload, request) == {"subscribed": True}
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 1cfb479..4f589fc 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -1797,3 +1797,27 @@ def test_client_error_navigation_is_not_hidden_by_cached_shell():
assert result["status"] == 401
assert result["body"] == "network"
assert result["state"]["puts"] == []
+
+
+def test_following_push_uses_generic_copy_and_opens_the_following_route():
+ result = run_worker_scenario("""
+await dispatchPush({
+ title:'leaked title', body:'leaked body', route:'#/my-work/following',
+ tag:'stackchain-following-0123456789abcdef', following_count:2,
+});
+await dispatchNotificationClick(
+ '#/my-work/following', '', null, 'stackchain-following-0123456789abcdef'
+);
+console.log(JSON.stringify({notifications:state.notifications,opened:state.opened,closed:state.notificationClosed}));
+""")
+
+ assert result["notifications"] == [{
+ "title": "2 watched items changed",
+ "options": {
+ "body": "Open Following to review the latest activity.",
+ "tag": "stackchain-following-0123456789abcdef",
+ "data": {"route": "#/my-work/following"},
+ },
+ }]
+ assert result["opened"] == ["https://forge.example/dashboard/#/my-work/following"]
+ assert result["closed"] is True
diff --git a/tests/test_start_day_reminders.py b/tests/test_start_day_reminders.py
index 95508c8..d60db48 100644
--- a/tests/test_start_day_reminders.py
+++ b/tests/test_start_day_reminders.py
@@ -208,6 +208,7 @@ async def test_push_poll_applies_configured_concurrency_to_start_day(
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3")
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch)
+ monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)