diff --git a/android/app/build.gradle b/android/app/build.gradle index 49a0efe..98b9f08 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId "md.zennotes" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 51 - versionName "1.1.48" + versionCode 52 + versionName "1.1.49" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/java/md/zennotes/MainActivity.java b/android/app/src/main/java/md/zennotes/MainActivity.java index cdb2429..f87d8b0 100644 --- a/android/app/src/main/java/md/zennotes/MainActivity.java +++ b/android/app/src/main/java/md/zennotes/MainActivity.java @@ -40,6 +40,7 @@ public void onCreate(Bundle savedInstanceState) { registerPlugin(ImagePastePlugin.class); registerPlugin(EdgeSwipePlugin.class); registerPlugin(OpenFilePlugin.class); + registerPlugin(SystemBarThemePlugin.class); super.onCreate(savedInstanceState); // Cold-start share: the launch intent IS the share. Stash it now; the // WebView drains the inbox after the vault opens (importPendingShares). diff --git a/android/app/src/main/java/md/zennotes/SystemBarThemePlugin.java b/android/app/src/main/java/md/zennotes/SystemBarThemePlugin.java new file mode 100644 index 0000000..0133b04 --- /dev/null +++ b/android/app/src/main/java/md/zennotes/SystemBarThemePlugin.java @@ -0,0 +1,96 @@ +package md.zennotes; + +import android.content.res.Configuration; +import android.graphics.Color; +import android.os.Build; +import android.view.Window; + +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsControllerCompat; + +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +/** + * Status and navigation bar icons in the ZenNotes theme's contrast, not the + * system's. Two Capacitor plugins drive the bars' icon color and neither + * knows the theme: core's SystemBars resolves its default style from the + * system's night mode when it loads (dark icons whenever the phone is in + * light mode) and posts it after the StatusBar plugin has applied the + * config's light icons, so it always wins; both then re-apply their own + * stale choice on every configuration change. On a WebView 140+ phone the + * page draws under the bars, so a dark theme got near-black icons on its + * dark background. + * + * The JS shell (src/bridge/system-bar-theme.ts) reads the theme's background + * and hands it here whenever it changes. This paints every strip the app + * owns with it, so what sits under the icons is the theme on any Android and + * WebView: from Android 15 the bars are transparent and below WebView 140 + * Capacitor pads the decor view, whose background shows through; before + * Android 15 the window's bar colors are what show. Then the icons take the + * contrasting color. A configuration change (rotation, the system's dark + * mode) re-applies it after the other two plugins have had their say. + * Android-only; the iPhone status bar sits over the page already. + */ +@CapacitorPlugin(name = "SystemBarTheme") +public class SystemBarThemePlugin extends Plugin { + + /** Null until the JS shell has measured the theme: the launch theme's + * dark chrome stays until then. */ + private Integer color = null; + private boolean light = false; + + @PluginMethod + public void setTheme(PluginCall call) { + String raw = call.getString("color"); + if (raw == null || raw.isEmpty()) { + call.reject("color must be #rrggbb"); + return; + } + int parsed; + try { + parsed = Color.parseColor(raw); + } catch (IllegalArgumentException e) { + call.reject("color must be #rrggbb"); + return; + } + boolean isLight = Boolean.TRUE.equals(call.getBoolean("light", false)); + getActivity().runOnUiThread(() -> { + color = parsed; + light = isLight; + apply(); + call.resolve(); + }); + } + + @Override + protected void handleOnConfigurationChanged(Configuration newConfig) { + super.handleOnConfigurationChanged(newConfig); + // SystemBars and StatusBar re-apply their styles in this same pass, + // in an order nothing promises; go after both. + getActivity().getWindow().getDecorView().post(this::apply); + } + + @SuppressWarnings("deprecation") + private void apply() { + if (color == null) return; + Window window = getActivity().getWindow(); + window.getDecorView().setBackgroundColor(color); + boolean edgeToEdge = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM; + if (!edgeToEdge) { + window.setStatusBarColor(color); + } + WindowInsetsControllerCompat bars = WindowCompat.getInsetsController(window, window.getDecorView()); + bars.setAppearanceLightStatusBars(light); + // Dark navigation buttons arrived in Android 8; before that a light + // bar would hide its white buttons, so it keeps the launch theme's. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!edgeToEdge) { + window.setNavigationBarColor(color); + } + bars.setAppearanceLightNavigationBars(light); + } + } +} diff --git a/android/app/src/main/java/md/zennotes/widgets/CalendarAgenda.java b/android/app/src/main/java/md/zennotes/widgets/CalendarAgenda.java index 17a5780..f9ad2c0 100644 --- a/android/app/src/main/java/md/zennotes/widgets/CalendarAgenda.java +++ b/android/app/src/main/java/md/zennotes/widgets/CalendarAgenda.java @@ -40,7 +40,8 @@ static final class Row { final int type; final String iso; final CalendarWidgetData.Task task; - /** Header: open tasks that day (including any past the snapshot's cap). */ + /** Header: open tasks that day (including any past the snapshot's + * cap), less the ones a widget tap has ticked since. */ final int count; /** Task: the latest widget tap on it, or null. */ final WidgetActions.Mark mark; @@ -64,7 +65,7 @@ static List rows(CalendarWidgetData data, String selected, Map rows(CalendarWidgetData data, String selected, Map marks) { + if (day == null) return 0; + int ticked = 0; + for (CalendarWidgetData.Task task : day.tasks) { + WidgetActions.Mark mark = marks.get(task.id); + if (mark != null && mark.checked) ticked++; + } + return Math.max(0, day.total - ticked); + } + /** Position of the selected day's header, to scroll the list to. */ static int positionOf(List rows, String iso) { for (int i = 0; i < rows.size(); i++) { diff --git a/android/app/src/test/java/md/zennotes/widgets/CalendarAgendaTest.java b/android/app/src/test/java/md/zennotes/widgets/CalendarAgendaTest.java new file mode 100644 index 0000000..50ddfb7 --- /dev/null +++ b/android/app/src/test/java/md/zennotes/widgets/CalendarAgendaTest.java @@ -0,0 +1,98 @@ +package md.zennotes.widgets; + +import static org.junit.Assert.assertEquals; + +import org.json.JSONObject; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class CalendarAgendaTest { + /** A snapshot for the week of Mon 2026-10-26: four tasks open on Tuesday, + * two of them listed (the rest past the snapshot's cap), one on Wednesday. */ + private static CalendarWidgetData week() throws Exception { + String json = "{" + + "\"todayIso\":\"2026-10-27\",\"weekStart\":1,\"dailyEnabled\":true," + + "\"from\":\"2026-09-28\",\"to\":\"2026-11-08\",\"vaultKey\":\"zn://My Vault\"," + + "\"agenda\":{" + + "\"2026-10-27\":{\"total\":4,\"tasks\":[" + + task("inbox/Errands.md#0", "Pick up the dry cleaning") + "," + + task("inbox/Errands.md#1", "Return the library books") + "]}," + + "\"2026-10-28\":{\"total\":1,\"tasks\":[" + + task("inbox/Errands.md#2", "Buy stamps") + "]}" + + "}}"; + return CalendarWidgetData.parse(new JSONObject(json)); + } + + private static String task(String id, String content) { + return "{\"id\":\"" + id + "\",\"path\":\"inbox/Errands.md\",\"noteTitle\":\"Errands\"," + + "\"content\":\"" + content + "\",\"line\":2,\"raw\":\"- [ ] " + content + "\"," + + "\"toggled\":\"- [x] " + content + "\"}"; + } + + private static int headerCount(List rows, String iso) { + for (CalendarAgenda.Row row : rows) { + if (row.type == CalendarAgenda.HEADER && row.iso.equals(iso)) return row.count; + } + throw new AssertionError("no header for " + iso); + } + + @Test + public void countsTheSnapshotsOpenTasksWhenNothingIsTicked() throws Exception { + List rows = CalendarAgenda.rows(week(), "2026-10-27", new HashMap<>()); + assertEquals(4, headerCount(rows, "2026-10-27")); + assertEquals(1, headerCount(rows, "2026-10-28")); + assertEquals(0, headerCount(rows, "2026-10-29")); + } + + @Test + public void aTickComesOffItsDaysCountAtOnce() throws Exception { + Map marks = new HashMap<>(); + marks.put("inbox/Errands.md#1", new WidgetActions.Mark(true, true)); + List rows = CalendarAgenda.rows(week(), "2026-10-27", marks); + assertEquals(3, headerCount(rows, "2026-10-27")); + assertEquals(1, headerCount(rows, "2026-10-28")); + } + + @Test + public void aTickTheAppHasYetToWriteCountsToo() throws Exception { + Map marks = new HashMap<>(); + marks.put("inbox/Errands.md#0", new WidgetActions.Mark(true, false)); + marks.put("inbox/Errands.md#2", new WidgetActions.Mark(true, false)); + List rows = CalendarAgenda.rows(week(), "2026-10-27", marks); + assertEquals(3, headerCount(rows, "2026-10-27")); + assertEquals(0, headerCount(rows, "2026-10-28")); + } + + @Test + public void anUntickPutsItBack() throws Exception { + Map marks = new HashMap<>(); + marks.put("inbox/Errands.md#1", new WidgetActions.Mark(false, true)); + assertEquals(4, headerCount(CalendarAgenda.rows(week(), "2026-10-27", marks), "2026-10-27")); + } + + @Test + public void aTickOnATaskTheSnapshotNoLongerListsIsNotTakenOffTwice() throws Exception { + // The app applied the tick and published before the log was pruned: + // the task has left the list and the total already counts it out. + Map marks = new HashMap<>(); + marks.put("inbox/Errands.md#9", new WidgetActions.Mark(true, true)); + assertEquals(4, headerCount(CalendarAgenda.rows(week(), "2026-10-27", marks), "2026-10-27")); + } + + @Test + public void neverDropsBelowZero() throws Exception { + // A snapshot whose total undercounts its own rows still reads 0, not -1. + CalendarWidgetData data = CalendarWidgetData.parse(new JSONObject("{" + + "\"weekStart\":1,\"dailyEnabled\":true,\"from\":\"2026-09-28\",\"to\":\"2026-11-08\"," + + "\"agenda\":{\"2026-10-27\":{\"total\":1,\"tasks\":[" + + task("a.md#0", "One") + "," + task("a.md#1", "Two") + "]}}}")); + Map marks = new HashMap<>(); + marks.put("a.md#0", new WidgetActions.Mark(true, true)); + marks.put("a.md#1", new WidgetActions.Mark(true, true)); + assertEquals(0, CalendarAgenda.openCount(data.agenda.get("2026-10-27"), marks)); + assertEquals(0, CalendarAgenda.openCount(null, marks)); + } +} diff --git a/package-lock.json b/package-lock.json index 3290d25..4e16ba5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-android", - "version": "1.1.48", + "version": "1.1.49", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-android", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.1", "@capacitor/android": "^8.5.3", diff --git a/package.json b/package.json index 7e5927b..7185a8b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-android", "private": true, - "version": "1.1.48", + "version": "1.1.49", "type": "module", "description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core", "homepage": "https://zennotes.org", diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index b65c70f..fa15749 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -156,7 +156,7 @@ import { import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core' import { isPhoneViewport } from '../viewport' -let appVersion = '1.1.48' +let appVersion = '1.1.49' export async function loadNativeAppVersion(): Promise { try { diff --git a/src/bridge/system-bar-theme-color.test.ts b/src/bridge/system-bar-theme-color.test.ts new file mode 100644 index 0000000..10b04d5 --- /dev/null +++ b/src/bridge/system-bar-theme-color.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { systemBarThemeFor } from './system-bar-theme-color.ts' + +// `--z-bg` of every built-in theme in core 2.78.0 (dist/styles/index.css). +const DARK = { + 'apple-dark': '28 28 30', + 'dark-hard': '29 32 33', + 'dark-medium': '40 40 40', + 'dark-soft': '50 48 47', + 'catppuccin-frappe': '48 52 70', + 'catppuccin-macchiato': '36 39 58', + 'catppuccin-mocha': '30 30 46', + 'rose-pine-main': '25 23 36', + 'rose-pine-moon': '35 33 54', + 'github-dark': '13 17 23', + 'github-dark-dimmed': '34 39 46', + 'github-dark-high-contrast': '0 0 0', + 'solarized-dark': '0 43 54', + 'one-dark': '40 44 52', + 'nord-dark': '46 52 64', + 'tokyo-night-storm': '36 40 59', + 'kanagawa-wave': '31 31 40', + 'kanagawa-dragon': '24 22 22', + 'kanagawa-paper-ink': '20 19 27', + 'black-metal': '0 0 0' +} +const LIGHT = { + 'apple-light': '255 255 255', + 'light-hard': '249 245 215', + 'light-medium': '251 241 199', + 'light-soft': '242 229 188', + 'catppuccin-latte': '239 241 245', + 'rose-pine-dawn': '250 244 237', + 'github-light': '255 255 255', + 'solarized-light': '253 246 227', + 'one-light': '250 250 250', + 'nord-light': '236 239 244', + 'tokyo-night-day': '225 226 231', + 'kanagawa-lotus': '242 236 188', + 'black-metal-day': '244 244 242' +} + +describe('systemBarThemeFor', () => { + it('gives every dark built-in theme light icons', () => { + for (const [id, bg] of Object.entries(DARK)) { + assert.equal(systemBarThemeFor(bg)?.light, false, id) + } + }) + + it('gives every light built-in theme dark icons', () => { + for (const [id, bg] of Object.entries(LIGHT)) { + assert.equal(systemBarThemeFor(bg)?.light, true, id) + } + }) + + it('hands the native side the background as #rrggbb', () => { + assert.deepEqual(systemBarThemeFor('29 32 33'), { color: '#1d2021', light: false }) + assert.deepEqual(systemBarThemeFor(' 251 241 199 '), { color: '#fbf1c7', light: true }) + }) + + it('picks the icon color with more contrast around the middle', () => { + // Relative luminance about 0.16 and 0.20, either side of the tie. + assert.equal(systemBarThemeFor('112 112 112')?.light, false) + assert.equal(systemBarThemeFor('124 124 124')?.light, true) + }) + + it('ignores anything that is not three channels', () => { + for (const raw of ['', '29 32', '29 32 33 1', 'rgb(29, 32, 33)', '#1d2021', '29 32 256', '29.5 32 33']) { + assert.equal(systemBarThemeFor(raw), null, raw) + } + }) +}) diff --git a/src/bridge/system-bar-theme-color.ts b/src/bridge/system-bar-theme-color.ts new file mode 100644 index 0000000..2db663c --- /dev/null +++ b/src/bridge/system-bar-theme-color.ts @@ -0,0 +1,41 @@ +/** + * The system bars' look for a ZenNotes theme: the theme's background, and + * whether the bar icons go dark on it. Pure, so node --test covers it; the + * wiring is in system-bar-theme.ts. + */ +export interface SystemBarTheme { + /** `#rrggbb`: what the native side paints the bars' strips with. */ + color: string + /** Dark icons (a light background) rather than light ones. */ + light: boolean +} + +/** sRGB channel to linear light, as WCAG's relative luminance has it. */ +function linear(channel: number): number { + const c = channel / 255 + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 +} + +/** + * From app-core's `--z-bg` ("29 32 33": the channels, space separated). + * Null for anything else, so a theme mid-swap never reaches the bars. + * + * The icons take whichever of white or black contrasts more with the + * background. The two tie at a relative luminance of about 0.179, well away + * from every built-in theme (dark ones sit under 0.06, light ones over 0.6). + */ +export function systemBarThemeFor(rawBackground: string): SystemBarTheme | null { + const channels = rawBackground.trim().split(/\s+/).map(Number) + if ( + channels.length !== 3 || + channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255) + ) { + return null + } + const [r, g, b] = channels as [number, number, number] + const luminance = 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) + const contrastWithWhite = 1.05 / (luminance + 0.05) + const contrastWithBlack = (luminance + 0.05) / 0.05 + const hex = channels.map((channel) => channel.toString(16).padStart(2, '0')).join('') + return { color: `#${hex}`, light: contrastWithBlack > contrastWithWhite } +} diff --git a/src/bridge/system-bar-theme.ts b/src/bridge/system-bar-theme.ts new file mode 100644 index 0000000..725f4b5 --- /dev/null +++ b/src/bridge/system-bar-theme.ts @@ -0,0 +1,56 @@ +/** + * Keeps Android's status and navigation bar icons readable on the ZenNotes + * theme: light icons on a dark theme, dark ones on a light theme, following + * every theme switch while the app runs. The system's own light or dark mode + * is not the question; the page under the icons is (SystemBarThemePlugin.java + * has why neither Capacitor bar plugin gets this right). + * + * The theme is read where it ends up, app-core's `--z-bg` on the root, so a + * built-in theme, Auto following the system, a custom CSS theme and a color + * tweak all count. Those arrive as root attributes or as style elements in + * the head, so both are watched, and the bars hear only real changes. + * Android-only and best-effort, like EdgeSwipe. + */ +import { Capacitor, registerPlugin } from '@capacitor/core' +import { systemBarThemeFor, type SystemBarTheme } from './system-bar-theme-color' + +interface SystemBarThemePlugin { + setTheme(options: SystemBarTheme): Promise +} + +const NativeSystemBarTheme = registerPlugin('SystemBarTheme') + +let sent = '' + +function sync(): void { + const theme = systemBarThemeFor( + getComputedStyle(document.documentElement).getPropertyValue('--z-bg') + ) + if (!theme) return + const key = `${theme.color}:${theme.light}` + if (key === sent) return + sent = key + void NativeSystemBarTheme.setTheme(theme).catch(() => { + sent = '' + }) +} + +export function installSystemBarTheme(): void { + if (Capacitor.getPlatform() !== 'android') return + let queued = false + const schedule = (): void => { + if (queued) return + queued = true + requestAnimationFrame(() => { + queued = false + sync() + }) + } + sync() + const observer = new MutationObserver(schedule) + observer.observe(document.documentElement, { attributes: true }) + observer.observe(document.head, { childList: true, subtree: true, characterData: true }) + // Auto mode flips the theme with the system; app-core's own listener sets + // the attributes, but a frame later is cheap insurance. + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', schedule) +} diff --git a/src/main.tsx b/src/main.tsx index 2b03f86..a48a302 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -20,6 +20,7 @@ import { mobileBackgroundSyncHost } from './bridge/mobile-bridge' import { installBackgroundSync } from './bridge/background-sync' +import { installSystemBarTheme } from './bridge/system-bar-theme' import { configureMobileCloudAuth } from './bridge/mobile-cloud-auth' import { maybeRunFirstRunOnboarding } from './ui-mobile/Onboarding' import { installWidgetPublisher } from './bridge/widgets' @@ -88,6 +89,8 @@ async function boot(): Promise { // on the transient tab before the guard puts Home back. installHomeGuard() wireKeyboard() + // Before onboarding, which draws in the theme too. + installSystemBarTheme() wireForegroundRescan() // True first run: welcome + storage choice BEFORE the vault is created, so diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 937deb6..1429ccd 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -61,6 +61,7 @@ import ensoUrl from '../assets/enso.png' import { getStoragePref } from '../bridge/icloud' import { backgroundSyncEnabled, setBackgroundSyncEnabled } from '../bridge/background-sync' import { getPinnedNotes, loadPins } from './pins' +import { installReadOpenGuard } from './read-open' import { placeVaultSettingsIsland, vaultSettingsAccessForLayout } from './vault-settings-access' import { getLayoutMode, isPhoneViewport, setLayoutMode, type LayoutMode } from '../viewport' import { @@ -3149,6 +3150,7 @@ function MobileShellRoot(): React.JSX.Element { export function mountMobileShell(): void { document.documentElement.classList.add('zn-mobile') + installReadOpenGuard() // Pin state loads async from native Preferences; the drawer re-renders via // the pins subscription when it lands. void loadPins() diff --git a/src/ui-mobile/deep-links.ts b/src/ui-mobile/deep-links.ts index cda751f..5a6db88 100644 --- a/src/ui-mobile/deep-links.ts +++ b/src/ui-mobile/deep-links.ts @@ -37,6 +37,7 @@ import { closeNoteMenu } from './note-actions' import { closeMobileSheet } from './sheet-state' import { parseWidgetLink, type WidgetLink } from './widget-links' import { closeCalendarSheet, requestCalendar } from './calendar-request' +import { openToRead } from './read-open' const DUPLICATE_WINDOW_MS = 5000 const LANDING_SETTLE_MS = 80 @@ -130,11 +131,12 @@ async function run(link: WidgetLink): Promise { await runAppCommand('note.new.inbox') return case 'open': - if (hasNote(s, link.path)) await openNote(link.path) + // A widget opens a note to be read: no keyboard until a tap asks for it. + if (hasNote(s, link.path)) await openToRead(() => openNote(link.path)) else goHome() return case 'task': - await openLinkedTask(s, link) + await openToRead(() => openLinkedTask(s, link)) return case 'tasks': await openAppPage('tasks') @@ -149,7 +151,7 @@ async function run(link: WidgetLink): Promise { case 'week': // A day with no note opens the Calendar on it, where "Day +" asks // before creating one, as tapping that day in the app would. - if (!(await openDateNote(link.kind === 'day' ? 'daily' : 'weekly', link.date))) { + if (!(await openToRead(() => openDateNote(link.kind === 'day' ? 'daily' : 'weekly', link.date)))) { requestCalendar({ date: link.date, add: false }) } return diff --git a/src/ui-mobile/keyboard-hold.test.ts b/src/ui-mobile/keyboard-hold.test.ts new file mode 100644 index 0000000..79944a9 --- /dev/null +++ b/src/ui-mobile/keyboard-hold.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { KeyboardHold, READ_OPEN_SETTLE_MS, READ_OPEN_WAIT_MS } from './keyboard-hold.ts' + +function clock(): { now: () => number; advance: (ms: number) => void } { + let t = 1_000_000 + return { now: () => t, advance: (ms) => { t += ms } } +} + +describe('KeyboardHold', () => { + it('lets every focus through until a note opens to be read', () => { + const c = clock() + const hold = new KeyboardHold(c.now) + assert.equal(hold.refuse(), false) + }) + + it('refuses core\'s focus and its retries, then lets go', () => { + const c = clock() + const hold = new KeyboardHold(c.now) + hold.arm() + c.advance(400) // the note takes a while to open + assert.equal(hold.refuse(), true) + for (let i = 0; i < 10; i++) { + c.advance(24) // focusEditorNormalMode's retry cadence + assert.equal(hold.refuse(), true) + } + c.advance(READ_OPEN_SETTLE_MS) + assert.equal(hold.refuse(), false) + }) + + it('lets a tap into the text through at once', () => { + const c = clock() + const hold = new KeyboardHold(c.now) + hold.arm() + assert.equal(hold.refuse(), true) + hold.release() + assert.equal(hold.refuse(), false) + }) + + it('does not wait forever for a focus that never comes', () => { + const c = clock() + const hold = new KeyboardHold(c.now) + hold.arm() + c.advance(READ_OPEN_WAIT_MS) + assert.equal(hold.active, false) + assert.equal(hold.refuse(), false) + }) + + it('stops waiting soon after an open it was told about finishes', () => { + const c = clock() + const hold = new KeyboardHold(c.now) + hold.arm() + c.advance(100) + hold.opened() + c.advance(READ_OPEN_SETTLE_MS - 1) + assert.equal(hold.active, true) + c.advance(1) + assert.equal(hold.refuse(), false) + }) + + it('ignores an open that finishes after a touch released it', () => { + const c = clock() + const hold = new KeyboardHold(c.now) + hold.arm() + hold.release() + hold.opened() + assert.equal(hold.refuse(), false) + }) +}) diff --git a/src/ui-mobile/keyboard-hold.ts b/src/ui-mobile/keyboard-hold.ts new file mode 100644 index 0000000..24598e5 --- /dev/null +++ b/src/ui-mobile/keyboard-hold.ts @@ -0,0 +1,59 @@ +/** + * The clock of the read-open guard (read-open.ts), kept apart from the DOM + * so node --test covers it. Same file in both phone shells. + * + * A note opened to be read arms it. Core then hands the editor focus the way + * desktop wants it, once or in a short burst of retries + * (focusEditorNormalMode tries for about a quarter of a second), and every + * try is refused until the burst has gone quiet. A touch releases it at + * once: tapping into the text is how the keyboard should come up, and + * anything else the user taps is a new intent. + */ + +/** How long an armed guard waits for core's focus: a note read from a + * folder vault can take a few seconds to open. */ +export const READ_OPEN_WAIT_MS = 5000 +/** How long it keeps refusing after a refused focus or a finished open, + * past core's retries. */ +export const READ_OPEN_SETTLE_MS = 700 + +export class KeyboardHold { + private waitUntil = 0 + private settleUntil = 0 + private readonly now: () => number + + constructor(now: () => number) { + this.now = now + } + + /** A note is opening to be read. */ + arm(): void { + this.waitUntil = this.now() + READ_OPEN_WAIT_MS + this.settleUntil = 0 + } + + /** The open has finished: core's focus, if any, follows within the settle. */ + opened(): void { + if (!this.active) return + this.waitUntil = Math.min(this.waitUntil, this.now() + READ_OPEN_SETTLE_MS) + } + + /** The user touched the screen. */ + release(): void { + this.waitUntil = 0 + this.settleUntil = 0 + } + + get active(): boolean { + const t = this.now() + return t < this.waitUntil || t < this.settleUntil + } + + /** An editor focus is about to land: true refuses it. */ + refuse(): boolean { + if (!this.active) return false + this.waitUntil = 0 + this.settleUntil = this.now() + READ_OPEN_SETTLE_MS + return true + } +} diff --git a/src/ui-mobile/read-open.ts b/src/ui-mobile/read-open.ts new file mode 100644 index 0000000..24be096 --- /dev/null +++ b/src/ui-mobile/read-open.ts @@ -0,0 +1,93 @@ +/** + * Opening a note to read it does not raise the soft keyboard on a phone. + * + * App-core hands the editor the keyboard focus whenever it opens a note + * from a list or jumps to a task (#863, #415): right on desktop, where + * focus decides where typing goes and nothing covers the screen. On a phone + * that focus is the soft keyboard over half the note, with the caret parked + * on the first line. Android raised it for Home's Recent rows and the Tasks + * list (a focus inside the tap's gesture shows the keyboard), the iPhone for + * a widget's task link too (Capacitor's WKWebView shows it for any focus). + * Core 2.57 brought those hand-offs to the phones with no phone decision + * behind them; the shells already keep the keyboard down for their own + * navigation (wikilink taps, Back, sheets). + * + * So on the phone layout, a note opened from a zennotes:// link or from one + * of core's note lists (Home, Tasks, Quick Notes, Tags, Archive, Trash) + * arms a short hold (keyboard-hold.ts) that refuses the editor focus core + * follows the open with. Tapping into the text releases it and raises the + * keyboard as always, and any other touch releases it too, so whatever a + * tap elsewhere starts (a new note, the ensō dial, the Calendar's "Add a + * task") focuses as before. Desktop-layout tablets keep core's behavior. + * Same file in both phone shells. + */ +import { Keyboard } from '@capacitor/keyboard' +import { isPhoneViewport } from '../viewport' +import { KeyboardHold } from './keyboard-hold' + +const hold = new KeyboardHold(() => Date.now()) + +/** Core's note rows that open their note on a tap. */ +const READ_ROWS = + '[data-home-nav] [data-home-item], [data-task-row], [data-quick-row], [data-tag-row], [data-archive-row], [data-trash-row]' +/** Controls inside a row that do something else (tick, pin, delete). */ +const ROW_CONTROLS = 'button, a, input, [role="button"], [role="checkbox"]' + +function isEditor(el: Element): boolean { + return el.closest('.cm-editor') !== null +} + +/** A tap on a row that opens its note (not on one of the row's own controls). */ +function opensToRead(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false + const row = target.closest(READ_ROWS) + if (!row) return false + const control = target.closest(ROW_CONTROLS) + return !control || control === row || !row.contains(control) || control.matches('[aria-label^="Open "]') +} + +let installed = false + +export function installReadOpenGuard(): void { + if (installed) return + installed = true + // CodeMirror's view.focus() is contentDOM.focus(): refusing the call keeps + // the keyboard from ever starting up, with no focus and blur for the + // editor to react to. + const focus = HTMLElement.prototype.focus + HTMLElement.prototype.focus = function (this: HTMLElement, options?: FocusOptions): void { + if (isEditor(this) && hold.refuse()) return + focus.call(this, options) + } + // Any other way focus lands in the editor while held, undone at once. + document.addEventListener( + 'focusin', + (event) => { + const target = event.target + if (!(target instanceof HTMLElement) || !isEditor(target) || !hold.refuse()) return + target.blur() + void Keyboard.hide().catch(() => {}) + }, + true + ) + document.addEventListener('pointerdown', () => hold.release(), true) + // After the pointerdown that released it: the row's own click arms it. + document.addEventListener( + 'click', + (event) => { + if (isPhoneViewport() && opensToRead(event.target)) hold.arm() + }, + true + ) +} + +/** Open a note to read it (a widget or zennotes:// link). */ +export async function openToRead(open: () => Promise): Promise { + if (!isPhoneViewport()) return open() + hold.arm() + try { + return await open() + } finally { + hold.opened() + } +}