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-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; }
|
||||
.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; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@
|
|||
const qs = (s, el=document) => el.querySelector(s);
|
||||
const announceWork = message => qs('#my-work-action-status').textContent = message;
|
||||
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 mobileComposerViewport = createMobileComposerViewport({
|
||||
viewport: window.visualViewport,
|
||||
|
|
@ -1487,6 +1492,7 @@
|
|||
},
|
||||
onPagination: pagination => {
|
||||
notificationPagination = pagination;
|
||||
mobileAppBadge.reconcile(pagination.total, {authoritative:true});
|
||||
const loaded = Math.min(pagination.total, pagination.page * 50);
|
||||
qs('#notification-page-status').textContent = pagination.total ?
|
||||
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
|
||||
|
|
@ -8292,6 +8298,8 @@
|
|||
protectStorage:() => deviceStorage.requestPersistence(),
|
||||
notificationReadiness:() => pushController?.notificationReadiness()
|
||||
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
|
||||
appBadgeReadiness:() => mobileAppBadge.readiness(),
|
||||
enableAppBadge:() => mobileAppBadge.enable(),
|
||||
enablePush:async () => {
|
||||
const controller = await pushControllerReady;
|
||||
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>
|
||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||
</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">
|
||||
<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">
|
||||
|
|
@ -210,6 +214,10 @@
|
|||
<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="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>
|
||||
<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>
|
||||
|
|
@ -2173,6 +2181,7 @@
|
|||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/mobile-insights.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/private-device-data.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'],
|
||||
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
|
||||
['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable'],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
|
||||
];
|
||||
].filter(([_name, button, status, action]) => button && status && action);
|
||||
let trigger = options.launcher;
|
||||
let ownsDetour = false;
|
||||
let backgroundInert = null;
|
||||
|
|
@ -167,10 +168,12 @@ function mountMobileDeviceSetup(options) {
|
|||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
protectionButton:qs('#device-setup-protection'),
|
||||
appBadgeButton:qs('#device-setup-app-badge'),
|
||||
deadlineButton:qs('#device-setup-deadline'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
protectionStatus:qs('#device-setup-protection-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'),
|
||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||
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.'},
|
||||
protection:options.storageProtectionReadiness(),
|
||||
push:options.notificationReadiness(),
|
||||
appBadge:options.appBadgeReadiness(),
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
protectStorage:options.protectStorage,
|
||||
enablePush:options.enablePush,
|
||||
enableAppBadge:options.enableAppBadge,
|
||||
enableDeadline:options.enableDeadline,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ const SHELL = [
|
|||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-insights.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/mobile-app-badge.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/private-data-inventory.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",
|
||||
),
|
||||
"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/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",
|
||||
|
|
|
|||
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-insights.js",
|
||||
"/dashboard/static/mobile-app-shortcuts.js",
|
||||
"/dashboard/static/mobile-app-badge.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/private-data-inventory.js",
|
||||
"/dashboard/static/private-device-data.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user