From 797fd0d5446153fd1521e6a5f70a081a99346b6c Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 11 Aug 2026 08:33:01 +0000 Subject: [PATCH] feat: guide mobile device readiness (Closes #555) --- frontend/dashboard.css | 13 ++++ frontend/dashboard.js | 81 ++++++++++++------- frontend/index.html | 29 +++++++ frontend/install-app.js | 21 ++++- frontend/mobile-device-setup.js | 95 +++++++++++++++++++++++ frontend/service-worker.js | 1 + src/frontend_bundle.py | 1 + tests/test_frontend_bundle.py | 2 +- tests/test_install_app.py | 47 +++++++++++ tests/test_mobile_device_setup.py | 124 ++++++++++++++++++++++++++++++ tests/test_service_worker.py | 1 + 11 files changed, 386 insertions(+), 29 deletions(-) create mode 100644 frontend/mobile-device-setup.js create mode 100644 tests/test_mobile_device_setup.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index f1e88d5..9972189 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -18,6 +18,16 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid # .active-devices-header h2, .active-devices-header p { margin-top:0; } .active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; } .active-devices-list { display:grid; gap:10px; margin-top:16px; } +.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); } +.device-setup-sheet[hidden] { display:none; } +.device-setup-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; } +.device-setup-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } +.device-setup-header h2, .device-setup-header p { margin-top:0; } +.device-setup-header button, .device-setup-action { min-height:44px; } +.device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; } +.device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; } +.device-setup-step p { margin:5px 0 0; } +.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; } .active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; } .active-device strong, .active-device span { display:block; overflow-wrap:anywhere; } .active-device-current { color:#55d6be; font-weight:700; } @@ -463,6 +473,9 @@ textarea { resize: vertical; min-height: 120px; } .app-menu:not([open]) > .app-menu-panel { display:none; } .app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); } .app-menu-panel button { min-height:44px; width:100%; } + .device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); } + .device-setup-step { grid-template-columns:1fr; } + .device-setup-action { width:100%; } .my-work { margin:0; } .my-work-header { align-items:flex-start; } .my-work-actions { width:100%; flex-wrap:nowrap; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 53c70b9..4bc6592 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -418,6 +418,7 @@ urls: { 'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '', 'push-notifications': document.querySelector('meta[name="stackchain-feature-push-notifications"]')?.content || '', + 'device-setup': document.querySelector('meta[name="stackchain-feature-device-setup"]')?.content || '', }, }); let sharedLaunchState = null; @@ -5066,20 +5067,25 @@ if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger); }); } - keepWorkOffline.addEventListener('change', async () => { - offlineWorkStore.setEnabled(keepWorkOffline.checked); - if (keepWorkOffline.checked && liveMode && lastContextSnapshot) { + async function setOfflineWorkEnabled(enabled) { + keepWorkOffline.checked = enabled; + offlineWorkStore.setEnabled(enabled); + if (enabled && liveMode && lastContextSnapshot) { const admitted = await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications, notification_pagination:notificationPagination }); if (admitted === false) { await updateOfflineWorkControls('Offline saving unavailable ยท retry after reconnect.'); - return; + return false; } } - if (!keepWorkOffline.checked) await offlineWorkStore.clear(); - await updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.'); - if (keepWorkOffline.checked) warmTodayOffline(); + if (!enabled) await offlineWorkStore.clear(); + await updateOfflineWorkControls(enabled ? 'Offline saving enabled.' : 'Offline work data cleared.'); + if (enabled) warmTodayOffline(); else offlineToday.cancel(); + return offlineWorkStore.enabled(); + } + keepWorkOffline.addEventListener('change', async () => { + await setOfflineWorkEnabled(keepWorkOffline.checked); }); deliveryReceipts.addEventListener('change', async () => { let enabled = deliveryReceipts.checked; @@ -5270,40 +5276,63 @@ renderMyWork(); if (workSession.active()) workSession.reconcile(); }); + let pushControllerReady = Promise.resolve(null); if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('service-worker.js').then(async () => { + pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => { await issueCaptureFeatures.load('push-notifications'); - return createPushNotifications({ + const controller = createPushNotifications({ control:qs('#push-updates'), status:qs('#push-update-status'), notification:window.Notification, serviceWorker:navigator.serviceWorker, fetchJson:fetchReviewJson, - }).init(); + }); + await controller.init(); + return controller; }).catch(error => { qs('#push-update-status').textContent = 'Update notification settings unavailable.'; console.warn('Push notifications unavailable', error); + return null; }); } else { qs('#push-updates').disabled = true; qs('#push-update-status').textContent = 'This browser does not support update notifications.'; } - const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) || - (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); - const isIosSafari = isIosDevice && /Safari/.test(navigator.userAgent) && - !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent); - createInstallApp({ - window, - card: qs('#install-app-card'), - installButton: qs('#install-app'), - dismissButton: qs('#dismiss-install-app'), - guidance: qs('#install-app-guidance'), - status: qs('#install-app-status'), - storage: localStorage, - isStandalone: () => window.matchMedia('(display-mode: standalone)').matches || - navigator.standalone === true, - isIosSafari: () => isIosSafari, - }).start(); + let deferredInstallPrompt = null; + window.addEventListener('beforeinstallprompt', event => { + event.preventDefault(); + deferredInstallPrompt = event; + }); + let deviceSetup = null; + qs('#open-device-setup').addEventListener('click', async event => { + if (deviceSetup) return; + await issueCaptureFeatures.load('device-setup'); + const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + const installApp = createInstallApp({ + window, initialPrompt:deferredInstallPrompt, + card:qs('#install-app-card'), installButton:qs('#install-app'), + dismissButton:qs('#dismiss-install-app'), guidance:qs('#install-app-guidance'), + status:qs('#install-app-status'), storage:localStorage, + isStandalone:() => window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true, + isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent), + }); + installApp.start(); + deviceSetup = createMobileDeviceSetup.mount({ + document, installApp, + offlineAvailable:() => offlineStorageReady, + offlineEnabled:() => offlineWorkStore.enabled(), + enableOffline:() => setOfflineWorkEnabled(true), + enablePush:async () => { + const controller = await pushControllerReady; + if (!controller) return; + qs('#push-updates').checked = true; + await controller.change(); + }, + }); + deviceSetup.start(); + await deviceSetup.open(event); + }); contextPoller.start(); document.addEventListener('visibilitychange', () => { contextPoller.setVisible(!document.hidden); diff --git a/frontend/index.html b/frontend/index.html index 848d3ec..c0f22e3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,6 +18,7 @@
+ @@ -59,6 +60,33 @@
+ +
@@ -776,6 +804,7 @@ + diff --git a/frontend/install-app.js b/frontend/install-app.js index c4bfb8d..ddafcde 100644 --- a/frontend/install-app.js +++ b/frontend/install-app.js @@ -3,7 +3,8 @@ else root.createInstallApp = factory; })(typeof self !== 'undefined' ? self : this, function createInstallApp(options) { const dismissedKey = 'stackchain.install.dismissed.v1'; - let deferredPrompt = null; + let deferredPrompt = options.initialPrompt || null; + let installed = false; function hide() { options.card.hidden = true; @@ -25,6 +26,7 @@ await prompt.prompt(); const choice = await prompt.userChoice; if (choice.outcome === 'accepted') { + installed = true; hide(); options.status.textContent = 'Stackchain was added to your device.'; } else { @@ -44,6 +46,7 @@ hide(); return; } + if (deferredPrompt) showNativePrompt(deferredPrompt); if (options.isIosSafari()) { options.installButton.hidden = true; options.guidance.hidden = false; @@ -51,6 +54,7 @@ } options.window.addEventListener('beforeinstallprompt', showNativePrompt); options.window.addEventListener('appinstalled', () => { + installed = true; deferredPrompt = null; hide(); }); @@ -58,5 +62,18 @@ options.dismissButton.addEventListener('click', dismiss); } - return {start}; + function state() { + if (installed || options.isStandalone()) { + return {state:'complete', detail:'Stackchain is installed on this device.'}; + } + if (options.isIosSafari()) { + return {state:'incomplete', detail:'On Safari, tap Share, then Add to Home Screen.'}; + } + if (deferredPrompt) { + return {state:'incomplete', detail:'Stackchain is ready to install.'}; + } + return {state:'unavailable', detail:'Installation is available from a supported browser.'}; + } + + return {start, install, state}; }); diff --git a/frontend/mobile-device-setup.js b/frontend/mobile-device-setup.js new file mode 100644 index 0000000..7a60640 --- /dev/null +++ b/frontend/mobile-device-setup.js @@ -0,0 +1,95 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory; + else root.createMobileDeviceSetup = factory; +})(typeof self !== 'undefined' ? self : this, function createMobileDeviceSetup(options) { + const steps = [ + ['install', options.installButton, options.installStatus, options.install], + ['offline', options.offlineButton, options.offlineStatus, options.enableOffline], + ['push', options.pushButton, options.pushStatus, options.enablePush], + ]; + let trigger = options.launcher; + + async function render() { + const readiness = await options.getReadiness(); + let available = 0; + let complete = 0; + steps.forEach(([name, button, status]) => { + const step = readiness[name]; + status.textContent = step.detail; + button.hidden = step.state === 'complete' || step.state === 'unavailable'; + button.disabled = step.state === 'pending'; + if (step.state !== 'unavailable') available += 1; + if (step.state === 'complete') complete += 1; + }); + options.readyStatus.textContent = complete === available + ? 'This device is ready.' + : `${complete} of ${available} available steps ready.`; + return readiness; + } + + async function open(event) { + trigger = event?.currentTarget || options.launcher; + await render(); + options.sheet.hidden = false; + options.closeButton.focus(); + } + + function close() { + options.sheet.hidden = true; + trigger?.focus?.(); + } + + function start() { + options.launcher.addEventListener('click', open); + options.closeButton.addEventListener('click', close); + options.sheet.addEventListener('click', event => { + if (event.target === options.sheet) close(); + }); + options.escapeTarget.addEventListener('keydown', event => { + if (event.key === 'Escape' && !options.sheet.hidden) close(); + }); + steps.forEach(([_name, button, _status, action]) => { + button.addEventListener('click', async () => { + button.disabled = true; + try { await action(); } + finally { await render(); } + }); + }); + } + + return {start, open, close, render}; +}); + +if (typeof module === 'object' && module.exports) { + module.exports.mount = mountMobileDeviceSetup; +} else { + self.createMobileDeviceSetup.mount = mountMobileDeviceSetup; +} + +function mountMobileDeviceSetup(options) { + const qs = selector => options.document.querySelector(selector); + return createMobileDeviceSetup({ + launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'), + sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'), + offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'), + installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'), + pushStatus:qs('#device-setup-push-status'), readyStatus:qs('#device-setup-ready-status'), + escapeTarget:options.document, + getReadiness:() => ({ + install:options.installApp.state(), + offline:!options.offlineAvailable() + ? {state:'unavailable', detail:'Offline saving is unavailable in this browser.'} + : options.offlineEnabled() + ? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'} + : {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'}, + push:qs('#push-updates').disabled + ? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'} + : qs('#push-updates').checked + ? {state:'complete', detail:'New update notifications are enabled.'} + : {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'}, + }), + install:() => options.installApp.install(), + enableOffline:options.enableOffline, + enablePush:options.enablePush, + }); +} diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 8fb53aa..5d81c21 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -60,6 +60,7 @@ const SHELL = [ BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-launch.js', BASE + 'static/install-app.js', + BASE + 'static/mobile-device-setup.js', BASE + 'static/mobile-search-viewport.js', BASE + 'static/mobile-composer-viewport.js', BASE + 'static/mention-composer.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 5102e14..adebbc6 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -20,6 +20,7 @@ FEATURE_SOURCES = { "issue-capture": ("static/create-issue-sheet.js",), "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"), "push-notifications": ("static/push-notifications.js",), + "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), } CACHE_DECLARATION = re.compile( r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';" diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 747a30f..efdc099 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -45,7 +45,7 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path): def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): first = build_frontend(FRONTEND) - assert set(first.feature_bundles) == {"issue-capture", "pull-workflow", "push-notifications"} + assert set(first.feature_bundles) == {"issue-capture", "pull-workflow", "push-notifications", "device-setup"} capture = first.feature_bundles["issue-capture"] pull_workflow = first.feature_bundles["pull-workflow"] assert b"function createIssueCapture" not in first.runtime_bytes diff --git a/tests/test_install_app.py b/tests/test_install_app.py index ed8bff6..9eae985 100644 --- a/tests/test_install_app.py +++ b/tests/test_install_app.py @@ -174,6 +174,53 @@ process.stdout.write(JSON.stringify({firstHidden:firstCard.hidden, remembered:va assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True} +def test_setup_api_reports_install_state_without_prompting(): + result = run_install_scenario( + """ +const windowTarget = new FakeTarget(); +const app = createInstallApp({ + window:windowTarget, card:{hidden:true}, installButton:new FakeButton(), dismissButton:new FakeButton(), + guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}}, + isStandalone:()=>false, isIosSafari:()=>false, +}); +app.start(); +const before = app.state(); +windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})}); +const after = app.state(); +process.stdout.write(JSON.stringify({before, after})); +""" + ) + + assert result == { + "before": {"state": "unavailable", "detail": "Installation is available from a supported browser."}, + "after": {"state": "incomplete", "detail": "Stackchain is ready to install."}, + } + + +def test_captured_install_prompt_remains_available_until_setup_opens(): + result = run_install_scenario( + """ +let prevented = 0; +const prompt = {preventDefault(){prevented += 1;}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})}; +const card = {hidden:true}; +const app = createInstallApp({ + window:new FakeTarget(), initialPrompt:prompt, card, + installButton:new FakeButton(), dismissButton:new FakeButton(), guidance:{hidden:true}, + status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}}, + isStandalone:()=>false, isIosSafari:()=>false, +}); +app.start(); +process.stdout.write(JSON.stringify({state:app.state(), shown:!card.hidden, prevented})); +""" + ) + + assert result == { + "state": {"state": "incomplete", "detail": "Stackchain is ready to install."}, + "shown": True, + "prevented": 1, + } + + def test_appinstalled_hides_an_visible_native_promotion(): result = run_install_scenario( """ diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py new file mode 100644 index 0000000..65e7729 --- /dev/null +++ b/tests/test_mobile_device_setup.py @@ -0,0 +1,124 @@ +import json +import subprocess +from pathlib import Path + + +MODULE = Path(__file__).parents[1] / "frontend" / "mobile-device-setup.js" + + +def run_scenario(script: str) -> dict: + harness = r""" +const createMobileDeviceSetup = require(__MODULE__); +class FakeTarget { + constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; } + addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); } + async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); } + focus() { state.focused = this; } +} +const state = {installCalls:0, offlineCalls:0, pushCalls:0, focused:null}; +const launcher = new FakeTarget(); +const closeButton = new FakeTarget(); +const sheet = new FakeTarget(); sheet.hidden = true; +const installButton = new FakeTarget(); +const offlineButton = new FakeTarget(); +const pushButton = new FakeTarget(); +const installStatus = new FakeTarget(); +const offlineStatus = new FakeTarget(); +const pushStatus = new FakeTarget(); +const readyStatus = new FakeTarget(); +const escapeTarget = new FakeTarget(); +let readiness = { + install:{state:'complete', detail:'Stackchain is installed.'}, + offline:{state:'incomplete', detail:'Offline work is off.'}, + push:{state:'unavailable', detail:'Notifications are unavailable.'}, +}; +const setup = createMobileDeviceSetup({ + launcher, closeButton, sheet, installButton, offlineButton, pushButton, + installStatus, offlineStatus, pushStatus, readyStatus, escapeTarget, + getReadiness:() => readiness, + install:async () => { state.installCalls += 1; }, + enableOffline:async () => { state.offlineCalls += 1; }, + enablePush:async () => { state.pushCalls += 1; }, +}); +(async () => { setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); }); +""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script) + completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True) + assert completed.returncode == 0, completed.stderr + return json.loads(completed.stdout) + + +def test_opening_setup_reports_actual_readiness_without_triggering_permissions(): + result = run_scenario(""" +await launcher.dispatch('click', {currentTarget:launcher}); +process.stdout.write(JSON.stringify({ + hidden:sheet.hidden, + install:installStatus.textContent, + offline:offlineStatus.textContent, + push:pushStatus.textContent, + summary:readyStatus.textContent, + calls:[state.installCalls,state.offlineCalls,state.pushCalls], +})); +""") + + assert result == { + "hidden": False, + "install": "Stackchain is installed.", + "offline": "Offline work is off.", + "push": "Notifications are unavailable.", + "summary": "1 of 2 available steps ready.", + "calls": [0, 0, 0], + } + + +def test_setup_action_rechecks_real_state_before_marking_step_ready(): + result = run_scenario(""" +await launcher.dispatch('click', {currentTarget:launcher}); +readiness.offline = {state:'complete', detail:'Offline work is saved.'}; +await offlineButton.dispatch('click'); +process.stdout.write(JSON.stringify({ + calls:state.offlineCalls, + offline:offlineStatus.textContent, + hidden:offlineButton.hidden, + summary:readyStatus.textContent, +})); +""") + + assert result == { + "calls": 1, + "offline": "Offline work is saved.", + "hidden": True, + "summary": "This device is ready.", + } + + +def test_escape_closes_setup_and_restores_launcher_focus(): + result = run_scenario(""" +await launcher.dispatch('click', {currentTarget:launcher}); +await escapeTarget.dispatch('keydown', {key:'Escape'}); +process.stdout.write(JSON.stringify({ + hidden:sheet.hidden, + launcherFocused:state.focused === launcher, +})); +""") + + assert result == {"hidden": True, "launcherFocused": True} + + +def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): + root = MODULE.parents[1] + html = (root / "frontend" / "index.html").read_text() + dashboard = (root / "frontend" / "dashboard.js").read_text() + worker = (root / "frontend" / "service-worker.js").read_text() + css = (root / "frontend" / "dashboard.css").read_text() + + assert 'id="open-device-setup"' in html + assert 'id="device-setup-sheet"' in html + assert 'aria-labelledby="device-setup-heading"' in html + assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push")) + assert '' in html + assert "createMobileDeviceSetup.mount({" in dashboard + assert "BASE + 'static/mobile-device-setup.js'" in worker + assert ".device-setup-panel" in css + assert "overflow-x:hidden" in css + assert "env(safe-area-inset-bottom)" in css + assert ".device-setup-action { min-height:44px;" in css diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 0739362..b64a1ce 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -502,6 +502,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/mobile-work-entry.js", "/dashboard/static/mobile-launch.js", "/dashboard/static/install-app.js", + "/dashboard/static/mobile-device-setup.js", "/dashboard/static/mobile-search-viewport.js", "/dashboard/static/mobile-composer-viewport.js", "/dashboard/static/mention-composer.js", -- 2.43.0