feat: show unread Updates on app icon (Closes #1278)
This commit is contained in:
parent
71631c6731
commit
be9de6596f
|
|
@ -241,6 +241,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.offline-status[hidden] { display:none; }
|
.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 { 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; }
|
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||||
|
.app-badge-setting { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||||
|
.app-badge-control { min-height:44px; }
|
||||||
.push-update-control { min-height:44px; }
|
.push-update-control { min-height:44px; }
|
||||||
.offline-work-controls input { width:20px; height:20px; }
|
.offline-work-controls input { width:20px; height:20px; }
|
||||||
.offline-work-controls button { min-height:44px; }
|
.offline-work-controls button { min-height:44px; }
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@
|
||||||
const qs = (s, el=document) => el.querySelector(s);
|
const qs = (s, el=document) => el.querySelector(s);
|
||||||
const announceWork = message => qs('#my-work-action-status').textContent = message;
|
const announceWork = message => qs('#my-work-action-status').textContent = message;
|
||||||
const fmt = (d) => new Date(d).toLocaleString();
|
const fmt = (d) => new Date(d).toLocaleString();
|
||||||
|
const mobileAppBadge = createMobileAppBadge({
|
||||||
|
control:qs('#app-badge-control'), status:qs('#app-badge-status'),
|
||||||
|
container:qs('#app-badge-setting'), navigator, storage:localStorage,
|
||||||
|
});
|
||||||
|
mobileAppBadge.start();
|
||||||
const cardPlanning = createCardPlanning(document);
|
const cardPlanning = createCardPlanning(document);
|
||||||
const mobileComposerViewport = createMobileComposerViewport({
|
const mobileComposerViewport = createMobileComposerViewport({
|
||||||
viewport: window.visualViewport,
|
viewport: window.visualViewport,
|
||||||
|
|
@ -1487,6 +1492,7 @@
|
||||||
},
|
},
|
||||||
onPagination: pagination => {
|
onPagination: pagination => {
|
||||||
notificationPagination = pagination;
|
notificationPagination = pagination;
|
||||||
|
mobileAppBadge.reconcile(pagination.total, {authoritative:true});
|
||||||
const loaded = Math.min(pagination.total, pagination.page * 50);
|
const loaded = Math.min(pagination.total, pagination.page * 50);
|
||||||
qs('#notification-page-status').textContent = pagination.total ?
|
qs('#notification-page-status').textContent = pagination.total ?
|
||||||
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
|
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
|
||||||
|
|
@ -8292,6 +8298,8 @@
|
||||||
protectStorage:() => deviceStorage.requestPersistence(),
|
protectStorage:() => deviceStorage.requestPersistence(),
|
||||||
notificationReadiness:() => pushController?.notificationReadiness()
|
notificationReadiness:() => pushController?.notificationReadiness()
|
||||||
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
|
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
|
||||||
|
appBadgeReadiness:() => mobileAppBadge.readiness(),
|
||||||
|
enableAppBadge:() => mobileAppBadge.enable(),
|
||||||
enablePush:async () => {
|
enablePush:async () => {
|
||||||
const controller = await pushControllerReady;
|
const controller = await pushControllerReady;
|
||||||
if (!controller) return;
|
if (!controller) return;
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,10 @@
|
||||||
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
||||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="device-setup-step" id="device-setup-app-badge-step">
|
||||||
|
<div><strong>Show unread Updates on the app icon</strong><p class="small" id="device-setup-app-badge-status" role="status"></p></div>
|
||||||
|
<button class="device-setup-action" id="device-setup-app-badge" type="button">Enable</button>
|
||||||
|
</li>
|
||||||
<li class="device-setup-step">
|
<li class="device-setup-step">
|
||||||
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
|
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
|
||||||
<div class="device-setup-deadline-controls">
|
<div class="device-setup-deadline-controls">
|
||||||
|
|
@ -210,6 +214,10 @@
|
||||||
<div class="offline-work-controls">
|
<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="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 for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||||
|
<span class="app-badge-setting" id="app-badge-setting">
|
||||||
|
<label class="app-badge-control" for="app-badge-control"><input id="app-badge-control" type="checkbox" /> Show unread Updates on the app icon</label>
|
||||||
|
<span class="small" id="app-badge-status" role="status" aria-live="polite"></span>
|
||||||
|
</span>
|
||||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</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>
|
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||||
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
|
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
|
||||||
|
|
@ -2173,6 +2181,7 @@
|
||||||
<script src="static/mobile-launch.js"></script>
|
<script src="static/mobile-launch.js"></script>
|
||||||
<script src="static/mobile-insights.js"></script>
|
<script src="static/mobile-insights.js"></script>
|
||||||
<script src="static/mobile-app-shortcuts.js"></script>
|
<script src="static/mobile-app-shortcuts.js"></script>
|
||||||
|
<script src="static/mobile-app-badge.js"></script>
|
||||||
<script src="static/install-app.js"></script>
|
<script src="static/install-app.js"></script>
|
||||||
<script src="static/private-device-data.js"></script>
|
<script src="static/private-device-data.js"></script>
|
||||||
<script src="static/device-storage.js"></script>
|
<script src="static/device-storage.js"></script>
|
||||||
|
|
|
||||||
90
frontend/mobile-app-badge.js
Normal file
90
frontend/mobile-app-badge.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
(function(root, factory) {
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||||
|
else root.createMobileAppBadge = factory;
|
||||||
|
})(typeof self !== 'undefined' ? self : this, function createMobileAppBadge({
|
||||||
|
control,
|
||||||
|
status,
|
||||||
|
container,
|
||||||
|
navigator,
|
||||||
|
storage,
|
||||||
|
}) {
|
||||||
|
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
|
||||||
|
let enabled = false;
|
||||||
|
let confirmedCount = 0;
|
||||||
|
let renderedCount = null;
|
||||||
|
|
||||||
|
function available() {
|
||||||
|
return typeof navigator?.setAppBadge === 'function'
|
||||||
|
&& typeof navigator?.clearAppBadge === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
if (!enabled || !available() || renderedCount === confirmedCount) return true;
|
||||||
|
try {
|
||||||
|
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
|
||||||
|
else await navigator.clearAppBadge();
|
||||||
|
renderedCount = confirmedCount;
|
||||||
|
if (status) status.textContent = 'App icon badge is on and up to date.';
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
if (status) status.textContent = 'Could not update the app icon badge. Stackchain still has the confirmed count.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function change() {
|
||||||
|
enabled = Boolean(control?.checked);
|
||||||
|
if (enabled) storage?.setItem(ENABLED_KEY, 'true');
|
||||||
|
else storage?.removeItem(ENABLED_KEY);
|
||||||
|
if (!enabled && available()) {
|
||||||
|
try {
|
||||||
|
await navigator.clearAppBadge();
|
||||||
|
renderedCount = null;
|
||||||
|
if (status) status.textContent = 'App icon badge is off.';
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
if (status) status.textContent = 'App icon badge is off, but the browser could not clear the old count.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (!available()) {
|
||||||
|
if (container) container.hidden = true;
|
||||||
|
if (control) control.disabled = true;
|
||||||
|
if (status) status.textContent = 'App icon badges are unavailable in this browser.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
enabled = storage?.getItem(ENABLED_KEY) === 'true';
|
||||||
|
if (control) {
|
||||||
|
control.checked = enabled;
|
||||||
|
control.addEventListener('change', change);
|
||||||
|
}
|
||||||
|
if (status) status.textContent = enabled ? 'App icon badge is on.' : 'App icon badge is off.';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readiness() {
|
||||||
|
if (!available()) return {state:'unavailable', detail:'App icon badges are unavailable in this browser.'};
|
||||||
|
return enabled
|
||||||
|
? {state:'complete', detail:'The app icon shows the confirmed unread Updates count.'}
|
||||||
|
: {state:'incomplete', detail:'Show the confirmed unread Updates count without opening Stackchain.'};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enable() {
|
||||||
|
if (!available()) return false;
|
||||||
|
if (control) control.checked = true;
|
||||||
|
await change();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconcile(count, {authoritative = false} = {}) {
|
||||||
|
if (!authoritative || !Number.isSafeInteger(count) || count < 0) return false;
|
||||||
|
confirmedCount = count;
|
||||||
|
return render();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {start, change, reconcile, readiness, enable};
|
||||||
|
});
|
||||||
|
|
@ -9,8 +9,9 @@
|
||||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
|
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
|
||||||
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
|
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
|
||||||
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
|
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
|
||||||
|
['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable'],
|
||||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
|
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
|
||||||
];
|
].filter(([_name, button, status, action]) => button && status && action);
|
||||||
let trigger = options.launcher;
|
let trigger = options.launcher;
|
||||||
let ownsDetour = false;
|
let ownsDetour = false;
|
||||||
let backgroundInert = null;
|
let backgroundInert = null;
|
||||||
|
|
@ -167,10 +168,12 @@ function mountMobileDeviceSetup(options) {
|
||||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||||
protectionButton:qs('#device-setup-protection'),
|
protectionButton:qs('#device-setup-protection'),
|
||||||
|
appBadgeButton:qs('#device-setup-app-badge'),
|
||||||
deadlineButton:qs('#device-setup-deadline'),
|
deadlineButton:qs('#device-setup-deadline'),
|
||||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||||
protectionStatus:qs('#device-setup-protection-status'),
|
protectionStatus:qs('#device-setup-protection-status'),
|
||||||
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
||||||
|
appBadgeStatus:qs('#device-setup-app-badge-status'),
|
||||||
readyStatus:qs('#device-setup-ready-status'),
|
readyStatus:qs('#device-setup-ready-status'),
|
||||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||||
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
||||||
|
|
@ -193,12 +196,14 @@ function mountMobileDeviceSetup(options) {
|
||||||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||||
protection:options.storageProtectionReadiness(),
|
protection:options.storageProtectionReadiness(),
|
||||||
push:options.notificationReadiness(),
|
push:options.notificationReadiness(),
|
||||||
|
appBadge:options.appBadgeReadiness(),
|
||||||
deadline:options.deadlineReadiness(),
|
deadline:options.deadlineReadiness(),
|
||||||
}),
|
}),
|
||||||
install:() => options.installApp.install(),
|
install:() => options.installApp.install(),
|
||||||
enableOffline:options.enableOffline,
|
enableOffline:options.enableOffline,
|
||||||
protectStorage:options.protectStorage,
|
protectStorage:options.protectStorage,
|
||||||
enablePush:options.enablePush,
|
enablePush:options.enablePush,
|
||||||
|
enableAppBadge:options.enableAppBadge,
|
||||||
enableDeadline:options.enableDeadline,
|
enableDeadline:options.enableDeadline,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,7 @@ const SHELL = [
|
||||||
BASE + 'static/mobile-launch.js',
|
BASE + 'static/mobile-launch.js',
|
||||||
BASE + 'static/mobile-insights.js',
|
BASE + 'static/mobile-insights.js',
|
||||||
BASE + 'static/mobile-app-shortcuts.js',
|
BASE + 'static/mobile-app-shortcuts.js',
|
||||||
|
BASE + 'static/mobile-app-badge.js',
|
||||||
BASE + 'static/install-app.js',
|
BASE + 'static/install-app.js',
|
||||||
BASE + 'static/private-data-inventory.js',
|
BASE + 'static/private-data-inventory.js',
|
||||||
BASE + 'static/private-device-data.js',
|
BASE + 'static/private-device-data.js',
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ FEATURE_SOURCES = {
|
||||||
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
|
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
|
||||||
),
|
),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||||
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||||
|
|
|
||||||
188
tests/test_mobile_app_badge.py
Normal file
188
tests/test_mobile_app_badge.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[1]
|
||||||
|
APP_BADGE = ROOT / "frontend" / "mobile-app-badge.js"
|
||||||
|
INDEX = ROOT / "frontend" / "index.html"
|
||||||
|
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||||
|
CSS = ROOT / "frontend" / "dashboard.css"
|
||||||
|
DEVICE_SETUP = ROOT / "frontend" / "mobile-device-setup.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_badge(scenario: str) -> dict:
|
||||||
|
assert APP_BADGE.exists(), "mobile app badge controller is not implemented"
|
||||||
|
script = f"""
|
||||||
|
const createMobileAppBadge = require({json.dumps(str(APP_BADGE))});
|
||||||
|
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
completed = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_badge_opt_in_reconciles_only_authoritative_unread_totals_without_churn():
|
||||||
|
result = run_badge("""
|
||||||
|
const listeners = {};
|
||||||
|
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
|
||||||
|
const status = {textContent:''};
|
||||||
|
const container = {hidden:false};
|
||||||
|
const values = [];
|
||||||
|
let clears = 0;
|
||||||
|
const storage = new Map();
|
||||||
|
const controller = createMobileAppBadge({
|
||||||
|
control, status, container,
|
||||||
|
navigator: {
|
||||||
|
async setAppBadge(value) { values.push(value); },
|
||||||
|
async clearAppBadge() { clears++; },
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
|
||||||
|
setItem(key, value) { storage.set(key, value); },
|
||||||
|
removeItem(key) { storage.delete(key); },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
controller.start();
|
||||||
|
await controller.reconcile(7, {authoritative:true});
|
||||||
|
await controller.reconcile(12, {authoritative:false});
|
||||||
|
control.checked = true;
|
||||||
|
await listeners.change();
|
||||||
|
await controller.reconcile(7, {authoritative:true});
|
||||||
|
await controller.reconcile(0, {authoritative:true});
|
||||||
|
console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-badge.enabled.v1'), status:status.textContent}));
|
||||||
|
""")
|
||||||
|
assert result == {
|
||||||
|
"values": [7],
|
||||||
|
"clears": 1,
|
||||||
|
"saved": "true",
|
||||||
|
"status": "App icon badge is on and up to date.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_badge_hides_unsupported_device_control_without_touching_storage():
|
||||||
|
result = run_badge("""
|
||||||
|
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};
|
||||||
|
const status = {textContent:''};
|
||||||
|
const container = {hidden:false};
|
||||||
|
let reads = 0;
|
||||||
|
const controller = createMobileAppBadge({
|
||||||
|
control, status, container, navigator:{},
|
||||||
|
storage:{getItem() { reads++; return 'true'; }},
|
||||||
|
});
|
||||||
|
const started = controller.start();
|
||||||
|
console.log(JSON.stringify({started, hidden:container.hidden, disabled:control.disabled, reads, status:status.textContent}));
|
||||||
|
""")
|
||||||
|
assert result == {
|
||||||
|
"started": False,
|
||||||
|
"hidden": True,
|
||||||
|
"disabled": True,
|
||||||
|
"reads": 0,
|
||||||
|
"status": "App icon badges are unavailable in this browser.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_badge_is_packaged_touch_safe_and_reconciled_from_server_total():
|
||||||
|
index = INDEX.read_text()
|
||||||
|
dashboard = DASHBOARD.read_text()
|
||||||
|
css = CSS.read_text()
|
||||||
|
|
||||||
|
assert '<script src="static/mobile-app-badge.js"></script>' in index
|
||||||
|
assert 'id="app-badge-control"' in index
|
||||||
|
assert 'id="app-badge-status" role="status"' in index
|
||||||
|
assert ".app-badge-control { min-height:44px;" in css
|
||||||
|
assert "const mobileAppBadge = createMobileAppBadge({" in dashboard
|
||||||
|
assert "mobileAppBadge.start();" in dashboard
|
||||||
|
assert "mobileAppBadge.reconcile(pagination.total, {authoritative:true});" in dashboard
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_badge_exposes_device_setup_readiness_and_enable_action():
|
||||||
|
result = run_badge("""
|
||||||
|
const control = {checked:false, disabled:false, addEventListener() {}};
|
||||||
|
const status = {textContent:''};
|
||||||
|
const saved = new Map();
|
||||||
|
const values = [];
|
||||||
|
const controller = createMobileAppBadge({
|
||||||
|
control, status, container:{hidden:false},
|
||||||
|
navigator:{async setAppBadge(value) { values.push(value); }, async clearAppBadge() {}},
|
||||||
|
storage:{
|
||||||
|
getItem(key) { return saved.get(key) || null; },
|
||||||
|
setItem(key, value) { saved.set(key, value); },
|
||||||
|
removeItem(key) { saved.delete(key); },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
controller.start();
|
||||||
|
await controller.reconcile(4, {authoritative:true});
|
||||||
|
const before = controller.readiness();
|
||||||
|
await controller.enable();
|
||||||
|
const after = controller.readiness();
|
||||||
|
console.log(JSON.stringify({before, after, checked:control.checked, values}));
|
||||||
|
""")
|
||||||
|
assert result == {
|
||||||
|
"before": {
|
||||||
|
"state": "incomplete",
|
||||||
|
"detail": "Show the confirmed unread Updates count without opening Stackchain.",
|
||||||
|
},
|
||||||
|
"after": {
|
||||||
|
"state": "complete",
|
||||||
|
"detail": "The app icon shows the confirmed unread Updates count.",
|
||||||
|
},
|
||||||
|
"checked": True,
|
||||||
|
"values": [4],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_setup_offers_app_badge_as_an_optional_supported_step():
|
||||||
|
index = INDEX.read_text()
|
||||||
|
setup = DEVICE_SETUP.read_text()
|
||||||
|
dashboard = DASHBOARD.read_text()
|
||||||
|
|
||||||
|
assert 'id="device-setup-app-badge-status" role="status"' in index
|
||||||
|
assert 'id="device-setup-app-badge" type="button"' in index
|
||||||
|
assert "['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable']" in setup
|
||||||
|
assert "appBadge:options.appBadgeReadiness()," in setup
|
||||||
|
assert "appBadgeReadiness:() => mobileAppBadge.readiness()," in dashboard
|
||||||
|
assert "enableAppBadge:() => mobileAppBadge.enable()," in dashboard
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_badge_api_failure_is_reported_without_rejecting_live_refresh():
|
||||||
|
result = run_badge("""
|
||||||
|
const control = {checked:true, disabled:false, addEventListener() {}};
|
||||||
|
const status = {textContent:''};
|
||||||
|
const controller = createMobileAppBadge({
|
||||||
|
control, status, container:{hidden:false},
|
||||||
|
navigator:{async setAppBadge() { throw new Error('platform failure'); }, async clearAppBadge() {}},
|
||||||
|
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
|
||||||
|
});
|
||||||
|
controller.start();
|
||||||
|
const reconciled = await controller.reconcile(3, {authoritative:true});
|
||||||
|
console.log(JSON.stringify({reconciled, status:status.textContent}));
|
||||||
|
""")
|
||||||
|
assert result == {
|
||||||
|
"reconciled": False,
|
||||||
|
"status": "Could not update the app icon badge. Stackchain still has the confirmed count.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabling_badge_keeps_preference_off_when_platform_clear_fails():
|
||||||
|
result = run_badge("""
|
||||||
|
const listeners = {};
|
||||||
|
const control = {checked:true, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
|
||||||
|
const status = {textContent:''};
|
||||||
|
const saved = new Map([['stackchain.app-badge.enabled.v1', 'true']]);
|
||||||
|
const controller = createMobileAppBadge({
|
||||||
|
control, status, container:{hidden:false},
|
||||||
|
navigator:{async setAppBadge() {}, async clearAppBadge() { throw new Error('platform failure'); }},
|
||||||
|
storage:{getItem(key) { return saved.get(key) || null; }, setItem(key, value) { saved.set(key, value); }, removeItem(key) { saved.delete(key); }},
|
||||||
|
});
|
||||||
|
controller.start();
|
||||||
|
control.checked = false;
|
||||||
|
const changed = await listeners.change();
|
||||||
|
console.log(JSON.stringify({changed, saved:saved.has('stackchain.app-badge.enabled.v1'), status:status.textContent}));
|
||||||
|
""")
|
||||||
|
assert result == {
|
||||||
|
"changed": False,
|
||||||
|
"saved": False,
|
||||||
|
"status": "App icon badge is off, but the browser could not clear the old count.",
|
||||||
|
}
|
||||||
|
|
@ -1402,6 +1402,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/mobile-launch.js",
|
"/dashboard/static/mobile-launch.js",
|
||||||
"/dashboard/static/mobile-insights.js",
|
"/dashboard/static/mobile-insights.js",
|
||||||
"/dashboard/static/mobile-app-shortcuts.js",
|
"/dashboard/static/mobile-app-shortcuts.js",
|
||||||
|
"/dashboard/static/mobile-app-badge.js",
|
||||||
"/dashboard/static/install-app.js",
|
"/dashboard/static/install-app.js",
|
||||||
"/dashboard/static/private-data-inventory.js",
|
"/dashboard/static/private-data-inventory.js",
|
||||||
"/dashboard/static/private-device-data.js",
|
"/dashboard/static/private-device-data.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user