feat: notify mobile operators of unread updates (Closes #549)
CI / lint (pull_request) Successful in 1m15s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 07:07:52 +00:00
1 parent 83702e75b0
commit 6e90e3fa9e
15 files changed
+702 -7

No files matched your search

+1
View File
@@ -81,6 +81,7 @@ textarea { resize: vertical; min-height: 120px; }
.offline-status[hidden] { display:none; }
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
.push-update-control { min-height:44px; }
.offline-work-controls input { width:20px; height:20px; }
.offline-work-controls button { min-height:44px; }
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
+17 -3
View File
@@ -417,6 +417,7 @@
document,
urls: {
'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '',
'push-notifications': document.querySelector('meta[name="stackchain-feature-push-notifications"]')?.content || '',
},
});
let sharedLaunchState = null;
@@ -5270,9 +5271,22 @@
if (workSession.active()) workSession.reconcile();
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('service-worker.js').catch(error =>
console.warn('Stackchain install support unavailable', error)
);
navigator.serviceWorker.register('service-worker.js').then(async () => {
await issueCaptureFeatures.load('push-notifications');
return createPushNotifications({
control:qs('#push-updates'),
status:qs('#push-update-status'),
notification:window.Notification,
serviceWorker:navigator.serviceWorker,
fetchJson:fetchReviewJson,
}).init();
}).catch(error => {
qs('#push-update-status').textContent = 'Update notification settings unavailable.';
console.warn('Push notifications unavailable', error);
});
} else {
qs('#push-updates').disabled = true;
qs('#push-update-status').textContent = 'This browser does not support update notifications.';
}
const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
+3
View File
@@ -97,6 +97,8 @@
<div class="offline-work-controls">
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</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>
<button id="clear-offline-work" type="button">Clear offline work data</button>
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
@@ -777,6 +779,7 @@
<script src="static/mobile-search-viewport.js"></script>
<script src="static/mobile-composer-viewport.js"></script>
<script src="static/mention-composer.js"></script>
<script src="static/push-notifications.js"></script>
<script src="static/dashboard.js"></script>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
(function(root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createPushNotifications = factory;
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
control, status, notification, serviceWorker, fetchJson,
}) {
let configuration = null;
function applicationServerKey(value) {
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
if (typeof atob === 'function') {
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
}
return Uint8Array.from(Buffer.from(padded, 'base64'));
}
async function disable() {
const registration = await serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
await subscription?.unsubscribe?.();
control.checked = false;
status.textContent = 'New update notifications are off for this device.';
}
async function enable() {
const permission = await notification.requestPermission();
if (permission !== 'granted') {
control.checked = false;
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
return;
}
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey(configuration.public_key),
});
}
await fetchJson('api/v1/push-subscription', {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(subscription.toJSON()),
});
control.checked = true;
status.textContent = 'New update notifications enabled for this device.';
}
async function change() {
control.disabled = true;
try {
if (control.checked) await enable();
else await disable();
} catch (error) {
control.checked = !control.checked;
status.textContent = 'Could not change update notifications. Check your connection and try again.';
} finally {
control.disabled = false;
}
}
async function init() {
if (!control || !notification || !serviceWorker) return;
control.addEventListener('change', change);
configuration = await fetchJson('api/v1/push-subscription');
if (!configuration.available) {
control.disabled = true;
status.textContent = 'New update notifications are not available on this server.';
return;
}
control.checked = Boolean(configuration.subscribed);
status.textContent = configuration.subscribed
? 'New update notifications enabled for this device.'
: 'New update notifications are off for this device.';
}
return {init, change};
});
+15
View File
@@ -63,6 +63,7 @@ const SHELL = [
BASE + 'static/mobile-search-viewport.js',
BASE + 'static/mobile-composer-viewport.js',
BASE + 'static/mention-composer.js',
BASE + 'static/push-notifications.js',
BASE + 'static/background-issue-sync.js',
];
@@ -268,6 +269,20 @@ self.addEventListener('message', event => {
})());
});
self.addEventListener('push', event => {
let payload;
try { payload = event.data?.json?.() || {}; }
catch (_error) { return; }
const route = String(payload.route || '');
const tag = String(payload.tag || '');
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
event.waitUntil(self.registration.showNotification('New work update', {
body: 'Tap to review it in Stackchain.',
tag,
data: {route},
}));
});
self.addEventListener('notificationclick', event => {
event.notification.close();
const route = String(event.notification.data?.route || '');