From eb58acf8d1c4ee2168504e80df3df75324f95126 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 1 Sep 2026 09:06:11 -0500 Subject: [PATCH 1/3] fix: expose remote vault management in the tablet layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Vault → Location only mounted the vault quick-switch island in the phone layout, and every app-core remote-workspace entry point is gated on runtime === 'desktop' — so a tablet (desktop layout, like the Galaxy Tab S9 FE report) had no path to Add Remote Vault… at all. Port of the iPhone shell's iPad fix (zennotesios 1ed7787): vaultSettingsAccessForLayout() keeps the full quick-switch on phones and mounts a compact Manage… button on tablets, opening the same canonical Vaults manager; display: contents keeps the button a flex peer of the row's own Change… button. Verified on a Pixel Tablet AVD (API 35) against a self-hosted ZenNotes server: connect with auth token, note read/edit round-trip, boot-time reconnect, Return to Local Vault, and the phone layout unchanged. --- src/ui-mobile/MobileShell.tsx | 87 ++++++++++++++------- src/ui-mobile/mobile.css | 12 +++ src/ui-mobile/vault-settings-access.test.ts | 13 +++ src/ui-mobile/vault-settings-access.ts | 7 ++ 4 files changed, 91 insertions(+), 28 deletions(-) create mode 100644 src/ui-mobile/vault-settings-access.test.ts create mode 100644 src/ui-mobile/vault-settings-access.ts diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 1f3254f..1fdf59f 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -66,6 +66,7 @@ import { } from './tags-empty-state' import { siblingNotesInDrawerOrder } from './note-order' import { getPinnedNotes, loadPins } from './pins' +import { vaultSettingsAccessForLayout } from './vault-settings-access' import { getLayoutMode, isPhoneViewport, setLayoutMode, type LayoutMode } from '../viewport' import { applyStatusBarPreference, @@ -2329,12 +2330,37 @@ function useContextMenuCleanup(): void { /** * Settings → Vault → Location grows the mobile vault features (the desktop - * switcher and remote-workspace sections are runtime-gated off there): a - * quick-switch list of every reachable vault when there is more than one, - * plus "New Vault…" and "Remote Vault…" actions. Mounted as a React island - * inside the location card (mobilizer pattern, no app-core changes). - * Switching keeps Settings open — the location card updates in place. + * switcher and remote-workspace sections are runtime-gated off there): the + * phone layout gets a quick-switch list of every reachable vault plus + * "New Vault…" and "Manage…" actions; the wider tablet layout keeps a compact + * "Manage…" entry point into the same canonical manager, which is where remote + * (self-hosted) vaults are added. Both are React islands inside the location + * card (mobilizer pattern, no app-core changes). Switching keeps Settings + * open — the location card updates in place. */ +const SETTINGS_VAULT_ACTION_CLASS = + 'shrink-0 rounded-xl border border-paper-300/70 bg-paper-100/80 px-3.5 py-2 text-xs font-medium text-ink-800' + +/** The manager is the one canonical surface — rename, move, delete, remote, + * external folders all live there. It replaces Settings rather than stacking + * under it. */ +function openVaultManagerFromSettings(): void { + useStore.getState().setSettingsOpen(false) + window.setTimeout(() => openMobileSheet('vaults'), 30) +} + +function SettingsVaultManageButton(): React.JSX.Element { + return ( + + ) +} + function SettingsVaultQuickSwitch(): React.JSX.Element { const currentName = useStore((s) => s.vault?.name ?? null) const currentRoot = useStore((s) => s.vault?.root ?? '') @@ -2423,7 +2449,7 @@ function SettingsVaultQuickSwitch(): React.JSX.Element {
@@ -2452,7 +2472,7 @@ function SettingsVaultQuickSwitch(): React.JSX.Element { function useVaultSettingsRows(): void { useEffect(() => { - if (!isPhoneWidth()) return + const access = vaultSettingsAccessForLayout(isPhoneWidth()) let container: HTMLElement | null = null let root: ReturnType | null = null const sync = (): void => { @@ -2468,20 +2488,31 @@ function useVaultSettingsRows(): void { if (container?.parentElement === host) return if (!container) { container = document.createElement('div') - // This wrapper — not the list inside it — is the row's flex child, so - // it is what has to claim the full line (see mobile.css). - container.className = 'zn-settings-vaults-host' + // The phone wrapper — not the list inside it — is the row's flex child, + // so it is what has to claim the full line (see mobile.css). On a + // tablet, display: contents makes the compact Manage button a peer of + // the row's own Change… button. + container.className = + access === 'quick-switch' + ? 'zn-settings-vaults-host' + : 'zn-settings-vaults-manage-host' root = ReactDOM.createRoot(container) - root.render() - } - // The host is the desktop two-column row (label + "Change…" button), - // which the phone stylesheet wraps into a stack. Mount between the label - // and the row's own buttons, so the card reads location → picker list → - // actions and mobile.css can hide those buttons in favor of the - // island's own action group. - const firstBtn = host.querySelector('button') - if (firstBtn) host.insertBefore(container, firstBtn) - else host.appendChild(container) + root.render( + access === 'quick-switch' ? : + ) + } + if (access === 'quick-switch') { + // The host is the desktop two-column row (label + "Change…" button), + // which the phone stylesheet wraps into a stack. Mount between the + // label and the row's own buttons, so the card reads location → + // picker list → actions and mobile.css can hide those buttons in + // favor of the island's own action group. + const firstBtn = host.querySelector('button') + if (firstBtn) host.insertBefore(container, firstBtn) + else host.appendChild(container) + } else { + host.appendChild(container) + } } const observer = new MutationObserver(() => sync()) observer.observe(document.body, { childList: true, subtree: true }) diff --git a/src/ui-mobile/mobile.css b/src/ui-mobile/mobile.css index a287fb2..2a6ec27 100644 --- a/src/ui-mobile/mobile.css +++ b/src/ui-mobile/mobile.css @@ -1878,6 +1878,18 @@ min-width: 100%; } +/* In the tablet settings row, keep only the compact manager entry point. The + wrapper disappears from flex layout so its button sits beside Change… + instead of forcing the phone quick-switch card into the row. */ +.zn-settings-vaults-manage-host { + display: contents; +} + +[data-settings-search-id='vault-location']:has(> .zn-settings-vaults-manage-host) { + flex-wrap: wrap; + row-gap: 0.5rem; +} + .zn-settings-vaults { padding-top: 0.625rem; border-top: 0.5px solid rgb(var(--z-bg-4) / 0.55); diff --git a/src/ui-mobile/vault-settings-access.test.ts b/src/ui-mobile/vault-settings-access.test.ts new file mode 100644 index 0000000..444d70d --- /dev/null +++ b/src/ui-mobile/vault-settings-access.test.ts @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { vaultSettingsAccessForLayout } from './vault-settings-access.ts' + +describe('vaultSettingsAccessForLayout', () => { + it('keeps the full quick-switch controls in the phone settings layout', () => { + assert.equal(vaultSettingsAccessForLayout(true), 'quick-switch') + }) + + it('keeps the vault manager reachable in the tablet settings layout', () => { + assert.equal(vaultSettingsAccessForLayout(false), 'manage-button') + }) +}) diff --git a/src/ui-mobile/vault-settings-access.ts b/src/ui-mobile/vault-settings-access.ts new file mode 100644 index 0000000..6779f53 --- /dev/null +++ b/src/ui-mobile/vault-settings-access.ts @@ -0,0 +1,7 @@ +export type VaultSettingsAccess = 'quick-switch' | 'manage-button' + +/** Phone settings have room for the full switcher; tablet settings retain the + * compact entry point into the same canonical vault manager. */ +export function vaultSettingsAccessForLayout(isPhoneLayout: boolean): VaultSettingsAccess { + return isPhoneLayout ? 'quick-switch' : 'manage-button' +} From e11f1d692e6aa8fb70e5b18debf9242622814b5b Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 1 Sep 2026 09:06:54 -0500 Subject: [PATCH 2/3] feat: Immersive Mode hides the navigation bar too (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Status bar card becomes System bars with three choices: Shown, No status bar (the 1.1.12 behavior, same stored value), and Immersive, which hides the navigation bar too so notes get the entire screen. The navigation bar goes through core's SystemBars plugin — the StatusBar plugin has no reach into it — while the status bar keeps its shipped path; both drive the same WindowInsetsController. MainActivity's transient-by-swipe behavior already covers all system bars, so a swipe from a hidden bar's edge peeks it and it slides away on its own, and with targetSdk 36 the app is edge-to-edge with insets re-applied on every change, so hidden bars leave no solid background strips. The preference rides the existing zn:hide-status-bar key ('1' keeps meaning hidden status bar, 'immersive' is new), so the native mirror in bootstrap.ts is untouched and an upgrade or downgrade degrades to the closest older behavior. Verified on the API 35 emulator with 3-button navigation: all three modes, transient reveal and auto-hide, cold-start persistence, and the legacy stored-value mapping. --- .../main/java/md/zennotes/MainActivity.java | 9 ++- src/ui-mobile/MobileShell.tsx | 73 ++++++++++--------- src/ui-mobile/fullscreen.ts | 55 ++++++++++---- src/ui-mobile/system-bars-mode.test.ts | 31 ++++++++ src/ui-mobile/system-bars-mode.ts | 24 ++++++ src/viewport.ts | 10 ++- 6 files changed, 144 insertions(+), 58 deletions(-) create mode 100644 src/ui-mobile/system-bars-mode.test.ts create mode 100644 src/ui-mobile/system-bars-mode.ts diff --git a/android/app/src/main/java/md/zennotes/MainActivity.java b/android/app/src/main/java/md/zennotes/MainActivity.java index 401c006..af21cc4 100644 --- a/android/app/src/main/java/md/zennotes/MainActivity.java +++ b/android/app/src/main/java/md/zennotes/MainActivity.java @@ -35,10 +35,11 @@ public void onCreate(Bundle savedInstanceState) { ShareInboxPlugin.stashFromIntent(this, getIntent()); neutralizeDoubleKeyboardInset(); installCrashProofWebViewClient(); - // Fullscreen writing (#22): while the JS shell hides the status bar - // via the StatusBar plugin, a swipe from the top edge should peek it - // transiently instead of bringing it back for good. The behavior is - // inert while the bar is visible, so it is safe to set once here. + // Fullscreen writing (#22, #42): while the JS shell hides the + // status bar (and, in Immersive Mode, the navigation bar), a swipe + // from a hidden bar's edge should peek it transiently instead of + // bringing it back for good. The behavior covers all system bars, + // is inert while they are visible, and is safe to set once here. new WindowInsetsControllerCompat(getWindow(), getWindow().getDecorView()) .setSystemBarsBehavior( WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 1fdf59f..bac8456 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -69,9 +69,10 @@ import { getPinnedNotes, loadPins } from './pins' import { vaultSettingsAccessForLayout } from './vault-settings-access' import { getLayoutMode, isPhoneViewport, setLayoutMode, type LayoutMode } from '../viewport' import { - applyStatusBarPreference, - isStatusBarHidden, - setStatusBarHidden + applySystemBarsPreference, + getSystemBarsMode, + setSystemBarsMode, + type SystemBarsMode } from './fullscreen' import { getGesturePrefs, @@ -2542,14 +2543,14 @@ const LAYOUT_CHOICES: { mode: LayoutMode; label: string }[] = [ { mode: 'desktop', label: 'Desktop' } ] -/** Assert the status-bar preference at boot and re-assert it on resume: +/** Assert the system-bars preference at boot and re-assert it on resume: * Android brings the system bars back on some background→foreground paths, - * and re-applying a visible bar is a no-op (#22). */ + * and re-applying visible bars is a no-op (#22, #42). */ function useFullscreenChrome(): void { useEffect(() => { - void applyStatusBarPreference() + void applySystemBarsPreference() const listener = CapApp.addListener('appStateChange', ({ isActive }) => { - if (isActive) void applyStatusBarPreference() + if (isActive) void applySystemBarsPreference() }) return () => { void listener.then((handle) => handle.remove()) @@ -2557,41 +2558,41 @@ function useFullscreenChrome(): void { }, []) } -function SettingsStatusBarRow(): React.JSX.Element { - const [hidden, setHidden] = useState(() => isStatusBarHidden()) - const choose = (next: boolean): void => { - if (next === hidden) return - setStatusBarHidden(next) - setHidden(next) +const SYSTEM_BARS_CHOICES: { mode: SystemBarsMode; label: string }[] = [ + { mode: 'shown', label: 'Shown' }, + { mode: 'status-hidden', label: 'No status bar' }, + { mode: 'immersive', label: 'Immersive' } +] + +function SettingsSystemBarsRow(): React.JSX.Element { + const [mode, setMode] = useState(() => getSystemBarsMode()) + const choose = (next: SystemBarsMode): void => { + if (next === mode) return + setSystemBarsMode(next) + setMode(next) } return (
-
Status bar
+
System bars
- Hidden gives your notes the whole screen. Swipe down from the top - edge to peek at the clock and battery. + Hide the status bar for more room, or go immersive to hide the + navigation bar too. Swipe from a hidden bar’s edge to peek at it.
-
- - +
+ {SYSTEM_BARS_CHOICES.map((choice) => ( + + ))}
) @@ -2767,7 +2768,7 @@ function useLayoutSettingsRow(): void { root.render( <> - + {isPhoneWidth() && } ) diff --git a/src/ui-mobile/fullscreen.ts b/src/ui-mobile/fullscreen.ts index 2fd0653..b3f1f28 100644 --- a/src/ui-mobile/fullscreen.ts +++ b/src/ui-mobile/fullscreen.ts @@ -1,41 +1,68 @@ /** - * Fullscreen writing: hide the Android status bar while ZenNotes is open - * (#22). The bar stays reachable — MainActivity sets the transient-by-swipe - * system-bar behavior, so a swipe from the top edge peeks it (clock, battery, - * notifications) and it slides away again on its own. + * Fullscreen writing: hide Android system chrome while ZenNotes is open. + * Two tiers — hide just the status bar (#22), or Immersive Mode (#42), + * which hides the navigation bar too so notes get the entire screen. The + * bars stay reachable either way: MainActivity sets the transient-by-swipe + * system-bar behavior, so a swipe from a hidden bar's edge peeks it and it + * slides away again on its own. * * The preference is a localStorage flag mirrored to native storage by * bootstrap.ts (same mechanism as the layout override): WebView storage * alone is not trusted to survive engine updates. Applying it is idempotent * and cheap, so the shell re-asserts it on app resume — Android occasionally * restores system bars when a backgrounded activity comes forward. + * + * The status bar keeps going through the @capacitor/status-bar plugin + * (shipped behavior since 1.1.12, config in capacitor.config.ts); only the + * navigation bar goes through core's SystemBars plugin, which the StatusBar + * plugin has no reach into. Both drive the same WindowInsetsController, so + * the split is invisible to the OS. With targetSdk 36 the app is + * edge-to-edge and Capacitor's insets pipeline re-runs on every change, so + * hidden bars leave no solid background strips, and transiently revealed + * bars draw translucent over the content (the issue-#42 transparency ask). */ +import { SystemBars, SystemBarType } from '@capacitor/core' import { StatusBar } from '@capacitor/status-bar' import { HIDE_STATUS_BAR_KEY } from '../viewport' +import { + storedFromSystemBarsMode, + systemBarsModeFromStored, + type SystemBarsMode +} from './system-bars-mode' + +export type { SystemBarsMode } from './system-bars-mode' -export function isStatusBarHidden(): boolean { +export function getSystemBarsMode(): SystemBarsMode { try { - return localStorage.getItem(HIDE_STATUS_BAR_KEY) === '1' + return systemBarsModeFromStored(localStorage.getItem(HIDE_STATUS_BAR_KEY)) } catch { - return false + return 'shown' } } -export function setStatusBarHidden(hidden: boolean): void { +export function setSystemBarsMode(mode: SystemBarsMode): void { try { - if (hidden) localStorage.setItem(HIDE_STATUS_BAR_KEY, '1') - else localStorage.removeItem(HIDE_STATUS_BAR_KEY) + const stored = storedFromSystemBarsMode(mode) + if (stored === null) localStorage.removeItem(HIDE_STATUS_BAR_KEY) + else localStorage.setItem(HIDE_STATUS_BAR_KEY, stored) } catch { // Storage unavailable: the choice applies to this session only. } - void applyStatusBarPreference() + void applySystemBarsPreference() } -export async function applyStatusBarPreference(): Promise { +export async function applySystemBarsPreference(): Promise { + const mode = getSystemBarsMode() try { - if (isStatusBarHidden()) await StatusBar.hide() - else await StatusBar.show() + if (mode === 'shown') await StatusBar.show() + else await StatusBar.hide() } catch { // Plugin unavailable (plain browser dev): visible chrome is the default. } + try { + if (mode === 'immersive') await SystemBars.hide({ bar: SystemBarType.NavigationBar }) + else await SystemBars.show({ bar: SystemBarType.NavigationBar }) + } catch { + // Same fallback; core's web SystemBars stub is a no-op anyway. + } } diff --git a/src/ui-mobile/system-bars-mode.test.ts b/src/ui-mobile/system-bars-mode.test.ts new file mode 100644 index 0000000..2ae49f1 --- /dev/null +++ b/src/ui-mobile/system-bars-mode.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { storedFromSystemBarsMode, systemBarsModeFromStored } from './system-bars-mode.ts' + +describe('systemBarsModeFromStored', () => { + it('keeps the 1.1.12 hidden-status-bar flag meaning status-hidden', () => { + assert.equal(systemBarsModeFromStored('1'), 'status-hidden') + }) + + it('reads the immersive flag', () => { + assert.equal(systemBarsModeFromStored('immersive'), 'immersive') + }) + + it('treats absent and unknown values as the shown default', () => { + assert.equal(systemBarsModeFromStored(null), 'shown') + assert.equal(systemBarsModeFromStored(''), 'shown') + assert.equal(systemBarsModeFromStored('true'), 'shown') + }) +}) + +describe('storedFromSystemBarsMode', () => { + it('round-trips every mode', () => { + for (const mode of ['shown', 'status-hidden', 'immersive'] as const) { + assert.equal(systemBarsModeFromStored(storedFromSystemBarsMode(mode)), mode) + } + }) + + it('clears the key for the shown default', () => { + assert.equal(storedFromSystemBarsMode('shown'), null) + }) +}) diff --git a/src/ui-mobile/system-bars-mode.ts b/src/ui-mobile/system-bars-mode.ts new file mode 100644 index 0000000..dac4162 --- /dev/null +++ b/src/ui-mobile/system-bars-mode.ts @@ -0,0 +1,24 @@ +/** Settings → Appearance → System bars: how much Android chrome stays on + * screen. 'status-hidden' is the original fullscreen-writing option (#22); + * 'immersive' (#42) hides the navigation bar too. */ +export type SystemBarsMode = 'shown' | 'status-hidden' | 'immersive' + +/** + * Stored-flag mapping. '1' is the 1.1.12 hidden-status-bar value (#22) and + * must keep meaning exactly that; 'immersive' rides the same localStorage + * key so the native mirror in bootstrap.ts needs no changes and a downgrade + * degrades to the closest older behavior (unknown value = shown). Absent + * means the default visible chrome. + */ +export function systemBarsModeFromStored(raw: string | null): SystemBarsMode { + if (raw === 'immersive') return 'immersive' + if (raw === '1') return 'status-hidden' + return 'shown' +} + +/** Inverse of the above; null means "remove the key" (the shown default). */ +export function storedFromSystemBarsMode(mode: SystemBarsMode): string | null { + if (mode === 'immersive') return 'immersive' + if (mode === 'status-hidden') return '1' + return null +} diff --git a/src/viewport.ts b/src/viewport.ts index 46a5e06..547b449 100644 --- a/src/viewport.ts +++ b/src/viewport.ts @@ -82,10 +82,12 @@ export function setLayoutMode(mode: LayoutMode): void { } } -/** localStorage key for the fullscreen (hidden status bar) preference (#22). - * Lives here so bootstrap.ts can mirror it natively without importing the - * status-bar plugin into the boot path; '1' means hidden, absent means the - * default visible chrome. */ +/** localStorage key for the fullscreen preference. Lives here so + * bootstrap.ts can mirror it natively without importing the status-bar + * plugin into the boot path; '1' means hidden status bar (#22), + * 'immersive' hides the navigation bar too (#42), absent means the + * default visible chrome. The key name predates immersive mode — kept so + * existing users' mirrored preference survives the upgrade. */ export const HIDE_STATUS_BAR_KEY = 'zn:hide-status-bar' /** localStorage key for the swipe-gesture assignments (#24); JSON, see From e7cfa9685f1df7ce94fe0cb47b591cda4f7ad307 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 1 Sep 2026 09:07:01 -0500 Subject: [PATCH 3/3] chore: bump 1.1.14 (vc 16) --- android/app/build.gradle | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- src/bridge/mobile-bridge.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 8ea70e8..8278711 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 15 - versionName "1.1.13" + versionCode 16 + versionName "1.1.14" 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/package-lock.json b/package-lock.json index 4a70fd5..e9a7ec7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-android", - "version": "1.1.10", + "version": "1.1.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-android", - "version": "1.1.10", + "version": "1.1.14", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@capacitor/android": "^8.0.0", diff --git a/package.json b/package.json index fe59b26..6dd3020 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-android", "private": true, - "version": "1.1.13", + "version": "1.1.14", "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 9a8c77c..f3d58ca 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -125,7 +125,7 @@ import { import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core' import { isPhoneViewport } from '../viewport' -let appVersion = '1.1.13' +let appVersion = '1.1.14' export async function loadNativeAppVersion(): Promise { try {