feat: recover blocked notification setup (Closes #1132)
CI / lint (pull_request) Successful in 2m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m22s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-19 13:36:20 +00:00
1 parent b3832d107f
commit 1248647aa4
16 files changed
+239 -42

No files matched your search

+10
View File
@@ -7990,9 +7990,15 @@
enableOffline:() => setOfflineWorkEnabled(true),
storageProtectionReadiness:() => deviceStorage.persistenceReadiness(),
protectStorage:() => deviceStorage.requestPersistence(),
notificationReadiness:() => pushController?.notificationReadiness()
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
enablePush:async () => {
const controller = await pushControllerReady;
if (!controller) return;
if (controller.notificationReadiness().state === 'blocked') {
await controller.recoverPermission('updates');
return;
}
qs('#push-updates').checked = true;
await controller.change();
},
@@ -8003,6 +8009,10 @@
if (!controller) return;
qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value;
qs('#push-deadline-days').value = qs('#device-setup-deadline-days').value;
if (controller.deadlineReadiness().state === 'blocked') {
await controller.recoverPermission('deadline');
return;
}
await controller.enableDeadline();
},
});
+8 -11
View File
@@ -5,11 +5,11 @@
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
const steps = [
['install', options.installButton, options.installStatus, options.install],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
['protection', options.protectionButton, options.protectionStatus, options.protectStorage],
['push', options.pushButton, options.pushStatus, options.enablePush],
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
['install', options.installButton, options.installStatus, options.install, 'Install'],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
];
let trigger = options.launcher;
let ownsDetour = false;
@@ -35,9 +35,10 @@
const readiness = await options.getReadiness();
let available = 0;
let complete = 0;
steps.forEach(([name, button, status]) => {
steps.forEach(([name, button, status, _action, defaultActionLabel]) => {
const step = readiness[name];
status.textContent = step.detail;
button.textContent = step.actionLabel || defaultActionLabel;
button.hidden = step.state === 'complete' || step.state === 'unavailable';
button.disabled = step.state === 'pending';
if (step.state !== 'unavailable') available += 1;
@@ -130,11 +131,7 @@ function mountMobileDeviceSetup(options) {
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
protection:options.storageProtectionReadiness(),
push:qs('#push-updates').disabled
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
: qs('#push-updates').checked
? {state:'complete', detail:'New update notifications are enabled.'}
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
push:options.notificationReadiness(),
deadline:options.deadlineReadiness(),
}),
install:() => options.installApp.install(),
+61 -4
View File
@@ -7,6 +7,34 @@
notification, serviceWorker, fetchJson,
}) {
let configuration = null;
let pendingIntent = null;
let recoveryPromise = null;
function blockedReadiness() {
return {
state:'blocked',
detail:'Notifications are blocked. Allow them in browser settings, then check again.',
actionLabel:'Check again',
};
}
function pendingRecoveryReadiness() {
return notification.permission === 'granted'
? {state:'blocked', detail:'Notification permission changed. Check again to finish setup.', actionLabel:'Check again'}
: blockedReadiness();
}
function notificationReadiness() {
if (!configuration?.available || control?.disabled) {
return {state:'unavailable', detail:status?.textContent || 'Update notifications are unavailable.'};
}
if (configuration.subscribed) {
return {state:'complete', detail:'New update notifications are enabled.'};
}
if (pendingIntent === 'updates') return pendingRecoveryReadiness();
if (notification.permission === 'denied') return blockedReadiness();
return {state:'incomplete', detail:status?.textContent || 'New update notifications are off.'};
}
function renderDeliveryHealth() {
const health = Object.values(configuration?.delivery_health || {});
@@ -77,6 +105,7 @@
if (!configuration?.available || deadlineControl?.disabled) {
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
}
if (pendingIntent === 'deadline') return pendingRecoveryReadiness();
return configuration.deadline_enabled
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
@@ -98,15 +127,20 @@
control.checked = false;
if (testControl) testControl.hidden = true;
if (deadlineControl) deadlineControl.checked = false;
configuration.subscribed = false;
configuration.deadline_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.';
}
async function ensureSubscription() {
const permission = await notification.requestPermission();
const permission = notification.permission === 'granted'
? 'granted'
: await notification.requestPermission();
if (permission !== 'granted') {
control.checked = false;
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
status.textContent = blockedReadiness().detail;
return null;
}
const registration = await serviceWorker.ready;
@@ -130,7 +164,10 @@
}
async function enable() {
await ensureSubscription();
pendingIntent = 'updates';
const subscription = await ensureSubscription();
if (subscription) pendingIntent = null;
return subscription;
}
async function change() {
@@ -153,6 +190,7 @@
try {
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (deadlineControl.checked) pendingIntent = 'deadline';
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
if (deadlineControl.checked && !subscription) {
deadlineControl.checked = false;
@@ -171,6 +209,7 @@
configuration.reminder_hour = reminderHour;
configuration.reminder_days = reminderDays;
configuration.timezone = timezone;
pendingIntent = null;
deadlineStatus.textContent = deadlineControl.checked
? enabledDeadlineText(reminderHour, reminderDays)
: 'Deadline reminders are off for this device.';
@@ -191,6 +230,24 @@
return changeDeadline();
}
async function recoverPermission(intent = null) {
if (!pendingIntent && (intent === 'updates' || intent === 'deadline')) pendingIntent = intent;
if (!pendingIntent || notification.permission !== 'granted') return false;
if (recoveryPromise) return recoveryPromise;
recoveryPromise = (async () => {
if (pendingIntent === 'deadline') {
deadlineControl.checked = true;
return changeDeadline();
}
return Boolean(await enable());
})();
try {
return await recoveryPromise;
} finally {
recoveryPromise = null;
}
}
async function init() {
if (!control || !notification || !serviceWorker) return;
control.addEventListener('change', change);
@@ -215,5 +272,5 @@
renderDeadlineSnooze();
}
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
return {init, change, changeDeadline, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
});
+1 -1
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v122';
const CACHE = 'stackchain-dashboard-shell-v123';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;