@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.
- package/Changes +12 -0
- package/README.md +2 -2
- package/lib/actions/news.js +15 -93
- package/lib/components/app-screen-top-bar.jsx +1 -1
- package/lib/components/app-screen.jsx +2 -1
- package/lib/components/hero-screen-fixed.jsx +21 -7
- package/lib/components/hero-screen-header.jsx +157 -0
- package/lib/components/hero-screen-parallax.jsx +50 -21
- package/lib/components/hero-screen.jsx +20 -6
- package/lib/components/panel.jsx +44 -10
- package/lib/components/parallax-scroll-view.js +5 -2
- package/lib/components/schedule.jsx +97 -0
- package/lib/debug.js +5 -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/utils/route-debug.js +21 -0
- 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 +2 -1
- package/lib/widgets/news-widget.jsx +0 -190
package/lib/components/panel.jsx
CHANGED
|
@@ -6,6 +6,16 @@ import React from 'react';
|
|
|
6
6
|
import { View, Pressable, StyleSheet } from 'react-native';
|
|
7
7
|
|
|
8
8
|
const defaultStyle = StyleSheet.create({
|
|
9
|
+
shadowWrap: {
|
|
10
|
+
borderRadius: 16,
|
|
11
|
+
},
|
|
12
|
+
shadowOn: {
|
|
13
|
+
elevation: 4,
|
|
14
|
+
shadowColor: '#000',
|
|
15
|
+
shadowOffset: { width: 0, height: 2 },
|
|
16
|
+
shadowOpacity: 0.15,
|
|
17
|
+
shadowRadius: 6,
|
|
18
|
+
},
|
|
9
19
|
surface: {
|
|
10
20
|
borderRadius: 16,
|
|
11
21
|
overflow: 'hidden',
|
|
@@ -25,11 +35,20 @@ const defaultStyle = StyleSheet.create({
|
|
|
25
35
|
* widgets (lists, text blocks, NewsWidget, etc.) render exactly as
|
|
26
36
|
* they would un-wrapped, just inside a rounded/backgrounded card.
|
|
27
37
|
*
|
|
38
|
+
* Internally split into two layers — an outer view that casts the
|
|
39
|
+
* shadow (when enabled) and an inner view that clips content to the
|
|
40
|
+
* rounded corners. This is deliberate: a shadow renders outside a
|
|
41
|
+
* view's own bounds, so a single view with both `overflow: hidden`
|
|
42
|
+
* and a shadow would clip its own shadow into invisibility. The
|
|
43
|
+
* split means `shadow` can be toggled on/off with no other changes
|
|
44
|
+
* needed, and panels that don't use it are unaffected.
|
|
45
|
+
*
|
|
28
46
|
* @param {object} props
|
|
29
47
|
* @param {React.ReactNode} props.children
|
|
30
48
|
* @param {string} [props.backgroundColor]
|
|
49
|
+
* @param {boolean} [props.shadow] Floating-card drop shadow. Defaults to false.
|
|
31
50
|
* @param {Function} [props.onPress] If provided, the whole panel becomes pressable.
|
|
32
|
-
* @param {object} [props.style] Style for the outer
|
|
51
|
+
* @param {object} [props.style] Style for the outer (shadow-casting) wrapper.
|
|
33
52
|
* @param {object} [props.contentStyle] Style for the inner content wrapper (default padding: 16).
|
|
34
53
|
*
|
|
35
54
|
* @returns {JSX.Element}
|
|
@@ -37,6 +56,7 @@ const defaultStyle = StyleSheet.create({
|
|
|
37
56
|
export default function Panel({
|
|
38
57
|
children,
|
|
39
58
|
backgroundColor,
|
|
59
|
+
shadow = false,
|
|
40
60
|
onPress,
|
|
41
61
|
style,
|
|
42
62
|
contentStyle,
|
|
@@ -44,7 +64,6 @@ export default function Panel({
|
|
|
44
64
|
const surfaceStyle = [
|
|
45
65
|
defaultStyle.surface,
|
|
46
66
|
backgroundColor ? { backgroundColor } : null,
|
|
47
|
-
style,
|
|
48
67
|
];
|
|
49
68
|
|
|
50
69
|
const content = (
|
|
@@ -53,13 +72,28 @@ export default function Panel({
|
|
|
53
72
|
</View>
|
|
54
73
|
);
|
|
55
74
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
const inner = onPress ? (
|
|
76
|
+
<Pressable onPress={onPress} style={surfaceStyle}>
|
|
77
|
+
{content}
|
|
78
|
+
</Pressable>
|
|
79
|
+
) : (
|
|
80
|
+
<View style={surfaceStyle}>{content}</View>
|
|
81
|
+
);
|
|
63
82
|
|
|
64
|
-
return
|
|
83
|
+
return (
|
|
84
|
+
<View
|
|
85
|
+
style={[
|
|
86
|
+
defaultStyle.shadowWrap,
|
|
87
|
+
shadow ? defaultStyle.shadowOn : null,
|
|
88
|
+
// Android's elevation shadow needs an opaque shape with a
|
|
89
|
+
// matching borderRadius to cast a correctly-rounded shadow —
|
|
90
|
+
// a fully transparent outer view can shadow as a square box
|
|
91
|
+
// instead of following the rounded silhouette.
|
|
92
|
+
shadow && backgroundColor ? { backgroundColor } : null,
|
|
93
|
+
style,
|
|
94
|
+
]}
|
|
95
|
+
>
|
|
96
|
+
{inner}
|
|
97
|
+
</View>
|
|
98
|
+
);
|
|
65
99
|
}
|
|
@@ -11,7 +11,7 @@ import Animated, {
|
|
|
11
11
|
} from 'react-native-reanimated';
|
|
12
12
|
import { useTopOffset, heroContentStyle } from './hero-screen-header';
|
|
13
13
|
|
|
14
|
-
const DEFAULT_HEADER_HEIGHT =
|
|
14
|
+
const DEFAULT_HEADER_HEIGHT = 220;
|
|
15
15
|
|
|
16
16
|
const defaultStyle = StyleSheet.create({
|
|
17
17
|
container: {
|
|
@@ -50,8 +50,11 @@ const defaultStyle = StyleSheet.create({
|
|
|
50
50
|
* Background color for the scroll view container.
|
|
51
51
|
* @param {string} [props.headerBackgroundColor]
|
|
52
52
|
* Background color shown behind the header image.
|
|
53
|
-
* @param {number} [props.headerHeight=
|
|
53
|
+
* @param {number} [props.headerHeight=220]
|
|
54
54
|
* Visible height of the parallax header image (excludes the top offset).
|
|
55
|
+
* Matches HeroScreenFixed/HeroScreenOverlay's default — kept in
|
|
56
|
+
* sync deliberately so all three variants render identically when
|
|
57
|
+
* no explicit headerHeight is passed.
|
|
55
58
|
* @param {React.ComponentType<{style?: any, children?: React.ReactNode}>}
|
|
56
59
|
* [props.ContentComponent=View]
|
|
57
60
|
* Wrapper used for the content area below the header.
|
|
@@ -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
|
+
}
|
package/lib/debug.js
ADDED
|
@@ -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,21 @@
|
|
|
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 { useNavigationState } from '@react-navigation/native';
|
|
6
|
+
import { useRouter } from 'expo-router';
|
|
7
|
+
|
|
8
|
+
export function debugRouteState() {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const navState = useNavigationState((state) => state);
|
|
11
|
+
|
|
12
|
+
console.log('[news stack]',
|
|
13
|
+
JSON.stringify(navState?.routes?.map((r) => (
|
|
14
|
+
{ name: r.name, params: r.params, key: r.key }
|
|
15
|
+
)),
|
|
16
|
+
null,
|
|
17
|
+
2
|
|
18
|
+
)
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
}
|