@opndev/react-native-events 0.0.14 → 0.0.16

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.
@@ -0,0 +1,189 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ import {
6
+ ActivityIndicator,
7
+ Pressable,
8
+ StyleSheet,
9
+ Text,
10
+ View,
11
+ } from 'react-native';
12
+ import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
13
+
14
+ const defaultStyle = StyleSheet.create({
15
+ container: {
16
+ gap: 8,
17
+ },
18
+ titleRow: {
19
+ flexDirection: 'row',
20
+ alignItems: 'center',
21
+ },
22
+ title: {
23
+ fontWeight: '700',
24
+ },
25
+ refreshButton: {
26
+ padding: 4,
27
+ },
28
+ divider: {
29
+ height: 3,
30
+ backgroundColor: 'rgba(0,0,0,0.15)',
31
+ marginTop: 4,
32
+ marginBottom: 4,
33
+ },
34
+ item: {
35
+ flexDirection: 'row',
36
+ gap: 8,
37
+ },
38
+ bullet: {
39
+ opacity: 0.6,
40
+ },
41
+ itemBody: {
42
+ flex: 1,
43
+ gap: 2,
44
+ },
45
+ });
46
+
47
+ /**
48
+ * DataListWidget
49
+ *
50
+ * The actual "title + divider + bullet list (+ optional second
51
+ * line) + refresh button" rendering — shared by RemoteDataWidget
52
+ * and StaticDataWidget, which differ only in WHERE `items` comes
53
+ * from (a polled API vs. data you already have). Neither of those
54
+ * two components renders anything of its own; both just call a
55
+ * data hook and hand the result to this component.
56
+ *
57
+ * Not meant to be used directly in app code — it has no data
58
+ * source of its own. Use RemoteDataWidget or StaticDataWidget.
59
+ *
60
+ * @param {object} props
61
+ * @param {Array<{id: any, title: string, description?: string}>} props.items
62
+ * @param {boolean} props.loading
63
+ * @param {string|null} props.error
64
+ * @param {Function} [props.manualRefresh]
65
+ * @param {boolean} [props.canManualRefresh]
66
+ * @param {string} [props.title] Defaults to null (no heading shown).
67
+ * @param {boolean} [props.showDivider] Defaults to true. Ignored if title is falsy.
68
+ * @param {object} [props.dividerStyle]
69
+ * @param {boolean} [props.showRefreshButton] Defaults to true.
70
+ * @param {boolean} [props.showBullet] Defaults to true.
71
+ * @param {string} [props.refreshIconColor] Defaults to '#000'.
72
+ * @param {string} [props.refreshIconDisabledColor] Color while on cooldown. Defaults to 'rgba(0,0,0,0.3)'.
73
+ * @param {React.ComponentType} [props.TextComponent] Defaults to Text.
74
+ * @param {Function} [props.onPressItem]
75
+ * @param {string} [props.emptyLabel] Defaults to 'No items.'
76
+ * @param {string} [props.errorLabel] Defaults to 'Unable to load data.'
77
+ * @param {object} [props.containerStyle]
78
+ * @param {object} [props.titleStyle]
79
+ * @param {object} [props.itemStyle]
80
+ * @param {object} [props.itemTextStyle]
81
+ * @param {object} [props.descriptionStyle]
82
+ * @param {object} [props.errorTextStyle]
83
+ *
84
+ * @returns {JSX.Element}
85
+ */
86
+ export default function DataListWidget({
87
+ items,
88
+ loading,
89
+ error,
90
+ manualRefresh,
91
+ canManualRefresh,
92
+
93
+ title = null,
94
+ showDivider = true,
95
+ dividerStyle,
96
+ showRefreshButton = true,
97
+ showBullet = true,
98
+ refreshIconColor = '#000',
99
+ refreshIconDisabledColor = 'rgba(0,0,0,0.3)',
100
+ TextComponent = Text,
101
+ onPressItem,
102
+
103
+ emptyLabel = 'No items.',
104
+ errorLabel = 'Unable to load data.',
105
+
106
+ containerStyle,
107
+ titleStyle,
108
+ itemStyle,
109
+ itemTextStyle,
110
+ descriptionStyle,
111
+ errorTextStyle,
112
+ }) {
113
+ const showTitleRow = title || showRefreshButton;
114
+
115
+ return (
116
+ <View
117
+ style={[
118
+ defaultStyle.container,
119
+ containerStyle,
120
+ ]}
121
+ >
122
+ {showTitleRow ? (
123
+ <View
124
+ style={[
125
+ defaultStyle.titleRow,
126
+ { justifyContent: title ? 'space-between' : 'flex-end' },
127
+ ]}
128
+ >
129
+ {title ? (
130
+ <TextComponent style={[defaultStyle.title, titleStyle]}>
131
+ {title}
132
+ </TextComponent>
133
+ ) : null}
134
+
135
+ {showRefreshButton ? (
136
+ <Pressable
137
+ onPress={manualRefresh}
138
+ disabled={!canManualRefresh}
139
+ style={defaultStyle.refreshButton}
140
+ >
141
+ <MaterialCommunityIcons
142
+ name="refresh"
143
+ size={18}
144
+ color={canManualRefresh ? refreshIconColor : refreshIconDisabledColor}
145
+ />
146
+ </Pressable>
147
+ ) : null}
148
+ </View>
149
+ ) : null}
150
+
151
+ {title && showDivider ? (
152
+ <View style={[defaultStyle.divider, dividerStyle]} />
153
+ ) : null}
154
+
155
+ {loading ? <ActivityIndicator /> : null}
156
+
157
+ {!loading && error ? (
158
+ <TextComponent style={errorTextStyle}>{error || errorLabel}</TextComponent>
159
+ ) : null}
160
+
161
+ {!loading && !error && !items.length ? (
162
+ <TextComponent style={errorTextStyle}>{emptyLabel}</TextComponent>
163
+ ) : null}
164
+
165
+ {!loading && !error && items.map((item) => (
166
+ <Pressable
167
+ key={item.id}
168
+ style={[defaultStyle.item, itemStyle]}
169
+ onPress={() => onPressItem?.(item)}
170
+ >
171
+ {showBullet ? (
172
+ <TextComponent style={[defaultStyle.bullet, itemTextStyle]}>{'\u2022'}</TextComponent>
173
+ ) : null}
174
+ <View style={defaultStyle.itemBody}>
175
+ <TextComponent style={itemTextStyle}>
176
+ {item.title}
177
+ </TextComponent>
178
+ {item.description ? (
179
+ <TextComponent style={descriptionStyle}>
180
+ {item.description}
181
+ </TextComponent>
182
+ ) : null}
183
+ </View>
184
+ </Pressable>
185
+ ))}
186
+ </View>
187
+ );
188
+ }
189
+
@@ -0,0 +1,88 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ import {
6
+ forwardRef,
7
+ useImperativeHandle,
8
+ } from 'react';
9
+
10
+ import { useJsonApiData } from '../hooks/use-json-api-data';
11
+ import DataListWidget from './data-list-widget';
12
+
13
+ /**
14
+ * RemoteDataWidget
15
+ *
16
+ * Fetches from a remote JSON endpoint via useJsonApiData, then hands
17
+ * the result to DataListWidget for rendering. This component owns no
18
+ * rendering of its own — it's the "where the data comes from" half
19
+ * of the pair, mirrored by StaticDataWidget for data you already
20
+ * have. `mapItem` is the contract: whatever shape your API actually
21
+ * returns, map it to `{ id, title, description? }` and neither this
22
+ * component nor DataListWidget needs to know anything else about it.
23
+ *
24
+ * Includes a small refresh button next to the title, rate-limited
25
+ * via useJsonApiData's manual-refresh cooldown (regularMs/
26
+ * manualLockMs/errorRefreshMs — see useJsonApiData for details).
27
+ * Set showRefreshButton={false} to omit it entirely.
28
+ *
29
+ * App-specific presets (e.g. a NewsWidget that always points at
30
+ * your news endpoint with your styling) are expected to be built on
31
+ * top of this in your own app code.
32
+ *
33
+ * @param {object} props
34
+ * @param {string} props.uri
35
+ * @param {string} [props.bearerToken]
36
+ * @param {string} [props.jpath] Dot-path to the array within the response. Omit if the response IS the array.
37
+ * @param {(rawItem: any) => { id: any, title: string, description?: string }} [props.mapItem] Defaults to identity.
38
+ * @param {Function} [props.fetcher] Override for custom auth/caching. See useJsonApiData.
39
+ * @param {number} [props.refreshIntervalMs] Defaults to 15 minutes. Pass 0 to disable polling.
40
+ * @param {number} [props.regularMs] Data freshness window. Defaults to 15 minutes.
41
+ * @param {number} [props.manualLockMs] Manual refresh button cooldown after success. Defaults to 15 minutes.
42
+ * @param {number} [props.errorRefreshMs] Manual refresh button cooldown after a failed attempt. Defaults to 5 minutes.
43
+ * ...plus all of DataListWidget's display props (title, dividerStyle, showRefreshButton, TextComponent, onPressItem, styles, etc.)
44
+ *
45
+ * @returns {JSX.Element}
46
+ */
47
+ const RemoteDataWidget = forwardRef(function RemoteDataWidget(
48
+ {
49
+ uri,
50
+ bearerToken,
51
+ jpath,
52
+ mapItem,
53
+ fetcher,
54
+ refreshIntervalMs = 15 * 60 * 1000,
55
+ regularMs,
56
+ manualLockMs,
57
+ errorRefreshMs,
58
+ ...displayProps
59
+ },
60
+ ref
61
+ ) {
62
+ const { items, loading, error, refresh, manualRefresh, canManualRefresh } = useJsonApiData({
63
+ uri,
64
+ bearerToken,
65
+ jpath,
66
+ mapItem,
67
+ fetcher,
68
+ refreshIntervalMs,
69
+ regularMs,
70
+ manualLockMs,
71
+ errorRefreshMs,
72
+ });
73
+
74
+ useImperativeHandle(ref, () => ({ refresh }), [refresh]);
75
+
76
+ return (
77
+ <DataListWidget
78
+ items={items}
79
+ loading={loading}
80
+ error={error}
81
+ manualRefresh={manualRefresh}
82
+ canManualRefresh={canManualRefresh}
83
+ {...displayProps}
84
+ />
85
+ );
86
+ });
87
+
88
+ export default RemoteDataWidget;
@@ -0,0 +1,50 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ import { useStaticData } from '../hooks/use-static-data';
6
+ import DataListWidget from './data-list-widget';
7
+
8
+ /**
9
+ * StaticDataWidget
10
+ *
11
+ * Renders data you already have via useStaticData, then hands the
12
+ * result to DataListWidget — the exact same rendering RemoteDataWidget
13
+ * uses. The two components are identical except for where `items`
14
+ * comes from: RemoteDataWidget digs through an API response (jpath,
15
+ * polling, caching); this one just reshapes whatever `data` you pass
16
+ * in. `mapItem` is the same contract either way — map your raw items
17
+ * to `{ id, title, description? }`.
18
+ *
19
+ * showRefreshButton AND showBullet both default to false here
20
+ * (unlike RemoteDataWidget, where both default to true) — there's
21
+ * nothing to refresh against static data, and events generally read
22
+ * cleaner flush-left than bulleted. Pass either explicitly if you
23
+ * want them anyway.
24
+ *
25
+ * @param {object} props
26
+ * @param {any[]} props.data
27
+ * @param {(rawItem: any) => { id: any, title: string, description?: string }} [props.mapItem] Defaults to identity.
28
+ * ...plus all of DataListWidget's display props (title, dividerStyle, TextComponent, onPressItem, styles, etc.)
29
+ *
30
+ * @returns {JSX.Element}
31
+ */
32
+ export default function StaticDataWidget({ data, mapItem, ...displayProps }) {
33
+ const { items, loading, error, manualRefresh, canManualRefresh } = useStaticData({
34
+ data,
35
+ mapItem,
36
+ });
37
+
38
+ return (
39
+ <DataListWidget
40
+ items={items}
41
+ loading={loading}
42
+ error={error}
43
+ manualRefresh={manualRefresh}
44
+ canManualRefresh={canManualRefresh}
45
+ showRefreshButton={false}
46
+ showBullet={false}
47
+ {...displayProps}
48
+ />
49
+ );
50
+ }
package/lib/widgets.js CHANGED
@@ -2,4 +2,8 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
4
 
