@applicaster/zapp-react-native-utils 16.0.0-alpha.1212303401 → 16.0.0-alpha.1263110541

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 (30) hide show
  1. package/actionsExecutor/ActionExecutorContext.tsx +16 -0
  2. package/actionsExecutor/actions/__tests__/goBack.test.ts +62 -0
  3. package/actionsExecutor/actions/__tests__/goHome.test.ts +19 -0
  4. package/actionsExecutor/actions/goBack.ts +60 -0
  5. package/actionsExecutor/actions/goHome.ts +31 -0
  6. package/actionsExecutor/actions/index.ts +4 -0
  7. package/actionsExecutor/consts.ts +2 -0
  8. package/appUtils/playerManager/__tests__/playerNative.test.ts +38 -0
  9. package/appUtils/playerManager/playerNative.ts +5 -3
  10. package/entryActions/__tests__/buildEntryActions.test.ts +156 -0
  11. package/entryActions/aliasPresentation.ts +124 -0
  12. package/entryActions/buildEntryActions.ts +166 -0
  13. package/entryActions/index.ts +17 -0
  14. package/entryActions/types.ts +39 -0
  15. package/manifestUtils/_internals/index.js +8 -3
  16. package/manifestUtils/defaultManifestConfigurations/player.js +22 -1
  17. package/package.json +2 -2
  18. package/playerUtils/__tests__/resolvePlayerActions.test.ts +138 -0
  19. package/playerUtils/index.ts +17 -1
  20. package/playerUtils/resolvePlayerActions.ts +71 -0
  21. package/reactHooks/actions/index.ts +67 -2
  22. package/uiActionsRegistrator/__tests__/resolveActionIdentifiers.test.ts +93 -0
  23. package/uiActionsRegistrator/__tests__/subscribe.test.ts +107 -0
  24. package/uiActionsRegistrator/__tests__/usableActionItems.test.ts +52 -0
  25. package/uiActionsRegistrator/index.ts +25 -203
  26. package/uiActionsRegistrator/registry.ts +335 -0
  27. package/uiActionsRegistrator/resolveActionIdentifiers.ts +100 -0
  28. package/uiActionsRegistrator/usableActionItems.ts +66 -0
  29. package/playerUtils/__tests__/getPlayerActionButtons.test.ts +0 -54
  30. package/playerUtils/getPlayerActionButtons.ts +0 -17
@@ -18,6 +18,8 @@ import { createLogger } from "../logger";
18
18
  import {
19
19
  appRestartAction,
20
20
  confirmDialogAction,
21
+ createGoBackAction,
22
+ createGoHomeAction,
21
23
  createNavigateToScreenAction,
22
24
  dismissBottomSheetAction,
23
25
  localStorageRemoveAction,
@@ -189,6 +191,20 @@ export function withActionExecutor(Component) {
189
191
  );
190
192
  }, [navigator, rivers, contentTypes]);
191
193
 
