feat: finish Today from the lock screen (Closes #1032)
This commit is contained in:
1 parent
ca520d2eb6
commit
11395b655a
6 files changed
+211
-52
No files matched your search
+41
-22
@@ -1945,28 +1945,6 @@
|
||||
todayRecapView.finish(selectedWorkFilter);
|
||||
},
|
||||
});
|
||||
todayLockScreen = createTodayLockScreen({
|
||||
storage:localStorage,
|
||||
getLogin:() => confirmedOwnerLogin,
|
||||
serviceWorker:navigator.serviceWorker,
|
||||
NotificationRef:window.Notification,
|
||||
control:qs('#today-lock-screen'),
|
||||
status:qs('#today-lock-screen-status'),
|
||||
locationRef:window.location,
|
||||
historyRef:window.history,
|
||||
onAction:action => {
|
||||
const state = timer.snapshot();
|
||||
if (!state.identity || (action === 'pause' && !state.running) || (action === 'resume' && state.running)) return;
|
||||
const changed = action === 'pause' ? timer.pause() : timer.resume();
|
||||
if (changed === false) return;
|
||||
selectTodayWork();
|
||||
const item = todayMyWork.find(entry => todayWork.identity(entry) === state.identity);
|
||||
if (item) workSession.reopen(item);
|
||||
timerView.render();
|
||||
},
|
||||
});
|
||||
todayLockScreen.consumeLaunchAction();
|
||||
todayLockScreen.sync(timer.snapshot(), workSession.checkpointed());
|
||||
function selectTodayWork() {
|
||||
qs('[data-work-filter="today"]').click();
|
||||
}
|
||||
@@ -2165,6 +2143,47 @@
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
advance: () => runTodayTransition('complete'),
|
||||
});
|
||||
todayLockScreen = createTodayLockScreen({
|
||||
storage:localStorage,
|
||||
getLogin:() => confirmedOwnerLogin,
|
||||
serviceWorker:navigator.serviceWorker,
|
||||
NotificationRef:window.Notification,
|
||||
control:qs('#today-lock-screen'),
|
||||
status:qs('#today-lock-screen-status'),
|
||||
locationRef:window.location,
|
||||
historyRef:window.history,
|
||||
onAction:(action, expectedIdentity) => {
|
||||
const state = timer.snapshot();
|
||||
if (action === 'complete') {
|
||||
selectTodayWork();
|
||||
if (!state.identity || state.identity !== expectedIdentity) {
|
||||
qs('#my-work-action-status').textContent =
|
||||
'That lock-screen action is stale. The current Today item was not changed.';
|
||||
return;
|
||||
}
|
||||
const item = todayMyWork.find(entry => todayWork.identity(entry) === expectedIdentity);
|
||||
if (!item) {
|
||||
qs('#my-work-action-status').textContent =
|
||||
'That Today item is no longer available. Nothing was changed.';
|
||||
return;
|
||||
}
|
||||
workSession.reopen(item);
|
||||
completeTodayItem(item);
|
||||
timerView.render();
|
||||
return;
|
||||
}
|
||||
if (!state.identity || (action === 'pause' && !state.running) ||
|
||||
(action === 'resume' && state.running)) return;
|
||||
const changed = action === 'pause' ? timer.pause() : timer.resume();
|
||||
if (changed === false) return;
|
||||
selectTodayWork();
|
||||
const item = todayMyWork.find(entry => todayWork.identity(entry) === state.identity);
|
||||
if (item) workSession.reopen(item);
|
||||
timerView.render();
|
||||
},
|
||||
});
|
||||
await todayLockScreen.sync(timer.snapshot(), workSession.checkpointed());
|
||||
await todayLockScreen.consumeLaunchAction();
|
||||
async function completeOwnershipExitToday(item) {
|
||||
if (!item || !todayWork.remove(item)) return null;
|
||||
todaySync.enqueue('remove', todayWork.identity(item));
|
||||
|
||||
+24
-11
@@ -420,13 +420,14 @@ self.addEventListener('sync', event => {
|
||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
||||
});
|
||||
|
||||
async function updateTodayLockScreen(active, running) {
|
||||
async function updateTodayLockScreen(active, running, rawActionToken = '') {
|
||||
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 : '';
|
||||
await self.registration.showNotification(running ? 'Today session running' : 'Today session paused', {
|
||||
body: running ? 'Your active Today timer is running.' : 'Your active Today timer is paused.',
|
||||
tag,
|
||||
@@ -434,9 +435,9 @@ async function updateTodayLockScreen(active, running) {
|
||||
silent:true,
|
||||
actions: [
|
||||
{ action:running ? 'pause-today' : 'resume-today', title:running ? 'Pause' : 'Resume' },
|
||||
{ action:'open-today', title:'Open Today' },
|
||||
...(actionToken ? [{ action:'finish-today', title:'Finish current' }] : []),
|
||||
],
|
||||
data: { route:'#/my-work/today' },
|
||||
data: { route:'#/my-work/today', ...(actionToken ? { actionToken } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -459,7 +460,11 @@ self.addEventListener('message', event => {
|
||||
}
|
||||
})());
|
||||
if (event.data?.type === 'stackchain-today-lock-screen') {
|
||||
event.waitUntil(updateTodayLockScreen(event.data.active === true, event.data.running === true));
|
||||
event.waitUntil(updateTodayLockScreen(
|
||||
event.data.active === true,
|
||||
event.data.running === true,
|
||||
String(event.data.actionToken || '')
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -553,16 +558,22 @@ async function openCanonicalIssueUrl(rawUrl) {
|
||||
return client.focus();
|
||||
}
|
||||
|
||||
async function applyTodayTimerAction(action) {
|
||||
if (!['pause', 'resume'].includes(action)) return;
|
||||
async function applyTodayTimerAction(action, actionToken = '') {
|
||||
if (!['pause', 'resume', 'complete'].includes(action)) return;
|
||||
if (action === 'complete' && !/^[A-Za-z0-9_-]{16,128}$/.test(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 });
|
||||
client.postMessage?.({
|
||||
type:'stackchain-today-timer-action', action,
|
||||
...(action === 'complete' ? { actionToken } : {}),
|
||||
});
|
||||
return client.focus?.();
|
||||
}
|
||||
const target = new URL(BASE + '?today_timer_action=' + action + route, self.location.origin).href;
|
||||
const query = '?today_timer_action=' + action +
|
||||
(action === 'complete' ? '&today_action_token=' + encodeURIComponent(actionToken) : '');
|
||||
const target = new URL(BASE + query + route, self.location.origin).href;
|
||||
return self.clients.openWindow(target);
|
||||
}
|
||||
|
||||
@@ -572,11 +583,13 @@ self.addEventListener('notificationclick', event => {
|
||||
if (
|
||||
event.notification.tag === 'stackchain-today-session'
|
||||
&& route === '#/my-work/today'
|
||||
&& ['pause-today', 'resume-today', 'open-today', ''].includes(event.action)
|
||||
&& ['pause-today', 'resume-today', 'finish-today', 'open-today', ''].includes(event.action)
|
||||
) {
|
||||
event.notification.close();
|
||||
if (event.action === 'pause-today' || event.action === 'resume-today') {
|
||||
event.waitUntil(applyTodayTimerAction(event.action === 'pause-today' ? 'pause' : 'resume'));
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -7,13 +7,57 @@ function createTodayLockScreen({
|
||||
status,
|
||||
locationRef,
|
||||
historyRef,
|
||||
randomToken = () => {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
|
||||
},
|
||||
fingerprint = async (token, identity) => {
|
||||
const input = new TextEncoder().encode(token + '\0' + identity);
|
||||
const digest = await globalThis.crypto.subtle.digest('SHA-256', input);
|
||||
return Array.from(new Uint8Array(digest), value =>
|
||||
value.toString(16).padStart(2, '0')).join('');
|
||||
},
|
||||
onAction = () => {},
|
||||
}) {
|
||||
let activeIdentity = '';
|
||||
const preferenceKey = () => {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-lock-screen.v1.' + encodeURIComponent(login) : '';
|
||||
};
|
||||
const supported = Boolean(serviceWorker && NotificationRef);
|
||||
const actionKey = () => {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-lock-screen-action.v1.' + encodeURIComponent(login) : '';
|
||||
};
|
||||
const readAction = () => {
|
||||
const key = actionKey();
|
||||
if (!key) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(key) || 'null');
|
||||
return typeof value?.token === 'string' && value.token &&
|
||||
typeof value.fingerprint === 'string' && value.fingerprint ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
};
|
||||
const clearAction = () => {
|
||||
const key = actionKey();
|
||||
try { if (key) storage?.removeItem(key); }
|
||||
catch (_error) { return false; }
|
||||
return true;
|
||||
};
|
||||
const actionFor = async identity => {
|
||||
const existing = readAction();
|
||||
if (existing && await fingerprint(existing.token, identity) === existing.fingerprint) return existing;
|
||||
const key = actionKey();
|
||||
if (!key) return null;
|
||||
try {
|
||||
const token = String(randomToken() || '');
|
||||
const value = { token, fingerprint:token ? await fingerprint(token, identity) : '' };
|
||||
if (!value.token) return null;
|
||||
storage?.setItem(key, JSON.stringify(value));
|
||||
return value;
|
||||
} catch (_error) { return null; }
|
||||
};
|
||||
const enabled = () => {
|
||||
const key = preferenceKey();
|
||||
return Boolean(key && storage?.getItem(key) === '1');
|
||||
@@ -36,9 +80,17 @@ function createTodayLockScreen({
|
||||
if (!supported) setStatus('Lock-screen controls are not supported on this device.');
|
||||
else if (enabled()) setStatus('Lock-screen Today controls are on.');
|
||||
};
|
||||
const consumeAction = action => {
|
||||
if (!enabled() || !['pause', 'resume'].includes(action)) return false;
|
||||
onAction(action);
|
||||
const consumeAction = async (action, token = '') => {
|
||||
if (!enabled() || !['pause', 'resume', 'complete'].includes(action)) return false;
|
||||
if (action !== 'complete') {
|
||||
onAction(action, null);
|
||||
return true;
|
||||
}
|
||||
const pending = readAction();
|
||||
if (!pending || !token || !activeIdentity || pending.token !== token ||
|
||||
await fingerprint(token, activeIdentity) !== pending.fingerprint) return false;
|
||||
if (!clearAction()) return false;
|
||||
onAction(action, activeIdentity);
|
||||
return true;
|
||||
};
|
||||
const api = {
|
||||
@@ -66,6 +118,7 @@ function createTodayLockScreen({
|
||||
async disable() {
|
||||
const key = preferenceKey();
|
||||
if (key) storage?.removeItem(key);
|
||||
clearAction();
|
||||
if (control) control.checked = false;
|
||||
setStatus('Lock-screen Today controls are off.');
|
||||
await hide();
|
||||
@@ -74,22 +127,30 @@ function createTodayLockScreen({
|
||||
async sync(snapshot, active) {
|
||||
if (!enabled() || NotificationRef?.permission !== 'granted') return false;
|
||||
const visible = Boolean(active && snapshot?.identity);
|
||||
activeIdentity = visible ? snapshot.identity : '';
|
||||
const pending = visible ? await actionFor(activeIdentity) : null;
|
||||
if (!visible) clearAction();
|
||||
await post({
|
||||
type:'stackchain-today-lock-screen',
|
||||
active:visible,
|
||||
running:visible && Boolean(snapshot.running),
|
||||
...(pending?.token ? { actionToken:pending.token } : {}),
|
||||
});
|
||||
if (visible && !pending) setStatus('Finish current is unavailable because its one-time action could not be saved.');
|
||||
return true;
|
||||
},
|
||||
consumeLaunchAction() {
|
||||
consumeAction,
|
||||
async consumeLaunchAction() {
|
||||
let url;
|
||||
try { url = new URL(locationRef?.href || ''); }
|
||||
catch (_error) { return false; }
|
||||
const action = url.searchParams.get('today_timer_action');
|
||||
if (!['pause', 'resume'].includes(action)) return false;
|
||||
if (!['pause', 'resume', 'complete'].includes(action)) return false;
|
||||
const token = url.searchParams.get('today_action_token') || '';
|
||||
url.searchParams.delete('today_timer_action');
|
||||
url.searchParams.delete('today_action_token');
|
||||
historyRef?.replaceState?.(null, '', url.pathname + url.search + url.hash);
|
||||
return consumeAction(action);
|
||||
return await consumeAction(action, token);
|
||||
},
|
||||
render,
|
||||
};
|
||||
@@ -98,7 +159,9 @@ function createTodayLockScreen({
|
||||
else api.disable();
|
||||
});
|
||||
serviceWorker?.addEventListener?.('message', event => {
|
||||
if (event.data?.type === 'stackchain-today-timer-action') consumeAction(String(event.data.action || ''));
|
||||
if (event.data?.type === 'stackchain-today-timer-action') {
|
||||
consumeAction(String(event.data.action || ''), String(event.data.actionToken || ''));
|
||||
}
|
||||
});
|
||||
render();
|
||||
return api;
|
||||
|
||||
Reference in new issue
Block a user