feat: notify devices when Following changes (Closes #1313)
This commit is contained in:
parent
187b0decae
commit
0efd44fd90
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
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
44
src/main.py
44
src/main.py
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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'}}};
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user