@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 +7 -0
- package/README.md +2 -2
- package/lib/actions/news.js +15 -93
- package/lib/components/hero-screen-header.jsx +4 -0
- package/lib/components/schedule.jsx +97 -0
- package/lib/hooks/use-json-api-data.js +262 -0
- package/lib/hooks/use-static-data.js +32 -0
- package/lib/index.js +1 -1
- package/lib/widgets/data-list-widget.jsx +189 -0
- package/lib/widgets/remote-data-widget.jsx +88 -0
- package/lib/widgets/static-data-widget.jsx +50 -0
- package/lib/widgets.js +5 -1
- package/package.json +1 -1
- package/lib/widgets/news-widget.jsx +0 -190
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/
|
|
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/
|
|
29
|
+
npm install @opndev/react-native-events
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
Peer dependencies are expected to be installed by the consuming app.
|
package/lib/actions/news.js
CHANGED
|
@@ -2,99 +2,21 @@
|
|
|
2
2
|
//
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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
|
@@ -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
|
-
|