feat: notify devices when Following changes (Closes #1313)
All checks were successful
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m8s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-23 17:16:28 +00:00
parent 187b0decae
commit 0efd44fd90
13 changed files with 491 additions and 7 deletions

View File

@ -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 and current review position unchanged. Following counts never influence the recommended Work queue. Set
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`. `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. 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, 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 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 # 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 # 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 # 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 # Browser push services must resolve exclusively to public IP addresses. Stackchain
# validates endpoints at enrollment and again before delivery, rejects redirects, # validates endpoints at enrollment and again before delivery, rejects redirects,
# and removes legacy subscriptions that resolve to private or reserved networks. # and removes legacy subscriptions that resolve to private or reserved networks.

View File

@ -8310,6 +8310,8 @@
startDayControl:qs('#push-start-day'), startDayControl:qs('#push-start-day'),
startDayStatus:qs('#push-start-day-status'), startDayStatus:qs('#push-start-day-status'),
startDayHour:qs('#push-start-day-hour'), startDayHour:qs('#push-start-day-hour'),
followingControl:qs('#push-following'),
followingStatus:qs('#push-following-status'),
deadlineSnooze:qs('#deadline-snooze'), deadlineSnooze:qs('#deadline-snooze'),
deadlineSnoozeStatus:qs('#deadline-snooze-status'), deadlineSnoozeStatus:qs('#deadline-snooze-status'),
deadlineSnoozeReview:qs('#review-snoozed-deadlines'), deadlineSnoozeReview:qs('#review-snoozed-deadlines'),

View File

@ -221,6 +221,8 @@
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label> <label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
<span class="small" id="push-update-status" role="status" aria-live="polite"></span> <span class="small" id="push-update-status" role="status" aria-live="polite"></span>
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button> <button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
<label class="push-update-control" for="push-following"><input id="push-following" type="checkbox" /> Notify me when Following changes</label>
<span class="small" id="push-following-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label> <label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label> <label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label> <label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>

View File

@ -4,6 +4,7 @@
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({ })(typeof self !== 'undefined' ? self : this, function createPushNotifications({
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour, startDayControl, startDayStatus, startDayHour,
followingControl, followingStatus,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines, deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
notification, serviceWorker, fetchJson, notification, serviceWorker, fetchJson,
}) { }) {
@ -129,12 +130,15 @@
if (testControl) testControl.hidden = true; if (testControl) testControl.hidden = true;
if (deadlineControl) deadlineControl.checked = false; if (deadlineControl) deadlineControl.checked = false;
if (startDayControl) startDayControl.checked = false; if (startDayControl) startDayControl.checked = false;
if (followingControl) followingControl.checked = false;
configuration.subscribed = false; configuration.subscribed = false;
configuration.deadline_enabled = false; configuration.deadline_enabled = false;
configuration.start_day_enabled = false; configuration.start_day_enabled = false;
configuration.following_enabled = false;
pendingIntent = null; pendingIntent = null;
status.textContent = 'New update notifications are off for this device.'; status.textContent = 'New update notifications are off for this device.';
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders 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() { 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() { async function enableDeadline() {
deadlineControl.checked = true; deadlineControl.checked = true;
return changeDeadline(); return changeDeadline();
} }
async function recoverPermission(intent = null) { 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 (!pendingIntent || notification.permission !== 'granted') return false;
if (recoveryPromise) return recoveryPromise; if (recoveryPromise) return recoveryPromise;
recoveryPromise = (async () => { recoveryPromise = (async () => {
@ -284,6 +320,10 @@
startDayControl.checked = true; startDayControl.checked = true;
return changeStartDay(); return changeStartDay();
} }
if (pendingIntent === 'following') {
followingControl.checked = true;
return changeFollowing();
}
return Boolean(await enable()); return Boolean(await enable());
})(); })();
try { try {
@ -299,18 +339,21 @@
testControl?.addEventListener('click', testDelivery); testControl?.addEventListener('click', testDelivery);
deadlineControl?.addEventListener('change', changeDeadline); deadlineControl?.addEventListener('change', changeDeadline);
startDayControl?.addEventListener('change', changeStartDay); startDayControl?.addEventListener('change', changeStartDay);
followingControl?.addEventListener('change', changeFollowing);
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines); deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
configuration = await fetchJson('api/v1/push-subscription'); configuration = await fetchJson('api/v1/push-subscription');
if (!configuration.available) { if (!configuration.available) {
control.disabled = true; control.disabled = true;
if (deadlineControl) deadlineControl.disabled = true; if (deadlineControl) deadlineControl.disabled = true;
if (startDayControl) startDayControl.disabled = true; if (startDayControl) startDayControl.disabled = true;
if (followingControl) followingControl.disabled = true;
status.textContent = 'New update notifications are not available on this server.'; status.textContent = 'New update notifications are not available on this server.';
return; return;
} }
control.checked = Boolean(configuration.subscribed); control.checked = Boolean(configuration.subscribed);
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled); if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_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 (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2); if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9); if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
@ -321,8 +364,11 @@
if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled
? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.` ? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.`
: 'Start-day reminders are off for this device.'; : '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(); renderDeadlineSnooze();
} }
return {init, change, changeDeadline, changeStartDay, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission}; return {init, change, changeDeadline, changeStartDay, changeFollowing, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
}); });

