@lowdefy/shell 3.23.3 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (356) hide show
  1. package/LICENSE +183 -183
  2. package/README.md +364 -2
  3. package/app/(t0,t1,t2,t3,t4)/[...path].js +38 -0
  4. package/app/(t0,t1,t2,t3,t4)/_layout.js +48 -0
  5. package/app/(t0,t1,t2,t3,t4)/index.js +38 -0
  6. package/app/+native-intent.js +39 -0
  7. package/app/_layout.js +36 -0
  8. package/app.config.ts +183 -0
  9. package/appLinks.js +65 -0
  10. package/applyShellPlugins.js +43 -0
  11. package/authCallbackIntentFilters.js +46 -0
  12. package/babel.config.js +25 -0
  13. package/checkShellConfig.js +172 -0
  14. package/checkShellPlugins.js +74 -0
  15. package/getPrivacyManifest.js +39 -0
  16. package/metro.config.js +73 -0
  17. package/native/LowdefyAppShortcuts.swift.template +84 -0
  18. package/native/withAppShortcuts.js +60 -0
  19. package/native-fingerprint.json +16 -0
  20. package/package.json +120 -40
  21. package/profiles.js +607 -0
  22. package/readShellConfig.js +36 -0
  23. package/resolveShellPlugins.js +109 -0
  24. package/runtimeVersion.js +112 -0
  25. package/scripts/checkReleaseBundle.mjs +358 -0
  26. package/scripts/createDayjsLocales.mjs +67 -0
  27. package/scripts/createShellAssets.mjs +54 -0
  28. package/scripts/createShellVersion.mjs +83 -0
  29. package/scripts/createTypeManifest.mjs +205 -0
  30. package/shell.dev.json +11 -0
  31. package/shellVariant.js +126 -0
  32. package/src/ShellContext.js +32 -0
  33. package/src/actions/CopyToClipboard.js +29 -0
  34. package/src/actions/CopyToClipboard.test.js +33 -0
  35. package/src/actions/GeolocationCurrentPosition.js +61 -0
  36. package/src/actions/GeolocationCurrentPosition.test.js +79 -0
  37. package/src/actions/GetLocalStorage.js +36 -0
  38. package/src/actions/RemoveLocalStorage.js +31 -0
  39. package/src/actions/ScrollTo.js +66 -0
  40. package/src/actions/ScrollTo.test.js +83 -0
  41. package/src/actions/SetFocus.js +33 -0
  42. package/src/actions/SetFocus.test.js +49 -0
  43. package/src/actions/SetLocalStorage.js +34 -0
  44. package/src/actions/localStorage.test.js +68 -0
  45. package/src/actions/measureInScrollView.js +36 -0
  46. package/src/actions/nativeActions.js +41 -0
  47. package/src/auth/AuthButton.js +44 -0
  48. package/src/auth/AuthGate.js +57 -0
  49. package/src/auth/AuthLink.js +43 -0
  50. package/src/auth/LockScreen.js +67 -0
  51. package/src/auth/LockScreen.test.js +37 -0
  52. package/src/auth/SignInScreen.js +268 -0
  53. package/src/auth/SignInScreen.test.js +187 -0
  54. package/src/auth/createAppAuth.js +325 -0
  55. package/src/auth/createAppAuth.test.js +424 -0
  56. package/src/auth/createAuthApi.js +115 -0
  57. package/src/auth/createAuthApi.test.js +138 -0
  58. package/src/auth/createCoreAuth.js +96 -0
  59. package/src/auth/createCoreAuth.test.js +188 -0
  60. package/src/auth/createDeviceKey.js +62 -0
  61. package/src/auth/createDeviceKey.test.js +54 -0
  62. package/src/auth/createSessionStores.js +45 -0
  63. package/src/auth/createTokenStore.js +103 -0
  64. package/src/auth/createTokenStore.test.js +116 -0
  65. package/src/auth/getAuthSettings.js +24 -0
  66. package/src/auth/getAuthView.js +39 -0
  67. package/src/auth/getAuthView.test.js +52 -0
  68. package/src/auth/getSheetReturnUrl.js +35 -0
  69. package/src/auth/getTokenStoreOptions.js +46 -0
  70. package/src/auth/isBackgroundShell.js +26 -0
  71. package/src/auth/isSheetReturnLink.js +35 -0
  72. package/src/auth/parseQuery.js +39 -0
  73. package/src/auth/purgeAppData.js +80 -0
  74. package/src/auth/purgeAppData.test.js +104 -0
  75. package/src/auth/shouldLock.js +26 -0
  76. package/src/auth/signInWithSheet.js +97 -0
  77. package/src/auth/signInWithSheet.test.js +205 -0
  78. package/src/auth/styles.js +78 -0
  79. package/src/auth/unregisterPushToken.js +62 -0
  80. package/src/auth/unregisterPushToken.test.js +67 -0
  81. package/src/auth/usesBiometricUnlock.js +23 -0
  82. package/src/auth/watchSession.js +47 -0
  83. package/src/auth/withoutCookies.js +26 -0
  84. package/src/cache/createSqliteCacheStore.js +113 -0
  85. package/src/cache/createSqliteCacheStore.test.js +93 -0
  86. package/src/cache/getCacheKey.js +25 -0
  87. package/src/cache/getLastCacheKeyName.js +22 -0
  88. package/src/components/AppProvider.js +118 -0
  89. package/src/components/Frame.js +116 -0
  90. package/src/components/Frame.test.js +101 -0
  91. package/src/components/LoadingScreen.js +35 -0
  92. package/src/components/MenuButton.js +121 -0
  93. package/src/components/MenuButton.test.js +74 -0
  94. package/src/components/MessageScreen.js +51 -0
  95. package/src/components/NavigationTheme.js +33 -0
  96. package/src/components/NoConnectionBanner.js +51 -0
  97. package/src/components/NoConnectionBanner.test.js +73 -0
  98. package/src/components/OtherPageScreen.js +95 -0
  99. package/src/components/OtherPageScreen.test.js +74 -0
  100. package/src/components/PageScreen.js +254 -0
  101. package/src/components/PageScreen.test.js +492 -0
  102. package/src/components/RouteErrorScreen.js +47 -0
  103. package/src/components/ShellRoot.js +34 -0
  104. package/src/components/ShellRoot.test.js +62 -0
  105. package/src/components/ShellThemeProvider.js +45 -0
  106. package/src/components/SwitchToTab.js +36 -0
  107. package/src/components/SwitchToTab.test.js +63 -0
  108. package/src/components/TabIconImages.js +72 -0
  109. package/src/components/UpdateScreen.js +40 -0
  110. package/src/components/getNavigationTheme.js +44 -0
  111. package/src/components/getNavigationTheme.test.js +41 -0
  112. package/src/components/styles.js +50 -0
  113. package/src/components/useSurfaceTokens.js +47 -0
  114. package/src/config/getAppApiBase.js +34 -0
  115. package/src/config/getShellConfig.js +37 -0
  116. package/src/config/getShellConfig.test.js +80 -0
  117. package/src/createShellApp.js +126 -0
  118. package/src/dev/DevShell.js +239 -0
  119. package/src/dev/DevShell.test.js +233 -0
  120. package/src/dev/connect/ConfirmSheet.js +71 -0
  121. package/src/dev/connect/ConnectScreen.js +117 -0
  122. package/src/dev/connect/QrScanner.js +89 -0
  123. package/src/dev/connection/createDevShellConfig.js +47 -0
  124. package/src/dev/connection/createDevTransport.js +51 -0
  125. package/src/dev/connection/createDevTransport.test.js +87 -0
  126. package/src/dev/connection/createServerList.js +48 -0
  127. package/src/dev/connection/isLoopbackHost.js +25 -0
  128. package/src/dev/connection/pairedShellApp.test.js +104 -0
  129. package/src/dev/connection/parseDevLink.js +66 -0
  130. package/src/dev/connection/parseDevLink.test.js +97 -0
  131. package/src/dev/connection/parseQuery.js +33 -0
  132. package/src/dev/connection/parseServerInput.js +40 -0
  133. package/src/dev/connection/parseServerUrl.js +45 -0
  134. package/src/dev/devStyles.js +97 -0
  135. package/src/dev/index.js +21 -0
  136. package/src/dev/inject/createDeviceInjections.js +188 -0
  137. package/src/dev/inject/createDeviceInjections.test.js +224 -0
  138. package/src/dev/inject/createOfflineFetch.js +35 -0
  139. package/src/dev/inject/createOfflineFetch.test.js +68 -0
  140. package/src/dev/inject/injectPush.js +60 -0
  141. package/src/dev/inject/injectShare.js +55 -0
  142. package/src/dev/inject/withInjectedDevice.js +98 -0
  143. package/src/dev/menu/BlockGallery.js +33 -0
  144. package/src/dev/menu/DevMenu.js +121 -0
  145. package/src/dev/menu/createLogBuffer.js +83 -0
  146. package/src/dev/menu/watchShake.js +52 -0
  147. package/src/dev/menu/watchShake.test.js +61 -0
  148. package/src/dev/recorder/createNativeRecorder.js +204 -0
  149. package/src/dev/recorder/createNativeRecorder.test.js +307 -0
  150. package/src/dev/redirectDevLink.js +30 -0
  151. package/src/dev/redirectDevLink.test.js +49 -0
  152. package/src/dev/steps/createStepRunner.js +184 -0
  153. package/src/dev/steps/createStepRunner.test.js +262 -0
  154. package/src/dev/steps/findBlock.js +78 -0
  155. package/src/dev/steps/isContextSettled.js +37 -0
  156. package/src/dev/steps/waitForSettle.js +39 -0
  157. package/src/dev/steps/waitUntil.js +40 -0
  158. package/src/dev/tab/DevTabClient.js +97 -0
  159. package/src/dev/tab/answerEval.js +39 -0
  160. package/src/dev/tab/answerInspect.js +45 -0
  161. package/src/dev/tab/createDevTabClient.js +272 -0
  162. package/src/dev/tab/createDevTabClient.test.js +317 -0
  163. package/src/dev/tab/describeTabError.js +35 -0
  164. package/src/dev/tab/devClientMarker.js +22 -0
  165. package/src/dev/tab/devClientMarker.test.js +23 -0
  166. package/src/dev/tab/findPageContext.js +43 -0
  167. package/src/dev/tab/getShownPage.js +42 -0
  168. package/src/dev/tab/readEventStream.js +68 -0
  169. package/src/dev/tab/reloadApp.js +42 -0
  170. package/src/dev/tab/reloadApp.test.js +115 -0
  171. package/src/device/DeviceHosts.js +32 -0
  172. package/src/device/basic/createFilesFeature.js +41 -0
  173. package/src/device/basic/createHapticsFeature.js +46 -0
  174. package/src/device/basic/createKeepAwakeFeature.js +37 -0
  175. package/src/device/basic/createPhotosFeature.js +40 -0
  176. package/src/device/basic/createRequestQueueFeature.js +37 -0
  177. package/src/device/basic/createShareFeature.js +63 -0
  178. package/src/device/basic/createShareFeature.test.js +47 -0
  179. package/src/device/basic/createSpeechFeature.js +43 -0
  180. package/src/device/basic/createUploadFeature.js +41 -0
  181. package/src/device/basic/toImagePickerOptions.js +41 -0
  182. package/src/device/basic/toPickedFile.js +54 -0
  183. package/src/device/calendar/index.js +68 -0
  184. package/src/device/contacts/index.js +67 -0
  185. package/src/device/createDevice.js +65 -0
  186. package/src/device/device.js +27 -0
  187. package/src/device/field/MapView.js +93 -0
  188. package/src/device/field/ScanCodeSheet.js +86 -0
  189. package/src/device/field/ScannerView.js +52 -0
  190. package/src/device/field/createAudioFeature.js +70 -0
  191. package/src/device/field/createBrightnessFeature.js +40 -0
  192. package/src/device/field/createCameraFeature.js +56 -0
  193. package/src/device/field/createDocumentScannerFeature.js +51 -0
  194. package/src/device/field/createLocationFeature.js +39 -0
  195. package/src/device/field/createScanRequests.js +55 -0
  196. package/src/device/field/index.js +39 -0
  197. package/src/device/field/scanRequests.js +22 -0
  198. package/src/device/injected.js +60 -0
  199. package/src/device/injected.test.js +39 -0
  200. package/src/device/nfc/index.js +89 -0
  201. package/src/device/nfc/toNfcRecord.js +44 -0
  202. package/src/device/nfc/toNfcRecord.test.js +48 -0
  203. package/src/device/tracking/createBacklog.js +63 -0
  204. package/src/device/tracking/createBacklog.test.js +47 -0
  205. package/src/device/tracking/index.js +211 -0
  206. package/src/device/tracking/index.test.js +110 -0
  207. package/src/host/createDeviceStore.js +32 -0
  208. package/src/host/createMemoryStore.js +32 -0
  209. package/src/host/createNetworkStatus.js +94 -0
  210. package/src/host/createNetworkStatus.test.js +94 -0
  211. package/src/host/createRouterAdapter.js +33 -0
  212. package/src/host/createShellFetch.js +62 -0
  213. package/src/host/createShellFetch.test.js +165 -0
  214. package/src/host/createShellGlobals.js +62 -0
  215. package/src/host/createShellHost.js +83 -0
  216. package/src/host/createShellHost.test.js +36 -0
  217. package/src/host/getOrigin.js +31 -0
  218. package/src/host/isNetworkError.js +24 -0
  219. package/src/host/isOnline.js +22 -0
  220. package/src/host/networkStatus.js +24 -0
  221. package/src/host/readServerError.js +45 -0
  222. package/src/host/readServerError.test.js +85 -0
  223. package/src/host/reportShellError.js +47 -0
  224. package/src/host/reportShellError.test.js +79 -0
  225. package/src/host/withNetworkErrors.js +38 -0
  226. package/src/host/withNetworkErrors.test.js +61 -0
  227. package/src/host/withShellHeader.js +25 -0
  228. package/src/host/withShellHeader.test.js +28 -0
  229. package/src/js/evaluateJsEntries.js +43 -0
  230. package/src/js/evaluateJsEntries.test.js +48 -0
  231. package/src/locale/createShellLocale.js +112 -0
  232. package/src/locale/createShellLocale.test.js +74 -0
  233. package/src/locale/dayjsLocales.js +165 -0
  234. package/src/locale/getDeviceLocales.js +23 -0
  235. package/src/locale/loadDayjsLocale.js +32 -0
  236. package/src/locale/resolveActiveLocale.js +46 -0
  237. package/src/locale/resolveActiveLocale.test.js +40 -0
  238. package/src/native/backgroundFetch.js +97 -0
  239. package/src/native/callEndpoint.js +63 -0
  240. package/src/native/createAppEventTrigger.js +70 -0
  241. package/src/native/currentApp.js +49 -0
  242. package/src/native/getPagePath.js +32 -0
  243. package/src/native/openPath.js +55 -0
  244. package/src/native/openPath.test.js +39 -0
  245. package/src/native/parseShellLink.js +38 -0
  246. package/src/native/parseShellLink.test.js +29 -0
  247. package/src/native/startAppServices.js +94 -0
  248. package/src/native/startAppServices.test.js +97 -0
  249. package/src/native/startShareIn.js +59 -0
  250. package/src/native/startShortcuts.js +88 -0
  251. package/src/native/startShortcuts.test.js +59 -0
  252. package/src/native/toSharePayload.js +47 -0
  253. package/src/native/toSharePayload.test.js +47 -0
  254. package/src/native/whenAppReady.js +48 -0
  255. package/src/navigation/SlotContext.js +24 -0
  256. package/src/navigation/frameSlots.js +27 -0
  257. package/src/navigation/getFrame.js +30 -0
  258. package/src/navigation/getInitialScreenOptions.js +63 -0
  259. package/src/navigation/getInitialScreenOptions.test.js +66 -0
  260. package/src/navigation/getPresentation.js +28 -0
  261. package/src/navigation/getSlotRootPath.js +27 -0
  262. package/src/navigation/getStackOptions.js +185 -0
  263. package/src/navigation/getTabHref.js +45 -0
  264. package/src/navigation/getTabIcons.js +43 -0
  265. package/src/navigation/isPageUnderHeader.js +46 -0
  266. package/src/navigation/navigation.test.js +342 -0
  267. package/src/navigation/toAppLinkPath.js +43 -0
  268. package/src/navigation/toAppLinkPath.test.js +53 -0
  269. package/src/navigation/toRouterHref.js +29 -0
  270. package/src/notifications/createNotificationsFeature.js +61 -0
  271. package/src/notifications/getCategoryAction.js +31 -0
  272. package/src/notifications/handleNotificationResponse.js +82 -0
  273. package/src/notifications/registerPushToken.js +71 -0
  274. package/src/notifications/startNotifications.js +114 -0
  275. package/src/notifications/toCategoryActions.js +42 -0
  276. package/src/notifications/toNotificationEvent.js +36 -0
  277. package/src/notifications/toScheduleRequest.js +62 -0
  278. package/src/plugins/appTypes.js +28 -0
  279. package/src/plugins/prefixTypes.js +28 -0
  280. package/src/plugins/shellPlugins.json +15 -0
  281. package/src/plugins/typeManifest.js +789 -0
  282. package/src/plugins/types.js +64 -0
  283. package/src/plugins/types.test.js +173 -0
  284. package/src/polyfills/installGetRandomValues.js +28 -0
  285. package/src/queue/createRequestQueue.js +198 -0
  286. package/src/queue/createRequestQueue.test.js +215 -0
  287. package/src/queue/createSqliteQueueStore.js +121 -0
  288. package/src/queue/summarizeQueue.js +39 -0
  289. package/src/session/createAppSession.js +323 -0
  290. package/src/session/createAppSession.test.js +391 -0
  291. package/src/session/getPageView.js +59 -0
  292. package/src/session/getPageView.test.js +137 -0
  293. package/src/sync/encodePagePath.js +27 -0
  294. package/src/sync/fetchPage.js +72 -0
  295. package/src/sync/fetchPage.test.js +84 -0
  296. package/src/sync/fetchRoot.js +43 -0
  297. package/src/sync/fetchUser.js +33 -0
  298. package/src/sync/runLimited.js +36 -0
  299. package/src/sync/toRootConfig.js +35 -0
  300. package/src/test/createFakeNetwork.js +40 -0
  301. package/src/test/createFakeSecureStore.js +40 -0
  302. package/src/test/createFakeServer.js +98 -0
  303. package/src/test/createMemoryCacheStore.js +59 -0
  304. package/src/test/createNetworkError.js +24 -0
  305. package/src/test/expoImage.js +42 -0
  306. package/src/test/expoNotifications.js +40 -0
  307. package/src/test/expoQuickActions.js +22 -0
  308. package/src/test/expoSpeechRecognition.js +31 -0
  309. package/src/test/expoTaskManager.js +25 -0
  310. package/src/test/expoUi.js +55 -0
  311. package/src/test/expoUiBottomSheet.js +64 -0
  312. package/src/test/expoUiDateTimePicker.js +26 -0
  313. package/src/test/expoUiMenu.js +46 -0
  314. package/src/test/expoUiSegmentedControl.js +47 -0
  315. package/src/test/expoUiSwiftUi.js +62 -0
  316. package/src/test/expoUiSwiftUiModifiers.js +23 -0
  317. package/src/test/expoWebBrowser.js +23 -0
  318. package/src/test/fixtures.js +98 -0
  319. package/src/test/nativeModule.js +33 -0
  320. package/src/test/renderPageScreen.js +109 -0
  321. package/src/types/checkTypeSet.js +39 -0
  322. package/src/types/collectPageTypes.js +49 -0
  323. package/src/types/collectPageTypes.test.js +90 -0
  324. package/src/types/findMissingPageTypes.js +40 -0
  325. package/src/version/compareVersions.js +38 -0
  326. package/src/version/isShellTooOld.js +30 -0
  327. package/src/version/shellVersion.js +21 -0
  328. package/src/version/shellVersion.test.js +32 -0
  329. package/src/widgets/fetchWidgetData.js +59 -0
  330. package/src/widgets/index.js +156 -0
  331. package/src/widgets/refreshWidgets.js +31 -0
  332. package/withExcludedNativeModules.js +93 -0
  333. package/dist/location.js +0 -33
  334. package/dist/public/apple-touch-icon.png +0 -0
  335. package/dist/public/icon-32.png +0 -0
  336. package/dist/public/icon-512.png +0 -0
  337. package/dist/public/icon.svg +0 -17
  338. package/dist/public/logo-dark-theme.png +0 -0
  339. package/dist/public/logo-light-theme.png +0 -0
  340. package/dist/public/logo-square-dark-theme.png +0 -0
  341. package/dist/public/logo-square-light-theme.png +0 -0
  342. package/dist/public/manifest.webmanifest +0 -16
  343. package/dist/shell/246.93432ff68186aae6c87b.js +0 -2
  344. package/dist/shell/246.93432ff68186aae6c87b.js.LICENSE.txt +0 -8
  345. package/dist/shell/454.b597ca21366afda000af.js +0 -2
  346. package/dist/shell/454.b597ca21366afda000af.js.LICENSE.txt +0 -14
  347. package/dist/shell/484.d645e6c09079f45ce044.js +0 -1
  348. package/dist/shell/706.a66931e02a549fd269bd.js +0 -2
  349. package/dist/shell/706.a66931e02a549fd269bd.js.LICENSE.txt +0 -8
  350. package/dist/shell/9.78be8c1b6c3986f404fc.js +0 -2
  351. package/dist/shell/9.78be8c1b6c3986f404fc.js.LICENSE.txt +0 -45
  352. package/dist/shell/922.43b7a3ebe22ef05b3049.js +0 -2
  353. package/dist/shell/922.43b7a3ebe22ef05b3049.js.LICENSE.txt +0 -14
  354. package/dist/shell/index.html +0 -57
  355. package/dist/shell/main.f01501a75909f38ccc32.js +0 -1
  356. package/dist/shell/runtime.e50f6c3a5f7c74a0a717.js +0 -1