194
+ useEffect(() => {
195
+ return _actionExecutor.registerAction(
196
+ ACTION_TYPES.GO_BACK,
197
+ createGoBackAction(navigator)
198
+ );
199
+ }, [navigator]);
200
+
201
+ useEffect(() => {
202
+ return _actionExecutor.registerAction(
203
+ ACTION_TYPES.GO_HOME,
204
+ createGoHomeAction(navigator)
205
+ );
206
+ }, [navigator]);
207
+
192
208
  return (
193
209
  <ActionExecutorContext.Provider value={handlers}>
194
210
  <Component {...props} />
@@ -0,0 +1,62 @@
1
+ import { createGoBackAction } from "../goBack";
2
+ import { ActionResult } from "../../ActionExecutor";
3
+
4
+ function createNavigator(canGoBack = true) {
5
+ return {
6
+ goBack: jest.fn(),
7
+ canGoBack: jest.fn().mockReturnValue(canGoBack),
8
+ } as unknown as QuickBrickAppNavigator;
9
+ }
10
+
11
+ describe("goBack", () => {
12
+ it("goes back with fallbackToHome enabled when no options are provided", async () => {
13
+ const navigator = createNavigator();
14
+
15
+ const result = await createGoBackAction(navigator)({ type: "goBack" });
16
+
17
+ expect(navigator.goBack).toHaveBeenCalledWith(true, false, false);
18
+ expect(result).toBe(ActionResult.Success);
19
+ });
20
+
21
+ it("forwards backToTop", async () => {
22
+ const navigator = createNavigator();
23
+
24
+ const action = {
25
+ type: "goBack",
26
+ options: { backToTop: true },
27
+ };
28
+
29
+ const result = await createGoBackAction(navigator)(action);
30
+
31
+ expect(navigator.goBack).toHaveBeenCalledWith(true, false, true);
32
+ expect(result).toBe(ActionResult.Success);
33
+ });
34
+
35
+ it("goes back without falling back to home when the stack allows it", async () => {
36
+ const navigator = createNavigator(true);
37
+
38
+ const action = {
39
+ type: "goBack",
40
+ options: { fallbackToHome: false },
41
+ };
42
+
43
+ const result = await createGoBackAction(navigator)(action);
44
+
45
+ expect(navigator.goBack).toHaveBeenCalledWith(false, false, false);
46
+ expect(result).toBe(ActionResult.Success);
47
+ });
48
+
49
+ it("does nothing when there is nothing to go back to and fallbackToHome is off", async () => {
50
+ const navigator = createNavigator(false);
51
+
52
+ const action = {
53
+ type: "goBack",
54
+ options: { fallbackToHome: false },
55
+ };
56
+
57
+ const result = await createGoBackAction(navigator)(action);
58
+
59
+ expect(navigator.goBack).not.toHaveBeenCalled();
60
+ expect(result).toBe(ActionResult.Error);
61
+ });
62
+ });
@@ -0,0 +1,19 @@
1
+ import { createGoHomeAction } from "../goHome";
2
+ import { ActionResult } from "../../ActionExecutor";
3
+
4
+ function createNavigator() {
5
+ return {
6
+ goHome: jest.fn(),
7
+ } as unknown as QuickBrickAppNavigator;
8
+ }
9
+
10
+ describe("goHome", () => {
11
+ it("navigates to the home screen", async () => {
12
+ const navigator = createNavigator();
13
+
14
+ const result = await createGoHomeAction(navigator)({ type: "goHome" });
15
+
16
+ expect(navigator.goHome).toHaveBeenCalledWith();
17
+ expect(result).toBe(ActionResult.Success);
18
+ });
19
+ });
@@ -0,0 +1,60 @@
1
+ /// <reference types="@applicaster/applicaster-types" />
2
+ import { ActionResult } from "../ActionExecutor";
3
+ import { ActionHandler } from "../types";
4
+ import { createLogger } from "../../logger";
5
+
6
+ const { log_info } = createLogger({
7
+ subsystem: "ActionExecutorContext",
8
+ category: "General",
9
+ });
10
+
11
+ /**
12
+ * Options for the goBack action.
13
+ */
14
+ export interface GoBackOptions {
15
+ /**
16
+ * Navigate to the home screen when there is no screen to go back to.
17
+ * Defaults to true.
18
+ */
19
+ fallbackToHome?: boolean;
20
+
21
+ /**
22
+ * Go back to the first screen of the stack instead of the previous one.
23
+ * Defaults to false.
24
+ */
25
+ backToTop?: boolean;
26
+ }
27
+
28
+ /**
29
+ * Creates a go back action handler with the provided dependencies.
30
+ * This factory pattern allows the action to access navigation state at runtime.
31
+ *
32
+ * @param navigator - The app navigator performing the back navigation
33
+ * @returns An action handler that navigates back
34
+ */
35
+ export function createGoBackAction(
36
+ navigator: QuickBrickAppNavigator
37
+ ): ActionHandler<GoBackOptions> {
38
+ return async function goBackAction(action): Promise<ActionResult> {
39
+ const fallbackToHome = action.options?.fallbackToHome ?? true;
40
+ const backToTop = action.options?.backToTop ?? false;
41
+
42
+ // With fallbackToHome on, the navigator goes home on an empty stack by
43
+ // itself. The guard only matters when the caller opted out of that.
44
+ if (!fallbackToHome && !navigator.canGoBack()) {
45
+ log_info(
46
+ "goBack: no screen to go back to and fallbackToHome is disabled, ignoring"
47
+ );
48
+
49
+ return ActionResult.Error;
50
+ }
51
+
52
+ log_info(
53
+ `goBack: navigating back, fallbackToHome: ${fallbackToHome}, backToTop: ${backToTop}`
54
+ );
55
+
56
+ navigator.goBack(fallbackToHome, false, backToTop);
57
+
58
+ return ActionResult.Success;
59
+ };
60
+ }
@@ -0,0 +1,31 @@
1
+ /// <reference types="@applicaster/applicaster-types" />
2
+ import { ActionResult } from "../ActionExecutor";
3
+ import { ActionHandler } from "../types";
4
+ import { createLogger } from "../../logger";
5
+
6
+ const { log_info } = createLogger({
7
+ subsystem: "ActionExecutorContext",
8
+ category: "General",
9
+ });
10
+
11
+ /**
12
+ * Creates a go home action handler with the provided dependencies.
13
+ * This factory pattern allows the action to access navigation state at runtime.
14
+ *
15
+ * The navigator's `initialLaunch` flag is intentionally not exposed: it selects
16
+ * the offline home river during app bootstrap and is not a per-action choice.
17
+ *
18
+ * @param navigator - The app navigator performing the navigation
19
+ * @returns An action handler that navigates to the home screen
20
+ */
21
+ export function createGoHomeAction(
22
+ navigator: QuickBrickAppNavigator
23
+ ): ActionHandler {
24
+ return async function goHomeAction(): Promise<ActionResult> {
25
+ log_info("goHome: navigating to the home screen");
26
+
27
+ navigator.goHome();
28
+
29
+ return ActionResult.Success;
30
+ };
31
+ }
@@ -27,6 +27,10 @@ export { screenToggleFlagAction } from "./screenToggleFlag";
27
27
 
28
28
  export { createNavigateToScreenAction } from "./navigateToScreen";
29
29
 
30
+ export { createGoBackAction } from "./goBack";
31
+
32
+ export { createGoHomeAction } from "./goHome";
33
+
30
34
  export { showToastAction } from "./showToast";
31
35
 
32
36
  export { openBottomSheetAction } from "./openBottomSheet";
@@ -9,6 +9,8 @@ export const ACTION_TYPES = {
9
9
  PRESENT_SUB_MENU: "presentSubMenu",
10
10
  SEND_CLOUD_EVENT: "sendCloudEvent",
11
11
  NAVIGATE_TO_SCREEN: "navigateToScreen",
12
+ GO_BACK: "goBack",
13
+ GO_HOME: "goHome",
12
14
  SHOW_TOAST: "showToast",
13
15
  LOCAL_STORAGE_SET: "localStorageSet",
14
16
  LOCAL_STORAGE_REMOVE: "localStorageRemove",
@@ -0,0 +1,38 @@
1
+ import { PlayerNative } from "../playerNative";
2
+
3
+ const playerWithEntry = (entry: any) =>
4
+ new PlayerNative({
5
+ entry,
6
+ player: null as any,
7
+ playerId: "test-player",
8
+ });
9
+
10
+ describe("PlayerNative.isAudioItem", () => {
11
+ it.each(["audio", "audio-player", "audio-detail", "audio-show", "audio-hub"])(
12
+ "treats the %s content type as audio",
13
+ (type) => {
14
+ expect(playerWithEntry({ type: { value: type } }).isAudioItem()).toBe(
15
+ true
16
+ );
17
+ }
18
+ );
19
+
20
+ it.each(["video", "video-detail", "comic-detail", "article-show"])(
21
+ "does not treat the %s content type as audio",
22
+ (type) => {
23
+ expect(playerWithEntry({ type: { value: type } }).isAudioItem()).toBe(
24
+ false
25
+ );
26
+ }
27
+ );
28
+
29
+ it("falls back to the media type when the content type says nothing", () => {
30
+ const entry = { content: { type: "audio/mpeg" }, type: { value: "link" } };
31
+
32
+ expect(playerWithEntry(entry).isAudioItem()).toBe(true);
33
+ });
34
+
35
+ it("returns false when there is no entry", () => {
36
+ expect(playerWithEntry(null).isAudioItem()).toBe(false);
37
+ });
38
+ });
@@ -296,9 +296,11 @@ export class PlayerNative extends Player {
296
296
  }, listeners);
297
297
  };
