feat: Notify mobile operators when Following changes #1316
10
README.md
10
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.
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -221,6 +221,8 @@
|
|||
<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>
|
||||
<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 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>
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
44
src/main.py
44
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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'}}};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user