@@ -0,0 +1,93 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { jest } from '@jest/globals';
18
+
19
+ import createSqliteCacheStore from './createSqliteCacheStore.js';
20
+
21
+ // A stand-in for an expo-sqlite database over the two tables the store uses.
22
+ function createFakeDb() {
23
+ const roots = new Map();
24
+ const pages = new Map();
25
+ return {
26
+ execAsync: jest.fn(async () => undefined),
27
+ getFirstAsync: jest.fn(async (sql, [cacheKey]) => roots.get(cacheKey) ?? null),
28
+ getAllAsync: jest.fn(async (sql, [cacheKey]) =>
29
+ [...pages.values()].filter((row) => row.cache_key === cacheKey)
30
+ ),
31
+ runAsync: jest.fn(async (sql, params) => {
32
+ if (sql.startsWith('INSERT OR REPLACE INTO roots')) {
33
+ const [cacheKey, root, rootEtag, user] = params;
34
+ roots.set(cacheKey, { root, root_etag: rootEtag, user });
35
+ } else if (sql.startsWith('INSERT OR REPLACE INTO pages')) {
36
+ const [cacheKey, pageId, page] = params;
37
+ pages.set(`${cacheKey}|${pageId}`, { cache_key: cacheKey, page_id: pageId, page });
38
+ } else if (sql.startsWith('DELETE FROM roots WHERE cache_key')) {
39
+ roots.delete(params[0]);
40
+ } else if (sql.startsWith('DELETE FROM pages WHERE cache_key = ? AND')) {
41
+ pages.delete(`${params[0]}|${params[1]}`);
42
+ } else if (sql.startsWith('DELETE FROM pages WHERE cache_key')) {
43
+ [...pages.keys()]
44
+ .filter((key) => pages.get(key).cache_key === params[0])
45
+ .forEach((key) => pages.delete(key));
46
+ } else if (sql.startsWith('DELETE FROM roots WHERE substr')) {
47
+ [...roots.keys()]
48
+ .filter((key) => key.startsWith(params[1]))
49
+ .forEach((key) => roots.delete(key));
50
+ } else if (sql.startsWith('DELETE FROM pages WHERE substr')) {
51
+ [...pages.keys()]
52
+ .filter((key) => pages.get(key).cache_key.startsWith(params[1]))
53
+ .forEach((key) => pages.delete(key));
54
+ }
55
+ }),
56
+ };
57
+ }
58
+
59
+ test('the SQLite cache keeps a root and its pages per cache key', async () => {
60
+ const db = createFakeDb();
61
+ const store = createSqliteCacheStore({ db });
62
+ expect(await store.read('field:c1:r1')).toBeNull();
63
+ await store.writeRoot({
64
+ cacheKey: 'field:c1:r1',
65
+ entry: { root: { homePageId: 'today' }, rootEtag: '"r1"', user: { id: 'u1' } },
66
+ });
67
+ await store.writePage({ cacheKey: 'field:c1:r1', pageId: 'today', page: { hash: 'h1' } });
68
+ await store.writePage({ cacheKey: 'field:c1:r1', pageId: 'jobs', page: { hash: 'h2' } });
69
+ await store.writePage({ cacheKey: 'field:c2:r1', pageId: 'today', page: { hash: 'other' } });
70
+ await store.removePages({ cacheKey: 'field:c1:r1', pageIds: ['jobs'] });
71
+ expect(await store.read('field:c1:r1')).toEqual({
72
+ root: { homePageId: 'today' },
73
+ rootEtag: '"r1"',
74
+ user: { id: 'u1' },
75
+ pages: { today: { hash: 'h1' } },
76
+ });
77
+ expect(db.execAsync).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ test('the SQLite cache drops one caller, or every caller of one app', async () => {
81
+ const store = createSqliteCacheStore({ db: createFakeDb() });
82
+ const entry = { root: {}, rootEtag: null, user: null };
83
+ await store.writeRoot({ cacheKey: 'field:c1:r1', entry });
84
+ await store.writePage({ cacheKey: 'field:c1:r1', pageId: 'today', page: { hash: 'h1' } });
85
+ await store.writeRoot({ cacheKey: 'field:c2:r1', entry });
86
+ await store.writeRoot({ cacheKey: 'fieldwork:c1:r1', entry });
87
+ await store.removeEntry({ cacheKey: 'field:c2:r1' });
88
+ expect(await store.read('field:c2:r1')).toBeNull();
89
+ expect(await store.read('field:c1:r1')).not.toBeNull();
90
+ await store.removeApp({ appId: 'field' });
91
+ expect(await store.read('field:c1:r1')).toBeNull();
92
+ expect(await store.read('fieldwork:c1:r1')).not.toBeNull();
93
+ });
@@ -0,0 +1,25 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ // The cache a shell keeps for one app and caller. The server filters an app's pages, menus and
18
+ // page index by the caller's roles, so pages are kept per app, per person and organisation (the
19
+ // caller hash of the server's x-lowdefy-caller stamp) and per set of roles (its roles hash).
20
+ // stamp is the parsed stamp of the root response; a response without one is the anonymous caller.
21
+ function getCacheKey({ appId, stamp }) {
22
+ return [appId, stamp?.caller ?? '', stamp?.roles ?? ''].join(':');
23
+ }
24
+
25
+ export default getCacheKey;
@@ -0,0 +1,22 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ // The device key holding the cache key an app last opened with, read on start to render at once.
18
+ function getLastCacheKeyName(appId) {
19
+ return `lowdefy-shell:last-cache-key:${appId}`;
20
+ }
21
+
22
+ export default getLastCacheKeyName;
@@ -0,0 +1,118 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import React, { useEffect, useState } from 'react';
18
+ import { View } from 'react-native';
19
+ import * as SplashScreen from 'expo-splash-screen';
20
+
21
+ import AuthGate from '../auth/AuthGate.js';
22
+ import createShellApp from '../createShellApp.js';
23
+ import DeviceHosts from '../device/DeviceHosts.js';
24
+ import LoadingScreen from './LoadingScreen.js';
25
+ import MessageScreen from './MessageScreen.js';
26
+ import NoConnectionBanner from './NoConnectionBanner.js';
27
+ import reportShellError from '../host/reportShellError.js';
28
+ import ShellContext, { useSessionState } from '../ShellContext.js';
29
+ import ShellThemeProvider from './ShellThemeProvider.js';
30
+ import startAppServices from '../native/startAppServices.js';
31
+
32
+ function AppGate({ children }) {
33
+ const { status, error } = useSessionState();
34
+ const value = React.useContext(ShellContext);
35
+ useEffect(() => {
36
+ if (status !== 'loading') SplashScreen.hideAsync();
37
+ }, [status]);
38
+ if (status === 'loading') {
39
+ return <LoadingScreen />;
40
+ }
41
+ if (status === 'error') {
42
+ return (
43
+ <MessageScreen
44
+ action={{ title: 'Try again', onPress: () => value.session.start() }}
45
+ body={error?.message}
46
+ testID="shell-app-error"
47
+ title="This app could not be opened"
48
+ />
49
+ );
50
+ }
51
+ return (
52
+ <View style={{ flex: 1 }}>
53
+ <NoConnectionBanner />
54
+ <ShellThemeProvider>{children}</ShellThemeProvider>
55
+ </View>
56
+ );
57
+ }
58
+
59
+ // Opens the shell's app (shell.app): builds its auth, session and host, renders its last pages from
60
+ // the cache and syncs it with the app's server. A sign-out opens it afresh, with nothing of the
61
+ // person's kept.
62
+ function AppProvider({ children, shell }) {
63
+ const [value, setValue] = useState(null);
64
+ const [openError, setOpenError] = useState(null);
65
+ const [openings, setOpenings] = useState(0);
66
+ useEffect(() => {
67
+ let active = true;
68
+ let opened = null;
69
+ let services = null;
70
+ setValue(null);
71
+ setOpenError(null);
72
+ const onSignedOut = () => setOpenings((count) => count + 1);
73
+ createShellApp({ onSignedOut, shell })
74
+ .then((created) => {
75
+ if (!active) return;
76
+ const { auth, host, locale, session } = created;
77
+ opened = auth;
78
+ services = startAppServices({ app: { ...created, shell } });
79
+ setValue({ auth, host, locale, services, session, shell });
80
+ auth.start({ session });
81
+ })
82
+ .catch((error) => {
83
+ // Without this the app would wait on the loading screen for ever.
84
+ if (!active) return;
85
+ reportShellError({ error });
86
+ setOpenError(error);
87
+ SplashScreen.hideAsync();
88
+ });
89
+ return () => {
90
+ active = false;
91
+ services?.stop();
92
+ opened?.dispose();
93
+ };
94
+ }, [openings, shell]);
95
+ if (openError !== null) {
96
+ return (
97
+ <MessageScreen
98
+ action={{ title: 'Try again', onPress: () => setOpenings((count) => count + 1) }}
99
+ body={openError.message}
100
+ testID="shell-open-error"
101
+ title="This app could not be opened"
102
+ />
103
+ );
104
+ }
105
+ if (value === null) {
106
+ return <LoadingScreen />;
107
+ }
108
+ return (
109
+ <ShellContext.Provider value={value}>
110
+ <AuthGate>
111
+ <AppGate>{children}</AppGate>
112
+ </AuthGate>
113
+ <DeviceHosts />
114
+ </ShellContext.Provider>
115
+ );
116
+ }
117
+
118
+ export default AppProvider;
@@ -0,0 +1,116 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
18
+ import { Platform } from 'react-native';
19
+ import { router, Stack } from 'expo-router';
20
+ import { NativeTabs } from 'expo-router/native-tabs';
21
+ import { useTheme } from '@lowdefy/client-native';
22
+
23
+ import FRAME_SLOTS from '../navigation/frameSlots.js';
24
+ import getFrame from '../navigation/getFrame.js';
25
+ import getTabIcons from '../navigation/getTabIcons.js';
26
+ import TabIconImages from './TabIconImages.js';
27
+ import { useSessionState, useShell } from '../ShellContext.js';
28
+
29
+ // One tab's icon: its SF Symbol on iOS, else its Lucide icon drawn as an image once ready. It is
30
+ // called, not rendered as a component: Expo Router reads a trigger's options from its direct
31
+ // NativeTabs.Trigger.Icon and Label children only, and drops any other element, even one that
32
+ // renders an Icon.
33
+ function renderTabIcon({ icon, image }) {
34
+ if (icon.sf !== null) {
35
+ return <NativeTabs.Trigger.Icon sf={icon.sf} />;
36
+ }
37
+ if (image === undefined) {
38
+ return null;
39
+ }
40
+ return <NativeTabs.Trigger.Icon renderingMode="template" src={image} />;
41
+ }
42
+
43
+ // The tabs frame: a native tab bar, each tab a route group with its own stack. The selected tab
44
+ // takes the theme's tint. iOS 26 draws the bar's glass itself; before it, the bar blurs in the
45
+ // mode showing, and Android's bar takes the cell colour. A `role: search` item is iOS 26's search tab, set apart at the
46
+ // right of the bar with a magnifier; elsewhere it is a tab with its search icon.
47
+ function TabsFrame({ items }) {
48
+ const { isDark, tokens } = useTheme();
49
+ const icons = useMemo(() => getTabIcons({ items, platform: Platform.OS }), [items]);
50
+ const [images, setImages] = useState({});
51
+ const onImage = useCallback(
52
+ (pageId, source) => setImages((current) => ({ ...current, [pageId]: source })),
53
+ []
54
+ );
55
+ const imageItems = icons.filter((icon) => icon.iconData !== null);
56
+ return (
57
+ <>
58
+ <NativeTabs
59
+ backgroundColor={Platform.OS === 'ios' ? undefined : tokens.colorCell}
60
+ blurEffect={isDark ? 'systemChromeMaterialDark' : 'systemChromeMaterialLight'}
61
+ iconColor={{ selected: tokens.colorTint }}
62
+ labelStyle={{ selected: { color: tokens.colorTint } }}
63
+ tintColor={tokens.colorTint}
64
+ >
65
+ {FRAME_SLOTS.map((slot, index) => {
66
+ const item = items[index];
67
+ return (
68
+ <NativeTabs.Trigger
69
+ hidden={item === undefined}
70
+ key={slot}
71
+ name={`(${slot})`}
72
+ role={item?.role}
73
+ >
74
+ {item === undefined
75
+ ? null
76
+ : renderTabIcon({ icon: icons[index], image: images[item.pageId] })}
77
+ <NativeTabs.Trigger.Label>
78
+ {item?.title ?? item?.pageId ?? ''}
79
+ </NativeTabs.Trigger.Label>
80
+ </NativeTabs.Trigger>
81
+ );
82
+ })}
83
+ </NativeTabs>
84
+ {imageItems.length > 0 ? <TabIconImages items={imageItems} onImage={onImage} /> : null}
85
+ </>
86
+ );
87
+ }
88
+
89
+ // The app's frame from its mobile menus: native tabs, one stack per tab, or one stack (with a menu
90
+ // button for a drawer). Each tab is a route group of app/(t0,t1,t2,t3,t4); tabs past the menu's
91
+ // items are hidden.
92
+ function Frame() {
93
+ const { session } = useShell();
94
+ const state = useSessionState();
95
+ const frame = getFrame(state.root.menus);
96
+
97
+ // After a caller change the shell starts again from the home page.
98
+ useEffect(() => {
99
+ if (!session.takeHomeRequest()) return;
100
+ if (router.canDismiss()) router.dismissAll();
101
+ router.replace('/');
102
+ }, [session, state.homeRequests]);
103
+
104
+ if (frame.kind === 'tabs') {
105
+ return <TabsFrame items={frame.items} />;
106
+ }
107
+ return (
108
+ <Stack initialRouteName="(t0)" screenOptions={{ headerShown: false }}>
109
+ {FRAME_SLOTS.map((slot) => (
110
+ <Stack.Screen key={slot} name={`(${slot})`} />
111
+ ))}
112
+ </Stack>
113
+ );
114
+ }
115
+
116
+ export default Frame;
@@ -0,0 +1,101 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import React from 'react';
18
+ import { jest } from '@jest/globals';
19
+ import { render } from '@testing-library/react-native';
20
+ import { ThemeContext } from '@lowdefy/client-native';
21
+
22
+ import Frame from './Frame.js';
23
+
24
+ // The native tabs as plain elements that keep their props, so a test reads the tab bar. A trigger
25
+ // keeps only its direct Icon and Label children, as Expo Router's convertTabPropsToOptions reads a
26
+ // trigger's options: any other element, even one that renders an Icon, is dropped.
27
+ jest.mock('expo-router/native-tabs', () => {
28
+ const { Children, createElement, isValidElement } = jest.requireActual('react');
29
+ const element = (name) => (props) => createElement(name, props, props.children);
30
+ const NativeTabs = element('NativeTabs');
31
+ const Icon = element('TriggerIcon');
32
+ const Label = ({ children }) => createElement('TriggerLabel', { label: children });
33
+ NativeTabs.Trigger = (props) =>
34
+ createElement(
35
+ 'Trigger',
36
+ props,
37
+ Children.toArray(props.children).filter(
38
+ (child) => isValidElement(child) && (child.type === Icon || child.type === Label)
39
+ )
40
+ );
41
+ NativeTabs.Trigger.Icon = Icon;
42
+ NativeTabs.Trigger.Label = Label;
43
+ return { NativeTabs };
44
+ });
45
+
46
+ jest.mock('expo-router', () => ({ router: {}, Stack: () => null }));
47
+
48
+ const mockState = {};
49
+ jest.mock('../ShellContext.js', () => ({
50
+ useSessionState: () => mockState,
51
+ useShell: () => ({ session: { takeHomeRequest: () => false } }),
52
+ }));
53
+
54
+ function findAll(node, predicate, found = []) {
55
+ if (node === null || typeof node === 'string') return found;
56
+ if (predicate(node)) found.push(node);
57
+ (node.children ?? []).forEach((child) => findAll(child, predicate, found));
58
+ return found;
59
+ }
60
+
61
+ const inbox = { node: [['path', { d: 'M1 1' }]] };
62
+ const tokens = { colorCell: '#FFFFFF', colorLabel: '#000000', colorTint: '#0A72D8' };
63
+
64
+ test('native tab bar items take SF Symbols, the search role and the theme tint', async () => {
65
+ mockState.homeRequests = 0;
66
+ mockState.root = {
67
+ menus: {
68
+ tabs: [
69
+ { pageId: 'my-work', title: 'My work', icon: 'Inbox', iconData: inbox },
70
+ { pageId: 'tasks', title: 'Tasks', icon: 'SquareCheck', sfSymbol: 'checklist' },
71
+ { pageId: 'search', title: 'Search', icon: 'search', role: 'search' },
72
+ ],
73
+ },
74
+ };
75
+ const view = await render(
76
+ <ThemeContext.Provider value={{ isDark: false, preference: 'system', tokens }}>
77
+ <Frame />
78
+ </ThemeContext.Provider>
79
+ );
80
+ const tabBar = findAll(view.root, (node) => node.type === 'NativeTabs')[0];
81
+ expect(tabBar.props.tintColor).toBe('#0A72D8');
82
+ expect(tabBar.props.iconColor).toEqual({ selected: '#0A72D8' });
83
+ expect(tabBar.props.blurEffect).toBe('systemChromeMaterialLight');
84
+ const triggers = findAll(view.root, (node) => node.type === 'Trigger');
85
+ expect(triggers.map((trigger) => [trigger.props.name, trigger.props.hidden])).toEqual([
86
+ ['(t0)', false],
87
+ ['(t1)', false],
88
+ ['(t2)', false],
89
+ ['(t3)', true],
90
+ ['(t4)', true],
91
+ ]);
92
+ expect(triggers[2].props.role).toBe('search');
93
+ const labels = findAll(view.root, (node) => node.type === 'TriggerLabel');
94
+ expect(labels.slice(0, 3).map((label) => label.props.label)).toEqual([
95
+ 'My work',
96
+ 'Tasks',
97
+ 'Search',
98
+ ]);
99
+ const icons = findAll(view.root, (node) => node.type === 'TriggerIcon');
100
+ expect(icons.map((icon) => icon.props.sf)).toEqual(['tray', 'checklist', 'magnifyingglass']);
101
+ });
@@ -0,0 +1,35 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import React from 'react';
18
+ import { ActivityIndicator, View } from 'react-native';
19
+
20
+ import styles from './styles.js';
21
+ import useSurfaceTokens from './useSurfaceTokens.js';
22
+
23
+ function LoadingScreen() {
24
+ const tokens = useSurfaceTokens();
25
+ return (
26
+ <View
27
+ style={[styles.centered, { backgroundColor: tokens.colorGroupedBackground }]}
28
+ testID="shell-loading"
29
+ >
30
+ <ActivityIndicator color={tokens.colorSecondaryLabel} />
31
+ </View>
32
+ );
33
+ }
34
+
35
+ export default LoadingScreen;
@@ -0,0 +1,121 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import React, { useState } from 'react';
18
+ import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
19
+ import { router } from 'expo-router';
20
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
21
+ import { Icon } from '@lowdefy/client-native';
22
+
23
+ import { useSessionState } from '../ShellContext.js';
24
+ import useSurfaceTokens from './useSurfaceTokens.js';
25
+
26
+ const styles = StyleSheet.create({
27
+ button: {
28
+ paddingHorizontal: 8,
29
+ paddingVertical: 4,
30
+ },
31
+ buttonText: {
32
+ fontSize: 16,
33
+ },
34
+ backdrop: {
35
+ flex: 1,
36
+ flexDirection: 'row',
37
+ backgroundColor: 'rgba(0,0,0,0.3)',
38
+ },
39
+ sheet: {
40
+ width: '80%',
41
+ maxWidth: 360,
42
+ },
43
+ item: {
44
+ alignItems: 'center',
45
+ columnGap: 16,
46
+ flexDirection: 'row',
47
+ paddingHorizontal: 24,
48
+ paddingVertical: 16,
49
+ },
50
+ itemText: {
51
+ fontSize: 17,
52
+ },
53
+ });
54
+
55
+ // The drawer frame: a header button that opens the app's drawer menu over the screen, each item
56
+ // with its icon drawn from the icon data the root carries. Picking an item replaces the stack with
57
+ // that page.
58
+ function MenuButton({ items }) {
59
+ const [open, setOpen] = useState(false);
60
+ const { root } = useSessionState();
61
+ const insets = useSafeAreaInsets();
62
+ const tokens = useSurfaceTokens();
63
+ function openPage(pageId) {
64
+ setOpen(false);
65
+ const path = root.pageIndex.find((entry) => entry.pageId === pageId)?.path;
66
+ if (router.canDismiss()) router.dismissAll();
67
+ router.replace(`/${path}`);
68
+ }
69
+ return (
70
+ <>
71
+ <Pressable
72
+ accessibilityLabel="Menu"
73
+ accessibilityRole="button"
74
+ onPress={() => setOpen(true)}
75
+ style={styles.button}
76
+ testID="shell-menu"
77
+ >
78
+ <Text style={[styles.buttonText, { color: tokens.colorTint }]}>Menu</Text>
79
+ </Pressable>
80
+ <Modal animationType="fade" onRequestClose={() => setOpen(false)} transparent visible={open}>
81
+ <View style={styles.backdrop}>
82
+ <ScrollView
83
+ style={[
84
+ styles.sheet,
85
+ { backgroundColor: tokens.colorGroupedBackground, paddingTop: insets.top },
86
+ ]}
87
+ >
88
+ {items.map((item) => (
89
+ <Pressable
90
+ accessibilityRole="button"
91
+ key={item.pageId}
92
+ onPress={() => openPage(item.pageId)}
93
+ style={styles.item}
94
+ testID={`menu-${item.pageId}`}
95
+ >
96
+ {item.iconData ? (
97
+ <Icon
98
+ color={tokens.colorLabel}
99
+ data={item.iconData}
100
+ size={22}
101
+ testID={`menu-${item.pageId}-icon`}
102
+ />
103
+ ) : null}
104
+ <Text style={[styles.itemText, { color: tokens.colorLabel }]}>
105
+ {item.title ?? item.pageId}
106
+ </Text>
107
+ </Pressable>
108
+ ))}
109
+ </ScrollView>
110
+ <Pressable
111
+ accessibilityLabel="Close menu"
112
+ onPress={() => setOpen(false)}
113
+ style={{ flex: 1 }}
114
+ />
115
+ </View>
116
+ </Modal>
117
+ </>
118
+ );
119
+ }
120
+
121
+ export default MenuButton;