298
298
 
299
- isAudioItem = () =>
300
- this.getEntry()?.content?.type?.includes?.("audio") ||
301
- this.getEntry()?.type?.value === "audio";
299
+ isAudioItem = (): boolean =>
300
+ Boolean(
301
+ this.getEntry()?.content?.type?.includes?.("audio") ||
302
+ this.getEntry()?.type?.value?.includes?.("audio")
303
+ );
302
304
 
303
305
  isFullScreenSupported = (): boolean => {
304
306
  const config = this.getConfig();
@@ -0,0 +1,156 @@
1
+ import { buildEntryActions } from "../buildEntryActions";
2
+
3
+ describe("buildEntryActions - alias & label resolution", () => {
4
+ const mockDeps = {
5
+ actionExecutor: { handleActions: jest.fn() } as any,
6
+ actionContext: {},
7
+ };
8
+
9
+ const resolveEntryActions = (entry: ZappEntry) =>
10
+ buildEntryActions(entry, mockDeps);
11
+
12
+ it("resolves entry action with explicit button title", () => {
13
+ const entry = {
14
+ id: "entry-1",
15
+ extensions: {
16
+ entry_action: [
17
+ {
18
+ button: {
19
+ title: "Custom Action Title",
20
+ iconURL: "http://example.com/icon.png",
21
+ },
22
+ actions: [{ type: "test" }],
23
+ dismiss_on_action: true,
24
+ },
25
+ ],
26
+ },
27
+ };
28
+
29
+ const actions = resolveEntryActions(entry as any);
30
+
31
+ expect(actions).toHaveLength(1);
32
+ expect(actions[0].identifier).toBe("entry_action_0");
33
+
34
+ const state = actions[0].action.initialEntryState(entry as any);
35
+
36
+ expect(state).toEqual({
37
+ state: 0,
38
+ label: "Custom Action Title",
39
+ asset: "http://example.com/icon.png",
40
+ });
41
+ });
42
+
43
+ it("resolves entry action with button label property", () => {
44
+ const entry = {
45
+ id: "entry-2",
46
+ extensions: {
47
+ entry_action: [
48
+ {
49
+ button: { label: "Label Property Action" },
50
+ actions: [{ type: "test" }],
51
+ },
52
+ ],
53
+ },
54
+ };
55
+
56
+ const actions = resolveEntryActions(entry as any);
57
+
58
+ expect(actions).toHaveLength(1);
59
+ const state = actions[0].action.initialEntryState(entry as any);
60
+ expect(state.label).toBe("Label Property Action");
61
+ });
62
+
63
+ it("resolves entry action with known backend button alias (e.g. add_all_to_queue)", () => {
64
+ const entry = {
65
+ id: "entry-3",
66
+ extensions: {
67
+ entry_action: [
68
+ {
69
+ button: { alias: "add_all_to_queue" },
70
+ actions: [{ type: "test" }],
71
+ dismiss_on_action: true,
72
+ },
73
+ {
74
+ button: { alias: "add_to_playlist" },
75
+ actions: [{ type: "test" }],
76
+ },
77
+ {
78
+ button: { alias: "delete_collection" },
79
+ actions: [{ type: "test" }],
80
+ },
81
+ ],
82
+ },
83
+ };
84
+
85
+ const actions = resolveEntryActions(entry as any);
86
+
87
+ expect(actions).toHaveLength(3);
88
+
89
+ expect(actions[0].action.initialEntryState(entry as any)).toMatchObject({
90
+ state: 0,
91
+ label: "Add all to Queue",
92
+ });
93
+
94
+ expect(actions[1].action.initialEntryState(entry as any)).toMatchObject({
95
+ state: 0,
96
+ label: "Add to Playlist",
97
+ });
98
+
99
+ expect(actions[2].action.initialEntryState(entry as any)).toMatchObject({
100
+ state: 0,
101
+ label: "Delete collection",
102
+ });
103
+ });
104
+
105
+ it("formats unknown alias nicely as fallback", () => {
106
+ const entry = {
107
+ id: "entry-4",
108
+ extensions: {
109
+ entry_action: [
110
+ {
111
+ button: { alias: "custom_unknown_action" },
112
+ actions: [{ type: "test" }],
113
+ },
114
+ ],
115
+ },
116
+ };
117
+
118
+ const actions = resolveEntryActions(entry as any);
119
+
120
+ expect(actions).toHaveLength(1);
121
+ const state = actions[0].action.initialEntryState(entry as any);
122
+ expect(state.label).toBe("Custom unknown action");
123
+ });
124
+
125
+ it("uses actionExecutor.getActionState for plugin-provided label and asset", () => {
126
+ mockDeps.actionExecutor.getActionState = jest.fn(() => ({
127
+ label: "From Plugin",
128
+ asset: "plugin_asset",
129
+ }));
130
+
131
+ const entry = {
132
+ id: "entry-plugin-state",
133
+ extensions: {
134
+ entry_action: [
135
+ {
136
+ button: { alias: "custom_plugin_action" },
137
+ actions: [{ type: "plugin-action" }],
138
+ },
139
+ ],
140
+ },
141
+ };
142
+
143
+ const actions = resolveEntryActions(entry as any);
144
+ const state = actions[0].action.initialEntryState(entry as any);
145
+
146
+ expect(mockDeps.actionExecutor.getActionState).toHaveBeenCalledWith(
147
+ "plugin-action",
148
+ entry
149
+ );
150
+
151
+ expect(state).toMatchObject({
152
+ label: "From Plugin",
153
+ asset: "plugin_asset",
154
+ });
155
+ });
156
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Presentation defaults for backend action aliases.
3
+ *
4
+ * Entries may declare an action by `alias` alone, without a label or an icon.
5
+ * These maps are the last-resort labels/icons for the aliases we know about; an
6
+ * unknown alias falls back to a humanized version of the alias itself.
7
+ *
8
+ * They are defaults, not a framework guarantee: an app that speaks a different
9
+ * alias vocabulary, needs other languages, or does not want to depend on these
10
+ * icon hosts passes its own through `EntryActionDeps.aliasPresentation`.
11
+ */
12
+
13
+ import { EntryAction } from "./types";
14
+
15
+ const ADD_TO_PLAYLIST_ICON =
16
+ "https://assets-secure.applicaster.com/zapp/assets/app_family/1710/906728572617/playlist_add_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.png";
17
+
18
+ const REMOVE_ICON =
19
+ "https://assets-secure.applicaster.com/zapp/assets/app_family/1710/372097240191/remove_24dp_EA3323_FILL0_wght400_GRAD0_opsz24.png";
20
+
21
+ const EDIT_ICON =
22
+ "https://upload.wikimedia.org/wikipedia/commons/b/b6/Edit-icon.png";
23
+
24
+ /** Known backend action alias labels. */
25
+ export const ALIAS_LABELS: Record<string, string> = {
26
+ add_to_playlist: "Add to Playlist",
27
+ add_to_queue: "Add to Queue",
28
+ add_all_to_queue: "Add all to Queue",
29
+ play_all: "Play All",
30
+ remove_item: "Remove item",
31
+ delete_collection: "Delete collection",
32
+ create_collection: "Create collection",
33
+ edit: "Edit",
34
+ edit_name: "Edit Name & Details",
35
+ };
36
+
37
+ /** Known backend action alias image assets. */
38
+ export const ALIAS_ASSETS: Record<string, string> = {
39
+ add_to_playlist: ADD_TO_PLAYLIST_ICON,
40
+ add_to_queue: ADD_TO_PLAYLIST_ICON,
41
+ add_all_to_queue: ADD_TO_PLAYLIST_ICON,
42
+ play_all: ADD_TO_PLAYLIST_ICON,
43
+ remove_item: REMOVE_ICON,
44
+ delete_collection: REMOVE_ICON,
45
+ edit: EDIT_ICON,
46
+ edit_name: EDIT_ICON,
47
+ };
48
+
49
+ /** Per-app overrides for the maps above. */
50
+ export type AliasPresentation = {
51
+ labels?: Record<string, string>;
52
+ assets?: Record<string, string>;
53
+ };
54
+
55
+ function formatAliasLabel(
56
+ alias: string,
57
+ overrides?: AliasPresentation
58
+ ): string {
59
+ const label = overrides?.labels?.[alias] ?? ALIAS_LABELS[alias];
60
+
61
+ if (label) {
62
+ return label;
63
+ }
64
+
65
+ const formatted = alias.replace(/_/g, " ");
66
+
67
+ return formatted.charAt(0).toUpperCase() + formatted.slice(1);
68
+ }
69
+
70
+ const nonEmptyString = (value: unknown): value is string =>
71
+ typeof value === "string" && value.length > 0;
72
+
73
+ /**
74
+ * Label the backend stated outright, if any. Distinct from
75
+ * {@link resolveButtonLabel} because an explicit label outranks the executor's
76
+ * action state, while the alias fallback does not.
77
+ */
78
+ export function resolveExplicitLabel(
79
+ button: EntryAction["button"]
80
+ ): string | undefined {
81
+ if (nonEmptyString(button?.title)) {
82
+ return button.title;
83
+ }
84
+
85
+ if (nonEmptyString(button?.label)) {
86
+ return button.label;
87
+ }
88
+
89
+ return undefined;
90
+ }
91
+
92
+ /** Label to show for an entry action: explicit title/label, else the alias. */
93
+ export function resolveButtonLabel(
94
+ button: EntryAction["button"],
95
+ overrides?: AliasPresentation
96
+ ): string {
97
+ const explicit = resolveExplicitLabel(button);
98
+
99
+ if (explicit) {
100
+ return explicit;
101
+ }
102
+
103
+ if (nonEmptyString(button?.alias)) {
104
+ return formatAliasLabel(button.alias, overrides);
105
+ }
106
+
107
+ return "";
108
+ }
109
+
110
+ /** Icon to show for an entry action: explicit iconURL, else the alias default. */
111
+ export function resolveButtonAsset(
112
+ button: EntryAction["button"],
113
+ overrides?: AliasPresentation
114
+ ): string | undefined {
115
+ if (button?.iconURL) {
116
+ return button.iconURL;
117
+ }
118
+
119
+ if (!button?.alias) {
120
+ return undefined;
121
+ }
122
+
123
+ return overrides?.assets?.[button.alias] ?? ALIAS_ASSETS[button.alias];
124
+ }