5
- export { default as NewsWidget } from './widgets/news-widget.jsx';
5
+ export { default as RemoteDataWidget } from './widgets/remote-data-widget';
6
+ export { default as StaticDataWidget } from './widgets/static-data-widget';
7
+ export { default as DataListWidget } from './widgets/data-list-widget';
8
+ export { useJsonApiData, fetchJson, clearJsonCache } from './hooks/use-json-api-data';
9
+ export { useStaticData } from './hooks/use-static-data';
package/package.json CHANGED
@@ -11,6 +11,7 @@
11
11
  "exports": {
12
12
  ".": "./lib/index.js",
13
13
  "./components": "./lib/components.js",
14
+ "./debug": "./lib/debug.js",
14
15
  "./globals": "./lib/screen-registry.js",
15
16
  "./hero-screen-registry": "./lib/hero-screen-registry.js",
16
17
  "./lite": "./lib/hero-screen-registry.js",
@@ -34,5 +35,5 @@
34
35
  },
35
36
  "sideEffects": false,
36
37
  "type": "module",
37
- "version": "0.0.14"
38
+ "version": "0.0.16"
38
39
  }
@@ -1,190 +0,0 @@
1
- // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
- //
3
- // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
-
5
- import React, {
6
- forwardRef,
7
- useEffect,
8
- useImperativeHandle,
9
- useRef,
10
- useState,
11
- } from 'react';
12
- import {
13
- ActivityIndicator,
14
- Pressable,
15
- StyleSheet,
16
- Text,
17
- View,
18
- } from 'react-native';
19
-
20
- import { fetchNewsJson } from '../actions/news';
21
-
22
- const defaultStyle = StyleSheet.create({
23
- container: {
24
- gap: 8,
25
- },
26
- title: {
27
- fontWeight: '700',
28
- },
29
- divider: {
30
- height: 1,
31
- backgroundColor: 'rgba(0,0,0,0.15)',
32
- marginTop: 4,
33
- marginBottom: 4,
34
- },
35
- item: {
36
- flexDirection: 'row',
37
- gap: 8,
38
- },
39
- bullet: {
40
- opacity: 0.6,
41
- },
42
- itemText: {
43
- flex: 1,
44
- },
45
- });
46
-
47
- /**
48
- * NewsWidget
49
- *
50
- * Compact, embeddable "latest N news items" panel — meant to sit
51
- * inside another page's body (e.g. as one of HeroScreen's children
52
- * sections), not to own the screen itself. Unlike NewsListScreen,
53
- * it has no ScrollView and no pull-to-refresh, since both break
54
- * once nested inside a parent page that's already scrollable.
55
- *
56
- * Refreshes itself automatically every `refreshIntervalMs` (default
57
- * 15 minutes). For anything else that should trigger a refetch —
58
- * most notably a push notification once that infra exists — attach
59
- * a ref and call `ref.current.refresh()` from wherever that handler
60
- * ends up living:
61
- *
62
- * const newsRef = useRef(null);
63
- * <NewsWidget ref={newsRef} uri={...} />
64
- * // later, e.g. inside a notification handler:
65
- * newsRef.current?.refresh();
66
- *
67
- * @param {object} props
68
- * @param {string} props.uri
69
- * @param {string} [props.bearerToken]
70
- * @param {number} [props.limit] Max items shown. Defaults to 4.
71
- * @param {string} [props.title] Defaults to 'News'.
72
- * @param {boolean} [props.showDivider] Thin line under the title, like 'News' / '-----'. Defaults to true. Ignored if title is falsy.
73
- * @param {object} [props.dividerStyle]
74
- * @param {React.ComponentType} [props.TextComponent] Defaults to Text.
75
- * @param {Function} [props.onPressItem]
76
- * @param {number} [props.refreshIntervalMs] Defaults to 15 minutes.
77
- * @param {string} [props.backgroundColor]
78
- * @param {object} [props.containerStyle]
79
- * @param {object} [props.titleStyle]
80
- * @param {object} [props.itemStyle]
81
- * @param {object} [props.itemTextStyle]
82
- * @param {object} [props.errorTextStyle]
83
- * @param {string} [props.emptyLabel]
84
- * @param {string} [props.errorLabel]
85
- *
86
- * @returns {JSX.Element}
87
- */
88
- const NewsWidget = forwardRef(function NewsWidget(
89
- {
90
- uri,
91
- bearerToken,
92
- limit = 4,
93
- title = 'News',
94
- showDivider = true,
95
- dividerStyle,
96
- TextComponent = Text,
97
- onPressItem,
98
- refreshIntervalMs = 15 * 60 * 1000,
99
-
100
- backgroundColor,
101
- containerStyle,
102
- titleStyle,
103
- itemStyle,
104
- itemTextStyle,
105
- errorTextStyle,
106
-
107
- emptyLabel = 'No news items.',
108
- errorLabel = 'Unable to load news.',
109
- },
110
- ref
111
- ) {
112
- const [items, setItems] = useState([]);
113
- const [loading, setLoading] = useState(true);
114
- const [error, setError] = useState(null);
115
- const intervalRef = useRef(null);
116
-
117
- async function load() {
118
- setError(null);
119
-
120
- try {
121
- const data = await fetchNewsJson({ uri, bearerToken, force: true });
122
- setItems((data.items || []).slice(0, limit));
123
- }
124
- catch (e) {
125
- setError(e.message || errorLabel);
126
- }
127
-
128
- setLoading(false);
129
- }
130
-
131
- useImperativeHandle(ref, () => ({
132
- refresh: load,
133
- }), [uri, bearerToken, limit]);
134
-
135
- useEffect(() => {
136
- setLoading(true);
137
- load();
138
-
139
- intervalRef.current = setInterval(load, refreshIntervalMs);
140
-
141
- return () => clearInterval(intervalRef.current);
142
- // eslint-disable-next-line react-hooks/exhaustive-deps
143
- }, [uri, bearerToken, limit, refreshIntervalMs]);
144
-
145
- return (
146
- <View
147
- style={[
148
- defaultStyle.container,
149
- backgroundColor ? { backgroundColor } : null,
150
- containerStyle,
151
- ]}
152
- >
153
- {title ? (
154
- <TextComponent style={[defaultStyle.title, titleStyle]}>
155
- {title}
156
- </TextComponent>
157
- ) : null}
158
-
159
- {title && showDivider ? (
160
- <View style={[defaultStyle.divider, dividerStyle]} />
161
- ) : null}
162
-
163
- {loading ? <ActivityIndicator /> : null}
164
-
165
- {!loading && error ? (
166
- <TextComponent style={errorTextStyle}>{error}</TextComponent>
167
- ) : null}
168
-
169
- {!loading && !error && !items.length ? (
170
- <TextComponent style={errorTextStyle}>{emptyLabel}</TextComponent>
171
- ) : null}
172
-
173
- {!loading && !error && items.map((item) => (
174
- <Pressable
175
- key={item.id}
176
- style={[defaultStyle.item, itemStyle]}
177
- onPress={() => onPressItem?.(item)}
178
- >
179
- <TextComponent style={defaultStyle.bullet}>{'\u2022'}</TextComponent>
180
- <TextComponent style={[defaultStyle.itemText, itemTextStyle]}>
181
- {item.title}
182
- </TextComponent>
183
- </Pressable>
184
- ))}
185
- </View>
186
- );
187
- });
188
-
189
- export default NewsWidget;
190
-