@lowdefy/shell 0.0.0-experimental-20261019052246
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.
- package/LICENSE +201 -0
- package/README.md +308 -0
- package/app/(t0,t1,t2,t3,t4)/[...path].js +38 -0
- package/app/(t0,t1,t2,t3,t4)/_layout.js +48 -0
- package/app/(t0,t1,t2,t3,t4)/index.js +38 -0
- package/app/+native-intent.js +30 -0
- package/app/_layout.js +33 -0
- package/app.config.ts +168 -0
- package/applyShellPlugins.js +43 -0
- package/authCallbackIntentFilters.js +46 -0
- package/babel.config.js +25 -0
- package/checkShellConfig.js +145 -0
- package/checkShellPlugins.js +74 -0
- package/getPrivacyManifest.js +39 -0
- package/metro.config.js +73 -0
- package/native-fingerprint.json +12 -0
- package/package.json +130 -0
- package/profiles.js +274 -0
- package/readShellConfig.js +36 -0
- package/resolveShellPlugins.js +109 -0
- package/runtimeVersion.js +105 -0
- package/scripts/checkReleaseBundle.mjs +358 -0
- package/scripts/createDayjsLocales.mjs +67 -0
- package/scripts/createShellAssets.mjs +54 -0
- package/scripts/createShellVersion.mjs +83 -0
- package/scripts/createTypeManifest.mjs +205 -0
- package/shell.dev.json +11 -0
- package/shellVariant.js +111 -0
- package/src/ShellContext.js +32 -0
- package/src/actions/CopyToClipboard.js +29 -0
- package/src/actions/CopyToClipboard.test.js +33 -0
- package/src/actions/GeolocationCurrentPosition.js +55 -0
- package/src/actions/GeolocationCurrentPosition.test.js +66 -0
- package/src/actions/GetLocalStorage.js +36 -0
- package/src/actions/RemoveLocalStorage.js +31 -0
- package/src/actions/ScrollTo.js +66 -0
- package/src/actions/ScrollTo.test.js +83 -0
- package/src/actions/SetFocus.js +33 -0
- package/src/actions/SetFocus.test.js +49 -0
- package/src/actions/SetLocalStorage.js +34 -0
- package/src/actions/localStorage.test.js +68 -0
- package/src/actions/measureInScrollView.js +36 -0
- package/src/actions/nativeActions.js +41 -0
- package/src/auth/AuthButton.js +44 -0
- package/src/auth/AuthGate.js +57 -0
- package/src/auth/AuthLink.js +43 -0
- package/src/auth/LockScreen.js +67 -0
- package/src/auth/LockScreen.test.js +37 -0
- package/src/auth/SignInScreen.js +256 -0
- package/src/auth/SignInScreen.test.js +182 -0
- package/src/auth/createAppAuth.js +306 -0
- package/src/auth/createAppAuth.test.js +414 -0
- package/src/auth/createAuthApi.js +99 -0
- package/src/auth/createAuthApi.test.js +135 -0
- package/src/auth/createCoreAuth.js +96 -0
- package/src/auth/createCoreAuth.test.js +188 -0
- package/src/auth/createSessionStores.js +45 -0
- package/src/auth/createTokenStore.js +103 -0
- package/src/auth/createTokenStore.test.js +116 -0
- package/src/auth/getAuthSettings.js +24 -0
- package/src/auth/getAuthView.js +39 -0
- package/src/auth/getAuthView.test.js +52 -0
- package/src/auth/getSheetReturnUrl.js +35 -0
- package/src/auth/getTokenStoreOptions.js +46 -0
- package/src/auth/isBackgroundShell.js +26 -0
- package/src/auth/isSheetReturnLink.js +35 -0
- package/src/auth/parseQuery.js +39 -0
- package/src/auth/purgeAppData.js +45 -0
- package/src/auth/shouldLock.js +26 -0
- package/src/auth/signInWithSheet.js +97 -0
- package/src/auth/signInWithSheet.test.js +205 -0
- package/src/auth/styles.js +78 -0
- package/src/auth/unregisterPushToken.js +21 -0
- package/src/auth/usesBiometricUnlock.js +23 -0
- package/src/auth/watchSession.js +47 -0
- package/src/auth/withoutCookies.js +26 -0
- package/src/cache/createSqliteCacheStore.js +113 -0
- package/src/cache/createSqliteCacheStore.test.js +93 -0
- package/src/cache/getCacheKey.js +25 -0
- package/src/cache/getLastCacheKeyName.js +22 -0
- package/src/components/AppProvider.js +90 -0
- package/src/components/Frame.js +116 -0
- package/src/components/Frame.test.js +101 -0
- package/src/components/LoadingScreen.js +35 -0
- package/src/components/MenuButton.js +108 -0
- package/src/components/MenuButton.test.js +51 -0
- package/src/components/MessageScreen.js +51 -0
- package/src/components/NavigationTheme.js +33 -0
- package/src/components/NoConnectionBanner.js +51 -0
- package/src/components/NoConnectionBanner.test.js +73 -0
- package/src/components/OtherPageScreen.js +56 -0
- package/src/components/PageScreen.js +244 -0
- package/src/components/PageScreen.test.js +492 -0
- package/src/components/ShellRoot.js +34 -0
- package/src/components/ShellRoot.test.js +62 -0
- package/src/components/ShellThemeProvider.js +45 -0
- package/src/components/SwitchToTab.js +36 -0
- package/src/components/SwitchToTab.test.js +63 -0
- package/src/components/TabIconImages.js +72 -0
- package/src/components/UpdateScreen.js +36 -0
- package/src/components/getNavigationTheme.js +44 -0
- package/src/components/getNavigationTheme.test.js +41 -0
- package/src/components/styles.js +50 -0
- package/src/components/useSurfaceTokens.js +47 -0
- package/src/config/getAppApiBase.js +31 -0
- package/src/config/getShellConfig.js +35 -0
- package/src/config/getShellConfig.test.js +80 -0
- package/src/createShellApp.js +105 -0
- package/src/dev/DevShell.js +228 -0
- package/src/dev/DevShell.test.js +233 -0
- package/src/dev/connect/ConfirmSheet.js +71 -0
- package/src/dev/connect/ConnectScreen.js +117 -0
- package/src/dev/connect/QrScanner.js +89 -0
- package/src/dev/connection/createDevShellConfig.js +47 -0
- package/src/dev/connection/createDevTransport.js +51 -0
- package/src/dev/connection/createDevTransport.test.js +87 -0
- package/src/dev/connection/createServerList.js +48 -0
- package/src/dev/connection/isLoopbackHost.js +25 -0
- package/src/dev/connection/pairedShellApp.test.js +104 -0
- package/src/dev/connection/parseDevLink.js +66 -0
- package/src/dev/connection/parseDevLink.test.js +97 -0
- package/src/dev/connection/parseQuery.js +33 -0
- package/src/dev/connection/parseServerInput.js +40 -0
- package/src/dev/connection/parseServerUrl.js +45 -0
- package/src/dev/devStyles.js +97 -0
- package/src/dev/index.js +21 -0
- package/src/dev/inject/createDeviceInjections.js +185 -0
- package/src/dev/inject/createDeviceInjections.test.js +208 -0
- package/src/dev/inject/createOfflineFetch.js +35 -0
- package/src/dev/inject/createOfflineFetch.test.js +68 -0
- package/src/dev/menu/BlockGallery.js +33 -0
- package/src/dev/menu/DevMenu.js +121 -0
- package/src/dev/menu/createLogBuffer.js +83 -0
- package/src/dev/menu/watchShake.js +52 -0
- package/src/dev/menu/watchShake.test.js +61 -0
- package/src/dev/recorder/createNativeRecorder.js +204 -0
- package/src/dev/recorder/createNativeRecorder.test.js +307 -0
- package/src/dev/redirectDevLink.js +30 -0
- package/src/dev/redirectDevLink.test.js +49 -0
- package/src/dev/steps/createStepRunner.js +184 -0
- package/src/dev/steps/createStepRunner.test.js +262 -0
- package/src/dev/steps/findBlock.js +78 -0
- package/src/dev/steps/isContextSettled.js +37 -0
- package/src/dev/steps/waitForSettle.js +39 -0
- package/src/dev/steps/waitUntil.js +40 -0
- package/src/dev/tab/DevTabClient.js +97 -0
- package/src/dev/tab/answerEval.js +39 -0
- package/src/dev/tab/answerInspect.js +45 -0
- package/src/dev/tab/answerLoadState.js +50 -0
- package/src/dev/tab/createDevTabClient.js +277 -0
- package/src/dev/tab/createDevTabClient.test.js +314 -0
- package/src/dev/tab/devClientMarker.js +22 -0
- package/src/dev/tab/devClientMarker.test.js +23 -0
- package/src/dev/tab/findPageContext.js +43 -0
- package/src/dev/tab/getShownPage.js +42 -0
- package/src/dev/tab/readEventStream.js +68 -0
- package/src/dev/tab/reloadApp.js +42 -0
- package/src/dev/tab/reloadApp.test.js +115 -0
- package/src/device/injected.js +60 -0
- package/src/device/injected.test.js +39 -0
- package/src/host/createDeviceStore.js +32 -0
- package/src/host/createMemoryStore.js +32 -0
- package/src/host/createNetworkStatus.js +94 -0
- package/src/host/createNetworkStatus.test.js +94 -0
- package/src/host/createRouterAdapter.js +33 -0
- package/src/host/createShellFetch.js +60 -0
- package/src/host/createShellFetch.test.js +150 -0
- package/src/host/createShellGlobals.js +62 -0
- package/src/host/createShellHost.js +73 -0
- package/src/host/createShellHost.test.js +36 -0
- package/src/host/getOrigin.js +26 -0
- package/src/host/isOnline.js +22 -0
- package/src/host/networkStatus.js +24 -0
- package/src/host/withShellHeader.js +25 -0
- package/src/host/withShellHeader.test.js +28 -0
- package/src/js/evaluateJsEntries.js +43 -0
- package/src/js/evaluateJsEntries.test.js +48 -0
- package/src/locale/createShellLocale.js +112 -0
- package/src/locale/createShellLocale.test.js +74 -0
- package/src/locale/dayjsLocales.js +165 -0
- package/src/locale/getDeviceLocales.js +23 -0
- package/src/locale/loadDayjsLocale.js +32 -0
- package/src/locale/resolveActiveLocale.js +46 -0
- package/src/locale/resolveActiveLocale.test.js +40 -0
- package/src/navigation/SlotContext.js +24 -0
- package/src/navigation/frameSlots.js +27 -0
- package/src/navigation/getFrame.js +30 -0
- package/src/navigation/getInitialScreenOptions.js +63 -0
- package/src/navigation/getInitialScreenOptions.test.js +66 -0
- package/src/navigation/getPresentation.js +28 -0
- package/src/navigation/getSlotRootPath.js +27 -0
- package/src/navigation/getStackOptions.js +185 -0
- package/src/navigation/getTabHref.js +45 -0
- package/src/navigation/getTabIcons.js +43 -0
- package/src/navigation/isPageUnderHeader.js +46 -0
- package/src/navigation/navigation.test.js +342 -0
- package/src/navigation/toRouterHref.js +29 -0
- package/src/plugins/appTypes.js +28 -0
- package/src/plugins/prefixTypes.js +28 -0
- package/src/plugins/shellPlugins.json +14 -0
- package/src/plugins/typeManifest.js +649 -0
- package/src/plugins/types.js +61 -0
- package/src/plugins/types.test.js +173 -0
- package/src/polyfills/installGetRandomValues.js +28 -0
- package/src/session/createAppSession.js +318 -0
- package/src/session/createAppSession.test.js +359 -0
- package/src/session/getPageView.js +59 -0
- package/src/session/getPageView.test.js +137 -0
- package/src/sync/encodePagePath.js +27 -0
- package/src/sync/fetchPage.js +49 -0
- package/src/sync/fetchRoot.js +38 -0
- package/src/sync/fetchUser.js +28 -0
- package/src/sync/runLimited.js +36 -0
- package/src/sync/toRootConfig.js +35 -0
- package/src/test/createFakeNetwork.js +40 -0
- package/src/test/createFakeSecureStore.js +40 -0
- package/src/test/createFakeServer.js +98 -0
- package/src/test/createMemoryCacheStore.js +59 -0
- package/src/test/expoImage.js +38 -0
- package/src/test/expoSpeechRecognition.js +31 -0
- package/src/test/expoUi.js +55 -0
- package/src/test/expoUiBottomSheet.js +64 -0
- package/src/test/expoUiDateTimePicker.js +26 -0
- package/src/test/expoUiMenu.js +46 -0
- package/src/test/expoUiSegmentedControl.js +47 -0
- package/src/test/expoUiSwiftUi.js +62 -0
- package/src/test/expoUiSwiftUiModifiers.js +23 -0
- package/src/test/expoWebBrowser.js +23 -0
- package/src/test/fixtures.js +98 -0
- package/src/test/renderPageScreen.js +109 -0
- package/src/types/checkTypeSet.js +39 -0
- package/src/types/collectPageTypes.js +49 -0
- package/src/types/collectPageTypes.test.js +90 -0
- package/src/types/findMissingPageTypes.js +40 -0
- package/src/version/compareVersions.js +38 -0
- package/src/version/isShellTooOld.js +30 -0
- package/src/version/shellVersion.js +21 -0
- package/src/version/shellVersion.test.js +32 -0
- package/withExcludedNativeModules.js +93 -0
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
// Biometric unlock is on when the app asks for it, except on a shared device, which signs out on
|
|
18
|
+
// every close instead.
|
|
19
|
+
function usesBiometricUnlock(settings) {
|
|
20
|
+
return settings?.unlock === 'biometric' && settings.sharedDevice !== true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default usesBiometricUnlock;
|
|
@@ -0,0 +1,47 @@
|
|
|
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 { parseCallerStamp } from '@lowdefy/helpers';
|
|
18
|
+
|
|
19
|
+
function sentToken(headers) {
|
|
20
|
+
return Object.keys(headers ?? {}).some((name) => name.toLowerCase() === 'authorization');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The fetch the app's session and core call the server with, watching what the server says of
|
|
25
|
+
* the session token a call carried:
|
|
26
|
+
* - a 401, or an answer as the signed-out caller (an empty x-lowdefy-caller stamp): the session is
|
|
27
|
+
* gone (signed out elsewhere, ended by an admin, expired), so onSessionEnded;
|
|
28
|
+
* - a 403: this organisation refused the person (removed, or no longer allowed), so onRefused.
|
|
29
|
+
* A call that carried no token says nothing about a session.
|
|
30
|
+
*/
|
|
31
|
+
function watchSession({ fetch, onRefused, onSessionEnded }) {
|
|
32
|
+
return async function watchedFetch(url, init = {}) {
|
|
33
|
+
const res = await fetch(url, init);
|
|
34
|
+
if (!sentToken(init.headers)) {
|
|
35
|
+
return res;
|
|
36
|
+
}
|
|
37
|
+
const stamp = parseCallerStamp(res.headers.get('x-lowdefy-caller'));
|
|
38
|
+
if (res.status === 401 || stamp?.caller === '') {
|
|
39
|
+
onSessionEnded();
|
|
40
|
+
} else if (res.status === 403) {
|
|
41
|
+
onRefused();
|
|
42
|
+
}
|
|
43
|
+
return res;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default watchSession;
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
// Every call a shell makes leaves cookies alone. React Native's fetch keeps a cookie jar unless
|
|
18
|
+
// told not to, and a request carrying a Cookie is not a native one to the server: it would get
|
|
19
|
+
// no set-auth-token on sign-in. The session travels only as the bearer token.
|
|
20
|
+
function withoutCookies(fetch) {
|
|
21
|
+
return function cookielessFetch(url, init = {}) {
|
|
22
|
+
return fetch(url, { ...init, credentials: 'omit' });
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default withoutCookies;
|
|
@@ -0,0 +1,113 @@
|
|
|
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 page cache in SQLite: per cache key (getCacheKey), the mobile root with its ETag and the
|
|
18
|
+
// caller, and each page with its index hash and ETag. All of it is the server's answers kept as
|
|
19
|
+
// JSON; nothing here is shared between callers.
|
|
20
|
+
const SCHEMA = `
|
|
21
|
+
CREATE TABLE IF NOT EXISTS roots (
|
|
22
|
+
cache_key TEXT PRIMARY KEY NOT NULL,
|
|
23
|
+
root TEXT NOT NULL,
|
|
24
|
+
root_etag TEXT,
|
|
25
|
+
user TEXT
|
|
26
|
+
);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
28
|
+
cache_key TEXT NOT NULL,
|
|
29
|
+
page_id TEXT NOT NULL,
|
|
30
|
+
page TEXT NOT NULL,
|
|
31
|
+
PRIMARY KEY (cache_key, page_id)
|
|
32
|
+
);
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {Object} params
|
|
37
|
+
* @param {Object} params.db An expo-sqlite database (openDatabaseSync).
|
|
38
|
+
*/
|
|
39
|
+
function createSqliteCacheStore({ db }) {
|
|
40
|
+
let ready = null;
|
|
41
|
+
function init() {
|
|
42
|
+
if (ready === null) {
|
|
43
|
+
ready = db.execAsync(SCHEMA);
|
|
44
|
+
}
|
|
45
|
+
return ready;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
async read(cacheKey) {
|
|
50
|
+
await init();
|
|
51
|
+
const row = await db.getFirstAsync(
|
|
52
|
+
'SELECT root, root_etag, user FROM roots WHERE cache_key = ?',
|
|
53
|
+
[cacheKey]
|
|
54
|
+
);
|
|
55
|
+
if (row === null) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const pageRows = await db.getAllAsync('SELECT page_id, page FROM pages WHERE cache_key = ?', [
|
|
59
|
+
cacheKey,
|
|
60
|
+
]);
|
|
61
|
+
return {
|
|
62
|
+
root: JSON.parse(row.root),
|
|
63
|
+
rootEtag: row.root_etag,
|
|
64
|
+
user: JSON.parse(row.user),
|
|
65
|
+
pages: Object.fromEntries(pageRows.map((page) => [page.page_id, JSON.parse(page.page)])),
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
async writeRoot({ cacheKey, entry }) {
|
|
69
|
+
await init();
|
|
70
|
+
await db.runAsync(
|
|
71
|
+
'INSERT OR REPLACE INTO roots (cache_key, root, root_etag, user) VALUES (?, ?, ?, ?)',
|
|
72
|
+
[cacheKey, JSON.stringify(entry.root), entry.rootEtag ?? null, JSON.stringify(entry.user)]
|
|
73
|
+
);
|
|
74
|
+
},
|
|
75
|
+
async writePage({ cacheKey, pageId, page }) {
|
|
76
|
+
await init();
|
|
77
|
+
await db.runAsync(
|
|
78
|
+
'INSERT OR REPLACE INTO pages (cache_key, page_id, page) VALUES (?, ?, ?)',
|
|
79
|
+
[cacheKey, pageId, JSON.stringify(page)]
|
|
80
|
+
);
|
|
81
|
+
},
|
|
82
|
+
// One caller's cache: an organisation that refused the person.
|
|
83
|
+
async removeEntry({ cacheKey }) {
|
|
84
|
+
await init();
|
|
85
|
+
await db.runAsync('DELETE FROM roots WHERE cache_key = ?', [cacheKey]);
|
|
86
|
+
await db.runAsync('DELETE FROM pages WHERE cache_key = ?', [cacheKey]);
|
|
87
|
+
},
|
|
88
|
+
// Every caller's cache of one app (getCacheKey starts each key with the app id): sign-out.
|
|
89
|
+
async removeApp({ appId }) {
|
|
90
|
+
await init();
|
|
91
|
+
const prefix = `${appId}:`;
|
|
92
|
+
await db.runAsync('DELETE FROM roots WHERE substr(cache_key, 1, length(?)) = ?', [
|
|
93
|
+
prefix,
|
|
94
|
+
prefix,
|
|
95
|
+
]);
|
|
96
|
+
await db.runAsync('DELETE FROM pages WHERE substr(cache_key, 1, length(?)) = ?', [
|
|
97
|
+
prefix,
|
|
98
|
+
prefix,
|
|
99
|
+
]);
|
|
100
|
+
},
|
|
101
|
+
async removePages({ cacheKey, pageIds }) {
|
|
102
|
+
await init();
|
|
103
|
+
for (const pageId of pageIds) {
|
|
104
|
+
await db.runAsync('DELETE FROM pages WHERE cache_key = ? AND page_id = ?', [
|
|
105
|
+
cacheKey,
|
|
106
|
+
pageId,
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default createSqliteCacheStore;
|
|
@@ -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,90 @@
|
|
|
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 LoadingScreen from './LoadingScreen.js';
|
|
24
|
+
import MessageScreen from './MessageScreen.js';
|
|
25
|
+
import NoConnectionBanner from './NoConnectionBanner.js';
|
|
26
|
+
import ShellContext, { useSessionState } from '../ShellContext.js';
|
|
27
|
+
import ShellThemeProvider from './ShellThemeProvider.js';
|
|
28
|
+
|
|
29
|
+
function AppGate({ children }) {
|
|
30
|
+
const { status, error } = useSessionState();
|
|
31
|
+
const value = React.useContext(ShellContext);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (status !== 'loading') SplashScreen.hideAsync();
|
|
34
|
+
}, [status]);
|
|
35
|
+
if (status === 'loading') {
|
|
36
|
+
return <LoadingScreen />;
|
|
37
|
+
}
|
|
38
|
+
if (status === 'error') {
|
|
39
|
+
return (
|
|
40
|
+
<MessageScreen
|
|
41
|
+
action={{ title: 'Try again', onPress: () => value.session.start() }}
|
|
42
|
+
body={error?.message}
|
|
43
|
+
testID="shell-app-error"
|
|
44
|
+
title="This app could not be opened"
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return (
|
|
49
|
+
<View style={{ flex: 1 }}>
|
|
50
|
+
<NoConnectionBanner />
|
|
51
|
+
<ShellThemeProvider>{children}</ShellThemeProvider>
|
|
52
|
+
</View>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Opens the shell's app (shell.app): builds its auth, session and host, renders its last pages from
|
|
57
|
+
// the cache and syncs it with the app's server. A sign-out opens it afresh, with nothing of the
|
|
58
|
+
// person's kept.
|
|
59
|
+
function AppProvider({ children, shell }) {
|
|
60
|
+
const [value, setValue] = useState(null);
|
|
61
|
+
const [openings, setOpenings] = useState(0);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
let active = true;
|
|
64
|
+
let opened = null;
|
|
65
|
+
setValue(null);
|
|
66
|
+
const onSignedOut = () => setOpenings((count) => count + 1);
|
|
67
|
+
createShellApp({ onSignedOut, shell }).then(({ auth, host, locale, session }) => {
|
|
68
|
+
if (!active) return;
|
|
69
|
+
opened = auth;
|
|
70
|
+
setValue({ auth, host, locale, session, shell });
|
|
71
|
+
auth.start({ session });
|
|
72
|
+
});
|
|
73
|
+
return () => {
|
|
74
|
+
active = false;
|
|
75
|
+
opened?.dispose();
|
|
76
|
+
};
|
|
77
|
+
}, [openings, shell]);
|
|
78
|
+
if (value === null) {
|
|
79
|
+
return <LoadingScreen />;
|
|
80
|
+
}
|
|
81
|
+
return (
|
|
82
|
+
<ShellContext.Provider value={value}>
|
|
83
|
+
<AuthGate>
|
|
84
|
+
<AppGate>{children}</AppGate>
|
|
85
|
+
</AuthGate>
|
|
86
|
+
</ShellContext.Provider>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
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
|
+
});
|