@vvedantb/native-ui 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ import { useContext, type ReactNode } from "react";
2
+ import { Text, View, type TextStyle, type ViewStyle } from "react-native";
3
+ import { Renderer, useMarkdown, type MarkedStyles } from "react-native-marked";
4
+ import { CodeBlock } from "../ai/code-block";
5
+ import { MONO_FONT, chatTextSize } from "../ai/chat-text";
6
+ import { ChatTextColorContext } from "../ai/response-text";
7
+ import { sizeStyle } from "../controlStyles";
8
+ import { palette, withAlpha } from "../palette";
9
+ import { useLargerText } from "../theme";
10
+ import { tokens } from "../tokens";
11
+ import { useScheme } from "../useScheme";
12
+
13
+ /** Instrument Sans faces, as the app loads them (see `INSTRUMENT_SANS` in tailwind-preset.cjs). */
14
+ const FONT = {
15
+ regular: "InstrumentSans_400Regular",
16
+ semibold: "InstrumentSans_600SemiBold",
17
+ } as const;
18
+
19
+ /**
20
+ * Sizes from the web `markdown.css` (`@vvedantb/ui/tokens/css/markdown.css`),
21
+ * in em of the body size: h1 1.43, h2 1.29, h3 1.14, h4-h6 1; inline code
22
+ * 0.86. Headings line-height 1.3; blocks 0.65rem apart.
23
+ */
24
+ export const MARKDOWN_SCALE = {
25
+ h1: 1.43,
26
+ h2: 1.29,
27
+ h3: 1.14,
28
+ h4: 1,
29
+ inlineCode: 0.86,
30
+ headingLineHeight: 1.3,
31
+ blockGap: 10,
32
+ } as const;
33
+
34
+ export type MarkdownProps = {
35
+ /** The markdown source. */
36
+ children: string;
37
+ /** With it, fenced code blocks get a copy button (native-ui adds no clipboard package). */
38
+ onCopyCode?: (code: string) => void | Promise<void>;
39
+ };
40
+
41
+ /**
42
+ * Markdown at chat size, styled as the web `.markdown` (Streamdown) block:
43
+ * compact headings, primary links underlined at 30%, inline code on a 6%
44
+ * foreground wash at radius 6, and fenced code as `CodeBlock` (the flat
45
+ * `surface-secondary` panel with a language and copy header). Text takes the
46
+ * enclosing `MessageContent` colour, like `ResponseText`.
47
+ *
48
+ * Needs the optional peer `react-native-marked` (it renders through
49
+ * `useMarkdown`, so it nests in a `ScrollView` without a `FlatList`).
50
+ */
51
+ export function Markdown({ children, onCopyCode }: MarkdownProps) {
52
+ const scheme = useScheme();
53
+ const largerText = useLargerText();
54
+ const inherited = useContext(ChatTextColorContext);
55
+ const body = sizeStyle(chatTextSize("body", largerText));
56
+ const color = inherited ?? tokens.color.foreground[scheme];
57
+ const renderer = new NativeUiMarkdownRenderer(scheme, body.fontSize, onCopyCode);
58
+ const elements = useMarkdown(children, {
59
+ colorScheme: scheme,
60
+ renderer,
61
+ theme: {
62
+ colors: {
63
+ text: color,
64
+ link: palette.primary[scheme],
65
+ code: tokens.color.surfaceSecondary[scheme],
66
+ border: tokens.color.border[scheme],
67
+ },
68
+ },
69
+ styles: markdownStyles(scheme, body, color),
70
+ });
71
+ return <>{elements}</>;
72
+ }
73
+
74
+ /** The style map `useMarkdown` takes, from the web scale. Exported for tests. */
75
+ export function markdownStyles(
76
+ scheme: "light" | "dark",
77
+ body: { fontSize: number; lineHeight: number },
78
+ color: string,
79
+ ): MarkedStyles {
80
+ const heading = (scale: number): TextStyle => ({
81
+ fontFamily: FONT.semibold,
82
+ fontWeight: "600",
83
+ fontSize: Math.round(body.fontSize * scale),
84
+ lineHeight: Math.round(body.fontSize * scale * MARKDOWN_SCALE.headingLineHeight),
85
+ letterSpacing: -0.01 * body.fontSize * scale,
86
+ color,
87
+ marginTop: 20,
88
+ marginBottom: 8,
89
+ });
90
+ const gap = { marginVertical: MARKDOWN_SCALE.blockGap / 2 };
91
+ return {
92
+ text: { fontFamily: FONT.regular, fontSize: body.fontSize, lineHeight: body.lineHeight, color },
93
+ paragraph: { ...gap, paddingVertical: 0 },
94
+ strong: { fontFamily: FONT.semibold, fontWeight: "600" },
95
+ em: { fontStyle: "italic" },
96
+ link: {
97
+ color: palette.primary[scheme],
98
+ textDecorationLine: "underline",
99
+ textDecorationColor: withAlpha(palette.primary[scheme], 0.3),
100
+ },
101
+ h1: heading(MARKDOWN_SCALE.h1),
102
+ h2: heading(MARKDOWN_SCALE.h2),
103
+ h3: heading(MARKDOWN_SCALE.h3),
104
+ h4: heading(MARKDOWN_SCALE.h4),
105
+ h5: heading(MARKDOWN_SCALE.h4),
106
+ h6: { ...heading(MARKDOWN_SCALE.h4), color: tokens.color.muted[scheme] },
107
+ li: { fontFamily: FONT.regular, fontSize: body.fontSize, lineHeight: body.lineHeight, color },
108
+ blockquote: {
109
+ ...gap,
110
+ paddingLeft: 13,
111
+ borderLeftWidth: 2,
112
+ borderLeftColor: tokens.color.border[scheme],
113
+ backgroundColor: "transparent",
114
+ },
115
+ hr: { marginVertical: 20, height: 1, backgroundColor: tokens.color.border[scheme] },
116
+ codespan: inlineCodeStyle(scheme, body.fontSize),
117
+ };
118
+ }
119
+
120
+ /** Web `.markdown-inline-code`: 0.86em mono on a 6% foreground wash, radius 6. */
121
+ export function inlineCodeStyle(scheme: "light" | "dark", fontSize: number): TextStyle {
122
+ return {
123
+ fontFamily: MONO_FONT,
124
+ fontStyle: "normal",
125
+ fontSize: Math.round(fontSize * MARKDOWN_SCALE.inlineCode),
126
+ backgroundColor: withAlpha(tokens.color.foreground[scheme], 0.06),
127
+ borderRadius: 6,
128
+ };
129
+ }
130
+
131
+ /** Web `.markdown` lists: 1.25rem (20) indent, 0.25rem (4) between items, muted markers (a 5-wide disc). */
132
+ export const MARKDOWN_LIST = { indent: 20, itemGap: 4, disc: 5 } as const;
133
+
134
+ /**
135
+ * `react-native-marked`'s renderer with native-ui code and lists: `CodeBlock`
136
+ * panels, inline-code chips, and lists drawn as the web `.markdown` lists
137
+ * (the library's own markers apply its marker style to every item).
138
+ */
139
+ class NativeUiMarkdownRenderer extends Renderer {
140
+ constructor(
141
+ private readonly scheme: "light" | "dark",
142
+ private readonly fontSize: number,
143
+ private readonly onCopyCode: MarkdownProps["onCopyCode"],
144
+ ) {
145
+ super();
146
+ }
147
+
148
+ override code(text: string, language?: string): ReactNode {
149
+ return <CodeBlock key={this.getKey()} code={text} language={language || undefined} onCopy={this.onCopyCode} />;
150
+ }
151
+
152
+ override codespan(text: string): ReactNode {
153
+ // Text in Text takes no padding: no-break spaces pad the chip and keep it on one line.
154
+ return (
155
+ <Text key={this.getKey()} selectable style={inlineCodeStyle(this.scheme, this.fontSize)}>
156
+ {`\u00A0${text}\u00A0`}
157
+ </Text>
158
+ );
159
+ }
160
+
161
+ override list(
162
+ ordered: boolean,
163
+ li: ReactNode[],
164
+ _listStyle?: ViewStyle,
165
+ textStyle?: TextStyle,
166
+ startIndex = 1,
167
+ ): ReactNode {
168
+ const muted = tokens.color.muted[this.scheme];
169
+ const lineHeight = typeof textStyle?.lineHeight === "number" ? textStyle.lineHeight : 24;
170
+ // A drawn disc: the font's bullet glyph is far smaller than the web `disc` marker.
171
+ const disc = MARKDOWN_LIST.disc;
172
+ return (
173
+ <View key={this.getKey()} style={{ marginVertical: MARKDOWN_SCALE.blockGap / 2, gap: MARKDOWN_LIST.itemGap }}>
174
+ {li.map((item, index) => (
175
+ <View key={index} style={{ flexDirection: "row" }}>
176
+ {ordered ? (
177
+ <Text style={[textStyle, { width: MARKDOWN_LIST.indent, color: muted, fontVariant: ["tabular-nums"] }]}>
178
+ {`${startIndex + index}.`}
179
+ </Text>
180
+ ) : (
181
+ <View style={{ width: MARKDOWN_LIST.indent, paddingTop: (lineHeight - disc) / 2 }}>
182
+ <View style={{ width: disc, height: disc, borderRadius: disc / 2, backgroundColor: muted }} />
183
+ </View>
184
+ )}
185
+ <View style={{ flex: 1 }}>{item}</View>
186
+ </View>
187
+ ))}
188
+ </View>
189
+ );
190
+ }
191
+ }
package/src/push.ts ADDED
@@ -0,0 +1,186 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { Platform } from "react-native";
3
+ import type * as ExpoConstants from "expo-constants";
4
+ import type * as ExpoDevice from "expo-device";
5
+ import type * as ExpoNotifications from "expo-notifications";
6
+
7
+ // `expo-notifications`, `expo-constants` and `expo-device` are optional peers.
8
+ // Metro bundles a `require` placed directly in a `try` block as an optional
9
+ // dependency, so a missing peer does not break the bundle.
10
+
11
+ export type ExpoNotificationsModule = typeof ExpoNotifications;
12
+ export type ExpoConstantsModule = typeof ExpoConstants;
13
+ export type ExpoDeviceModule = typeof ExpoDevice;
14
+
15
+ let notifications: ExpoNotificationsModule | null | undefined;
16
+ let constants: ExpoConstantsModule | null | undefined;
17
+ let device: ExpoDeviceModule | null | undefined;
18
+
19
+ /** `expo-notifications`, or `null` when the optional peer is not installed. */
20
+ export function loadNotifications(): ExpoNotificationsModule | null {
21
+ if (notifications === undefined) {
22
+ try {
23
+ notifications = require("expo-notifications") as ExpoNotificationsModule;
24
+ } catch {
25
+ notifications = null;
26
+ }
27
+ }
28
+ return notifications;
29
+ }
30
+
31
+ /** `expo-constants` (its default export is the `Constants` object), or `null` when not installed. */
32
+ export function loadConstants(): ExpoConstantsModule | null {
33
+ if (constants === undefined) {
34
+ try {
35
+ constants = require("expo-constants") as ExpoConstantsModule;
36
+ } catch {
37
+ constants = null;
38
+ }
39
+ }
40
+ return constants;
41
+ }
42
+
43
+ /** `expo-device`, or `null` when the optional peer is not installed. */
44
+ export function loadDevice(): ExpoDeviceModule | null {
45
+ if (device === undefined) {
46
+ try {
47
+ device = require("expo-device") as ExpoDeviceModule;
48
+ } catch {
49
+ device = null;
50
+ }
51
+ }
52
+ return device;
53
+ }
54
+
55
+ export type ExpoPushPlatform = "ios" | "android";
56
+ export type ExpoPushRegisterArgs = { token: string; platform: ExpoPushPlatform };
57
+
58
+ /** A tapped push: the notification's request id and its `data` object. */
59
+ export type ExpoPushTap = {
60
+ identifier: string;
61
+ data: Record<string, unknown>;
62
+ response: ExpoNotifications.NotificationResponse;
63
+ };
64
+
65
+ export type GetExpoPushTokenOptions = {
66
+ /** Show the permission prompt when it was never answered. */
67
+ ask: boolean;
68
+ /** Android channel id, also used as its name. Default `"default"`. */
69
+ channelName?: string;
70
+ };
71
+
72
+ /**
73
+ * The Expo push token for this device, or `null` when push is not possible:
74
+ * a peer is missing, a simulator or emulator, no permission, no EAS project id
75
+ * (`expo.extra.eas.projectId`), or no FCM credentials in this Android build.
76
+ */
77
+ export async function getExpoPushToken({ ask, channelName = "default" }: GetExpoPushTokenOptions): Promise<string | null> {
78
+ const Notifications = loadNotifications();
79
+ const constantsModule = loadConstants();
80
+ const deviceModule = loadDevice();
81
+ if (!Notifications || !constantsModule || !deviceModule) return null;
82
+ if (!deviceModule.isDevice) return null;
83
+ const projectId: unknown = constantsModule.default.expoConfig?.extra?.eas?.projectId;
84
+ if (typeof projectId !== "string" || projectId === "") return null;
85
+ if (Platform.OS === "android") {
86
+ await Notifications.setNotificationChannelAsync(channelName, {
87
+ name: channelName,
88
+ importance: Notifications.AndroidImportance.HIGH,
89
+ });
90
+ }
91
+ const current = await Notifications.getPermissionsAsync();
92
+ const granted = current.granted || (ask && (await Notifications.requestPermissionsAsync()).granted);
93
+ if (!granted) return null;
94
+ try {
95
+ return (await Notifications.getExpoPushTokenAsync({ projectId })).data;
96
+ } catch {
97
+ // No google-services.json in this build: FCM cannot issue a token.
98
+ return null;
99
+ }
100
+ }
101
+
102
+ let foregroundHandlerSet = false;
103
+
104
+ /** Shows pushes as banners while the app is open. Runs once; the hook calls it. */
105
+ export function showPushesInForeground(): void {
106
+ const Notifications = loadNotifications();
107
+ if (!Notifications || foregroundHandlerSet) return;
108
+ foregroundHandlerSet = true;
109
+ Notifications.setNotificationHandler({
110
+ handleNotification: async () => ({
111
+ shouldShowBanner: true,
112
+ shouldShowList: true,
113
+ shouldPlaySound: true,
114
+ shouldSetBadge: false,
115
+ }),
116
+ });
117
+ }
118
+
119
+ export type UseExpoPushRegistrationOptions = {
120
+ /**
121
+ * Saves the token on your backend, for example `useMutation(api.push.register)`.
122
+ * Called once on mount, with the latest function.
123
+ */
124
+ register: (args: ExpoPushRegisterArgs) => Promise<unknown>;
125
+ /** Called when the user taps a push, including the tap that cold-started the app. Once per push. */
126
+ onNotificationTap?: (tap: ExpoPushTap) => void;
127
+ /** Android channel id, also used as its name. Default `"default"`. */
128
+ channelName?: string;
129
+ /** Show the permission prompt when it was never answered. Default `true`. */
130
+ ask?: boolean;
131
+ /** Show pushes as banners while the app is open. Default `true`. */
132
+ showInForeground?: boolean;
133
+ };
134
+
135
+ function toTap(response: ExpoNotifications.NotificationResponse | null | undefined): ExpoPushTap | null {
136
+ if (!response) return null;
137
+ const { identifier, content } = response.notification.request;
138
+ const data = content.data;
139
+ return { identifier, data: typeof data === "object" && data !== null ? data : {}, response };
140
+ }
141
+
142
+ /**
143
+ * Mount once under your auth gate. Registers this device's token through
144
+ * `register` and reports taps on pushes through `onNotificationTap`. Does
145
+ * nothing when a peer is missing. Navigation is yours: route from
146
+ * `tap.data` in the callback.
147
+ */
148
+ export function useExpoPushRegistration(options: UseExpoPushRegistrationOptions): void {
149
+ const { channelName, ask = true, showInForeground = true } = options;
150
+ const latest = useRef(options);
151
+ latest.current = options;
152
+
153
+ useEffect(() => {
154
+ if (showInForeground) showPushesInForeground();
155
+ let cancelled = false;
156
+ void getExpoPushToken({ ask, channelName }).then((token) => {
157
+ if (token && !cancelled) {
158
+ void latest.current.register({ token, platform: Platform.OS === "ios" ? "ios" : "android" });
159
+ }
160
+ });
161
+ return () => {
162
+ cancelled = true;
163
+ };
164
+ }, [ask, channelName, showInForeground]);
165
+
166
+ useEffect(() => {
167
+ const Notifications = loadNotifications();
168
+ if (!Notifications) return;
169
+ const seen = new Set<string>();
170
+ const deliver = (response: ExpoNotifications.NotificationResponse | null | undefined) => {
171
+ const tap = toTap(response);
172
+ if (!tap || seen.has(tap.identifier)) return;
173
+ seen.add(tap.identifier);
174
+ latest.current.onNotificationTap?.(tap);
175
+ };
176
+ let cancelled = false;
177
+ void Notifications.getLastNotificationResponseAsync().then((response) => {
178
+ if (!cancelled) deliver(response);
179
+ });
180
+ const subscription = Notifications.addNotificationResponseReceivedListener(deliver);
181
+ return () => {
182
+ cancelled = true;
183
+ subscription.remove();
184
+ };
185
+ }, []);
186
+ }