View File

@ -649,6 +649,7 @@ self.addEventListener('push', event => {
const updateCount = Number(payload.update_count); const updateCount = Number(payload.update_count);
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN; const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
const deadlineCount = Number(payload.deadline_count); const deadlineCount = Number(payload.deadline_count);
const followingCount = Number(payload.following_count);
const planDate = String(payload.plan_date || ''); const planDate = String(payload.plan_date || '');
if ( if (
route === '#/my-work/start-day' route === '#/my-work/start-day'
@ -685,6 +686,23 @@ self.addEventListener('push', event => {
)); ));
return; 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 ( if (
route === '#/my-work/updates' route === '#/my-work/updates'
&& tag === 'stackchain-update-digest' && tag === 'stackchain-update-digest'

View File

@ -55,6 +55,7 @@ from src.passkey_store import PasskeyStore
from src.push_notifications import ( from src.push_notifications import (
PushConfiguration, PushConfiguration,
dispatch_deadline_reminders, dispatch_deadline_reminders,
dispatch_following_changes,
dispatch_start_day_reminders, dispatch_start_day_reminders,
dispatch_unread_updates, dispatch_unread_updates,
send_web_push, 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) 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: async def _push_poll_loop() -> None:
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30"))) interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
deadline_interval = max( deadline_interval = max(
@ -173,6 +178,17 @@ async def _push_poll_loop() -> None:
max_concurrency=max_concurrency, 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: async def dispatch_start_day() -> None:
await dispatch_start_day_reminders( await dispatch_start_day_reminders(
_push_subscription_store, _push_subscription_store,
@ -186,6 +202,7 @@ async def _push_poll_loop() -> None:
channel_tasks = ( channel_tasks = (
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)), asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
asyncio.create_task(_push_channel_loop(dispatch_following, interval=interval)),
asyncio.create_task( asyncio.create_task(
_push_channel_loop(dispatch_deadlines, interval=deadline_interval) _push_channel_loop(dispatch_deadlines, interval=deadline_interval)
), ),
@ -474,6 +491,10 @@ class StartDayReminderPayload(BaseModel):
return value return value
class FollowingNotificationPayload(BaseModel):
enabled: bool
StepUpAction = Literal[ StepUpAction = Literal[
"merge_pull", "merge_pull",
"submit_pull_review", "submit_pull_review",
@ -2322,6 +2343,9 @@ async def push_status(request: Request):
start_day_preferences = await asyncio.to_thread( start_day_preferences = await asyncio.to_thread(
_push_subscription_store.start_day_preferences, device_id _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( delivery_health = await asyncio.to_thread(
_push_subscription_store.delivery_health, device_id _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_enabled": start_day_preferences["enabled"],
"start_day_timezone": start_day_preferences["timezone"], "start_day_timezone": start_day_preferences["timezone"],
"start_day_reminder_hour": start_day_preferences["reminder_hour"], "start_day_reminder_hour": start_day_preferences["reminder_hour"],
"following_enabled": following_preferences["enabled"],
"delivery_health": delivery_health, "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") @app.patch("/api/v1/push-subscription/deadlines/snooze")
async def snooze_deadline_reminder(request: Request): async def snooze_deadline_reminder(request: Request):
device_id = await dashboard_auth.session_management_id( device_id = await dashboard_auth.session_management_id(

View File

@ -1,4 +1,5 @@
import asyncio import asyncio
import hashlib
import json import json
import secrets import secrets
import time 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( async def dispatch_unread_updates(
store: PushSubscriptionStore, store: PushSubscriptionStore,
configuration: PushConfiguration, configuration: PushConfiguration,

View File

@ -52,6 +52,13 @@ class StartDayReminderDevice:
delivered_plan_date: str | None delivered_plan_date: str | None
@dataclass(frozen=True)
class FollowingNotificationDevice:
session_id: str
subscription: dict
delivered_fingerprint: str | None
class DisabledPushSubscriptionStore: class DisabledPushSubscriptionStore:
"""No-persistence store used when Web Push is not configured.""" """No-persistence store used when Web Push is not configured."""
@ -81,6 +88,12 @@ class DisabledPushSubscriptionStore:
def start_day_reminder_devices(self) -> list[StartDayReminderDevice]: def start_day_reminder_devices(self) -> list[StartDayReminderDevice]:
return [] 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]: def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
return [] return []
@ -117,6 +130,12 @@ class DisabledPushSubscriptionStore:
def mark_start_day_reminder_delivered(self, *args, **kwargs) -> None: def mark_start_day_reminder_delivered(self, *args, **kwargs) -> None:
return 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: def reconcile_unread(self, *args, **kwargs) -> None:
return None return None
@ -236,6 +255,13 @@ class PushSubscriptionStore:
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE 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 = { delivery_columns = {
@ -569,6 +595,46 @@ class PushSubscriptionStore:
(plan_date, session_id), (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( def claim_unseen(
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]] self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
) -> list[PushDelivery]: ) -> list[PushDelivery]:

View File

@ -29,6 +29,12 @@ def test_following_queue_is_phone_usable_at_narrow_viewport(viewport):
row = page.locator('[data-mobile-queue="following"]') row = page.locator('[data-mobile-queue="following"]')
expect(row).to_have_count(1) expect(row).to_have_count(1)
expect(row).to_contain_text("Issues and pull requests you watch") 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("""() => { page.evaluate("""() => {
globalThis.fetch = async () => ({ globalThis.fetch = async () => ({
ok:true, ok:true,

View File

@ -4,6 +4,8 @@ from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "push-notifications.js" 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: def run_scenario(script: str) -> dict:
@ -27,6 +29,11 @@ const startDayControl = {
}; };
const startDayHour = {value:'9', disabled:false}; const startDayHour = {value:'9', disabled:false};
const startDayStatus = {set textContent(value) { state.startDayText = value; }, get textContent() { return state.startDayText; }}; 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 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 testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback};
const deadlineSnooze = {hidden:true}; const deadlineSnooze = {hidden:true};
@ -40,6 +47,7 @@ const registration = {pushManager:{
const feature = createPushNotifications({ const feature = createPushNotifications({
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour, startDayControl, startDayStatus, startDayHour,
followingControl, followingStatus,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
onReviewDeadlines:() => { state.reviewed = true; }, onReviewDeadlines:() => { state.reviewed = true; },
notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }}, 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." 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(): def test_degraded_device_can_run_a_test_notification_and_show_recovery():
result = run_scenario(""" result = run_scenario("""
state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}}; state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}};

View File

@ -12,6 +12,7 @@ import requests
from src import dashboard_auth, gitea_proxy, main from src import dashboard_auth, gitea_proxy, main
from src.push_notifications import ( from src.push_notifications import (
PushConfiguration, PushConfiguration,
dispatch_following_changes,
dispatch_unread_updates, dispatch_unread_updates,
send_web_push, 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 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): def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
state_dir = tmp_path / "push-state" state_dir = tmp_path / "push-state"
previous_umask = os.umask(0) 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.setenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "4")
monkeypatch.setattr(main.asyncio, "sleep", no_wait) monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture) 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_deadline_reminders", hold_dispatch)
monkeypatch.setattr(main, "dispatch_start_day_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): async def no_wait(_seconds):
nonlocal sleeps nonlocal sleeps
sleeps += 1 sleeps += 1
if sleeps <= 3: if sleeps <= 4:
if sleeps == 3: if sleeps == 4:
first_tick.set() first_tick.set()
await first_tick.wait() await first_tick.wait()
else: 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): async def dispatch_start_day(*_args, **_kwargs):
calls.append("start-day") calls.append("start-day")
async def dispatch_following(*_args, **_kwargs):
calls.append("following")
monkeypatch.setattr(main.asyncio, "sleep", no_wait) monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread) 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_deadline_reminders", dispatch_deadlines)
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day) monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
with pytest.raises(asyncio.CancelledError): with pytest.raises(asyncio.CancelledError):
await main._push_poll_loop() await main._push_poll_loop()
assert sorted(calls) == ["deadline", "start-day", "unread"] assert sorted(calls) == ["deadline", "following", "start-day", "unread"]
@pytest.mark.anyio @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.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread) 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_deadline_reminders", dispatch_deadlines)
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day) 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() 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 @pytest.mark.anyio
@ -1282,6 +1381,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
"start_day_enabled": False, "start_day_enabled": False,
"start_day_timezone": "UTC", "start_day_timezone": "UTC",
"start_day_reminder_hour": 9, "start_day_reminder_hour": 9,
"following_enabled": False,
"delivery_health": {}, "delivery_health": {},
} }
assert await main.subscribe_push(payload, request) == {"subscribed": True} assert await main.subscribe_push(payload, request) == {"subscribed": True}

View File

@ -1797,3 +1797,27 @@ def test_client_error_navigation_is_not_hidden_by_cached_shell():
assert result["status"] == 401 assert result["status"] == 401
assert result["body"] == "network" assert result["body"] == "network"
assert result["state"]["puts"] == [] 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

View File

@ -208,6 +208,7 @@ async def test_push_poll_applies_configured_concurrency_to_start_day(
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3") monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3")
monkeypatch.setattr(main.asyncio, "sleep", no_wait) monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch) 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_deadline_reminders", hold_dispatch)
monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day) monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)