feat: keep background Updates badge current (Closes #1281)
This commit is contained in:
parent
b18a50fec5
commit
05405bf2e8
|
|
@ -6,6 +6,7 @@
|
|||
const mobileAppBadge = createMobileAppBadge({
|
||||
control:qs('#app-badge-control'), status:qs('#app-badge-status'),
|
||||
container:qs('#app-badge-setting'), navigator, storage:localStorage,
|
||||
serviceWorker:navigator.serviceWorker,
|
||||
});
|
||||
mobileAppBadge.start();
|
||||
const cardPlanning = createCardPlanning(document);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
container,
|
||||
navigator,
|
||||
storage,
|
||||
serviceWorker,
|
||||
}) {
|
||||
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
|
||||
let enabled = false;
|
||||
|
|
@ -18,6 +19,16 @@
|
|||
&& typeof navigator?.clearAppBadge === 'function';
|
||||
}
|
||||
|
||||
async function syncPreference() {
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({type:'stackchain-app-badge-preference', enabled});
|
||||
return Boolean(registration?.active);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function render() {
|
||||
if (!enabled || !available() || renderedCount === confirmedCount) return true;
|
||||
try {
|
||||
|
|
@ -36,6 +47,7 @@
|
|||
enabled = Boolean(control?.checked);
|
||||
if (enabled) storage?.setItem(ENABLED_KEY, 'true');
|
||||
else storage?.removeItem(ENABLED_KEY);
|
||||
await syncPreference();
|
||||
if (!enabled && available()) {
|
||||
try {
|
||||
await navigator.clearAppBadge();
|
||||
|
|
@ -58,6 +70,7 @@
|
|||
return false;
|
||||
}
|
||||
enabled = storage?.getItem(ENABLED_KEY) === 'true';
|
||||
if (enabled) void syncPreference();
|
||||
if (control) {
|
||||
control.checked = enabled;
|
||||
control.addEventListener('change', change);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
'stackchain-search-reply-drafts-v1',
|
||||
'stackchain-conversation-reply-drafts-v1',
|
||||
'stackchain-today-action-mailbox-v1',
|
||||
'stackchain-app-badge-preference-v1',
|
||||
]);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
|
||||
else root.stackchainPrivateDatabases = databases;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,57 @@ const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
|
|||
const TODAY_ACTION_TTL_MS = 2 * 60 * 1000;
|
||||
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
|
||||
|
||||
function createAppBadgePreference() {
|
||||
const dbName = 'stackchain-app-badge-preference-v1';
|
||||
const storeName = 'preferences';
|
||||
const open = () => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
|
||||
});
|
||||
return {
|
||||
async get() {
|
||||
const database = await open();
|
||||
const enabled = await new Promise((resolve, reject) => {
|
||||
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('enabled');
|
||||
request.onsuccess = () => resolve(request.result === true);
|
||||
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
|
||||
});
|
||||
database.close();
|
||||
return enabled;
|
||||
},
|
||||
async set(enabled) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put(enabled === true, 'enabled');
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('App badge preference could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBadgePreference();
|
||||
let renderedBackgroundBadgeCount = null;
|
||||
|
||||
async function reconcileBackgroundAppBadge(count) {
|
||||
if (!Number.isSafeInteger(count) || count < 0 || count > 9999
|
||||
|| typeof self.registration.setAppBadge !== 'function'
|
||||
|| typeof self.registration.clearAppBadge !== 'function') return false;
|
||||
let enabled = false;
|
||||
try { enabled = await appBadgePreference.get(); } catch (_error) { return false; }
|
||||
if (!enabled || renderedBackgroundBadgeCount === count) return false;
|
||||
try {
|
||||
if (count > 0) await self.registration.setAppBadge(count);
|
||||
else await self.registration.clearAppBadge();
|
||||
renderedBackgroundBadgeCount = count;
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function createTodayActionStore() {
|
||||
const dbName = 'stackchain-today-action-mailbox-v1';
|
||||
const storeName = 'commands';
|
||||
|
|
@ -524,6 +575,19 @@ async function updateTodayLockScreen(active, running, rawActionToken = '', rawBr
|
|||
}
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-app-badge-preference') {
|
||||
event.waitUntil((async () => {
|
||||
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
const enabled = event.data.enabled === true;
|
||||
try {
|
||||
await appBadgePreference.set(enabled);
|
||||
renderedBackgroundBadgeCount = null;
|
||||
if (!enabled && typeof self.registration.clearAppBadge === 'function') {
|
||||
await self.registration.clearAppBadge();
|
||||
}
|
||||
} catch (_error) { /* Page preference remains authoritative on next launch. */ }
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
|
||||
await issueSync.resume();
|
||||
await flushAndNotify();
|
||||
|
|
@ -582,6 +646,7 @@ self.addEventListener('push', event => {
|
|||
const tag = String(payload.tag || '');
|
||||
const notificationId = Number(payload.notification_id);
|
||||
const updateCount = Number(payload.update_count);
|
||||
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
const planDate = String(payload.plan_date || '');
|
||||
if (
|
||||
|
|
@ -626,11 +691,12 @@ self.addEventListener('push', event => {
|
|||
&& updateCount > 0
|
||||
&& updateCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
}));
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge(unreadCount),
|
||||
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;
|
||||
|
|
@ -650,7 +716,10 @@ self.addEventListener('push', event => {
|
|||
];
|
||||
options.data.notificationId = notificationId;
|
||||
}
|
||||
event.waitUntil(self.registration.showNotification('New work update', options));
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge(unreadCount),
|
||||
self.registration.showNotification('New work update', options),
|
||||
]));
|
||||
});
|
||||
|
||||
async function openWorkRoute(route) {
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ async def dispatch_unread_updates(
|
|||
for item in page.get("items", [])
|
||||
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
|
||||
}
|
||||
unread_count = min(len(thread_revisions), 9999)
|
||||
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
|
||||
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
|
||||
if session_statuses is not None:
|
||||
|
|
@ -201,6 +202,7 @@ async def dispatch_unread_updates(
|
|||
"route": f"#/my-work/update/{thread_id}",
|
||||
"tag": f"stackchain-update-{thread_id}",
|
||||
"notification_id": thread_id,
|
||||
"unread_count": unread_count,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
|
@ -258,6 +260,7 @@ async def dispatch_unread_updates(
|
|||
"route": "#/my-work/updates",
|
||||
"tag": "stackchain-update-digest",
|
||||
"update_count": len(overflow_revisions),
|
||||
"unread_count": unread_count,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -364,7 +364,7 @@ process.stdout.write(JSON.stringify(state));
|
|||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
|
|
@ -392,7 +392,7 @@ process.stdout.write(JSON.stringify(state));
|
|||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
|
|
@ -525,7 +525,7 @@ process.stdout.write(JSON.stringify(state));
|
|||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
|
|
@ -550,7 +550,7 @@ process.stdout.write(JSON.stringify(state));
|
|||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
|
|
@ -648,7 +648,7 @@ process.stdout.write(JSON.stringify(state));
|
|||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
|
|
|||
|
|
@ -186,3 +186,27 @@ console.log(JSON.stringify({changed, saved:saved.has('stackchain.app-badge.enabl
|
|||
"saved": False,
|
||||
"status": "App icon badge is off, but the browser could not clear the old count.",
|
||||
}
|
||||
|
||||
|
||||
def test_app_badge_synchronizes_opt_in_with_the_active_service_worker():
|
||||
result = run_badge("""
|
||||
const listeners = {};
|
||||
const messages = [];
|
||||
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
|
||||
const controller = createMobileAppBadge({
|
||||
control, status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
|
||||
storage:{getItem() { return null; }, setItem() {}, removeItem() {}},
|
||||
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
|
||||
});
|
||||
controller.start();
|
||||
control.checked = true;
|
||||
await listeners.change();
|
||||
control.checked = false;
|
||||
await listeners.change();
|
||||
console.log(JSON.stringify(messages));
|
||||
""")
|
||||
assert result == [
|
||||
{"type": "stackchain-app-badge-preference", "enabled": True},
|
||||
{"type": "stackchain-app-badge-preference", "enabled": False},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ process.stdout.write(JSON.stringify(databases));
|
|||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-app-badge-preference-v1",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const clear = createPrivateDeviceDataPurger({{
|
|||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
|
|
|
|||
|
|
@ -613,6 +613,7 @@ async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path
|
|||
"route": "#/my-work/update/42",
|
||||
"tag": "stackchain-update-42",
|
||||
"notification_id": 42,
|
||||
"unread_count": 1,
|
||||
}
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
assert "Secret title" not in json.dumps(sent)
|
||||
|
|
@ -692,7 +693,9 @@ async def test_update_burst_sends_bounded_individual_pushes_and_one_private_dige
|
|||
"route": "#/my-work/updates",
|
||||
"tag": "stackchain-update-digest",
|
||||
"update_count": 3,
|
||||
"unread_count": 5,
|
||||
}
|
||||
assert [payload["unread_count"] for payload in sent] == [5, 5, 5]
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
assert "Secret update" not in json.dumps(sent)
|
||||
assert await dispatch_unread_updates(
|
||||
|
|
@ -792,6 +795,7 @@ async def test_production_notification_page_dispatches_one_unread_push(tmp_path)
|
|||
"route": "#/my-work/update/42",
|
||||
"tag": "stackchain-update-42",
|
||||
"notification_id": 42,
|
||||
"unread_count": 1,
|
||||
}]
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
assert "Secret title" not in json.dumps(sent)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
|||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
|
|
@ -78,6 +78,10 @@ const context = {{
|
|||
}},
|
||||
purge: async () => {{ state.todayCommands = {{}}; }},
|
||||
}},
|
||||
__STACKCHAIN_APP_BADGE_PREFERENCE: {{
|
||||
get: async () => state.badgeEnabled,
|
||||
set: async enabled => {{ state.badgeEnabled = enabled; }},
|
||||
}},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{
|
||||
|
|
@ -88,6 +92,8 @@ const context = {{
|
|||
registration: {{
|
||||
showNotification: async (title, options) => state.notifications.push({{title,options}}),
|
||||
getNotifications: async () => [{{close:()=>{{state.closedNotifications += 1;}}}}],
|
||||
setAppBadge: async value => state.appBadges.push(value),
|
||||
clearAppBadge: async () => {{ state.clearedAppBadges += 1; }},
|
||||
}},
|
||||
__issueSync: {{
|
||||
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
|
||||
|
|
@ -479,6 +485,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
|
|||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert result["state"]["clientMessages"] == [
|
||||
|
|
@ -800,6 +807,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
|
|||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["replies"] == [{"ok": True}]
|
||||
|
||||
|
|
@ -973,6 +981,40 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c
|
|||
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_opted_in_background_updates_reconcile_authoritative_badge_without_churn():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchMessage({type:'stackchain-app-badge-preference', enabled:true}, [], {url:'https://forge.example/dashboard/'});
|
||||
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:7});
|
||||
await dispatchPush({tag:'stackchain-update-digest', route:'#/my-work/updates', update_count:2, unread_count:7});
|
||||
await dispatchPush({tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda', protect_route:'#/my-work/agenda/protect-today', deadline_count:1, unread_count:99});
|
||||
await dispatchPush({tag:'stackchain-update-43', route:'#/my-work/update/43', notification_id:43, unread_count:'invalid'});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["badgeEnabled"] is True
|
||||
assert result["appBadges"] == [7]
|
||||
assert result["clearedAppBadges"] == 0
|
||||
assert len(result["notifications"]) == 4
|
||||
|
||||
|
||||
def test_disabling_background_badge_clears_it_and_future_updates_leave_it_off():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.badgeEnabled = true;
|
||||
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3});
|
||||
await dispatchMessage({type:'stackchain-app-badge-preference', enabled:false}, [], {url:'https://forge.example/dashboard/'});
|
||||
await dispatchPush({tag:'stackchain-update-43', route:'#/my-work/update/43', notification_id:43, unread_count:4});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["badgeEnabled"] is False
|
||||
assert result["appBadges"] == [3]
|
||||
assert result["clearedAppBadges"] == 1
|
||||
|
||||
|
||||
def test_deadline_digest_push_offers_protect_today_and_snooze_without_rendering_private_copy():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
|
@ -1565,6 +1607,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
|
|||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert "private cached dashboard" not in result["body"]
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user