@vvedantb/native-ui 0.7.0 → 0.9.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.
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
+ }
@@ -0,0 +1,105 @@
1
+ import { Linking, Platform, Text, View } from "react-native";
2
+ import { APP_ICONS } from "../brand/AppIcons";
3
+ import { AppMark } from "../brand/AppMark";
4
+ import type { AppName } from "../brand/svg";
5
+ import { cn } from "../cn";
6
+ import { textStyle, typeStyle } from "../controlStyles";
7
+ import { ListRow } from "../ListRow";
8
+ import { tokens } from "../tokens";
9
+ import { useScheme } from "../useScheme";
10
+ import { SettingsSection } from "./SettingsSection";
11
+ import {
12
+ openProjectVApp,
13
+ otherProjectVMobileApps,
14
+ type ProjectVMobileAppId,
15
+ type ProjectVOpenTarget,
16
+ } from "./projectVMobileApps";
17
+
18
+ /**
19
+ * The mark for each phone app. V Store has no mark of its own (it was left out
20
+ * of the brand marks in 0.3.9), so it shows the Project V mark.
21
+ */
22
+ const MARK: Record<ProjectVMobileAppId, AppName> = {
23
+ vibot: "vibot",
24
+ verve: "verve",
25
+ velth: "velth",
26
+ vmem: "vmem",
27
+ vstore: "projectv",
28
+ };
29
+
30
+ export type FromProjectVProps = {
31
+ className?: string;
32
+ testID?: string;
33
+ };
34
+
35
+ /** A small muted "from Project V" line with the Project V mark, for the end of a settings screen. */
36
+ export function FromProjectV({ className, testID }: FromProjectVProps) {
37
+ const scheme = useScheme();
38
+ const muted = tokens.color.muted[scheme];
39
+ const ProjectVMark = APP_ICONS.projectv;
40
+ return (
41
+ <View
42
+ testID={testID}
43
+ accessible
44
+ accessibilityLabel="from Project V"
45
+ className={cn("flex-row items-center justify-center gap-1.5 py-2", className)}
46
+ >
47
+ <Text className="text-sm font-instrument-sans" style={textStyle("muted", scheme)}>
48
+ from
49
+ </Text>
50
+ <ProjectVMark size={14} color={muted} />
51
+ <Text
52
+ className="text-sm font-instrument-sans-semibold"
53
+ style={[textStyle("muted", scheme), typeStyle("semibold")]}
54
+ >
55
+ Project V
56
+ </Text>
57
+ </View>
58
+ );
59
+ }
60
+
61
+ export type MoreFromProjectVProps = {
62
+ /** The app showing the section. It is left out of the list. */
63
+ current: ProjectVMobileAppId;
64
+ /** `"section"` (default): a settings section listing the other apps. `"footer"`: only the small "from Project V" line. */
65
+ variant?: "section" | "footer";
66
+ /** Section title. Default "More from Project V". */
67
+ title?: string;
68
+ /** Called after a row opens something: the app, V Store or its website (`null` if nothing opened). */
69
+ onOpen?: (id: ProjectVMobileAppId, target: ProjectVOpenTarget | null) => void;
70
+ className?: string;
71
+ };
72
+
73
+ /**
74
+ * Settings section listing the other Project V phone apps, each with its mark,
75
+ * name and one line. A row opens the app if it is installed, else V Store's
76
+ * home, else its website (`openProjectVApp`).
77
+ */
78
+ export function MoreFromProjectV({
79
+ current,
80
+ variant = "section",
81
+ title = "More from Project V",
82
+ onOpen,
83
+ className,
84
+ }: MoreFromProjectVProps) {
85
+ if (variant === "footer") return <FromProjectV className={className} />;
86
+ return (
87
+ <SettingsSection title={title} bodyVariant="list" className={className}>
88
+ <View className="flex-col gap-1 p-1">
89
+ {otherProjectVMobileApps(current).map((app) => (
90
+ <ListRow
91
+ key={app.id}
92
+ testID={`more-from-project-v-${app.id}`}
93
+ title={app.name}
94
+ meta={app.description}
95
+ leading={<AppMark app={MARK[app.id]} size={28} />}
96
+ accessibilityLabel={`${app.name}, ${app.description}`}
97
+ onPress={() => {
98
+ void openProjectVApp(app.id, Linking, Platform.OS).then((target) => onOpen?.(app.id, target));
99
+ }}
100
+ />
101
+ ))}
102
+ </View>
103
+ </SettingsSection>
104
+ );
105
+ }
@@ -0,0 +1,145 @@
1
+ import { familyApp } from "../brand/familyApps";
2
+
3
+ /** The Project V phone apps, in display order. */
4
+ export type ProjectVMobileAppId = "vibot" | "verve" | "velth" | "vmem" | "vstore";
5
+
6
+ export type ProjectVMobileApp = {
7
+ id: ProjectVMobileAppId;
8
+ /** Display name. */
9
+ name: string;
10
+ /** One line for a settings row. */
11
+ description: string;
12
+ /** Android application id. */
13
+ androidPackage: string;
14
+ /** The app's URL scheme, without `://`. */
15
+ scheme: string;
16
+ /** Home page of the app: the fallback when neither the app nor V Store is installed. */
17
+ website: string;
18
+ };
19
+
20
+ /**
21
+ * V Store's home screen. V Store's `app/[id]` route takes the app's Convex
22
+ * document `_id`, not its catalog id or Android package, and native-ui cannot
23
+ * know live Convex ids. So the store step opens V Store's home, not the app's page.
24
+ */
25
+ export const VSTORE_HOME_URL = "vstore://";
26
+
27
+ // Names and sites come from `FAMILY_APPS` where it has the app. V Store is not
28
+ // a family app there (that file is a byte-identical copy of `@vvedantb/ui`).
29
+ const vibot = familyApp("vibot");
30
+ const verve = familyApp("verve");
31
+ const velth = familyApp("velth");
32
+ const vmem = familyApp("vmem");
33
+
34
+ /** The five Project V phone apps, in display order. */
35
+ export const PROJECT_V_MOBILE_APPS: readonly ProjectVMobileApp[] = [
36
+ {
37
+ id: "vibot",
38
+ name: vibot.name,
39
+ description: "AI chat, agents and tools",
40
+ androidPackage: "com.projectvdotuk.vibot",
41
+ scheme: "vibot",
42
+ website: vibot.url,
43
+ },
44
+ {
45
+ id: "verve",
46
+ name: verve.name,
47
+ description: "Notes, pages and writing",
48
+ androidPackage: "com.projectvdotuk.verve",
49
+ scheme: "verve",
50
+ website: verve.url,
51
+ },
52
+ {
53
+ id: "velth",
54
+ name: velth.name,
55
+ description: "Health insights and camera scans",
56
+ androidPackage: "com.projectvdotuk.velth",
57
+ scheme: "velth",
58
+ website: velth.url,
59
+ },
60
+ {
61
+ id: "vmem",
62
+ name: vmem.name,
63
+ description: "Your personal memory",
64
+ androidPackage: "com.projectvdotuk.vmem",
65
+ scheme: "vmem",
66
+ website: vmem.url,
67
+ },
68
+ {
69
+ id: "vstore",
70
+ name: "V Store",
71
+ description: "Install Project V apps",
72
+ androidPackage: "com.projectvdotuk.vstore",
73
+ scheme: "vstore",
74
+ website: "https://v-store-chi.vercel.app",
75
+ },
76
+ ];
77
+
78
+ /** Entry of `PROJECT_V_MOBILE_APPS` for an app. */
79
+ export function projectVMobileApp(id: ProjectVMobileAppId): ProjectVMobileApp {
80
+ return PROJECT_V_MOBILE_APPS.find((app) => app.id === id)!;
81
+ }
82
+
83
+ /** The phone apps other than `current`, in display order. */
84
+ export function otherProjectVMobileApps(current?: ProjectVMobileAppId): ProjectVMobileApp[] {
85
+ return PROJECT_V_MOBILE_APPS.filter((app) => app.id !== current);
86
+ }
87
+
88
+ /** Android intent URL that opens the installed app with this package. */
89
+ export function androidIntentUrl(app: ProjectVMobileApp): string {
90
+ return `intent://#Intent;scheme=${app.scheme};package=${app.androidPackage};end`;
91
+ }
92
+
93
+ /** How `openProjectVApp` opened an app. */
94
+ export type ProjectVOpenTarget = "app" | "store" | "website";
95
+
96
+ /** One URL to try, in order. `check`: ask `canOpenURL` first; otherwise try `openURL` and move on if it fails. */
97
+ export type ProjectVOpenStep = {
98
+ target: ProjectVOpenTarget;
99
+ url: string;
100
+ check: boolean;
101
+ };
102
+
103
+ /**
104
+ * The URLs `openProjectVApp` tries, in order: the app's scheme; on Android an
105
+ * intent to its package; V Store's home (`VSTORE_HOME_URL`); the website.
106
+ */
107
+ export function projectVAppOpenSteps(id: ProjectVMobileAppId, os: string): ProjectVOpenStep[] {
108
+ const app = projectVMobileApp(id);
109
+ const steps: ProjectVOpenStep[] = [{ target: "app", url: `${app.scheme}://`, check: true }];
110
+ if (os === "android") steps.push({ target: "app", url: androidIntentUrl(app), check: false });
111
+ if (id !== "vstore") steps.push({ target: "store", url: VSTORE_HOME_URL, check: true });
112
+ steps.push({ target: "website", url: app.website, check: false });
113
+ return steps;
114
+ }
115
+
116
+ /** The part of React Native `Linking` that `openProjectVApp` uses. */
117
+ export type ProjectVLinking = {
118
+ canOpenURL(url: string): Promise<boolean>;
119
+ openURL(url: string): Promise<unknown>;
120
+ };
121
+
122
+ /**
123
+ * Opens a Project V app: the app if installed, else V Store's home if V Store
124
+ * is installed, else its website. Resolves to what it opened, or
125
+ * `null` when every URL failed. Pass React Native `Linking` and `Platform.OS`.
126
+ *
127
+ * `canOpenURL` needs the schemes declared by the calling app:
128
+ * `LSApplicationQueriesSchemes` on iOS and `<queries>` on Android 11+.
129
+ */
130
+ export async function openProjectVApp(
131
+ id: ProjectVMobileAppId,
132
+ linking: ProjectVLinking,
133
+ os: string,
134
+ ): Promise<ProjectVOpenTarget | null> {
135
+ for (const step of projectVAppOpenSteps(id, os)) {
136
+ try {
137
+ if (step.check && !(await linking.canOpenURL(step.url))) continue;
138
+ await linking.openURL(step.url);
139
+ return step.target;
140
+ } catch {
141
+ // Not installed, or no handler: try the next URL.
142
+ }
143
+ }
144
+ return null;
145
+ }