@opndev/react-native-events 0.0.15 → 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.
package/Changes CHANGED
@@ -1,5 +1,12 @@
1
1
  Revision history for @opndev/opndev-react-native-events
2
2
 
3
+ 0.0.16 2026-06-27 23:25:11Z
4
+
5
+ * Add widgets for dynamic and static content:
6
+ news now is dynamic and some other bits are static because we don't need to
7
+ do call to a backend to decide what scheduling or rules there are. Offline
8
+ mode is cool too
9
+
3
10
  0.0.15 2026-06-26 22:56:26Z
4
11
 
5
12
  * Add debugRouteState() to debug export to debug route states more easily
package/README.md CHANGED
@@ -5,7 +5,7 @@ SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
5
5
  SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
6
6
  -->
7
7
 
8
- # Welcome to @opndev/opndev-react-native-events
8
+ # Welcome to @opndev/react-native-events
9
9
 
10
10
  Reusable React Native / Expo components for event-style apps.
11
11
 
@@ -26,7 +26,7 @@ This package provides a small set of building blocks for:
26
26
  ## Installation
27
27
 
28
28
  ```bash
29
- npm install @opndev/opndev-react-native-events
29
+ npm install @opndev/react-native-events
30
30
  ```
31
31
 
32
32
  Peer dependencies are expected to be installed by the consuming app.
@@ -2,99 +2,21 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
4
 
