465 lines
20 KiB
JavaScript
465 lines
20 KiB
JavaScript
(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, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
|
startDayControl, startDayStatus, startDayHour,
|
|
followingControl, followingStatus,
|
|
humanGateControl, humanGateStatus,
|
|
quietControl = globalThis.document?.querySelector('#push-quiet-hours'),
|
|
quietStart = globalThis.document?.querySelector('#push-quiet-start'),
|
|
quietEnd = globalThis.document?.querySelector('#push-quiet-end'),
|
|
quietStatus = globalThis.document?.querySelector('#push-quiet-status'),
|
|
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
|
|
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 || {});
|
|
const degraded = health.find(item => item?.state === 'degraded');
|
|
if (testControl) testControl.hidden = !configuration?.subscribed;
|
|
if (configuration?.subscribed && degraded) {
|
|
const count = Number(degraded.consecutive_failures || 1);
|
|
status.textContent = `Update notifications need attention after ${count} failed ${count === 1 ? 'delivery' : 'deliveries'}. Send a test notification.`;
|
|
return;
|
|
}
|
|
status.textContent = configuration?.subscribed
|
|
? 'New update notifications enabled for this device.'
|
|
: 'New update notifications are off for this device.';
|
|
}
|
|
|
|
async function testDelivery() {
|
|
if (!testControl) return;
|
|
testControl.disabled = true;
|
|
status.textContent = 'Sending a test notification…';
|
|
try {
|
|
await fetchJson('api/v1/push-subscription/test', {method:'POST'});
|
|
configuration.delivery_health = {unread:{state:'healthy',consecutive_failures:0}};
|
|
status.textContent = 'Test delivered. Update notifications are working on this device.';
|
|
} catch (error) {
|
|
status.textContent = 'Test delivery failed. Check your connection, then try again.';
|
|
} finally {
|
|
testControl.disabled = false;
|
|
}
|
|
}
|
|
|
|
function formattedHour(value) {
|
|
return `${String(Number(value)).padStart(2, '0')}:00`;
|
|
}
|
|
|
|
function enabledDeadlineText(hour, days) {
|
|
const value = Number(days ?? 2);
|
|
const horizon = value === 0 ? 'due today' : `next ${value} days`;
|
|
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
|
|
}
|
|
|
|
function renderDeadlineSnooze() {
|
|
if (!deadlineSnooze) return;
|
|
const wakeAt = Number(configuration?.snoozed_until || 0);
|
|
deadlineSnooze.hidden = !wakeAt;
|
|
if (wakeAt && deadlineSnoozeStatus) {
|
|
const localTime = new Intl.DateTimeFormat(undefined, {
|
|
hour:'numeric', minute:'2-digit',
|
|
}).format(new Date(wakeAt * 1000));
|
|
deadlineSnoozeStatus.textContent = `Deadline reminders snoozed until ${localTime}.`;
|
|
}
|
|
}
|
|
|
|
async function reviewSnoozedDeadlines() {
|
|
deadlineSnoozeReview.disabled = true;
|
|
try {
|
|
await fetchJson('api/v1/push-subscription/deadlines/snooze', {method:'DELETE'});
|
|
configuration.snoozed_until = null;
|
|
renderDeadlineSnooze();
|
|
onReviewDeadlines?.();
|
|
} catch (error) {
|
|
deadlineSnoozeStatus.textContent = 'Could not resume deadline reminders. Check your connection and try again.';
|
|
} finally {
|
|
deadlineSnoozeReview.disabled = false;
|
|
}
|
|
}
|
|
|
|
function deadlineReadiness() {
|
|
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.'};
|
|
}
|
|
|
|
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;
|
|
if (testControl) testControl.hidden = true;
|
|
if (deadlineControl) deadlineControl.checked = false;
|
|
if (startDayControl) startDayControl.checked = false;
|
|
if (followingControl) followingControl.checked = false;
|
|
if (humanGateControl) humanGateControl.checked = false;
|
|
configuration.subscribed = false;
|
|
configuration.deadline_enabled = false;
|
|
configuration.start_day_enabled = false;
|
|
configuration.following_enabled = false;
|
|
configuration.human_gates_enabled = false;
|
|
pendingIntent = null;
|
|
status.textContent = 'New update notifications are off for this device.';
|
|
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
|
if (followingStatus) followingStatus.textContent = 'Following change alerts are off for this device.';
|
|
if (humanGateStatus) humanGateStatus.textContent = 'Human Gate decision alerts are off for this device.';
|
|
}
|
|
|
|
async function ensureSubscription() {
|
|
const permission = notification.permission === 'granted'
|
|
? 'granted'
|
|
: await notification.requestPermission();
|
|
if (permission !== 'granted') {
|
|
control.checked = false;
|
|
status.textContent = blockedReadiness().detail;
|
|
return null;
|
|
}
|
|
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.';
|
|
configuration.subscribed = true;
|
|
if (testControl) testControl.hidden = false;
|
|
return subscription;
|
|
}
|
|
|
|
async function enable() {
|
|
pendingIntent = 'updates';
|
|
const subscription = await ensureSubscription();
|
|
if (subscription) pendingIntent = null;
|
|
return subscription;
|
|
}
|
|
|
|
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 changeDeadline() {
|
|
deadlineControl.disabled = true;
|
|
if (deadlineHour) deadlineHour.disabled = true;
|
|
if (deadlineDays) deadlineDays.disabled = true;
|
|
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;
|
|
deadlineStatus.textContent = status.textContent;
|
|
return false;
|
|
}
|
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9);
|
|
const reminderDays = Number(deadlineDays?.value ?? configuration?.reminder_days ?? 2);
|
|
await fetchJson('api/v1/push-subscription/deadlines', {
|
|
method:'PUT',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour, reminder_days:reminderDays}),
|
|
});
|
|
configuration.deadline_enabled = deadlineControl.checked;
|
|
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.';
|
|
return true;
|
|
} catch (error) {
|
|
deadlineControl.checked = !deadlineControl.checked;
|
|
deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.';
|
|
return false;
|
|
} finally {
|
|
deadlineControl.disabled = false;
|
|
if (deadlineHour) deadlineHour.disabled = false;
|
|
if (deadlineDays) deadlineDays.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function changeStartDay() {
|
|
startDayControl.disabled = true;
|
|
if (startDayHour) startDayHour.disabled = true;
|
|
try {
|
|
const registration = await serviceWorker.ready;
|
|
let subscription = await registration.pushManager.getSubscription();
|
|
if (startDayControl.checked) pendingIntent = 'start-day';
|
|
if (startDayControl.checked && !subscription) subscription = await ensureSubscription();
|
|
if (startDayControl.checked && !subscription) {
|
|
startDayControl.checked = false;
|
|
startDayStatus.textContent = status.textContent;
|
|
return false;
|
|
}
|
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
const reminderHour = Number(startDayHour?.value ?? configuration?.start_day_reminder_hour ?? 9);
|
|
await fetchJson('api/v1/push-subscription/start-day', {
|
|
method:'PUT',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({enabled:startDayControl.checked, timezone, reminder_hour:reminderHour}),
|
|
});
|
|
configuration.start_day_enabled = startDayControl.checked;
|
|
configuration.start_day_timezone = timezone;
|
|
configuration.start_day_reminder_hour = reminderHour;
|
|
pendingIntent = null;
|
|
startDayStatus.textContent = startDayControl.checked
|
|
? `Start-day reminder enabled for ${formattedHour(reminderHour)} local time.`
|
|
: 'Start-day reminders are off for this device.';
|
|
return true;
|
|
} catch (_error) {
|
|
startDayControl.checked = !startDayControl.checked;
|
|
startDayStatus.textContent = 'Could not change start-day reminders. Check your connection and try again.';
|
|
return false;
|
|
} finally {
|
|
startDayControl.disabled = false;
|
|
if (startDayHour) startDayHour.disabled = false;
|
|
}
|
|
}
|
|
|
|
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 changeHumanGates() {
|
|
humanGateControl.disabled = true;
|
|
try {
|
|
const registration = await serviceWorker.ready;
|
|
let subscription = await registration.pushManager.getSubscription();
|
|
if (humanGateControl.checked) pendingIntent = 'human-gates';
|
|
if (humanGateControl.checked && !subscription) subscription = await ensureSubscription();
|
|
if (humanGateControl.checked && !subscription) {
|
|
humanGateControl.checked = false;
|
|
humanGateStatus.textContent = status.textContent;
|
|
return false;
|
|
}
|
|
await fetchJson('api/v1/push-subscription/human-gates', {
|
|
method:'PUT',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({enabled:humanGateControl.checked}),
|
|
});
|
|
configuration.human_gates_enabled = humanGateControl.checked;
|
|
pendingIntent = null;
|
|
humanGateStatus.textContent = humanGateControl.checked
|
|
? 'Human Gate decision alerts enabled for this device.'
|
|
: 'Human Gate decision alerts are off for this device.';
|
|
return true;
|
|
} catch (_error) {
|
|
humanGateControl.checked = !humanGateControl.checked;
|
|
humanGateStatus.textContent = 'Could not change Human Gate alerts. Check your connection and try again.';
|
|
return false;
|
|
} finally {
|
|
humanGateControl.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function changeQuietHours() {
|
|
if (!quietControl) return false;
|
|
for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = true;
|
|
try {
|
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
await fetchJson('api/v1/push-subscription/quiet-hours', {
|
|
method:'PUT',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({
|
|
enabled:quietControl.checked,
|
|
start:quietStart?.value || '22:00',
|
|
end:quietEnd?.value || '07:00',
|
|
timezone,
|
|
}),
|
|
});
|
|
configuration.quiet_hours_enabled = quietControl.checked;
|
|
configuration.quiet_hours_start = quietStart?.value || '22:00';
|
|
configuration.quiet_hours_end = quietEnd?.value || '07:00';
|
|
quietStatus.textContent = quietControl.checked
|
|
? `Routine alerts paused from ${configuration.quiet_hours_start} to ${configuration.quiet_hours_end} local time.`
|
|
: 'Routine alert quiet hours are off for this device.';
|
|
return true;
|
|
} catch (_error) {
|
|
quietStatus.textContent = 'Could not save quiet hours. Check your connection and try again.';
|
|
return false;
|
|
} finally {
|
|
for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function enableDeadline() {
|
|
deadlineControl.checked = true;
|
|
return changeDeadline();
|
|
}
|
|
|
|
async function recoverPermission(intent = null) {
|
|
if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following', 'human-gates'].includes(intent)) pendingIntent = intent;
|
|
if (!pendingIntent || notification.permission !== 'granted') return false;
|
|
if (recoveryPromise) return recoveryPromise;
|
|
recoveryPromise = (async () => {
|
|
if (pendingIntent === 'deadline') {
|
|
deadlineControl.checked = true;
|
|
return changeDeadline();
|
|
}
|
|
if (pendingIntent === 'start-day') {
|
|
startDayControl.checked = true;
|
|
return changeStartDay();
|
|
}
|
|
if (pendingIntent === 'following') {
|
|
followingControl.checked = true;
|
|
return changeFollowing();
|
|
}
|
|
if (pendingIntent === 'human-gates') {
|
|
humanGateControl.checked = true;
|
|
return changeHumanGates();
|
|
}
|
|
return Boolean(await enable());
|
|
})();
|
|
try {
|
|
return await recoveryPromise;
|
|
} finally {
|
|
recoveryPromise = null;
|
|
}
|
|
}
|
|
|
|
async function init() {
|
|
if (!control || !notification || !serviceWorker) return;
|
|
control.addEventListener('change', change);
|
|
testControl?.addEventListener('click', testDelivery);
|
|
deadlineControl?.addEventListener('change', changeDeadline);
|
|
startDayControl?.addEventListener('change', changeStartDay);
|
|
followingControl?.addEventListener('change', changeFollowing);
|
|
humanGateControl?.addEventListener('change', changeHumanGates);
|
|
quietControl?.addEventListener('change', changeQuietHours);
|
|
quietStart?.addEventListener('change', changeQuietHours);
|
|
quietEnd?.addEventListener('change', changeQuietHours);
|
|
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
|
configuration = await fetchJson('api/v1/push-subscription');
|
|
if (!configuration.available) {
|
|
control.disabled = true;
|
|
if (deadlineControl) deadlineControl.disabled = true;
|
|
if (startDayControl) startDayControl.disabled = true;
|
|
if (followingControl) followingControl.disabled = true;
|
|
if (humanGateControl) humanGateControl.disabled = true;
|
|
if (quietControl) quietControl.disabled = true;
|
|
status.textContent = 'New update notifications are not available on this server.';
|
|
return;
|
|
}
|
|
control.checked = Boolean(configuration.subscribed);
|
|
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
|
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
|
|
if (followingControl) followingControl.checked = Boolean(configuration.following_enabled);
|
|
if (humanGateControl) humanGateControl.checked = Boolean(configuration.human_gates_enabled);
|
|
if (quietControl) quietControl.checked = Boolean(configuration.quiet_hours_enabled);
|
|
if (quietStart) quietStart.value = configuration.quiet_hours_start || '22:00';
|
|
if (quietEnd) quietEnd.value = configuration.quiet_hours_end || '07:00';
|
|
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
|
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
|
if (startDayHour) startDayHour.value = String(configuration.start_day_reminder_hour ?? 9);
|
|
renderDeliveryHealth();
|
|
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
|
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
|
: 'Deadline reminders are off for this device.';
|
|
if (startDayStatus) startDayStatus.textContent = configuration.start_day_enabled
|
|
? `Start-day reminder enabled for ${formattedHour(configuration.start_day_reminder_hour)} local time.`
|
|
: 'Start-day reminders are off for this device.';
|
|
if (followingStatus) followingStatus.textContent = configuration.following_enabled
|
|
? 'Following change alerts enabled for this device.'
|
|
: 'Following change alerts are off for this device.';
|
|
if (humanGateStatus) humanGateStatus.textContent = configuration.human_gates_enabled
|
|
? 'Human Gate decision alerts enabled for this device.'
|
|
: 'Human Gate decision alerts are off for this device.';
|
|
if (quietStatus) quietStatus.textContent = configuration.quiet_hours_enabled
|
|
? `Routine alerts paused from ${quietStart.value} to ${quietEnd.value} local time.`
|
|
: 'Routine alert quiet hours are off for this device.';
|
|
renderDeadlineSnooze();
|
|
}
|
|
|
|
return {init, change, changeDeadline, changeStartDay, changeFollowing, changeHumanGates, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
|
});
|