@opndev/react-native-events 0.0.12 → 0.0.14

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,16 @@
1
1
  Revision history for @opndev/opndev-react-native-events
2
2
 
3
+ 0.0.14 2026-06-26 04:53:23Z
4
+
5
+ * Add Panel instead of a Tile, Tiles are more buttony thing. Panels are
6
+ panels. ha.
7
+
8
+ 0.0.13 2026-06-26 03:59:55Z
9
+
10
+ * Update news item segment to be included as a widget.
11
+ This infra is highly sus for generic dynamic pages infra. I need some more
12
+ hours in a day to get perhaps get that to work nicely. but getting there.
13
+
3
14
  0.0.12 2026-06-25 23:24:45Z
4
15
 
5
16
  * Add top bar to package
@@ -2,8 +2,9 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
4
 
5
- import { View, Image, StyleSheet } from 'react-native';
6
- import { useAppScreenOffset } from '../hooks/use-app-screen-offset.js';
5
+ import { View, Image, Pressable, StyleSheet } from 'react-native';
6
+ import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
7
+ import { useAppScreenOffset } from '../hooks/use-app-screen-offset';
7
8
 
8
9
  const styles = StyleSheet.create({
9
10
  headerImage: { width: '100%', height: '100%' },
@@ -11,8 +12,48 @@ const styles = StyleSheet.create({
11
12
  // Matches ParallaxScrollView's own `content` style — single source of
12
13
  // truth so every Hero variant lines up on body padding/gap.
13
14
  heroContent: { padding: 32, gap: 16 },
15
+ backButton: {
16
+ width: 40,
17
+ height: 40,
18
+ borderRadius: 20,
19
+ backgroundColor: 'rgba(0,0,0,0.55)',
20
+ alignItems: 'center',
21
+ justifyContent: 'center',
22
+ elevation: 4,
23
+ shadowColor: '#000',
24
+ shadowOffset: { width: 0, height: 2 },
25
+ shadowOpacity: 0.3,
26
+ shadowRadius: 3,
27
+ },
14
28
  });
15
29
 
30
+ /**
31
+ * BackButton
32
+ *
33
+ * Floating back-chevron button meant to sit over a Hero variant's
34
+ * header image. Router-agnostic — same pattern as onSlidePress
35
+ * elsewhere — the package renders the button, the app decides what
36
+ * tapping it does (typically router.back()).
37
+ *
38
+ * Renders nothing if `onBack` isn't provided, so adding this to a
39
+ * Hero variant is a no-op for screens that don't pass it.
40
+ *
41
+ * @param {object} props
42
+ * @param {Function} [props.onBack]
43
+ * @param {string} [props.iconColor] Defaults to white.
44
+ * @param {number} [props.iconSize] Defaults to 22.
45
+ * @param {object} [props.style] Positioning is the caller's responsibility (e.g. absolute top/left) — this component doesn't position itself.
46
+ */
47
+ export function BackButton({ onBack, iconColor = '#fff', iconSize = 22, style }) {
48
+ if (!onBack) return null;
49
+
50
+ return (
51
+ <Pressable onPress={onBack} style={[styles.backButton, style]}>
52
+ <MaterialCommunityIcons name="arrow-left" size={iconSize} color={iconColor} />
53
+ </Pressable>
54
+ );
55
+ }
56
+
16
57
  export function renderHeaderImage(headerImage) {
17
58
  if (!headerImage) return null;
18
59
  const fit = headerImage.fit || 'cover';
@@ -6,6 +6,7 @@ import { View, ScrollView, StyleSheet } from 'react-native';
6
6
  import {
7
7
  renderHeaderImage,
8
8
  HeaderOverlay,
9
+ BackButton,
9
10
  useHeroHeaderHeight,
10
11
  heroContentStyle,
11
12
  } from './hero-screen-header';
@@ -37,7 +38,7 @@ const defaultStyle = StyleSheet.create({
37
38
  * headerBackgroundColor strip (sized to the safe-area top inset,
38
39
  * via useHeroHeaderHeight) sits above it. Body content is wrapped
39
40
  * the same way ParallaxScrollView wraps its children — padding: 32,
40
- * gap: 8 — so spacing matches the parallax variant exactly. The
41
+ * gap: 16 — so spacing matches the parallax variant exactly. The
41
42
  * spacer that reserves room for the header stays outside that
42
43
  * padded wrapper, same as Parallax keeps its header outside its
43
44
  * own padded content area.
@@ -46,6 +47,7 @@ const defaultStyle = StyleSheet.create({
46
47
  * @param {React.ReactNode} props.children
47
48
  * @param {object} [props.headerImage]
48
49
  * @param {React.ReactNode} [props.headerOverlay]
50
+ * @param {Function} [props.onBack] Renders a back button over the header image when provided. Omit for no back button.
49
51
  * @param {string} [props.backgroundColor]
50
52
  * @param {string} [props.headerBackgroundColor]
51
53
  * @param {number} [props.headerHeight] Visible image height (excludes the safe-area inset)
@@ -60,6 +62,7 @@ export default function HeroScreenOverlay({
60
62
  children,
61
63
  headerImage,
62
64
  headerOverlay,
65
+ onBack,
63
66
  backgroundColor,
64
67
  headerBackgroundColor,
65
68
  headerHeight = 220,
@@ -106,6 +109,11 @@ export default function HeroScreenOverlay({
106
109
  >
107
110
  {renderHeaderImage(headerImage)}
108
111
  </View>
112
+
113
+ <BackButton
114
+ onBack={onBack}
115
+ style={{ position: 'absolute', top: topOffset + 12, left: 12 }}
116
+ />
109
117
  </View>
110
118
  </View>
111
119
  );
@@ -0,0 +1,65 @@
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 from 'react';
6
+ import { View, Pressable, StyleSheet } from 'react-native';
7
+
8
+ const defaultStyle = StyleSheet.create({
9
+ surface: {
10
+ borderRadius: 16,
11
+ overflow: 'hidden',
12
+ },
13
+ content: {
14
+ padding: 16,
15
+ },
16
+ });
17
+
18
+ /**
19
+ * Panel
20
+ *
21
+ * Simple rounded-corner surface that sizes to its content. Unlike
22
+ * Tile (built for a centered icon+label grid cell with flex: 1),
23
+ * Panel imposes no layout assumptions on its children at all — no
24
+ * forced centering, no forced flex sizing — so content-driven
25
+ * widgets (lists, text blocks, NewsWidget, etc.) render exactly as
26
+ * they would un-wrapped, just inside a rounded/backgrounded card.
27
+ *
28
+ * @param {object} props
29
+ * @param {React.ReactNode} props.children
30
+ * @param {string} [props.backgroundColor]
31
+ * @param {Function} [props.onPress] If provided, the whole panel becomes pressable.
32
+ * @param {object} [props.style] Style for the outer rounded surface.
33
+ * @param {object} [props.contentStyle] Style for the inner content wrapper (default padding: 16).
34
+ *
35
+ * @returns {JSX.Element}
36
+ */
37
+ export default function Panel({
38
+ children,
39
+ backgroundColor,
40
+ onPress,
41
+ style,
42
+ contentStyle,
43
+ }) {
44
+ const surfaceStyle = [
45
+ defaultStyle.surface,
46
+ backgroundColor ? { backgroundColor } : null,
47
+ style,
48
+ ];
49
+
50
+ const content = (
51
+ <View style={[defaultStyle.content, contentStyle]}>
52
+ {children}
53
+ </View>
54
+ );
55
+
56
+ if (onPress) {
57
+ return (
58
+ <Pressable onPress={onPress} style={surfaceStyle}>
59
+ {content}
60
+ </Pressable>
61
+ );
62
+ }
63
+
64
+ return <View style={surfaceStyle}>{content}</View>;
65
+ }
package/lib/components.js CHANGED
@@ -11,6 +11,7 @@ export { default as Tile } from './components/tile.jsx';
11
11
  export { default as GradientTile } from './components/gradient-tile.jsx';
12
12
  export { default as QRCodeForm } from './components/qr-code-form.jsx';
13
13
  export { default as ScaledLogo } from './components/scaled-logo.jsx';
14
+ export { default as Panel } from './components/panel.jsx';
14
15
 
15
16
  export { openUrl, openApp, openExternal } from './utils/launch.js';
16
17
 
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.12";
5
+ const VERSION = "0.0.14";
6
6
 
7
7
  // TODO: @opndev/util?
8
8
  export { formatPrice } from './utils/format-price.js';
@@ -13,7 +13,6 @@ import {
13
13
  View,
14
14
  } from 'react-native';
15
15
  import Markdown from 'react-native-markdown-display';
16
-
17
16
  import HeroScreen from '../components/hero-screen';
18
17
  import { fetchNewsJson } from '../actions/news';
19
18
 
@@ -53,18 +52,16 @@ export default function NewsItemScreen({
53
52
  TextComponent = Text,
54
53
  MarkdownComponent = Markdown,
55
54
  defaultHeaderImage,
56
-
55
+ onBack,
57
56
  backgroundColor,
58
57
  headerBackgroundColor,
59
58
  headerHeight,
60
-
61
59
  containerStyle,
62
60
  headerStyle,
63
61
  contentStyle,
64
62
  summaryStyle,
65
63
  titleStyle,
66
64
  markdownStyle,
67
-
68
65
  loadingLabel = 'Loading...',
69
66
  errorLabel = 'Unable to load news item.',
70
67
  }) {
@@ -100,10 +97,7 @@ export default function NewsItemScreen({
100
97
  setLoading(false);
101
98
  }
102
99
 
103
- if (!item) return;
104
-
105
100
  const headerImage = defaultHeaderImage;
106
-
107
101
  // const headerImage = item?.image
108
102
  // ? {
109
103
  // source: { uri: item.image },
@@ -112,11 +106,47 @@ export default function NewsItemScreen({
112
106
  // }
113
107
  // : defaultHeaderImage;
114
108
 
109
+ // Loading and error states now render through the same HeroScreen
110
+ // shell (so chrome/background/header stay consistent) instead of
111
+ // bailing out to a blank screen before item is set.
112
+ if (loading || error || !item) {
113
+ return (
114
+ <HeroScreen
115
+ TextComponent={TextComponent}
116
+ titleStyle={titleStyle}
117
+ headerImage={headerImage}
118
+ onBack={onBack}
119
+ backgroundColor={backgroundColor}
120
+ headerBackgroundColor={headerBackgroundColor}
121
+ headerHeight={headerHeight}
122
+ containerStyle={containerStyle}
123
+ headerStyle={headerStyle}
124
+ contentStyle={contentStyle}
125
+ >
126
+ <View style={defaultStyle.content}>
127
+ {loading ? (
128
+ <View>
129
+ <ActivityIndicator />
130
+ <TextComponent>{loadingLabel}</TextComponent>
131
+ </View>
132
+ ) : null}
133
+
134
+ {!loading && error ? (
135
+ <TextComponent>
136
+ {error}
137
+ </TextComponent>
138
+ ) : null}
139
+ </View>
140
+ </HeroScreen>
141
+ );
142
+ }
143
+
115
144
  return (
116
145
  <HeroScreen
117
146
  TextComponent={TextComponent}
118
147
  titleStyle={titleStyle}
119
148
  headerImage={headerImage}
149
+ onBack={onBack}
120
150
  headerOverlay={
121
151
  <TextComponent type="title">
122
152
  {item.title}
@@ -130,24 +160,9 @@ export default function NewsItemScreen({
130
160
  contentStyle={contentStyle}
131
161
  >
132
162
  <View style={defaultStyle.content}>
133
- {loading ? (
134
- <View>
135
- <ActivityIndicator />
136
- <TextComponent>{loadingLabel}</TextComponent>
137
- </View>
138
- ) : null}
139
-
140
- {!loading && error ? (
141
- <TextComponent>
142
- {error}
143
- </TextComponent>
144
- ) : null}
145
-
146
- {!loading && !error ? (
147
- <MarkdownComponent style={markdownStyle}>
148
- {item?.content || ''}
149
- </MarkdownComponent>
150
- ) : null}
163
+ <MarkdownComponent style={markdownStyle}>
164
+ {item?.content || ''}
165
+ </MarkdownComponent>
151
166
  </View>
152
167
  </HeroScreen>
153
168
  );
@@ -0,0 +1,190 @@
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
+
package/lib/widgets.js ADDED
@@ -0,0 +1,5 @@
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
+ export { default as NewsWidget } from './widgets/news-widget.jsx';
package/package.json CHANGED
@@ -13,12 +13,12 @@
13
13
  "./components": "./lib/components.js",
14
14
  "./globals": "./lib/screen-registry.js",
15
15
  "./hero-screen-registry": "./lib/hero-screen-registry.js",
16
- "./hooks/use-safe-area-top-inset": "./lib/hooks/use-safe-area-top-inset.js",
17
16
  "./lite": "./lib/hero-screen-registry.js",
18
17
  "./notifications": "./lib/notifications.js",
19
18
  "./notifications-fcm": "./lib/notifications/fcm.js",
20
19
  "./screen-registry": "./lib/screen-registry.js",
21
- "./screens": "./lib/screens.js"
20
+ "./screens": "./lib/screens.js",
21
+ "./widgets": "./lib/widgets.js"
22
22
  },
23
23
  "keywords": [],
24
24
  "license": "GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions",
@@ -34,5 +34,5 @@
34
34
  },
35
35
  "sideEffects": false,
36
36
  "type": "module",
37
- "version": "0.0.12"
37
+ "version": "0.0.14"
38
38
  }