5
- /**
6
- * News API cache time-to-live.
7
- */
8
- const TTL_MS = 15 * 60 * 1000;
9
-
10
- /**
11
- * In-memory cache for news API responses.
12
- */
13
- const cache = new Map();
14
-
15
- /**
16
- * Builds a cache key for a given URI and bearer token.
17
- *
18
- * @param {string} uri
19
- * @param {string} [bearerToken]
20
- *
21
- * @returns {string}
22
- */
23
- function getCacheKey(uri, bearerToken) {
24
- return `${uri}::${bearerToken || ''}`;
25
- }
26
-
27
- /**
28
- * Returns true when a cache entry is still fresh.
29
- *
30
- * @param {object} entry
31
- *
32
- * @returns {boolean}
33
- */
34
- function isFresh(entry) {
35
- if (!entry) {
36
- return false;
37
- }
38
-
39
- return (Date.now() - entry.fetchedAt) < TTL_MS;
40
- }
41
-
42
- /**
43
- * Fetches JSON from the news API with optional bearer token support.
44
- *
45
- * Cached responses are reused for 15 minutes unless `force` is set.
46
- *
47
- * @param {object} args
48
- * @param {string} args.uri
49
- * @param {string} [args.bearerToken]
50
- * @param {boolean} [args.force]
51
- *
52
- * @returns {Promise<any>}
53
- */
54
- export async function fetchNewsJson({
55
- uri,
56
- bearerToken,
57
- force = false,
58
- }) {
59
- const key = getCacheKey(uri, bearerToken);
60
- const entry = cache.get(key);
61
-
62
- if (!force && isFresh(entry)) {
63
- return entry.data;
64
- }
65
-
66
- const headers = {};
67
- if (bearerToken) {
68
- headers.Authorization = `Bearer ${bearerToken}`;
69
- }
70
-
71
- const res = await fetch(uri, {
72
- method: 'GET',
73
- headers,
74
- });
75
-
76
- const data = await res.json();
77
-
78
- cache.set(key, {
79
- data,
80
- fetchedAt: Date.now(),
81
- });
82
-
83
- return data;
5
+ // This used to be its own standalone cache implementation. The
6
+ // logic was always fully generic underneath (just a TTL cache
7
+ // around fetch + bearer auth) — it's been moved to
8
+ // @opndev/react-native-events as useJsonApiData's default fetcher,
9
+ // shared with RemoteDataWidget. This file now just re-exports under
10
+ // the original names so NewsListScreen/NewsItemScreen don't need to
11
+ // change, and there's only one actual cache implementation rather
12
+ // than two that could drift apart.
13
+
14
+ import { fetchJson, clearJsonCache } from '@opndev/react-native-events/widgets';
15
+
16
+ export function fetchNewsJson({ uri, bearerToken, force }) {
17
+ return fetchJson({ uri, bearerToken, force });
84
18
  }
85
19
 
86
- /**
87
- * Clears the cached response for a specific URI.
88
- *
89
- * @param {object} args
90
- * @param {string} args.uri
91
- * @param {string} [args.bearerToken]
92
- *
93
- * @returns {void}
94
- */
95
- export function clearNewsCache({
96
- uri,
97
- bearerToken,
98
- }) {
99
- cache.delete(getCacheKey(uri, bearerToken));
20
+ export function clearNewsCache({ uri, bearerToken }) {
21
+ return clearJsonCache({ uri, bearerToken });
100
22
  }
@@ -1,3 +1,7 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
1
5
  import { useState, isValidElement, Children } from 'react';
2
6
  import { View, Image, Pressable, StyleSheet } from 'react-native';
3
7
  import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
@@ -0,0 +1,97 @@
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 { View, Text, StyleSheet } from 'react-native';
6
+ import Panel from './panel';
7
+
8
+ const defaultStyle = StyleSheet.create({
9
+ container: {
10
+ gap: 16,
11
+ },
12
+ dayHeading: {
13
+ fontWeight: '700',
14
+ marginBottom: 8,
15
+ },
16
+ event: {
17
+ gap: 4,
18
+ marginBottom: 12,
19
+ },
20
+ eventTitle: {
21
+ fontWeight: '700',
22
+ },
23
+ });
24
+
25
+ /**
26
+ * Schedule
27
+ *
28
+ * Renders a multi-day event schedule — one Panel per day. Panels
29
+ * cycle through `washColors` in order (e.g. [primarySoft,
30
+ * secondarySoft] gives primary/secondary/primary/secondary...), so
31
+ * consecutive days are visually distinct without any per-day
32
+ * configuration needed.
33
+ *
34
+ * @param {object} props
35
+ * @param {object} props.schedule Shape: { [dateKey]: Array<{ event: string, description?: string }> }
36
+ * @param {string[]} [props.washColors] Colors cycled through per day, in order. Omit for no alternation (every Panel uses Panel's own default).
37
+ * @param {(dateKey: string) => string} [props.formatDate] Defaults to showing the raw key as-is.
38
+ * @param {React.ComponentType} [props.TextComponent] Defaults to Text.
39
+ * @param {Function} [props.onPressEvent] (event, dateKey) => void
40
+ * @param {boolean} [props.shadow] Passed through to every Panel.
41
+ * @param {object} [props.containerStyle]
42
+ * @param {object} [props.dayHeadingStyle]
43
+ * @param {object} [props.eventTitleStyle]
44
+ * @param {object} [props.eventDescriptionStyle]
45
+ *
46
+ * @returns {JSX.Element}
47
+ */
48
+ export default function Schedule({
49
+ schedule,
50
+ washColors = [],
51
+ formatDate = (dateKey) => dateKey,
52
+ TextComponent = Text,
53
+ onPressEvent,
54
+ shadow,
55
+ containerStyle,
56
+ dayHeadingStyle,
57
+ eventTitleStyle,
58
+ eventDescriptionStyle,
59
+ }) {
60
+ const days = Object.keys(schedule || {});
61
+
62
+ return (
63
+ <View style={[defaultStyle.container, containerStyle]}>
64
+ {days.map((dateKey, dayIndex) => {
65
+ const events = schedule[dateKey] || [];
66
+ const backgroundColor = washColors.length
67
+ ? washColors[dayIndex % washColors.length]
68
+ : undefined;
69
+
70
+ return (
71
+ <Panel key={dateKey} backgroundColor={backgroundColor} shadow={shadow}>
72
+ <TextComponent style={[defaultStyle.dayHeading, dayHeadingStyle]}>
73
+ {formatDate(dateKey)}
74
+ </TextComponent>
75
+
76
+ {events.map((item, eventIndex) => (
77
+ <View key={eventIndex} style={defaultStyle.event}>
78
+ <TextComponent
79
+ style={[defaultStyle.eventTitle, eventTitleStyle]}
80
+ onPress={onPressEvent ? () => onPressEvent(item, dateKey) : undefined}
81
+ >
82
+ {item.event}
83
+ </TextComponent>
84
+
85
+ {item.description ? (
86
+ <TextComponent style={eventDescriptionStyle}>
87
+ {item.description}
88
+ </TextComponent>
89
+ ) : null}
90
+ </View>
91
+ ))}
92
+ </Panel>
93
+ );
94
+ })}
95
+ </View>
96
+ );
97
+ }
@@ -0,0 +1,262 @@
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 { useEffect, useRef, useState } from 'react';
6
+
7
+ function getByPath(obj, path) {
8
+ if (!path) return obj;
9
+ return path.split('.').reduce((acc, key) => (acc == null ? acc : acc[key]), obj);
10
+ }
11
+
12
+ const DEFAULT_REGULAR_MS = 15 * 60 * 1000;
13
+ const cache = new Map();
14
+
15
+ function getCacheKey(uri, bearerToken) {
16
+ return `${uri}::${bearerToken || ''}`;
17
+ }
18
+
19
+ function isFresh(entry, regularMs) {
20
+ if (!entry) return false;
21
+ return (Date.now() - entry.fetchedAt) < regularMs;
22
+ }
23
+
24
+ /**
25
+ * fetchJson
26
+ *
27
+ * Generic cached JSON fetch with bearer-token support. This is the
28
+ * same logic that used to live as news-specific `fetchNewsJson` —
29
+ * it was always fully generic underneath, just named after its
30
+ * first caller. Shares one in-memory cache across every caller,
31
+ * keyed by uri+bearerToken — two different widgets requesting the
32
+ * same uri within the TTL window share a single cached response
33
+ * rather than both fetching.
34
+ *
35
+ * @param {object} args
36
+ * @param {string} args.uri
37
+ * @param {string} [args.bearerToken]
38
+ * @param {boolean} [args.force] Bypass the cache for this call.
39
+ * @param {number} [args.regularMs] Defaults to 15 minutes.
40
+ *
41
+ * @returns {Promise<any>}
42
+ */
43
+ export async function fetchJson({ uri, bearerToken, force = false, regularMs = DEFAULT_REGULAR_MS }) {
44
+ const key = getCacheKey(uri, bearerToken);
45
+ const entry = cache.get(key);
46
+
47
+ if (!force && isFresh(entry, regularMs)) {
48
+ return entry.data;
49
+ }
50
+
51
+ const headers = {};
52
+ if (bearerToken) {
53
+ headers.Authorization = `Bearer ${bearerToken}`;
54
+ }
55
+
56
+ const res = await fetch(uri, { method: 'GET', headers });
57
+
58
+ if (!res.ok) {
59
+ throw new Error(`Request failed (${res.status})`);
60
+ }
61
+
62
+ const data = await res.json();
63
+ cache.set(key, { data, fetchedAt: Date.now() });
64
+ return data;
65
+ }
66
+
67
+ /**
68
+ * clearJsonCache
69
+ *
70
+ * @param {object} args
71
+ * @param {string} args.uri
72
+ * @param {string} [args.bearerToken]
73
+ */
74
+ export function clearJsonCache({ uri, bearerToken }) {
75
+ cache.delete(getCacheKey(uri, bearerToken));
76
+ }
77
+
78
+ async function defaultFetcher({ uri, bearerToken, force, regularMs }) {
79
+ return fetchJson({ uri, bearerToken, force, regularMs });
80
+ }
81
+
82
+ // ── Manual refresh cooldown ──────────────────────────────────
83
+ // A second, separate tracker from the data cache above — this
84
+ // governs "when is the refresh BUTTON allowed to be pressed again",
85
+ // not "is the data stale". Shared per uri+bearerToken (same key
86
+ // scheme as the data cache), so multiple widgets pointed at the
87
+ // same endpoint share one cooldown rather than each tracking their
88
+ // own independently.
89
+
90
+ const MANUAL_LOCK_MS = 15 * 60 * 1000;
91
+ const ERROR_REFRESH_MS = 5 * 60 * 1000;
92
+ const cooldowns = new Map(); // key -> expiresAt timestamp
93
+
94
+ function getCooldownRemaining(key) {
95
+ const expiresAt = cooldowns.get(key);
96
+ if (!expiresAt) return 0;
97
+ return Math.max(0, expiresAt - Date.now());
98
+ }
99
+
100
+ function setCooldown(key, durationMs) {
101
+ cooldowns.set(key, Date.now() + durationMs);
102
+ }
103
+
104
+ function clearCooldown(key) {
105
+ cooldowns.delete(key);
106
+ }
107
+
108
+ /**
109
+ * useJsonApiData
110
+ *
111
+ * Generic fetch + poll + reshape — the data-fetching half of what
112
+ * used to live inline inside NewsWidget, now reusable by any
113
+ * RemoteDataWidget-style component regardless of what it's actually
114
+ * fetching.
115
+ *
116
+ * Always returns the same shape:
117
+ * { items, loading, error, refresh, manualRefresh, canManualRefresh }
118
+ *
119
+ * `refresh` is unchanged from before — exposed so a component can
120
+ * wire it into useImperativeHandle for an external
121
+ * ref.current.refresh() call (e.g. triggered by a push
122
+ * notification). It always works and is NOT subject to the
123
+ * cooldown below — a real notification shouldn't get silently
124
+ * dropped because someone tapped a refresh button five minutes ago.
125
+ *
126
+ * `manualRefresh` is new — meant for a UI refresh button. It
127
+ * respects a per-endpoint cooldown:
128
+ * - succeeds → button locked for 15 minutes
129
+ * - errors → button locked for only 5 minutes (fail faster,
130
+ * recover faster)
131
+ * - whenever the automatic polling interval fires (success OR
132
+ * error), the cooldown is cleared immediately — the data's
133
+ * fresh either way, no reason to keep the button blocked.
134
+ * `canManualRefresh` reflects whether the button should currently
135
+ * be enabled.
136
+ *
137
+ * Caching behaviour (the underlying data, separate from the above):
138
+ * the initial load may reuse a fresh cached response. Interval
139
+ * ticks and any forced refresh always force a real fetch.
140
+ *
141
+ * @param {object} options
142
+ * @param {string} options.uri
143
+ * @param {string} [options.bearerToken]
144
+ * @param {string} [options.jpath] Dot-path to the array within the response (e.g. 'data.results'). Omit if the response IS the array.
145
+ * @param {(rawItem: any) => any} [options.mapItem] Reshapes each raw item into whatever the consuming widget expects. Defaults to identity (no reshaping).
146
+ * @param {number} [options.refreshIntervalMs] Polling interval. Defaults to 0 (no polling — fetch once on mount).
147
+ * @param {number} [options.regularMs] Cache freshness window, passed to the default fetcher. Defaults to 15 minutes.
148
+ * @param {number} [options.manualLockMs] Defaults to 15 minutes.
149
+ * @param {number} [options.errorRefreshMs] Defaults to 5 minutes.
150
+ * @param {(args: { uri: string, bearerToken?: string, force: boolean }) => Promise<any>} [options.fetcher] Defaults to the cached fetchJson above.
151
+ *
152
+ * @returns {{ items: any[], loading: boolean, error: string|null, refresh: () => Promise<void>, manualRefresh: () => Promise<void>, canManualRefresh: boolean }}
153
+ */
154
+ export function useJsonApiData({
155
+ uri,
156
+ bearerToken,
157
+ jpath,
158
+ mapItem = (item) => item,
159
+ refreshIntervalMs = 0,
160
+ regularMs,
161
+ fetcher = defaultFetcher,
162
+ manualLockMs = MANUAL_LOCK_MS,
163
+ errorRefreshMs = ERROR_REFRESH_MS,
164
+ }) {
165
+ const [items, setItems] = useState([]);
166
+ const [loading, setLoading] = useState(true);
167
+ const [error, setError] = useState(null);
168
+ const intervalRef = useRef(null);
169
+ const cooldownTimeoutRef = useRef(null);
170
+
171
+ const cooldownKey = getCacheKey(uri, bearerToken);
172
+
173
+ const [canManualRefresh, setCanManualRefresh] = useState(
174
+ () => getCooldownRemaining(cooldownKey) <= 0
175
+ );
176
+
177
+ function applyCooldown(durationMs) {
178
+ setCooldown(cooldownKey, durationMs);
179
+ setCanManualRefresh(false);
180
+
181
+ if (cooldownTimeoutRef.current) {
182
+ clearTimeout(cooldownTimeoutRef.current);
183
+ }
184
+ cooldownTimeoutRef.current = setTimeout(() => {
185
+ setCanManualRefresh(true);
186
+ }, durationMs);
187
+ }
188
+
189
+ function releaseCooldown() {
190
+ clearCooldown(cooldownKey);
191
+ if (cooldownTimeoutRef.current) {
192
+ clearTimeout(cooldownTimeoutRef.current);
193
+ cooldownTimeoutRef.current = null;
194
+ }
195
+ setCanManualRefresh(true);
196
+ }
197
+
198
+ async function load(force = false, source = 'initial') {
199
+ setError(null);
200
+ let succeeded = true;
201
+
202
+ try {
203
+ const raw = await fetcher({ uri, bearerToken, force, regularMs });
204
+ const resolved = getByPath(raw, jpath);
205
+
206
+ if (!Array.isArray(resolved)) {
207
+ console.warn(
208
+ jpath
209
+ ? `useJsonApiData: jpath "${jpath}" did not resolve to an array (got ${typeof resolved}). Check that this path actually points at the array in your API's response. Falling back to an empty list.`
210
+ : `useJsonApiData: expected the response to be an array, but got ${typeof resolved}. If your API wraps the array in an object (e.g. { items: [...] }), pass jpath to point at it (e.g. jpath="items"). Falling back to an empty list.`
211
+ );
212
+ }
213
+
214
+ setItems(Array.isArray(resolved) ? resolved.map(mapItem) : []);
215
+ }
216
+ catch (e) {
217
+ succeeded = false;
218
+ setError(e.message || 'Unable to load data.');
219
+ }
220
+
221
+ setLoading(false);
222
+
223
+ if (source === 'manual') {
224
+ applyCooldown(succeeded ? manualLockMs : errorRefreshMs);
225
+ }
226
+ else if (source === 'system') {
227
+ releaseCooldown();
228
+ }
229
+ }
230
+
231
+ useEffect(() => {
232
+ setLoading(true);
233
+ setCanManualRefresh(getCooldownRemaining(cooldownKey) <= 0);
234
+ load(false, 'initial');
235
+
236
+ if (refreshIntervalMs > 0) {
237
+ intervalRef.current = setInterval(() => load(true, 'system'), refreshIntervalMs);
238
+ return () => clearInterval(intervalRef.current);
239
+ }
240
+ // eslint-disable-next-line react-hooks/exhaustive-deps
241
+ }, [uri, bearerToken, jpath, refreshIntervalMs]);
242
+
243
+ useEffect(() => {
244
+ return () => {
245
+ if (cooldownTimeoutRef.current) {
246
+ clearTimeout(cooldownTimeoutRef.current);
247
+ }
248
+ };
249
+ }, []);
250
+
251
+ return {
252
+ items,
253
+ loading,
254
+ error,
255
+ refresh: () => load(true, 'external'),
256
+ manualRefresh: () => {
257
+ if (getCooldownRemaining(cooldownKey) > 0) return;
258
+ load(true, 'manual');
259
+ },
260
+ canManualRefresh,
261
+ };
262
+ }
@@ -0,0 +1,32 @@
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
+ /**
6
+ * useStaticData
7
+ *
8
+ * The "I already have this data" counterpart to useJsonApiData —
9
+ * same returned shape, so DataListWidget (or anything else
10
+ * consuming either hook) never needs to know which one it's
11
+ * actually talking to. No fetch, no polling, no cache, no manual
12
+ * refresh — there's nothing to refresh; the data only changes if
13
+ * the caller passes different `data` on a re-render.
14
+ *
15
+ * @param {object} options
16
+ * @param {any[]} options.data
17
+ * @param {(rawItem: any) => any} [options.mapItem] Reshapes each raw item into whatever the consuming widget expects. Defaults to identity (no reshaping).
18
+ *
19
+ * @returns {{ items: any[], loading: false, error: null, refresh: () => void, manualRefresh: () => void, canManualRefresh: false }}
20
+ */
21
+ export function useStaticData({ data, mapItem = (item) => item }) {
22
+ const items = (data || []).map(mapItem);
23
+
24
+ return {
25
+ items,
26
+ loading: false,
27
+ error: null,
28
+ refresh: () => {},
29
+ manualRefresh: () => {},
30
+ canManualRefresh: false,
31
+ };
32
+ }
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
4
 
5
- const VERSION = "0.0.15";
5
+ const VERSION = "0.0.16";
6
6
 
7
7
  // TODO: @opndev/util?
8
8
  export { formatPrice } from './utils/format-price.js';
@@ -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
@@ -35,5 +35,5 @@
35
35
  },
36
36
  "sideEffects": false,
37
37
  "type": "module",
38
- "version": "0.0.15"
38
+ "version": "0.0.16"
39
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
-