@applicaster/quick-brick-core 16.0.0-rc.99 → 17.0.0-rc.1
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/App/ActionSetters/index.ts +2 -2
- package/App/ActionsProvider/ActionsProvider.tsx +1 -1
- package/App/ActionsProvider/LegacyActionsRegistryAdapter.tsx +17 -1
- package/App/ActionsProvider/__tests__/LegacyActionsRegistryAdapter.test.tsx +64 -0
- package/App/AssetCache/logger.ts +1 -1
- package/App/DeepLinking/URLSchemeHandler/SchemeHandlerHooks/usePresentSchemeHandler.ts +3 -0
- package/App/DeepLinking/URLSchemeListener/URLSchemeContextProvider.tsx +4 -1
- package/App/DeepLinking/logger.ts +1 -1
- package/App/ErrorBoundary/index.tsx +5 -2
- package/App/NavigationProvider/NavigationProvider.tsx +17 -11
- package/App/NavigationProvider/__tests__/navToolbox.test.ts +227 -0
- package/App/NavigationProvider/__tests__/navigationProvider.test.tsx +56 -0
- package/App/NavigationProvider/navToolbox.ts +224 -0
- package/App/NavigationProvider/utils.ts +23 -2
- package/App/NetworkStatusProvider/NetworkStatusProvider.tsx +40 -4
- package/App/NetworkStatusProvider/ResumeVerificationController.ts +282 -0
- package/App/NetworkStatusProvider/__tests__/NetworkStatusProvider.test.tsx +167 -1
- package/App/NetworkStatusProvider/__tests__/ResumeVerificationController.test.ts +486 -0
- package/App/NetworkStatusProvider/__tests__/resumeVerification.test.ts +126 -0
- package/App/NetworkStatusProvider/__tests__/useResumeVerifiedNetInfo.test.ts +204 -0
- package/App/NetworkStatusProvider/resumeVerification.ts +54 -0
- package/App/NetworkStatusProvider/useResumeVerifiedNetInfo.ts +33 -0
- package/App/ThemeManager/index.tsx +6 -1
- package/App/appLifeCycleManager/index.tsx +2 -2
- package/App/appRemoteDataLoader/index.tsx +2 -2
- package/App/appRemoteDataLoader/remoteDataLoader.ts +2 -2
- package/App/index.tsx +8 -2
- package/App/layoutPreview/LayoutPreviewReminder.tsx +136 -0
- package/App/layoutPreview/__tests__/persistence.test.ts +63 -0
- package/App/layoutPreview/persistence.ts +228 -0
- package/App/logger.js +2 -2
- package/App/remoteContextReloader/__tests__/getNativeRemoteContextData.test.ts +148 -0
- package/App/remoteContextReloader/__tests__/getPromiseForType.test.js +31 -0
- package/App/remoteContextReloader/getRemoteContextData/getNativeRemoteContextData.ts +107 -29
- package/App/remoteContextReloader/helpers.ts +2 -1
- package/App/uiActions/index.ts +13 -0
- package/App/uiActions/muteUnmute/__tests__/assetResolver.test.ts +226 -0
- package/App/uiActions/muteUnmute/__tests__/binding.test.ts +51 -0
- package/App/uiActions/muteUnmute/__tests__/defaultAssets.test.ts +47 -0
- package/App/uiActions/muteUnmute/__tests__/entryState.test.ts +57 -0
- package/App/uiActions/muteUnmute/__tests__/labelResolver.test.ts +35 -0
- package/App/uiActions/muteUnmute/assetResolver.ts +87 -0
- package/App/uiActions/muteUnmute/defaultAssets.ts +44 -0
- package/App/uiActions/muteUnmute/entryState.ts +28 -0
- package/App/uiActions/muteUnmute/index.ts +79 -0
- package/App/uiActions/muteUnmute/labelResolver.ts +15 -0
- package/App/uiActions/muteUnmute/types.ts +18 -0
- package/package.json +8 -8
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { NativeModules } from "react-native";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SUPPRESSION_MS,
|
|
5
|
+
applyPersistedLayoutPreview,
|
|
6
|
+
leavePreviewForDefault,
|
|
7
|
+
shouldKeepOverride,
|
|
8
|
+
shouldShowReminder,
|
|
9
|
+
} from "../persistence";
|
|
10
|
+
|
|
11
|
+
const mockStore: Record<string, string> = {};
|
|
12
|
+
|
|
13
|
+
jest.mock(
|
|
14
|
+
"@applicaster/zapp-react-native-bridge/ZappStorage/LocalStorage",
|
|
15
|
+
() => ({
|
|
16
|
+
localStorage: {
|
|
17
|
+
getItem: async (key: string) => mockStore[key] ?? null,
|
|
18
|
+
setItem: async (key: string, value: string) => {
|
|
19
|
+
mockStore[key] = value;
|
|
20
|
+
},
|
|
21
|
+
removeItem: async (key: string) => {
|
|
22
|
+
delete mockStore[key];
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
describe("layout preview persistence", () => {
|
|
29
|
+
it("keeps a non-default layout id and drops the version default", () => {
|
|
30
|
+
expect(shouldKeepOverride("preview", "default")).toBe(true);
|
|
31
|
+
expect(shouldKeepOverride("default", "default")).toBe(false);
|
|
32
|
+
expect(shouldKeepOverride(null, "default")).toBe(false);
|
|
33
|
+
expect(shouldKeepOverride("preview", null)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("suppresses the reminder for two hours after acknowledgment", () => {
|
|
37
|
+
const now = 1_000_000;
|
|
38
|
+
|
|
39
|
+
expect(shouldShowReminder(true, null, now)).toBe(true);
|
|
40
|
+
expect(shouldShowReminder(false, null, now)).toBe(false);
|
|
41
|
+
|
|
42
|
+
expect(shouldShowReminder(true, now - SUPPRESSION_MS + 1, now)).toBe(false);
|
|
43
|
+
|
|
44
|
+
expect(shouldShowReminder(true, now - SUPPRESSION_MS, now)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps the version default for exactly one load after Back to Default", async () => {
|
|
48
|
+
const switchLayout = jest.fn(async () => true);
|
|
49
|
+
|
|
50
|
+
NativeModules.AppLoaderBridge = {
|
|
51
|
+
getDefaultLayoutId: async () => "default",
|
|
52
|
+
switchLayout,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
mockStore.layout_preview_id = "preview";
|
|
56
|
+
|
|
57
|
+
await leavePreviewForDefault();
|
|
58
|
+
|
|
59
|
+
expect(await applyPersistedLayoutPreview()).toBe("default");
|
|
60
|
+
expect(switchLayout).toHaveBeenLastCalledWith("default", null);
|
|
61
|
+
expect(await applyPersistedLayoutPreview()).toBe("absent");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { NativeModules } from "react-native";
|
|
2
|
+
import { localStorage } from "@applicaster/zapp-react-native-bridge/ZappStorage/LocalStorage";
|
|
3
|
+
|
|
4
|
+
// Separate from active_layout_id, which the switchLayout action owns.
|
|
5
|
+
export const LAYOUT_PREVIEW_ID_KEY = "layout_preview_id";
|
|
6
|
+
|
|
7
|
+
export const LAYOUT_PREVIEW_ACK_KEY = "layout_preview_ack_at";
|
|
8
|
+
|
|
9
|
+
// Set by Back to Default; makes the reload that follows skip layout selectors once.
|
|
10
|
+
export const LAYOUT_PREVIEW_RESTORE_DEFAULT_KEY =
|
|
11
|
+
"layout_preview_restore_default";
|
|
12
|
+
|
|
13
|
+
export const SUPPRESSION_MS = 2 * 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
export const PREVIEW_TITLE = "You're Viewing a Preview Layout";
|
|
16
|
+
|
|
17
|
+
export const PREVIEW_MESSAGE =
|
|
18
|
+
"This device is currently displaying a preview layout instead of the version default layout.";
|
|
19
|
+
|
|
20
|
+
export const PREVIEW_CONTINUE = "Continue Preview";
|
|
21
|
+
|
|
22
|
+
export const PREVIEW_BACK = "Back to Default Layout";
|
|
23
|
+
|
|
24
|
+
export const UNAVAILABLE_TITLE = "Preview layout unavailable";
|
|
25
|
+
|
|
26
|
+
export const UNAVAILABLE_MESSAGE =
|
|
27
|
+
"This device was returned to the version default layout.";
|
|
28
|
+
|
|
29
|
+
export type PreviewApplyResult = "applied" | "absent" | "invalid" | "default";
|
|
30
|
+
|
|
31
|
+
let unavailableNotice = false;
|
|
32
|
+
|
|
33
|
+
export function shouldKeepOverride(
|
|
34
|
+
layoutId: string | null | undefined,
|
|
35
|
+
defaultId: string | null | undefined
|
|
36
|
+
): boolean {
|
|
37
|
+
return Boolean(layoutId) && layoutId !== defaultId;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function shouldShowReminder(
|
|
41
|
+
hasOverride: boolean,
|
|
42
|
+
ackAt: number | null,
|
|
43
|
+
now: number
|
|
44
|
+
): boolean {
|
|
45
|
+
if (!hasOverride) return false;
|
|
46
|
+
if (ackAt == null || Number.isNaN(ackAt)) return true;
|
|
47
|
+
|
|
48
|
+
return now - ackAt >= SUPPRESSION_MS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function markPreviewUnavailable(): void {
|
|
52
|
+
unavailableNotice = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function consumePreviewUnavailable(): boolean {
|
|
56
|
+
const pending = unavailableNotice;
|
|
57
|
+
unavailableNotice = false;
|
|
58
|
+
|
|
59
|
+
return pending;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function removeKey(key: string): Promise<void> {
|
|
63
|
+
try {
|
|
64
|
+
await localStorage.removeItem(key);
|
|
65
|
+
} catch {
|
|
66
|
+
// key was already absent
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readOverrideId(): Promise<string | null> {
|
|
71
|
+
try {
|
|
72
|
+
const id = await localStorage.getItem(LAYOUT_PREVIEW_ID_KEY);
|
|
73
|
+
|
|
74
|
+
return id || null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function getDefaultLayoutId(): Promise<string | null> {
|
|
81
|
+
try {
|
|
82
|
+
const id = await NativeModules.AppLoaderBridge?.getDefaultLayoutId?.();
|
|
83
|
+
|
|
84
|
+
return id || null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function clearLayoutPreview(): Promise<void> {
|
|
91
|
+
await removeKey(LAYOUT_PREVIEW_ID_KEY);
|
|
92
|
+
await removeKey(LAYOUT_PREVIEW_ACK_KEY);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Drops the pin and points the loader at the version default.
|
|
97
|
+
* Used by Back to Default and by a preview link that targets the default layout.
|
|
98
|
+
*/
|
|
99
|
+
export async function returnToDefaultLayout(): Promise<void> {
|
|
100
|
+
const defaultId = await getDefaultLayoutId();
|
|
101
|
+
|
|
102
|
+
await clearLayoutPreview();
|
|
103
|
+
|
|
104
|
+
if (!defaultId || !NativeModules.AppLoaderBridge?.switchLayout) return;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
await NativeModules.AppLoaderBridge.switchLayout(defaultId, null);
|
|
108
|
+
} catch {
|
|
109
|
+
// the pin is already gone; the next load uses the version default
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Back to Default from the reminder. The app reloads next, and that load must stay on the version default. */
|
|
114
|
+
export async function leavePreviewForDefault(): Promise<void> {
|
|
115
|
+
await localStorage.setItem(LAYOUT_PREVIEW_RESTORE_DEFAULT_KEY, "true");
|
|
116
|
+
await returnToDefaultLayout();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Same as returnToDefaultLayout, plus a one-shot unavailable notice. */
|
|
120
|
+
export async function restoreInvalidPreview(): Promise<void> {
|
|
121
|
+
markPreviewUnavailable();
|
|
122
|
+
await returnToDefaultLayout();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Pins a layout id, or clears the pin when the id is the version default.
|
|
127
|
+
* A storage failure still leaves the in-session preview in place.
|
|
128
|
+
*/
|
|
129
|
+
export async function rememberLayoutPreview(layoutId: string): Promise<void> {
|
|
130
|
+
if (!layoutId) return;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const defaultId = await getDefaultLayoutId();
|
|
134
|
+
|
|
135
|
+
if (!shouldKeepOverride(layoutId, defaultId)) {
|
|
136
|
+
await returnToDefaultLayout();
|
|
137
|
+
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const current = await readOverrideId();
|
|
142
|
+
|
|
143
|
+
if (current !== layoutId) {
|
|
144
|
+
await removeKey(LAYOUT_PREVIEW_ACK_KEY);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await localStorage.setItem(LAYOUT_PREVIEW_ID_KEY, layoutId);
|
|
148
|
+
} catch {
|
|
149
|
+
// this session already loaded the layout; the pin is best-effort
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function acknowledgeLayoutPreview(): Promise<void> {
|
|
154
|
+
await localStorage.setItem(LAYOUT_PREVIEW_ACK_KEY, String(Date.now()));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function readReminderState(): Promise<{
|
|
158
|
+
hasOverride: boolean;
|
|
159
|
+
ackAt: number | null;
|
|
160
|
+
}> {
|
|
161
|
+
const layoutId = await readOverrideId();
|
|
162
|
+
const defaultId = await getDefaultLayoutId();
|
|
163
|
+
let ackRaw: string | null = null;
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
ackRaw = await localStorage.getItem(LAYOUT_PREVIEW_ACK_KEY);
|
|
167
|
+
} catch {
|
|
168
|
+
ackRaw = null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const ackAt = ackRaw == null || ackRaw === "" ? null : Number(ackRaw);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
hasOverride: shouldKeepOverride(layoutId, defaultId),
|
|
175
|
+
ackAt: ackAt != null && !Number.isNaN(ackAt) ? ackAt : null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Re-applies a stored preview before layout-manager selection.
|
|
181
|
+
* "invalid" means the pin was cleared and the version default was restored,
|
|
182
|
+
* so the caller must not run selectLayout over the top of that fallback.
|
|
183
|
+
*/
|
|
184
|
+
export async function applyPersistedLayoutPreview(): Promise<PreviewApplyResult> {
|
|
185
|
+
let restoreDefault = null;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
restoreDefault = await localStorage.getItem(
|
|
189
|
+
LAYOUT_PREVIEW_RESTORE_DEFAULT_KEY
|
|
190
|
+
);
|
|
191
|
+
} catch {
|
|
192
|
+
restoreDefault = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (restoreDefault) {
|
|
196
|
+
await removeKey(LAYOUT_PREVIEW_RESTORE_DEFAULT_KEY);
|
|
197
|
+
|
|
198
|
+
const defaultId = await getDefaultLayoutId();
|
|
199
|
+
|
|
200
|
+
if (defaultId) {
|
|
201
|
+
await NativeModules.AppLoaderBridge.switchLayout(defaultId, null);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return "default";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const layoutId = await readOverrideId();
|
|
208
|
+
|
|
209
|
+
if (!layoutId) return "absent";
|
|
210
|
+
|
|
211
|
+
const defaultId = await getDefaultLayoutId();
|
|
212
|
+
|
|
213
|
+
if (!shouldKeepOverride(layoutId, defaultId)) {
|
|
214
|
+
await clearLayoutPreview();
|
|
215
|
+
|
|
216
|
+
return "absent";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
await NativeModules.AppLoaderBridge.switchLayout(layoutId, null);
|
|
221
|
+
|
|
222
|
+
return "applied";
|
|
223
|
+
} catch {
|
|
224
|
+
await restoreInvalidPreview();
|
|
225
|
+
|
|
226
|
+
return "invalid";
|
|
227
|
+
}
|
|
228
|
+
}
|
package/App/logger.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { subsystemLogger } from "@applicaster/zapp-react-native-utils/logger";
|
|
2
2
|
|
|
3
|
-
export const coreAppLogger =
|
|
3
|
+
export const coreAppLogger = subsystemLogger("app");
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { loadAppContextData } from "@applicaster/zapp-react-native-redux";
|
|
2
|
+
import { getPromiseForType } from "../helpers";
|
|
3
|
+
import {
|
|
4
|
+
applyPersistedLayoutPreview,
|
|
5
|
+
restoreInvalidPreview,
|
|
6
|
+
} from "../../layoutPreview/persistence";
|
|
7
|
+
import { getNativeRemoteContextData } from "../getRemoteContextData/getNativeRemoteContextData";
|
|
8
|
+
|
|
9
|
+
jest.mock("@applicaster/zapp-react-native-redux", () => ({
|
|
10
|
+
...jest.requireActual("@applicaster/zapp-react-native-redux"),
|
|
11
|
+
loadAppContextData: jest.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
jest.mock("../helpers", () => ({
|
|
15
|
+
getPromiseForType: jest.fn(),
|
|
16
|
+
fetchPluginConfiguration: () => (plugin) => plugin,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
jest.mock("../../AssetCache", () => ({
|
|
20
|
+
cacheAssets: jest.fn(async (data) => data),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
jest.mock("../../layoutPreview/persistence", () => ({
|
|
24
|
+
applyPersistedLayoutPreview: jest.fn(),
|
|
25
|
+
restoreInvalidPreview: jest.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const mockedGetPromiseForType = getPromiseForType as jest.Mock;
|
|
29
|
+
|
|
30
|
+
const FILES = {
|
|
31
|
+
remoteConfigurations: { remoteConfigurations: {} },
|
|
32
|
+
pluginConfigurations: { pluginConfigurations: [] },
|
|
33
|
+
pipesEndpoints: { pipesEndpoints: {} },
|
|
34
|
+
layout: { layout: { api_version: "v2", content_types: {} } },
|
|
35
|
+
cellStyles: { cellStyles: { style: {} } },
|
|
36
|
+
presetsMapping: { presetsMapping: { preset: {} } },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const LAYOUT_FILES = ["layout", "cellStyles", "presetsMapping"];
|
|
40
|
+
|
|
41
|
+
/** Holds every request open until the test releases it. */
|
|
42
|
+
function deferredFiles(files = FILES) {
|
|
43
|
+
const pending: Record<string, () => void> = {};
|
|
44
|
+
|
|
45
|
+
mockedGetPromiseForType.mockImplementation(
|
|
46
|
+
(key: string) =>
|
|
47
|
+
new Promise((resolve, reject) => {
|
|
48
|
+
pending[key] = () =>
|
|
49
|
+
files[key] ? resolve(files[key]) : reject(new Error(key));
|
|
50
|
+
})
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return pending;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Keys of the app context handed to the store on the final load. */
|
|
57
|
+
const loadedKeys = () =>
|
|
58
|
+
Object.keys((loadAppContextData as jest.Mock).mock.calls.at(-1)[1]);
|
|
59
|
+
|
|
60
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
61
|
+
|
|
62
|
+
describe("getNativeRemoteContextData", () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
jest.clearAllMocks();
|
|
65
|
+
(applyPersistedLayoutPreview as jest.Mock).mockResolvedValue("absent");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("requests the layout files together, not one after another", async () => {
|
|
69
|
+
const pending = deferredFiles();
|
|
70
|
+
const dispatch = jest.fn();
|
|
71
|
+
|
|
72
|
+
const done = getNativeRemoteContextData(dispatch, {}, []);
|
|
73
|
+
|
|
74
|
+
await flush();
|
|
75
|
+
|
|
76
|
+
["remoteConfigurations", "pluginConfigurations", "pipesEndpoints"].forEach(
|
|
77
|
+
(key) => pending[key]()
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await flush();
|
|
81
|
+
|
|
82
|
+
expect(Object.keys(pending)).toEqual(expect.arrayContaining(LAYOUT_FILES));
|
|
83
|
+
|
|
84
|
+
LAYOUT_FILES.forEach((key) => pending[key]());
|
|
85
|
+
await done;
|
|
86
|
+
|
|
87
|
+
expect(loadedKeys()).toEqual(
|
|
88
|
+
expect.arrayContaining(["rivers", "cellStyles", "presetsMapping"])
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("tolerates a missing presetsMapping", async () => {
|
|
93
|
+
const { presetsMapping: _presetsMapping, ...withoutPresets } = FILES;
|
|
94
|
+
|
|
95
|
+
mockedGetPromiseForType.mockImplementation(async (key: string) => {
|
|
96
|
+
if (!withoutPresets[key]) throw new Error(key);
|
|
97
|
+
|
|
98
|
+
return withoutPresets[key];
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const dispatch = jest.fn();
|
|
102
|
+
|
|
103
|
+
await expect(
|
|
104
|
+
getNativeRemoteContextData(dispatch, {}, [])
|
|
105
|
+
).resolves.toBeUndefined();
|
|
106
|
+
|
|
107
|
+
expect(loadedKeys()).toContain("rivers");
|
|
108
|
+
expect(loadedKeys()).not.toContain("presetsMapping");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("rejects when cellStyles cannot be loaded", async () => {
|
|
112
|
+
const { cellStyles: _cellStyles, ...withoutCellStyles } = FILES;
|
|
113
|
+
|
|
114
|
+
mockedGetPromiseForType.mockImplementation(async (key: string) => {
|
|
115
|
+
if (!withoutCellStyles[key]) throw new Error(key);
|
|
116
|
+
|
|
117
|
+
return withoutCellStyles[key];
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await expect(getNativeRemoteContextData(jest.fn(), {}, [])).rejects.toThrow(
|
|
121
|
+
"cellStyles"
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("refetches every layout file after restoring a broken preview", async () => {
|
|
126
|
+
(applyPersistedLayoutPreview as jest.Mock).mockResolvedValue("applied");
|
|
127
|
+
|
|
128
|
+
let restored = false;
|
|
129
|
+
|
|
130
|
+
(restoreInvalidPreview as jest.Mock).mockImplementation(async () => {
|
|
131
|
+
restored = true;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
mockedGetPromiseForType.mockImplementation(async (key: string) => {
|
|
135
|
+
if (key === "layout" && !restored) throw new Error("preview layout");
|
|
136
|
+
|
|
137
|
+
return FILES[key];
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await getNativeRemoteContextData(jest.fn(), {}, []);
|
|
141
|
+
|
|
142
|
+
const requests = mockedGetPromiseForType.mock.calls.map(([key]) => key);
|
|
143
|
+
|
|
144
|
+
LAYOUT_FILES.forEach((key) => {
|
|
145
|
+
expect(requests.filter((request) => request === key)).toHaveLength(2);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const { NativeModules } = require("react-native");
|
|
2
|
+
|
|
3
|
+
NativeModules.AppLoaderBridge = { getFile: jest.fn() };
|
|
4
|
+
|
|
5
|
+
const { getPromiseForType } = require("../helpers");
|
|
6
|
+
|
|
7
|
+
const layout = { id: "layout", content_types: {} };
|
|
8
|
+
|
|
9
|
+
describe("getPromiseForType", () => {
|
|
10
|
+
it("parses the JSON string native bridges resolve", async () => {
|
|
11
|
+
NativeModules.AppLoaderBridge.getFile.mockResolvedValue(
|
|
12
|
+
JSON.stringify(layout)
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
await expect(getPromiseForType("layout")).resolves.toEqual({ layout });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("passes through the parsed file the web bridge resolves", async () => {
|
|
19
|
+
NativeModules.AppLoaderBridge.getFile.mockResolvedValue(layout);
|
|
20
|
+
|
|
21
|
+
await expect(getPromiseForType("layout")).resolves.toEqual({ layout });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects when the bridge fails", async () => {
|
|
25
|
+
NativeModules.AppLoaderBridge.getFile.mockRejectedValue(new Error("404"));
|
|
26
|
+
|
|
27
|
+
await expect(getPromiseForType("layout")).rejects.toThrow(
|
|
28
|
+
"cannot retrieve configuration data for layout"
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import * as R from "ramda";
|
|
2
|
-
import isEmpty from "lodash/isEmpty";
|
|
3
2
|
import { Dimensions, NativeModules } from "react-native";
|
|
4
3
|
import {
|
|
5
4
|
loadAppContextData,
|
|
@@ -7,6 +6,7 @@ import {
|
|
|
7
6
|
} from "@applicaster/zapp-react-native-redux";
|
|
8
7
|
import { isTablet } from "@applicaster/zapp-react-native-utils/reactHooks";
|
|
9
8
|
import { mapPromises } from "@applicaster/zapp-react-native-utils/arrayUtils";
|
|
9
|
+
import { isEmpty } from "@applicaster/zapp-react-native-utils/utils";
|
|
10
10
|
import { fetchPluginConfiguration, getPromiseForType } from "../helpers";
|
|
11
11
|
import { cacheAssets } from "../../AssetCache";
|
|
12
12
|
import { logger } from "@applicaster/zapp-react-native-utils/logger";
|
|
@@ -14,6 +14,83 @@ import { ACTIVE_LAYOUT_ID_STORAGE_KEY } from "../consts";
|
|
|
14
14
|
const { AppLoaderBridge } = NativeModules;
|
|
15
15
|
import { localStorage } from "@applicaster/zapp-react-native-bridge/ZappStorage/LocalStorage";
|
|
16
16
|
import { useErrorStore } from "../../ErrorBoundary/store";
|
|
17
|
+
import {
|
|
18
|
+
PreviewApplyResult,
|
|
19
|
+
applyPersistedLayoutPreview,
|
|
20
|
+
restoreInvalidPreview,
|
|
21
|
+
} from "../../layoutPreview/persistence";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Points the loader at the layout to fetch. A persisted device preview wins
|
|
25
|
+
* over the layout-manager plugin (experiment, geo, language) and over active_layout_id.
|
|
26
|
+
*/
|
|
27
|
+
async function selectActiveLayout(
|
|
28
|
+
runtimeConfigurationUrls,
|
|
29
|
+
plugins,
|
|
30
|
+
isTabletDevice: boolean
|
|
31
|
+
): Promise<PreviewApplyResult> {
|
|
32
|
+
let preview: PreviewApplyResult = "absent";
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
preview = await applyPersistedLayoutPreview();
|
|
36
|
+
|
|
37
|
+
if (preview !== "absent") return preview;
|
|
38
|
+
|
|
39
|
+
const layoutManagerPlugin = R.find(
|
|
40
|
+
R.pathSatisfies(R.is(Function), ["module", "selectLayout"])
|
|
41
|
+
)(plugins);
|
|
42
|
+
|
|
43
|
+
if (layoutManagerPlugin) {
|
|
44
|
+
await layoutManagerPlugin.module?.selectLayout({
|
|
45
|
+
runtimeConfigurationUrls,
|
|
46
|
+
plugin: layoutManagerPlugin,
|
|
47
|
+
isTablet: isTabletDevice,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return preview;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const layoutId = await localStorage.getItem(ACTIVE_LAYOUT_ID_STORAGE_KEY);
|
|
54
|
+
|
|
55
|
+
if (layoutId) {
|
|
56
|
+
await AppLoaderBridge?.switchLayout(layoutId, null);
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
logger.error({
|
|
60
|
+
message: "Error selecting layout using layout manager plugin",
|
|
61
|
+
data: error,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return preview;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fetches the files that belong to the selected layout. They all derive from
|
|
70
|
+
* the same layout id, so they are requested together rather than one round
|
|
71
|
+
* trip after another. A missing presetsMapping is tolerated; a failing layout
|
|
72
|
+
* or cellStyles request rejects, as it did when they were fetched in sequence.
|
|
73
|
+
*/
|
|
74
|
+
type Settled<T> =
|
|
75
|
+
| { status: "fulfilled"; value: T }
|
|
76
|
+
| { status: "rejected"; reason: unknown };
|
|
77
|
+
|
|
78
|
+
// Promise.allSettled is not guaranteed on older TV engines, so settle by hand
|
|
79
|
+
const settle = <T>(promise: Promise<T>): Promise<Settled<T>> =>
|
|
80
|
+
promise.then(
|
|
81
|
+
(value) => ({ status: "fulfilled", value }),
|
|
82
|
+
(reason) => ({ status: "rejected", reason })
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
async function fetchLayoutFiles() {
|
|
86
|
+
const [layout, cellStyles, presetsMapping] = await Promise.all([
|
|
87
|
+
settle(getPromiseForType<any>("layout")),
|
|
88
|
+
settle(getPromiseForType<any>("cellStyles")),
|
|
89
|
+
settle(getPromiseForType<any>("presetsMapping")),
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
return { layout, cellStyles, presetsMapping };
|
|
93
|
+
}
|
|
17
94
|
|
|
18
95
|
export async function getNativeRemoteContextData(
|
|
19
96
|
dispatch,
|
|
@@ -35,32 +112,32 @@ export async function getNativeRemoteContextData(
|
|
|
35
112
|
|
|
36
113
|
loadAppContextData(dispatch, R.mergeAll([...generalConfiguration]));
|
|
37
114
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
115
|
+
const preview = await selectActiveLayout(
|
|
116
|
+
runtimeConfigurationUrls,
|
|
117
|
+
plugins,
|
|
118
|
+
isTabletDevice
|
|
119
|
+
);
|
|
42
120
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
isTablet: isTabletDevice,
|
|
48
|
-
});
|
|
49
|
-
} else {
|
|
50
|
-
const layoutId = await localStorage.getItem(ACTIVE_LAYOUT_ID_STORAGE_KEY);
|
|
121
|
+
let layoutFiles = await fetchLayoutFiles();
|
|
122
|
+
|
|
123
|
+
if (layoutFiles.layout.status === "rejected") {
|
|
124
|
+
if (preview !== "applied") throw layoutFiles.layout.reason;
|
|
51
125
|
|
|
52
|
-
if (layoutId) {
|
|
53
|
-
await AppLoaderBridge?.switchLayout(layoutId, null);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
} catch (error) {
|
|
57
126
|
logger.error({
|
|
58
|
-
message: "
|
|
59
|
-
data:
|
|
127
|
+
message: "Persisted preview layout failed to load",
|
|
128
|
+
data: layoutFiles.layout.reason,
|
|
60
129
|
});
|
|
130
|
+
|
|
131
|
+
// The files fetched so far belong to the broken preview layout
|
|
132
|
+
await restoreInvalidPreview();
|
|
133
|
+
layoutFiles = await fetchLayoutFiles();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (layoutFiles.layout.status === "rejected") {
|
|
137
|
+
throw layoutFiles.layout.reason;
|
|
61
138
|
}
|
|
62
139
|
|
|
63
|
-
const layout =
|
|
140
|
+
const layout = layoutFiles.layout.value;
|
|
64
141
|
|
|
65
142
|
const layoutLoadingErrorMessage = "Failed to load layout data";
|
|
66
143
|
|
|
@@ -78,16 +155,17 @@ export async function getNativeRemoteContextData(
|
|
|
78
155
|
|
|
79
156
|
dispatch(AppData.actions.merge({ layoutVersion: apiVersion || "v1" }));
|
|
80
157
|
|
|
81
|
-
|
|
158
|
+
if (layoutFiles.cellStyles.status === "rejected") {
|
|
159
|
+
throw layoutFiles.cellStyles.reason;
|
|
160
|
+
}
|
|
82
161
|
|
|
83
|
-
|
|
162
|
+
const resolvedPromises = [layoutFiles.cellStyles.value];
|
|
84
163
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
164
|
+
// don't break loading sequence if presetsMapping does not exist
|
|
165
|
+
const optionalResolvedPromise =
|
|
166
|
+
layoutFiles.presetsMapping.status === "fulfilled"
|
|
167
|
+
? layoutFiles.presetsMapping.value
|
|
168
|
+
: undefined;
|
|
91
169
|
|
|
92
170
|
const remoteData = R.mergeAll([
|
|
93
171
|
...resolvedPromises,
|
|
@@ -119,8 +119,9 @@ export async function getPromiseForType<T = FileResponse>(
|
|
|
119
119
|
try {
|
|
120
120
|
const response = await AppLoaderBridge?.getFile(key, null);
|
|
121
121
|
|
|
122
|
+
// Native bridges resolve a JSON string; the web bridge resolves the parsed file.
|
|
122
123
|
return {
|
|
123
|
-
[key]: JSON.parse(response),
|
|
124
|
+
[key]: typeof response === "string" ? JSON.parse(response) : response,
|
|
124
125
|
} as T;
|
|
125
126
|
} catch (e) {
|
|
126
127
|
throw new Error("cannot retrieve configuration data for " + key);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { isTV } from "@applicaster/zapp-react-native-utils/reactUtils";
|
|
2
|
+
|
|
3
|
+
import { registerMuteUnmuteUIAction } from "./muteUnmute";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* App-wide UI actions owned by QuickBrick, registered once at startup.
|
|
7
|
+
* Actions scoped to a live object (e.g. player speed) register with their owner.
|
|
8
|
+
*/
|
|
9
|
+
export const registerCoreUIActions = (): (() => void) => {
|
|
10
|
+
const unregisters = isTV() ? [] : [registerMuteUnmuteUIAction()];
|
|
11
|
+
|
|
12
|
+
return () => unregisters.forEach((unregister) => unregister());
|
|
13
|
+
};
|