758 lines
29 KiB
JavaScript
758 lines
29 KiB
JavaScript
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 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;
|
|
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
|
|
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
|
|
const SHELL = [
|
|
BASE,
|
|
BASE + 'manifest.webmanifest',
|
|
BASE + 'static/dashboard.css',
|
|
BASE + 'static/dashboard.js',
|
|
BASE + 'static/icons/stackchain-192.png',
|
|
BASE + 'static/icons/stackchain-512.png',
|
|
BASE + 'static/session.js',
|
|
BASE + 'static/feature-loader.js',
|
|
BASE + 'static/workspace-bootstrap.js',
|
|
BASE + 'static/conversation-action-hydrator.js',
|
|
BASE + 'static/security-center.js',
|
|
BASE + 'static/markdown.js',
|
|
BASE + 'static/commands.js',
|
|
BASE + 'static/saved-searches.js',
|
|
BASE + 'static/search-preview.js',
|
|
BASE + 'static/search-reply-draft-store.js',
|
|
BASE + 'static/conversation-reply-draft-store.js',
|
|
BASE + 'static/conversation-photo-drafts.js',
|
|
BASE + 'static/search-defer.js',
|
|
BASE + 'static/widgets.js',
|
|
BASE + 'static/drafts.js',
|
|
BASE + 'static/unfiled-captures.js',
|
|
BASE + 'static/unfiled-draft-sync.js',
|
|
BASE + 'static/shared-image-capture.js',
|
|
BASE + 'static/draft-filing-session.js',
|
|
BASE + 'static/draft-capacity-dialog.js',
|
|
BASE + 'static/outbox-coordinator.js',
|
|
BASE + 'static/issue-outbox.js',
|
|
BASE + 'static/reconnect-outboxes.js',
|
|
BASE + 'static/authored-outbox.js',
|
|
BASE + 'static/offline-issue-close.js',
|
|
BASE + 'static/offline-issue-blocker.js',
|
|
BASE + 'static/notification-read-outbox.js',
|
|
BASE + 'static/offline-work.js',
|
|
BASE + 'static/offline-today.js',
|
|
BASE + 'static/my-work.js',
|
|
BASE + 'static/agenda-replan.js',
|
|
BASE + 'static/agenda-calendar.js',
|
|
BASE + 'static/protect-today.js',
|
|
BASE + 'static/notification-undo.js',
|
|
BASE + 'static/card-planning.js',
|
|
BASE + 'static/work-selection.js',
|
|
BASE + 'static/today-work.js',
|
|
BASE + 'static/today-timer.js',
|
|
BASE + 'static/today-break.js',
|
|
BASE + 'static/today-progress.js',
|
|
BASE + 'static/today-lock-screen.js',
|
|
BASE + 'static/today-session-sync.js',
|
|
BASE + 'static/today-recap.js',
|
|
BASE + 'static/today-wrap-up.js',
|
|
BASE + 'static/today-summary.js',
|
|
BASE + 'static/today-handoff.js',
|
|
BASE + 'static/today-completion.js',
|
|
BASE + 'static/today-readiness.js',
|
|
BASE + 'static/comment-next.js',
|
|
BASE + 'static/update-reply-read-next.js',
|
|
BASE + 'static/plan-today.js',
|
|
BASE + 'static/plan-today-readiness.js',
|
|
BASE + 'static/plan-today-preview.js',
|
|
BASE + 'static/today-sync.js',
|
|
BASE + 'static/today-rollover.js',
|
|
BASE + 'static/update-ownership.js',
|
|
BASE + 'static/update-follow-up.js',
|
|
BASE + 'static/later-work.js',
|
|
BASE + 'static/later-sync.js',
|
|
BASE + 'static/later-and-start.js',
|
|
BASE + 'static/detail-defer.js',
|
|
BASE + 'static/later-picker.js',
|
|
BASE + 'static/pick-work.js',
|
|
BASE + 'static/batch-find-work.js',
|
|
BASE + 'static/search-batch-plan.js',
|
|
BASE + 'static/conversation.js',
|
|
BASE + 'static/comment-actions.js',
|
|
BASE + 'static/issue-evidence-review.js',
|
|
BASE + 'static/issue-evidence-editor.js',
|
|
BASE + 'static/issue-attachment.js',
|
|
BASE + 'static/issue-filing-review.js',
|
|
BASE + 'static/issue-sheet.js',
|
|
BASE + 'static/mobile-issue-detail-nav.js',
|
|
BASE + 'static/mobile-update-detail-nav.js',
|
|
BASE + 'static/mobile-review-detail-nav.js',
|
|
BASE + 'static/mobile-search-preview-nav.js',
|
|
BASE + 'static/mobile-plan-today-nav.js',
|
|
BASE + 'static/mobile-find-work-nav.js',
|
|
BASE + 'static/mobile-pull-refresh.js',
|
|
BASE + 'static/checklist-conflict.js',
|
|
BASE + 'static/voice-transcript-store.js',
|
|
BASE + 'static/voice-issue-capture.js',
|
|
BASE + 'static/voice-conversation-capture.js',
|
|
BASE + 'static/create-issue-sheet.js',
|
|
BASE + 'static/mobile-create-issue-nav.js',
|
|
BASE + 'static/create-and-start.js',
|
|
BASE + 'static/assign-and-start.js',
|
|
BASE + 'static/filed-claim.js',
|
|
BASE + 'static/queue-today.js',
|
|
BASE + 'static/pull-sheet.js',
|
|
BASE + 'static/review-sheet.js',
|
|
BASE + 'static/release-receipt.js',
|
|
BASE + 'static/work-route.js',
|
|
BASE + 'static/task-overlay-history.js',
|
|
BASE + 'static/context-poller.js',
|
|
BASE + 'static/live-data-status.js',
|
|
BASE + 'static/mobile-today-command-bar.js',
|
|
BASE + 'static/mobile-task-dock.js',
|
|
BASE + 'static/mobile-work-entry.js',
|
|
BASE + 'static/mobile-queue-launcher.js',
|
|
BASE + 'static/mobile-delivery-recovery.js',
|
|
BASE + 'static/mobile-start-day.js',
|
|
BASE + 'static/update-triage-session.js',
|
|
BASE + 'static/update-review-handoff.js',
|
|
BASE + 'static/update-read-position.js',
|
|
BASE + 'static/work-detail-position.js',
|
|
BASE + 'static/update-triage-launcher.js',
|
|
BASE + 'static/update-triage-gesture.js',
|
|
BASE + 'static/update-decision-transaction.js',
|
|
BASE + 'static/agenda-session-launcher.js',
|
|
BASE + 'static/mobile-launch.js',
|
|
BASE + 'static/mobile-insights.js',
|
|
BASE + 'static/mobile-app-shortcuts.js',
|
|
BASE + 'static/install-app.js',
|
|
BASE + 'static/private-data-inventory.js',
|
|
BASE + 'static/private-device-data.js',
|
|
BASE + 'static/device-storage.js',
|
|
BASE + 'static/mobile-device-setup.js',
|
|
BASE + 'static/mobile-search-viewport.js',
|
|
BASE + 'static/mobile-composer-viewport.js',
|
|
BASE + 'static/mention-composer.js',
|
|
BASE + 'static/push-notifications.js',
|
|
BASE + 'static/issue-filing-receipt.js',
|
|
BASE + 'static/background-issue-sync.js',
|
|
];
|
|
const OPTIONAL_FEATURES = [
|
|
];
|
|
|
|
const SHARED_IMAGE_ID = 'shared-image';
|
|
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
|
const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024;
|
|
const SHARE_TARGET_PATH = BASE + 'share-target';
|
|
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
|
|
|
|
function boundedShareField(form, name, limit) {
|
|
const value = form.get(name);
|
|
return typeof value === 'string' ? value.trim().slice(0, limit) : '';
|
|
}
|
|
|
|
function sharedContentRedirect(marker) {
|
|
const target = new URL(BASE, self.location.origin);
|
|
target.searchParams.set('launch', 'new');
|
|
if (marker) target.searchParams.set('shared', marker);
|
|
return new Response(null, {status:303, headers:{Location:target.pathname + target.search}});
|
|
}
|
|
|
|
async function acceptSharedContent(request) {
|
|
if (request.headers.get('Sec-Fetch-Site') === 'cross-site') {
|
|
return new Response('Cross-site share submissions are not accepted.', {
|
|
status:403,
|
|
headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'},
|
|
});
|
|
}
|
|
const form = await request.formData();
|
|
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
|
|
const content = {
|
|
title:boundedShareField(form, 'title', 255),
|
|
text:boundedShareField(form, 'text', 10000),
|
|
url:boundedShareField(form, 'url', 2048),
|
|
};
|
|
if (images.length > 5) return sharedContentRedirect('multiple');
|
|
const supported = images.every(image =>
|
|
SHARED_IMAGE_TYPES.has(String(image.type || '')) && image.size <= MAX_SHARED_IMAGE_BYTES
|
|
);
|
|
const totalBytes = images.reduce((total, image) => total + image.size, 0);
|
|
if (!supported || totalBytes > MAX_SHARED_IMAGE_BYTES) return sharedContentRedirect('unsupported');
|
|
if (!images.length && !Object.values(content).some(Boolean)) return sharedContentRedirect('unsupported');
|
|
const attachments = images.map(image => ({
|
|
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
|
contentType:String(image.type), blob:image,
|
|
}));
|
|
try {
|
|
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {...content, attachments});
|
|
} catch (_error) {
|
|
return sharedContentRedirect('unavailable');
|
|
}
|
|
return sharedContentRedirect('bundle');
|
|
}
|
|
|
|
async function fetchNavigation(request) {
|
|
const controller = new AbortController();
|
|
let timeout;
|
|
const deadline = new Promise((resolve, reject) => {
|
|
timeout = setTimeout(() => {
|
|
controller.abort();
|
|
reject(new Error('Dashboard navigation timed out.'));
|
|
}, NAVIGATION_TIMEOUT_MS);
|
|
});
|
|
try {
|
|
return await Promise.race([
|
|
fetch(request, { signal: controller.signal }),
|
|
deadline,
|
|
]);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
async function sessionCsrf(signal) {
|
|
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
|
|
headers: { Accept: 'application/json' },
|
|
signal,
|
|
});
|
|
if (!response.ok) return '';
|
|
const payload = await response.json().catch(() => ({}));
|
|
return typeof payload.csrf_token === 'string' ? payload.csrf_token : '';
|
|
}
|
|
|
|
let batchedCsrf = null;
|
|
let batchedCsrfController = null;
|
|
async function withSessionCsrf(work) {
|
|
batchedCsrfController = new AbortController();
|
|
batchedCsrf = sessionCsrf(batchedCsrfController.signal);
|
|
try {
|
|
return await work();
|
|
} finally {
|
|
batchedCsrfController.abort();
|
|
batchedCsrf = null;
|
|
batchedCsrfController = null;
|
|
}
|
|
}
|
|
|
|
async function deletePrivateDatabase(name) {
|
|
if (!indexedDB) return;
|
|
await new Promise((resolve, reject) => {
|
|
let request;
|
|
try { request = indexedDB.deleteDatabase(name); }
|
|
catch (error) { reject(error); return; }
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
|
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
|
});
|
|
}
|
|
|
|
async function deletePrivateDatabases() {
|
|
for (const name of PRIVATE_DATABASES) await deletePrivateDatabase(name);
|
|
}
|
|
|
|
async function purgeRevokedSessionData() {
|
|
await issueSync.purge();
|
|
await deletePrivateDatabases();
|
|
const keys = await caches.keys();
|
|
await Promise.all(
|
|
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
|
);
|
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
|
|
}
|
|
|
|
async function notifyIdleSession() {
|
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' }));
|
|
}
|
|
|
|
async function storeOfflineLease(expiresAt, idleExpiresAt) {
|
|
if (
|
|
!Number.isInteger(expiresAt) || expiresAt <= 0
|
|
|| !Number.isInteger(idleExpiresAt) || idleExpiresAt <= 0
|
|
) return;
|
|
const cache = await caches.open(CACHE);
|
|
await cache.put(OFFLINE_LEASE_URL, new Response(
|
|
JSON.stringify({ expires_at: expiresAt, idle_expires_at: idleExpiresAt }),
|
|
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
|
|
));
|
|
}
|
|
|
|
async function offlineLeaseState(cache) {
|
|
const response = await cache.match(OFFLINE_LEASE_URL);
|
|
const payload = await response?.json?.().catch(() => ({})) || {};
|
|
const current = Math.floor(Date.now() / 1000);
|
|
if (!Number.isInteger(payload.expires_at) || payload.expires_at <= current) return 'expired';
|
|
if (!Number.isInteger(payload.idle_expires_at) || payload.idle_expires_at <= current) return 'idle';
|
|
return 'valid';
|
|
}
|
|
|
|
async function expiredOfflineResponse() {
|
|
await issueSync.purge();
|
|
await deletePrivateDatabases();
|
|
const keys = await caches.keys();
|
|
await Promise.all(
|
|
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
|
);
|
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-expired' }));
|
|
return new Response(
|
|
'Your Stackchain session expired. Reconnect and sign in.',
|
|
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
|
|
);
|
|
}
|
|
|
|
async function idleOfflineResponse() {
|
|
await notifyIdleSession();
|
|
return new Response(
|
|
'Your Stackchain session is locked. Reconnect and sign in.',
|
|
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
|
|
);
|
|
}
|
|
|
|
async function cachedShellWithValidLease(cache) {
|
|
const state = await offlineLeaseState(cache);
|
|
if (state === 'expired') return expiredOfflineResponse();
|
|
if (state === 'idle') return idleOfflineResponse();
|
|
return cache.match(BASE);
|
|
}
|
|
|
|
async function fetchJson(url, options = {}) {
|
|
const requestOptions = { ...options };
|
|
const method = String(options.method || 'GET').toUpperCase();
|
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
|
const headers = new Headers(options.headers || {});
|
|
const cancelBatchedCsrf = () => batchedCsrfController?.abort();
|
|
if (batchedCsrf && options.signal) {
|
|
if (options.signal.aborted) cancelBatchedCsrf();
|
|
else options.signal.addEventListener('abort', cancelBatchedCsrf, { once: true });
|
|
}
|
|
let csrf;
|
|
try {
|
|
csrf = await (batchedCsrf || sessionCsrf(options.signal));
|
|
} finally {
|
|
options.signal?.removeEventListener('abort', cancelBatchedCsrf);
|
|
}
|
|
if (options.signal?.aborted) throw new Error('Background request aborted.');
|
|
if (csrf) headers.set('X-CSRF-Token', csrf);
|
|
requestOptions.headers = headers;
|
|
}
|
|
const response = await fetch(new URL(url, self.location.origin), requestOptions);
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
const code = payload.code || payload.detail?.code;
|
|
if (response.status === 401 && code === 'session_revoked') {
|
|
await purgeRevokedSessionData();
|
|
}
|
|
if (response.status === 401 && code === 'session_idle') {
|
|
await notifyIdleSession();
|
|
}
|
|
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
|
|
error.status = response.status;
|
|
error.code = code;
|
|
throw error;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
const issueSync = self.__issueSync || createBackgroundIssueSync({
|
|
store: createIssueSyncStore(), fetchJson, base: BASE, batch: withSessionCsrf,
|
|
});
|
|
|
|
async function flushAndNotify() {
|
|
const result = await issueSync.flush();
|
|
if (!result?.login || !result.receipts?.length ||
|
|
!await issueSync.getReceiptPreference?.(result.login)) return;
|
|
for (const receipt of result.receipts) {
|
|
const needsAttention = receipt.status === 'attention';
|
|
const needsAuthorization = receipt.status === 'authorization';
|
|
const title = needsAuthorization ? 'Queued review needs authorization' : needsAttention
|
|
? 'Queued work needs attention'
|
|
: receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent';
|
|
await self.registration.showNotification(title, {
|
|
body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' :
|
|
needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
|
tag: 'stackchain-delivery-' + receipt.id,
|
|
data: receipt.url ? { url: receipt.url } : { route: receipt.route },
|
|
});
|
|
}
|
|
}
|
|
|
|
self.addEventListener('install', event => {
|
|
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
|
|
});
|
|
|
|
async function warmOptionalFeature(cache, staleCacheNames, asset) {
|
|
for (const cacheName of staleCacheNames) {
|
|
const staleCache = await caches.open(cacheName);
|
|
const cached = await staleCache.match(asset);
|
|
if (cached) {
|
|
await cache.put(asset, cached);
|
|
return;
|
|
}
|
|
}
|
|
await cache.add(asset);
|
|
}
|
|
|
|
async function cachedOptionalFeature(request) {
|
|
const cached = await caches.match(request);
|
|
if (cached) return cached;
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
const cache = await caches.open(CACHE);
|
|
await cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
}
|
|
|
|
self.addEventListener('activate', event => {
|
|
event.waitUntil((async () => {
|
|
const keys = await caches.keys();
|
|
const staleCacheNames = keys.filter(
|
|
key => key.startsWith('stackchain-dashboard-') && key !== CACHE
|
|
);
|
|
const cache = await caches.open(CACHE);
|
|
await Promise.allSettled(
|
|
OPTIONAL_FEATURES.map(asset => warmOptionalFeature(cache, staleCacheNames, asset))
|
|
);
|
|
await Promise.all(staleCacheNames.map(key => caches.delete(key)));
|
|
await self.clients.claim();
|
|
})());
|
|
});
|
|
|
|
self.addEventListener('sync', event => {
|
|
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
|
});
|
|
|
|
async function updateTodayLockScreen(active, running, rawActionToken = '', rawBreakDeadline = 0) {
|
|
const tag = 'stackchain-today-session';
|
|
if (!active) {
|
|
const notifications = await self.registration.getNotifications({ tag });
|
|
notifications.forEach(notification => notification.close());
|
|
return;
|
|
}
|
|
const actionToken = /^[A-Za-z0-9_-]{16,128}$/.test(rawActionToken) ? rawActionToken : '';
|
|
const now = Date.now();
|
|
const breakDeadline = Number(rawBreakDeadline);
|
|
const onBreak = !running && actionToken && Number.isSafeInteger(breakDeadline) &&
|
|
breakDeadline > now && breakDeadline <= now + 120 * 60 * 1000;
|
|
const title = onBreak ? 'On a Today break' :
|
|
running ? 'Today session running' : 'Today session paused';
|
|
const body = onBreak ? 'Return at ' + new Date(breakDeadline).toLocaleTimeString([], {
|
|
hour:'numeric', minute:'2-digit',
|
|
}) : running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.';
|
|
await self.registration.showNotification(title, {
|
|
body,
|
|
tag,
|
|
renotify:false,
|
|
silent:true,
|
|
actions: onBreak ? [
|
|
{ action:'resume-today', title:'Resume now' },
|
|
{ action:'open-today', title:'Open Today' },
|
|
] : [
|
|
{ action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' },
|
|
...(actionToken ? [{ action:'finish-today', title:'Finish current' }] : []),
|
|
],
|
|
data: { route:'#/my-work/today', ...(actionToken ? { actionToken } : {}) },
|
|
});
|
|
}
|
|
|
|
self.addEventListener('message', event => {
|
|
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
|
|
await issueSync.resume();
|
|
await flushAndNotify();
|
|
})());
|
|
if (event.data?.type === 'stackchain-session-lease') {
|
|
event.waitUntil(storeOfflineLease(event.data.expiresAt, event.data.idleExpiresAt));
|
|
}
|
|
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
|
try {
|
|
await issueSync.purge();
|
|
await deletePrivateDatabases();
|
|
await updateTodayLockScreen(false, false);
|
|
event.ports?.[0]?.postMessage({ ok: true });
|
|
} catch (error) {
|
|
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
|
}
|
|
})());
|
|
if (event.data?.type === 'stackchain-today-lock-screen') {
|
|
event.waitUntil(updateTodayLockScreen(
|
|
event.data.active === true,
|
|
event.data.running === true,
|
|
String(event.data.actionToken || ''),
|
|
Number(event.data.breakDeadlineAt || 0)
|
|
));
|
|
}
|
|
});
|
|
|
|
self.addEventListener('push', event => {
|
|
let payload;
|
|
try { payload = event.data?.json?.() || {}; }
|
|
catch (_error) { return; }
|
|
const route = String(payload.route || '');
|
|
const protectRoute = String(payload.protect_route || '');
|
|
const tag = String(payload.tag || '');
|
|
const notificationId = Number(payload.notification_id);
|
|
const updateCount = Number(payload.update_count);
|
|
const deadlineCount = Number(payload.deadline_count);
|
|
if (
|
|
route === '#/my-work/agenda'
|
|
&& protectRoute === '#/my-work/agenda/protect-today'
|
|
&& /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag)
|
|
&& Number.isSafeInteger(deadlineCount)
|
|
&& deadlineCount > 0
|
|
&& deadlineCount <= 50
|
|
) {
|
|
event.waitUntil(self.registration.showNotification(
|
|
deadlineCount + ' deadline' + (deadlineCount === 1 ? '' : 's') + ' need' + (deadlineCount === 1 ? 's' : '') + ' attention',
|
|
{
|
|
body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'),
|
|
tag,
|
|
actions: [
|
|
{ action: 'protect-today', title: 'Protect Today' },
|
|
{ action: 'snooze-deadline', title: 'Remind in 1 hour' },
|
|
],
|
|
data: {route, protectRoute},
|
|
}
|
|
));
|
|
return;
|
|
}
|
|
if (
|
|
route === '#/my-work/updates'
|
|
&& tag === 'stackchain-update-digest'
|
|
&& Number.isSafeInteger(updateCount)
|
|
&& updateCount > 0
|
|
&& updateCount <= 50
|
|
) {
|
|
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
|
|
body: 'Tap to review them in Stackchain.',
|
|
tag,
|
|
data: {route},
|
|
}));
|
|
return;
|
|
}
|
|
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
|
|
const options = {
|
|
body: 'Tap to review it in Stackchain.',
|
|
tag,
|
|
data: {route},
|
|
};
|
|
if (
|
|
Number.isSafeInteger(notificationId) && notificationId > 0
|
|
&& route === '#/my-work/update/' + notificationId
|
|
&& tag === 'stackchain-update-' + notificationId
|
|
) {
|
|
options.actions = [
|
|
{ action: 'mark-read', title: 'Mark read' },
|
|
{ action: 'tomorrow', title: 'Tomorrow' },
|
|
];
|
|
options.data.notificationId = notificationId;
|
|
}
|
|
event.waitUntil(self.registration.showNotification('New work update', options));
|
|
});
|
|
|
|
async function openWorkRoute(route) {
|
|
const target = new URL(BASE + route, self.location.origin).href;
|
|
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
|
if (!client) return self.clients.openWindow(target);
|
|
if (client.navigate) await client.navigate(target);
|
|
return client.focus();
|
|
}
|
|
|
|
async function openCanonicalIssueUrl(rawUrl) {
|
|
let target;
|
|
try { target = new URL(rawUrl); } catch (_error) { return; }
|
|
if (
|
|
target.origin !== self.location.origin
|
|
|| !/^\/git\/[^/]+\/[^/]+\/issues\/\d+$/.test(target.pathname)
|
|
|| target.search || target.hash
|
|
) return;
|
|
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
|
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
|
if (!client) return self.clients.openWindow(target.href);
|
|
if (client.navigate) await client.navigate(target.href);
|
|
return client.focus();
|
|
}
|
|
|
|
async function applyTodayTimerAction(action, actionToken = '') {
|
|
if (!['pause', 'resume', 'complete'].includes(action)) return;
|
|
if (actionToken && !/^[A-Za-z0-9_-]{16,128}$/.test(actionToken)) return;
|
|
if (action === 'complete' && !actionToken) return;
|
|
const route = '#/my-work/today';
|
|
const windows = await self.clients.matchAll({ type:'window', includeUncontrolled:true });
|
|
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
|
if (client) {
|
|
client.postMessage?.({
|
|
type:'stackchain-today-timer-action', action,
|
|
...(actionToken ? { actionToken } : {}),
|
|
});
|
|
return client.focus?.();
|
|
}
|
|
const query = '?today_timer_action=' + action +
|
|
(actionToken ? '&today_action_token=' + encodeURIComponent(actionToken) : '');
|
|
const target = new URL(BASE + query + route, self.location.origin).href;
|
|
return self.clients.openWindow(target);
|
|
}
|
|
|
|
self.addEventListener('notificationclick', event => {
|
|
const route = String(event.notification.data?.route || '');
|
|
const issueUrl = String(event.notification.data?.url || '');
|
|
if (
|
|
event.notification.tag === 'stackchain-today-session'
|
|
&& route === '#/my-work/today'
|
|
&& ['pause-today', 'resume-today', 'finish-today', 'open-today', ''].includes(event.action)
|
|
) {
|
|
event.notification.close();
|
|
if (['pause-today', 'resume-today', 'finish-today'].includes(event.action)) {
|
|
const action = event.action === 'pause-today' ? 'pause' :
|
|
event.action === 'resume-today' ? 'resume' : 'complete';
|
|
event.waitUntil(applyTodayTimerAction(action, String(event.notification.data?.actionToken || '')));
|
|
} else {
|
|
event.waitUntil(openWorkRoute(route));
|
|
}
|
|
return;
|
|
}
|
|
if (issueUrl) {
|
|
event.notification.close();
|
|
event.waitUntil(openCanonicalIssueUrl(issueUrl));
|
|
return;
|
|
}
|
|
if (event.action === 'protect-today') {
|
|
const protectRoute = String(event.notification.data?.protectRoute || route);
|
|
if (
|
|
protectRoute !== '#/my-work/agenda/protect-today'
|
|
|| !['#/my-work/agenda', protectRoute].includes(route)
|
|
) return;
|
|
event.notification.close();
|
|
event.waitUntil(openWorkRoute(protectRoute));
|
|
return;
|
|
}
|
|
if (event.action === 'snooze-deadline') {
|
|
if (
|
|
route !== '#/my-work/agenda'
|
|
|| !/^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(event.notification.tag)
|
|
) return;
|
|
event.waitUntil((async () => {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
|
try {
|
|
await fetchJson(BASE + 'api/v1/push-subscription/deadlines/snooze', {
|
|
method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal,
|
|
});
|
|
event.notification.close();
|
|
} catch (_error) {
|
|
await openWorkRoute(route);
|
|
event.notification.close();
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
})());
|
|
return;
|
|
}
|
|
if (event.action === 'tomorrow') {
|
|
const notificationId = Number(event.notification.data?.notificationId);
|
|
if (
|
|
!Number.isSafeInteger(notificationId) || notificationId <= 0
|
|
|| route !== '#/my-work/update/' + notificationId
|
|
|| event.notification.tag !== 'stackchain-update-' + notificationId
|
|
) return;
|
|
event.waitUntil((async () => {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
|
try {
|
|
const wake = new Date(self.__STACKCHAIN_NOW?.() || Date.now());
|
|
wake.setDate(wake.getDate() + 1);
|
|
wake.setHours(9, 0, 0, 0);
|
|
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/later', {
|
|
method: 'PATCH',
|
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ wake_at: wake.toISOString() }),
|
|
signal: controller.signal,
|
|
});
|
|
event.notification.close();
|
|
} catch (_error) {
|
|
await openWorkRoute(route);
|
|
event.notification.close();
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
})());
|
|
return;
|
|
}
|
|
if (event.action === 'mark-read') {
|
|
const notificationId = Number(event.notification.data?.notificationId);
|
|
if (
|
|
!Number.isSafeInteger(notificationId) || notificationId <= 0
|
|
|| route !== '#/my-work/update/' + notificationId
|
|
) return;
|
|
event.waitUntil((async () => {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
|
try {
|
|
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/read', {
|
|
method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal,
|
|
});
|
|
event.notification.close();
|
|
} catch (_error) {
|
|
await openWorkRoute(route);
|
|
event.notification.close();
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
})());
|
|
return;
|
|
}
|
|
event.notification.close();
|
|
if (!route.startsWith('#/my-work/')) return;
|
|
event.waitUntil(openWorkRoute(route));
|
|
});
|
|
|
|
self.addEventListener('fetch', event => {
|
|
const request = event.request;
|
|
const requestUrl = new URL(request.url);
|
|
if (
|
|
request.method === 'POST' && request.mode === 'navigate'
|
|
&& requestUrl.origin === self.location.origin && requestUrl.pathname === SHARE_TARGET_PATH
|
|
) {
|
|
event.respondWith(acceptSharedContent(request));
|
|
return;
|
|
}
|
|
if (request.method !== 'GET' || request.url.includes('/api/')) return;
|
|
const url = new URL(request.url);
|
|
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
|
if (request.mode === 'navigate') {
|
|
event.respondWith(
|
|
fetchNavigation(request).then(async response => {
|
|
const cache = await caches.open(CACHE);
|
|
const responseUrl = new URL(response.url || request.url);
|
|
const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE;
|
|
if (response.ok && !response.redirected && isDashboardShell) {
|
|
await cache.put(BASE, response.clone());
|
|
} else if (OUTAGE_STATUSES.has(response.status)) {
|
|
const cached = await cachedShellWithValidLease(cache);
|
|
if (cached) return cached;
|
|
}
|
|
return response;
|
|
}).catch(async () => {
|
|
const cache = await caches.open(CACHE);
|
|
const cached = await cachedShellWithValidLease(cache);
|
|
return cached || new Response(
|
|
'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.',
|
|
{ status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
|
|
);
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
|
|
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
|
return;
|
|
}
|
|
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
|
|
event.respondWith(cachedOptionalFeature(request));
|
|
}
|
|
});
|