@applicaster/zapp-react-native-ui-components 16.0.0-rc.70 → 16.0.0-rc.72
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/Components/MasterCell/DefaultComponents/ActionButton.tsx +92 -16
- package/Components/ModalComponent/ActionsBottomSheet/ActionButton.tsx +54 -0
- package/Components/ModalComponent/ActionsBottomSheet/boundActionButton.tsx +21 -0
- package/Components/ModalComponent/ActionsBottomSheet/index.ts +9 -0
- package/Components/ModalComponent/ActionsBottomSheet/openActionsBottomSheet.ts +58 -0
- package/Hooks/useEntryActionsExpander.ts +65 -0
- package/package.json +5 -5
|
@@ -2,10 +2,22 @@ import React, { useCallback, useEffect } from "react";
|
|
|
2
2
|
import { TouchableOpacity, ViewStyle } from "react-native";
|
|
3
3
|
|
|
4
4
|
import { useActions } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
|
|
5
|
+
import {
|
|
6
|
+
observeEntryState,
|
|
7
|
+
RegisteredActionValue,
|
|
8
|
+
} from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
|
|
9
|
+
|
|
10
|
+
import { masterCellLogger } from "../logger";
|
|
5
11
|
|
|
6
12
|
import Image from "./Image";
|
|
7
13
|
type Props = {
|
|
8
14
|
item: ZappEntry | ZappFeed;
|
|
15
|
+
/**
|
|
16
|
+
* Already resolved action value. Pass it when the caller took the action out
|
|
17
|
+
* of `uiActionsRegistry` (entry actions have no plugin context to look up),
|
|
18
|
+
* and this skips the `useActions` lookup entirely.
|
|
19
|
+
*/
|
|
20
|
+
actionContext?: RegisteredActionValue;
|
|
9
21
|
flavour?: "flavour_1" | "flavour_2";
|
|
10
22
|
asset?: {
|
|
11
23
|
props: {};
|
|
@@ -32,6 +44,19 @@ function isStringAsset(asset) {
|
|
|
32
44
|
return typeof asset === "string" || Array.isArray(asset);
|
|
33
45
|
}
|
|
34
46
|
|
|
47
|
+
/**
|
|
48
|
+
* `CellActionEntryState["asset"]` is either something `Image` can take (a URI,
|
|
49
|
+
* or a per-flavour array) or a component to render. A plain object is neither -
|
|
50
|
+
* a locale/state map reaches us that way - and handing one to JSX throws
|
|
51
|
+
* "Element type is invalid", so it has to be recognised rather than assumed.
|
|
52
|
+
*/
|
|
53
|
+
function isComponentAsset(asset): boolean {
|
|
54
|
+
return (
|
|
55
|
+
typeof asset === "function" ||
|
|
56
|
+
(typeof asset === "object" && asset !== null && "$$typeof" in asset)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
35
60
|
function getAssetValue(asset, flavour, fallbackAsset = null) {
|
|
36
61
|
if (!asset) {
|
|
37
62
|
return null;
|
|
@@ -55,26 +80,52 @@ function getAssetValue(asset, flavour, fallbackAsset = null) {
|
|
|
55
80
|
export const ActionButton = React.memo(function ActionButtonComponent(
|
|
56
81
|
props: Props
|
|
57
82
|
) {
|
|
58
|
-
const {
|
|
59
|
-
|
|
83
|
+
const {
|
|
84
|
+
item,
|
|
85
|
+
action,
|
|
86
|
+
asset,
|
|
87
|
+
flavour = "flavour_1",
|
|
88
|
+
cellUUID,
|
|
89
|
+
actionContext: providedActionContext,
|
|
90
|
+
} = props;
|
|
91
|
+
|
|
92
|
+
// Passing `undefined` makes `useActions` a no-op lookup instead of warning
|
|
93
|
+
// about a plugin that was never meant to be there. Callers are expected to
|
|
94
|
+
// either always or never provide the context for a given mounted button -
|
|
95
|
+
// `useActions` already branches on plugin presence internally, so a value
|
|
96
|
+
// that appears mid-life would change the hook count either way.
|
|
97
|
+
const lookedUpActionContext = useActions(
|
|
98
|
+
providedActionContext ? undefined : action?.identifier
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const actionContext = providedActionContext ?? lookedUpActionContext;
|
|
60
102
|
|
|
61
103
|
// TODO: add subscription API for action availability
|
|
62
104
|
const actionDisabled =
|
|
63
105
|
typeof actionContext?.isActionAvailable === "function" &&
|
|
64
106
|
!actionContext.isActionAvailable(item);
|
|
65
107
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
actionDisabled || !actionContext
|
|
69
|
-
? null
|
|
70
|
-
: actionContext.initialEntryState(item)
|
|
108
|
+
const [actionState, setActionState] = React.useState(() =>
|
|
109
|
+
actionDisabled ? null : actionContext?.initialEntryState?.(item)
|
|
71
110
|
);
|
|
72
111
|
|
|
112
|
+
// `observeEntryState` is the one way to follow an action's state: it uses the
|
|
113
|
+
// action's own stream when it has one, and otherwise builds an equivalent one
|
|
114
|
+
// from `initialEntryState` + `addListener`. Reading only the latter pair
|
|
115
|
+
// leaves stream-backed actions - playback speed, sleep timer - frozen at the
|
|
116
|
+
// value they had when the button mounted.
|
|
73
117
|
useEffect(() => {
|
|
74
|
-
if (
|
|
75
|
-
|
|
118
|
+
if (actionDisabled || !actionContext) {
|
|
119
|
+
return undefined;
|
|
76
120
|
}
|
|
77
|
-
|
|
121
|
+
|
|
122
|
+
const subscription = observeEntryState(actionContext, item).subscribe(
|
|
123
|
+
setActionState
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return () => subscription.unsubscribe();
|
|
127
|
+
// eslint-disable-next-line @wogns3623/better-exhaustive-deps/exhaustive-deps
|
|
128
|
+
}, [actionDisabled, item?.id, actionContext]);
|
|
78
129
|
|
|
79
130
|
const onPress = useCallback(() => {
|
|
80
131
|
actionContext.invokeAction(item, {
|
|
@@ -82,15 +133,30 @@ export const ActionButton = React.memo(function ActionButtonComponent(
|
|
|
82
133
|
setActionState(state);
|
|
83
134
|
},
|
|
84
135
|
});
|
|
85
|
-
}, [
|
|
136
|
+
}, [actionContext, item]);
|
|
137
|
+
|
|
138
|
+
// An asset that is present but of a shape nothing can render (a locale or
|
|
139
|
+
// state map, say) drops the button for good - unlike a missing asset, which
|
|
140
|
+
// is usually just state that has not loaded yet. Reported from an effect so
|
|
141
|
+
// it is said once per action/entry rather than on every render.
|
|
142
|
+
const unrenderableAsset =
|
|
143
|
+
Boolean(actionState?.asset) &&
|
|
144
|
+
!isStringAsset(actionState?.asset) &&
|
|
145
|
+
!isComponentAsset(actionState?.asset);
|
|
86
146
|
|
|
87
147
|
useEffect(() => {
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
148
|
+
if (unrenderableAsset) {
|
|
149
|
+
masterCellLogger.warning({
|
|
150
|
+
message: `ActionButton: the asset of action "${action?.identifier}" is neither an image source nor a component - the button is not rendered`,
|
|
151
|
+
data: {
|
|
152
|
+
identifier: action?.identifier,
|
|
153
|
+
entryId: item?.id,
|
|
154
|
+
assetType: typeof actionState?.asset,
|
|
155
|
+
},
|
|
91
156
|
});
|
|
92
157
|
}
|
|
93
|
-
|
|
158
|
+
// eslint-disable-next-line @wogns3623/better-exhaustive-deps/exhaustive-deps
|
|
159
|
+
}, [unrenderableAsset, action?.identifier, item?.id]);
|
|
94
160
|
|
|
95
161
|
if (actionDisabled || !actionContext) return null;
|
|
96
162
|
const AssetComponent = actionState?.asset;
|
|
@@ -98,12 +164,22 @@ export const ActionButton = React.memo(function ActionButtonComponent(
|
|
|
98
164
|
// Default state is not yet ready
|
|
99
165
|
if (!AssetComponent) return null;
|
|
100
166
|
|
|
167
|
+
// Neither an image source nor a component - nothing renderable.
|
|
168
|
+
if (!isStringAsset(AssetComponent) && !isComponentAsset(AssetComponent)) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
101
172
|
return (
|
|
102
173
|
<TouchableOpacity
|
|
103
174
|
activeOpacity={1}
|
|
104
175
|
onPress={onPress}
|
|
105
176
|
testID={props?.testID || `${item?.id}`}
|
|
106
|
-
|
|
177
|
+
accessibilityRole="button"
|
|
178
|
+
accessibilityLabel={
|
|
179
|
+
props?.accessibilityLabel ||
|
|
180
|
+
(typeof actionState?.label === "string" ? actionState.label : null) ||
|
|
181
|
+
`${item?.id}`
|
|
182
|
+
}
|
|
107
183
|
accessibilityHint={props?.accessibilityHint}
|
|
108
184
|
accessible={!!(props?.testID || props?.accessibilityLabel)}
|
|
109
185
|
style={props?.style}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useEntryActionState } from "@applicaster/zapp-react-native-utils/reactHooks/actions";
|
|
3
|
+
import {
|
|
4
|
+
isActionAvailableFor,
|
|
5
|
+
RegisteredAction,
|
|
6
|
+
} from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
|
|
7
|
+
|
|
8
|
+
import { Button } from "../Button";
|
|
9
|
+
|
|
10
|
+
export type ActionButtonProps = {
|
|
11
|
+
entry: ZappEntry;
|
|
12
|
+
item: RegisteredAction;
|
|
13
|
+
configuration: any;
|
|
14
|
+
width: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Props = ActionButtonProps & {
|
|
18
|
+
onPress?: (item: any) => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Unified action button for the modal bottom sheet.
|
|
23
|
+
*
|
|
24
|
+
* Every item is a `RegisteredAction` resolved from the registry (entry actions,
|
|
25
|
+
* registry actions and legacy context-provider actions alike), so the button
|
|
26
|
+
* simply reads `item.action` and drives it through `useEntryActionState`
|
|
27
|
+
* (backed by the action's observable / `addListener`).
|
|
28
|
+
*/
|
|
29
|
+
export function ActionButton(props: Props) {
|
|
30
|
+
const { item, entry, onPress: onItemPress } = props;
|
|
31
|
+
|
|
32
|
+
const action = item?.action;
|
|
33
|
+
|
|
34
|
+
const { state, invokeAction } = useEntryActionState(action, entry);
|
|
35
|
+
|
|
36
|
+
const onPress = React.useCallback(
|
|
37
|
+
(_item) => {
|
|
38
|
+
invokeAction({
|
|
39
|
+
context: "toast",
|
|
40
|
+
dismiss: () => onItemPress?.(item),
|
|
41
|
+
});
|
|
42
|
+
},
|
|
43
|
+
[invokeAction, item, onItemPress]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (!isActionAvailableFor(action, entry as ZappEntry)) return null;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Button {...props} item={state} onPress={onPress} label={state?.label} />
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Explicitly set display name for the component
|
|
54
|
+
ActionButton.displayName = "ActionButton";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
import { ActionButton, ActionButtonProps } from "./ActionButton";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Binds an entry to the sheet's row button.
|
|
7
|
+
*
|
|
8
|
+
* `BottomSheetModalContent` renders `buttonComponent` per item and does not
|
|
9
|
+
* know about entries, so the entry is closed over here instead.
|
|
10
|
+
*/
|
|
11
|
+
export const boundActionButton = (entry: ZappEntry | ZappFeed) => {
|
|
12
|
+
const ActionButtonWithEntry = (props: ActionButtonProps) => (
|
|
13
|
+
<ActionButton {...props} entry={entry as ZappEntry} />
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
ActionButtonWithEntry.displayName = "ActionButtonWithEntry";
|
|
17
|
+
|
|
18
|
+
return ActionButtonWithEntry;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
boundActionButton.displayName = "boundActionButton";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { ActionButton } from "./ActionButton";
|
|
2
|
+
|
|
3
|
+
export type { ActionButtonProps } from "./ActionButton";
|
|
4
|
+
|
|
5
|
+
export { boundActionButton } from "./boundActionButton";
|
|
6
|
+
|
|
7
|
+
export { openActionsBottomSheet } from "./openActionsBottomSheet";
|
|
8
|
+
|
|
9
|
+
export type { OpenActionsBottomSheetArgs } from "./openActionsBottomSheet";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createLogger } from "@applicaster/zapp-react-native-utils/logger";
|
|
2
|
+
import { openBottomSheetModal } from "@applicaster/zapp-react-native-utils/modalState";
|
|
3
|
+
import { noop } from "@applicaster/zapp-react-native-utils/functionUtils";
|
|
4
|
+
import { RegisteredAction } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
|
|
5
|
+
|
|
6
|
+
import { boundActionButton } from "./boundActionButton";
|
|
7
|
+
|
|
8
|
+
const { log_warning } = createLogger({
|
|
9
|
+
subsystem: "ActionsBottomSheet",
|
|
10
|
+
category: "Presentation",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type OpenActionsBottomSheetArgs = {
|
|
14
|
+
/** Entry the actions act on; bound into every row button. */
|
|
15
|
+
entry: ZappEntry | ZappFeed;
|
|
16
|
+
/** Already resolved and merged actions, in the order they should appear. */
|
|
17
|
+
actions: RegisteredAction[];
|
|
18
|
+
title?: string;
|
|
19
|
+
summary?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Presents a list of resolved actions in the shared bottom sheet.
|
|
24
|
+
*
|
|
25
|
+
* This is the core entry point for "show these actions in a sheet": the
|
|
26
|
+
* open-modal-bottom-sheet plugin uses it for its configured list, and the
|
|
27
|
+
* player overflow uses it for the actions that did not fit on the overlay.
|
|
28
|
+
* Neither has to know about the other, and neither needs the plugin installed.
|
|
29
|
+
*/
|
|
30
|
+
export function openActionsBottomSheet({
|
|
31
|
+
entry,
|
|
32
|
+
actions,
|
|
33
|
+
title,
|
|
34
|
+
summary,
|
|
35
|
+
}: OpenActionsBottomSheetArgs): void {
|
|
36
|
+
if (!actions?.length) {
|
|
37
|
+
// Every action resolved away - unavailable for this entry, or not
|
|
38
|
+
// invokable. An empty sheet looks broken, so say why and stay put.
|
|
39
|
+
log_warning(
|
|
40
|
+
"openActionsBottomSheet: nothing to present - not opening the sheet",
|
|
41
|
+
{ entryId: (entry as ZappEntry)?.id }
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
openBottomSheetModal({
|
|
48
|
+
modalBottomSheetContentProps: {
|
|
49
|
+
// TODO: should be optional - the button handles its own tap, but
|
|
50
|
+
// BottomSheetModalContent still requires an onPress.
|
|
51
|
+
onPress: noop,
|
|
52
|
+
items: actions,
|
|
53
|
+
buttonComponent: boundActionButton(entry),
|
|
54
|
+
title,
|
|
55
|
+
summary,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { ActionExecutorContext } from "@applicaster/zapp-react-native-utils/actionsExecutor/ActionExecutorContext";
|
|
4
|
+
import {
|
|
5
|
+
buildEntryActions,
|
|
6
|
+
EntryActionDeps,
|
|
7
|
+
} from "@applicaster/zapp-react-native-utils/entryActions";
|
|
8
|
+
import { useRoute } from "@applicaster/zapp-react-native-utils/reactHooks/navigation";
|
|
9
|
+
import { useScreenStateStore } from "@applicaster/zapp-react-native-utils/reactHooks/navigation/useScreenStateStore";
|
|
10
|
+
import {
|
|
11
|
+
useCurrentScreenData,
|
|
12
|
+
useScreenContext,
|
|
13
|
+
} from "@applicaster/zapp-react-native-utils/reactHooks/screen";
|
|
14
|
+
import { RegisteredAction } from "@applicaster/zapp-react-native-utils/uiActionsRegistrator";
|
|
15
|
+
|
|
16
|
+
import { ZappPipesEntryContext } from "../Contexts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Expands `entry.extensions.entry_action` into invokable actions, against the
|
|
20
|
+
* screen this hook is called from.
|
|
21
|
+
*
|
|
22
|
+
* Entry actions are data on the entry, not an installed capability, so there is
|
|
23
|
+
* nothing to look up by identifier: the surface showing the entry expands them.
|
|
24
|
+
* That also decides which screen they execute against — the one whose tree this
|
|
25
|
+
* hook runs in, rather than whichever screen happened to be current when a
|
|
26
|
+
* global provider was registered.
|
|
27
|
+
*
|
|
28
|
+
* Pass the result to `resolveActionIdentifiers` / `resolvePlayerActions` as
|
|
29
|
+
* `expandEntryActions`.
|
|
30
|
+
*/
|
|
31
|
+
export function useEntryActionsExpander(): (
|
|
32
|
+
entry: ZappEntry | ZappFeed
|
|
33
|
+
) => RegisteredAction[] {
|
|
34
|
+
const { pathname } = useRoute();
|
|
35
|
+
|
|
36
|
+
const { context: screenEntryContext } = React.useContext(
|
|
37
|
+
ZappPipesEntryContext.Context
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const screenData = useCurrentScreenData();
|
|
41
|
+
const screenState = useScreenContext()?.options;
|
|
42
|
+
const screenStateStore = useScreenStateStore();
|
|
43
|
+
const actionExecutor = React.useContext(ActionExecutorContext);
|
|
44
|
+
|
|
45
|
+
// Read through a ref so the returned expander keeps a stable identity: it
|
|
46
|
+
// feeds `useMemo` dependency lists on surfaces that re-resolve their actions
|
|
47
|
+
// on every registry change.
|
|
48
|
+
const depsRef = React.useRef<EntryActionDeps>();
|
|
49
|
+
|
|
50
|
+
depsRef.current = {
|
|
51
|
+
actionExecutor,
|
|
52
|
+
actionContext: {
|
|
53
|
+
screenData,
|
|
54
|
+
screenState,
|
|
55
|
+
screenRoute: pathname,
|
|
56
|
+
screenStateStore,
|
|
57
|
+
screenEntry: screenEntryContext?.data,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return React.useCallback(
|
|
62
|
+
(entry: ZappEntry | ZappFeed) => buildEntryActions(entry, depsRef.current),
|
|
63
|
+
[]
|
|
64
|
+
);
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@applicaster/zapp-react-native-ui-components",
|
|
3
|
-
"version": "16.0.0-rc.
|
|
3
|
+
"version": "16.0.0-rc.72",
|
|
4
4
|
"description": "Applicaster Zapp React Native ui components for the Quick Brick App",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
},
|
|
29
29
|
"homepage": "https://github.com/applicaster/quickbrick#readme",
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@applicaster/applicaster-types": "16.0.0-rc.
|
|
32
|
-
"@applicaster/zapp-react-native-bridge": "16.0.0-rc.
|
|
33
|
-
"@applicaster/zapp-react-native-redux": "16.0.0-rc.
|
|
34
|
-
"@applicaster/zapp-react-native-utils": "16.0.0-rc.
|
|
31
|
+
"@applicaster/applicaster-types": "16.0.0-rc.72",
|
|
32
|
+
"@applicaster/zapp-react-native-bridge": "16.0.0-rc.72",
|
|
33
|
+
"@applicaster/zapp-react-native-redux": "16.0.0-rc.72",
|
|
34
|
+
"@applicaster/zapp-react-native-utils": "16.0.0-rc.72",
|
|
35
35
|
"fast-json-stable-stringify": "^2.1.0",
|
|
36
36
|
"promise": "^8.3.0",
|
|
37
37
|
"react-native-sortables": "1.7.1",
|