@applicaster/quick-brick-core 16.0.0-rc.123 → 16.0.0-rc.125

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.
@@ -28,6 +28,7 @@ import {
28
28
 
29
29
  import { log_error, log_info } from "../../logger";
30
30
  import { useRivers } from "@applicaster/zapp-react-native-utils/reactHooks/state";
31
+ import { rememberLayoutPreview } from "../../../layoutPreview/persistence";
31
32
 
32
33
  const getFeed = (query) => ({
33
34
  content: { src: query.data_source },
@@ -155,6 +156,8 @@ export function usePresentSchemeHandler({
155
156
  rivers_configuration_id
156
157
  );
157
158
 
159
+ await rememberLayoutPreview(rivers_configuration_id);
160
+
158
161
  onFinish((done) => {
159
162
  actions?.setAppReady();
160
163
  navigator.replace(homeRiver);
package/App/index.tsx CHANGED
@@ -32,6 +32,7 @@ import { withScreenDataContextProvider } from "@applicaster/zapp-react-native-ui
32
32
  import { LogicAggregatorContainer } from "./LogicAggregatorContainer";
33
33
  import { LoadingOverlay } from "./LoadingOverlay";
34
34
  import { NotificationToastRenderer } from "./NotificationToastRenderer";
35
+ import { LayoutPreviewReminder } from "./layoutPreview/LayoutPreviewReminder";
35
36
  import { ZappAppWrapper } from "./components/ZappAppWrapper";
36
37
  import { registerCoreUIActions } from "./uiActions";
37
38
 
@@ -82,6 +83,7 @@ const App = ({
82
83
  <ModalProvider />
83
84
  </URLSchemeListener>
84
85
  <LoadingOverlay />
86
+ <LayoutPreviewReminder />
85
87
  </ErrorBoundary>
86
88
  <LogicAggregatorContainer
87
89
  components={React.useMemo(
@@ -0,0 +1,136 @@
1
+ import * as React from "react";
2
+ import { AppState, Platform } from "react-native";
3
+
4
+ import { useAppSelector } from "@applicaster/zapp-react-native-redux/hooks";
5
+ import { selectAppReady } from "@applicaster/zapp-react-native-redux";
6
+ import {
7
+ showAlertDialog,
8
+ showConfirmationDialog,
9
+ } from "@applicaster/zapp-react-native-utils/alertUtils";
10
+ import { confirmationDialogStore } from "@applicaster/zapp-react-native-ui-components/Contexts/ConfirmDialogState";
11
+ import { isWeb } from "@applicaster/zapp-react-native-utils/reactUtils";
12
+ import * as QuickBrickManager from "@applicaster/zapp-react-native-bridge/QuickBrick";
13
+ import { QUICK_BRICK_EVENTS } from "@applicaster/zapp-react-native-bridge/QuickBrick";
14
+ import { notificationToastManager } from "../NotificationToastRenderer/NotificationToastManager";
15
+ import {
16
+ PREVIEW_BACK,
17
+ PREVIEW_CONTINUE,
18
+ PREVIEW_MESSAGE,
19
+ PREVIEW_TITLE,
20
+ UNAVAILABLE_MESSAGE,
21
+ UNAVAILABLE_TITLE,
22
+ acknowledgeLayoutPreview,
23
+ consumePreviewUnavailable,
24
+ leavePreviewForDefault,
25
+ readReminderState,
26
+ shouldShowReminder,
27
+ } from "./persistence";
28
+
29
+ let promptOpen = false;
30
+ let checkingPrompt = false;
31
+
32
+ function showUnavailableNotice(): void {
33
+ if (Platform.isTV && !isWeb()) {
34
+ showAlertDialog({
35
+ title: UNAVAILABLE_TITLE,
36
+ message: UNAVAILABLE_MESSAGE,
37
+ okButtonText: "OK",
38
+ });
39
+
40
+ return;
41
+ }
42
+
43
+ notificationToastManager.showToast({
44
+ message: UNAVAILABLE_TITLE,
45
+ extraMessage: UNAVAILABLE_MESSAGE,
46
+ });
47
+ }
48
+
49
+ function showPreviewDialog(): void {
50
+ const onContinue = () => {
51
+ promptOpen = false;
52
+ void acknowledgeLayoutPreview();
53
+ };
54
+
55
+ const onBack = () => {
56
+ promptOpen = false;
57
+
58
+ void leavePreviewForDefault().finally(() => {
59
+ QuickBrickManager.sendQuickBrickEvent(
60
+ QUICK_BRICK_EVENTS.FORCE_APP_RELOAD
61
+ );
62
+ });
63
+ };
64
+
65
+ showConfirmationDialog({
66
+ title: PREVIEW_TITLE,
67
+ message: PREVIEW_MESSAGE,
68
+ cancelButtonText: PREVIEW_CONTINUE,
69
+ okButtonText: PREVIEW_BACK,
70
+ cancelCompletion: onContinue,
71
+ confirmCompletion: onBack,
72
+ });
73
+
74
+ if (!isWeb()) return;
75
+
76
+ // Remote Back hides the web dialog without calling either completion.
77
+ const unsubscribe = confirmationDialogStore.subscribe((state) => {
78
+ if (state.isDialogVisible) return;
79
+
80
+ promptOpen = false;
81
+ unsubscribe();
82
+ });
83
+ }
84
+
85
+ async function presentLayoutPreviewPrompt(): Promise<void> {
86
+ if (promptOpen || checkingPrompt) return;
87
+
88
+ checkingPrompt = true;
89
+
90
+ try {
91
+ if (consumePreviewUnavailable()) {
92
+ showUnavailableNotice();
93
+
94
+ return;
95
+ }
96
+
97
+ const { hasOverride, ackAt } = await readReminderState();
98
+
99
+ if (!shouldShowReminder(hasOverride, ackAt, Date.now())) return;
100
+
101
+ promptOpen = true;
102
+ showPreviewDialog();
103
+ } finally {
104
+ checkingPrompt = false;
105
+ }
106
+ }
107
+
108
+ export function LayoutPreviewReminder(): null {
109
+ const appReady = useAppSelector(selectAppReady);
110
+ const checkedForeground = React.useRef(false);
111
+
112
+ React.useEffect(() => {
113
+ if (!appReady) return;
114
+
115
+ const showIfNeeded = () => {
116
+ if (checkedForeground.current) return;
117
+
118
+ checkedForeground.current = true;
119
+ void presentLayoutPreviewPrompt();
120
+ };
121
+
122
+ showIfNeeded();
123
+
124
+ const subscription = AppState.addEventListener("change", (state) => {
125
+ if (state === "background") {
126
+ checkedForeground.current = false;
127
+ }
128
+
129
+ if (state === "active") showIfNeeded();
130
+ });
131
+
132
+ return () => subscription.remove();
133
+ }, [appReady]);
134
+
135
+ return null;
136
+ }
@@ -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
+ }
@@ -14,6 +14,56 @@ 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
+ }
17
67
 
18
68
  export async function getNativeRemoteContextData(
19
69
  dispatch,
@@ -35,32 +85,27 @@ export async function getNativeRemoteContextData(
35
85
 
36
86
  loadAppContextData(dispatch, R.mergeAll([...generalConfiguration]));
37
87
 
38
- try {
39
- const layoutManagerPlugin = R.find(
40
- R.pathSatisfies(R.is(Function), ["module", "selectLayout"])
41
- )(plugins);
88
+ const preview = await selectActiveLayout(
89
+ runtimeConfigurationUrls,
90
+ plugins,
91
+ isTabletDevice
92
+ );
42
93
 
43
- if (layoutManagerPlugin) {
44
- await layoutManagerPlugin.module?.selectLayout({
45
- runtimeConfigurationUrls,
46
- plugin: layoutManagerPlugin,
47
- isTablet: isTabletDevice,
48
- });
49
- } else {
50
- const layoutId = await localStorage.getItem(ACTIVE_LAYOUT_ID_STORAGE_KEY);
94
+ let layout;
51
95
 
52
- if (layoutId) {
53
- await AppLoaderBridge?.switchLayout(layoutId, null);
54
- }
55
- }
96
+ try {
97
+ layout = await getPromiseForType("layout");
56
98
  } catch (error) {
99
+ if (preview !== "applied") throw error;
100
+
57
101
  logger.error({
58
- message: "Error selecting layout using layout manager plugin",
102
+ message: "Persisted preview layout failed to load",
59
103
  data: error,
60
104
  });
61
- }
62
105
 
63
- const layout = await getPromiseForType("layout");
106
+ await restoreInvalidPreview();
107
+ layout = await getPromiseForType("layout");
108
+ }
64
109
 
65
110
  const layoutLoadingErrorMessage = "Failed to load layout data";
66
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/quick-brick-core",
3
- "version": "16.0.0-rc.123",
3
+ "version": "16.0.0-rc.125",
4
4
  "description": "Core package for Applicaster's Quick Brick App",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -28,13 +28,13 @@
28
28
  },
29
29
  "homepage": "https://github.com/applicaster/quickbrick#readme",
30
30
  "dependencies": {
31
- "@applicaster/applicaster-types": "16.0.0-rc.123",
32
- "@applicaster/quick-brick-core-plugins": "16.0.0-rc.123",
33
- "@applicaster/zapp-pipes-v2-client": "16.0.0-rc.123",
34
- "@applicaster/zapp-react-native-bridge": "16.0.0-rc.123",
35
- "@applicaster/zapp-react-native-redux": "16.0.0-rc.123",
36
- "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.123",
37
- "@applicaster/zapp-react-native-utils": "16.0.0-rc.123",
31
+ "@applicaster/applicaster-types": "16.0.0-rc.125",
32
+ "@applicaster/quick-brick-core-plugins": "16.0.0-rc.125",
33
+ "@applicaster/zapp-pipes-v2-client": "16.0.0-rc.125",
34
+ "@applicaster/zapp-react-native-bridge": "16.0.0-rc.125",
35
+ "@applicaster/zapp-react-native-redux": "16.0.0-rc.125",
36
+ "@applicaster/zapp-react-native-ui-components": "16.0.0-rc.125",
37
+ "@applicaster/zapp-react-native-utils": "16.0.0-rc.125",
38
38
  "atob": "^2.1.2",
39
39
  "axios": "^0.28.0",
40
40
  "btoa": "^1.2.1",