@ramonclaudio/create-vexpo 0.1.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/README.md +50 -0
- package/dist/index.js +183 -0
- package/dist/templates/default/.eas/workflows/asc-events.yml +84 -0
- package/dist/templates/default/.eas/workflows/deploy-production.yml +129 -0
- package/dist/templates/default/.eas/workflows/development-builds.yml +19 -0
- package/dist/templates/default/.eas/workflows/e2e-tests.yml +42 -0
- package/dist/templates/default/.eas/workflows/pr-preview.yml +98 -0
- package/dist/templates/default/.eas/workflows/release.yml +44 -0
- package/dist/templates/default/.eas/workflows/rollback.yml +86 -0
- package/dist/templates/default/.eas/workflows/rollout.yml +84 -0
- package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +42 -0
- package/dist/templates/default/.eas/workflows/testflight.yml +57 -0
- package/dist/templates/default/.github/workflows/check.yml +28 -0
- package/dist/templates/default/.maestro/launch.yaml +18 -0
- package/dist/templates/default/AGENTS.md +79 -0
- package/dist/templates/default/DESIGN.md +331 -0
- package/dist/templates/default/LICENSE +21 -0
- package/dist/templates/default/README.md +153 -0
- package/dist/templates/default/SETUP.md +618 -0
- package/dist/templates/default/__tests__/convex/constants.test.ts +49 -0
- package/dist/templates/default/__tests__/convex/validators.test.ts +23 -0
- package/dist/templates/default/__tests__/convex/webhook.test.ts +343 -0
- package/dist/templates/default/__tests__/lib/deep-link.test.ts +67 -0
- package/dist/templates/default/_easignore +22 -0
- package/dist/templates/default/_editorconfig +9 -0
- package/dist/templates/default/_env.example +34 -0
- package/dist/templates/default/_fingerprintignore +24 -0
- package/dist/templates/default/_gitattributes +7 -0
- package/dist/templates/default/_gitignore +69 -0
- package/dist/templates/default/_oxfmtrc.json +3 -0
- package/dist/templates/default/_oxlintrc.json +34 -0
- package/dist/templates/default/app/(app)/(tabs)/(home)/index.tsx +50 -0
- package/dist/templates/default/app/(app)/(tabs)/(home,search)/_layout.tsx +44 -0
- package/dist/templates/default/app/(app)/(tabs)/(search)/index.tsx +247 -0
- package/dist/templates/default/app/(app)/(tabs)/_layout.tsx +77 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/_layout.tsx +37 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/index.tsx +362 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/preferences.tsx +184 -0
- package/dist/templates/default/app/(app)/_layout.tsx +73 -0
- package/dist/templates/default/app/(app)/debug.tsx +389 -0
- package/dist/templates/default/app/(app)/help.tsx +254 -0
- package/dist/templates/default/app/(app)/linked.tsx +116 -0
- package/dist/templates/default/app/(app)/privacy.tsx +159 -0
- package/dist/templates/default/app/(app)/profile.tsx +915 -0
- package/dist/templates/default/app/(app)/sessions.tsx +191 -0
- package/dist/templates/default/app/(app)/welcome.tsx +140 -0
- package/dist/templates/default/app/(auth)/_layout.tsx +31 -0
- package/dist/templates/default/app/(auth)/forgot-password.tsx +168 -0
- package/dist/templates/default/app/(auth)/reset-password.tsx +314 -0
- package/dist/templates/default/app/(auth)/sign-in.tsx +453 -0
- package/dist/templates/default/app/(auth)/sign-up.tsx +563 -0
- package/dist/templates/default/app/+native-intent.tsx +14 -0
- package/dist/templates/default/app/+not-found.tsx +51 -0
- package/dist/templates/default/app/_layout.tsx +102 -0
- package/dist/templates/default/app-store/screenshots/.gitkeep +0 -0
- package/dist/templates/default/app-store/screenshots/README.md +13 -0
- package/dist/templates/default/app.config.ts +201 -0
- package/dist/templates/default/app.json +11 -0
- package/dist/templates/default/assets/brand-icon-dark.png +0 -0
- package/dist/templates/default/assets/brand-icon-light.png +0 -0
- package/dist/templates/default/assets/fonts/Geist-Black.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-BlackItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Bold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-BoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraBold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraBoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraLight.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraLightItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Light.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-LightItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Medium.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-MediumItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Regular.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-SemiBold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-SemiBoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Thin.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ThinItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Variable-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Variable.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Bold.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-BoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Medium.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-MediumItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Regular.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistPixel-Square.ttf +0 -0
- package/dist/templates/default/assets/icon.png +0 -0
- package/dist/templates/default/assets/sounds/notification.wav +0 -0
- package/dist/templates/default/assets/splash-image-dark.png +0 -0
- package/dist/templates/default/assets/splash-image-light.png +0 -0
- package/dist/templates/default/bun.lock +1860 -0
- package/dist/templates/default/components/auth/otp-verification.tsx +255 -0
- package/dist/templates/default/components/auth/password-field.tsx +121 -0
- package/dist/templates/default/components/auth/segmented-toggle.tsx +47 -0
- package/dist/templates/default/components/ui/convex-error.tsx +32 -0
- package/dist/templates/default/components/ui/error-boundary.tsx +57 -0
- package/dist/templates/default/components/ui/loading-screen.tsx +31 -0
- package/dist/templates/default/components/ui/material.tsx +94 -0
- package/dist/templates/default/components/ui/offline-banner.tsx +58 -0
- package/dist/templates/default/components/ui/prominent-button.tsx +71 -0
- package/dist/templates/default/components/ui/skeleton.tsx +107 -0
- package/dist/templates/default/components/ui/status-text.tsx +49 -0
- package/dist/templates/default/components/ui/update-banner.tsx +82 -0
- package/dist/templates/default/constants/layout.ts +102 -0
- package/dist/templates/default/constants/theme.ts +401 -0
- package/dist/templates/default/constants/ui.ts +77 -0
- package/dist/templates/default/convex/_generated/api.d.ts +77 -0
- package/dist/templates/default/convex/_generated/api.js +23 -0
- package/dist/templates/default/convex/_generated/dataModel.d.ts +60 -0
- package/dist/templates/default/convex/_generated/server.d.ts +143 -0
- package/dist/templates/default/convex/_generated/server.js +93 -0
- package/dist/templates/default/convex/admin.ts +102 -0
- package/dist/templates/default/convex/auth.config.ts +6 -0
- package/dist/templates/default/convex/auth.ts +335 -0
- package/dist/templates/default/convex/constants.ts +46 -0
- package/dist/templates/default/convex/convex.config.ts +11 -0
- package/dist/templates/default/convex/crons.ts +42 -0
- package/dist/templates/default/convex/email.ts +109 -0
- package/dist/templates/default/convex/env.ts +31 -0
- package/dist/templates/default/convex/errors.ts +33 -0
- package/dist/templates/default/convex/functions.ts +54 -0
- package/dist/templates/default/convex/http.ts +176 -0
- package/dist/templates/default/convex/log.ts +81 -0
- package/dist/templates/default/convex/pushTokens.ts +114 -0
- package/dist/templates/default/convex/rateLimit.ts +92 -0
- package/dist/templates/default/convex/schema.ts +28 -0
- package/dist/templates/default/convex/tsconfig.json +18 -0
- package/dist/templates/default/convex/users.ts +279 -0
- package/dist/templates/default/convex/validators.ts +74 -0
- package/dist/templates/default/convex/webhook.ts +193 -0
- package/dist/templates/default/convex.json +6 -0
- package/dist/templates/default/eas.json +56 -0
- package/dist/templates/default/fingerprint.config.js +9 -0
- package/dist/templates/default/hooks/use-debounce.ts +20 -0
- package/dist/templates/default/hooks/use-deep-link.ts +43 -0
- package/dist/templates/default/hooks/use-navigation-tracking.ts +15 -0
- package/dist/templates/default/hooks/use-network.ts +11 -0
- package/dist/templates/default/hooks/use-notifications.ts +107 -0
- package/dist/templates/default/hooks/use-onboarding.ts +15 -0
- package/dist/templates/default/hooks/use-reduced-motion.ts +11 -0
- package/dist/templates/default/hooks/use-theme.ts +53 -0
- package/dist/templates/default/hooks/use-updates.ts +86 -0
- package/dist/templates/default/lib/a11y.ts +5 -0
- package/dist/templates/default/lib/app.ts +14 -0
- package/dist/templates/default/lib/assets.ts +17 -0
- package/dist/templates/default/lib/auth-client.ts +21 -0
- package/dist/templates/default/lib/convex-auth.tsx +79 -0
- package/dist/templates/default/lib/deep-link.ts +71 -0
- package/dist/templates/default/lib/dev-menu.ts +119 -0
- package/dist/templates/default/lib/device.ts +40 -0
- package/dist/templates/default/lib/dynamic-font.ts +49 -0
- package/dist/templates/default/lib/env.ts +10 -0
- package/dist/templates/default/lib/haptics.ts +24 -0
- package/dist/templates/default/lib/notifications.ts +276 -0
- package/dist/templates/default/lib/preferences.ts +45 -0
- package/dist/templates/default/lib/schemas.ts +137 -0
- package/dist/templates/default/lib/storage.ts +47 -0
- package/dist/templates/default/lib/updates.ts +107 -0
- package/dist/templates/default/metro.config.js +14 -0
- package/dist/templates/default/package.json +129 -0
- package/dist/templates/default/patches/PR-368.patch +91 -0
- package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
- package/dist/templates/default/plugins/README.md +9 -0
- package/dist/templates/default/plugins/with-auto-signing.js +45 -0
- package/dist/templates/default/plugins/with-pod-deployment-target.js +35 -0
- package/dist/templates/default/scripts/README.md +36 -0
- package/dist/templates/default/scripts/_run.mjs +77 -0
- package/dist/templates/default/scripts/clean.ts +543 -0
- package/dist/templates/default/scripts/rotate-apple-jwt.mjs +80 -0
- package/dist/templates/default/store.config.json +58 -0
- package/dist/templates/default/tsconfig.json +13 -0
- package/dist/templates/default/vitest.config.ts +21 -0
- package/package.json +69 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { expoClient } from "@better-auth/expo/client";
|
|
2
|
+
import { convexClient } from "@convex-dev/better-auth/client/plugins";
|
|
3
|
+
import { createAuthClient } from "better-auth/react";
|
|
4
|
+
import { emailOTPClient, usernameClient } from "better-auth/client/plugins";
|
|
5
|
+
import Constants from "expo-constants";
|
|
6
|
+
import * as SecureStore from "expo-secure-store";
|
|
7
|
+
|
|
8
|
+
import { env } from "./env";
|
|
9
|
+
|
|
10
|
+
const rawScheme = Constants.expoConfig?.scheme;
|
|
11
|
+
const scheme = Array.isArray(rawScheme) ? rawScheme[0] : rawScheme;
|
|
12
|
+
|
|
13
|
+
export const authClient = createAuthClient({
|
|
14
|
+
baseURL: env.convexSiteUrl,
|
|
15
|
+
plugins: [
|
|
16
|
+
convexClient(),
|
|
17
|
+
usernameClient(),
|
|
18
|
+
emailOTPClient(),
|
|
19
|
+
expoClient({ scheme, storagePrefix: scheme ?? "better-auth", storage: SecureStore }),
|
|
20
|
+
],
|
|
21
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ConvexProviderWithAuth, type ConvexReactClient } from "convex/react";
|
|
2
|
+
import { useCallback, useMemo, useRef, type ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { authClient } from "./auth-client";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Custom Better Auth → Convex bridge.
|
|
8
|
+
*
|
|
9
|
+
* Replaces `ConvexBetterAuthProvider` from `@convex-dev/better-auth/react`,
|
|
10
|
+
* which has two bugs that prevent Convex from authenticating on Expo:
|
|
11
|
+
*
|
|
12
|
+
* 1. `fetchAccessToken` is wrapped in `useCallback(..., [sessionId])`. The
|
|
13
|
+
* server-side session id rotates on every `/convex/token` call, so the
|
|
14
|
+
* fetcher's identity changes on every render. `ConvexProviderWithAuth`'s
|
|
15
|
+
* effect depends on that identity; when it changes it runs the cleanup,
|
|
16
|
+
* which sets `isConvexAuthenticated` back to null/false. The next render
|
|
17
|
+
* calls `setAuth` again and the cycle repeats. `useConvexAuth().isAuthenticated`
|
|
18
|
+
* never settles.
|
|
19
|
+
* 2. `cachedToken` is captured by closure inside a `useMemo` whose factory
|
|
20
|
+
* only re-runs when `authClient` changes (never). State updates don't
|
|
21
|
+
* reach the inner closure, so the cache is stale.
|
|
22
|
+
*
|
|
23
|
+
* This bridge does the minimum the platform actually needs:
|
|
24
|
+
* - `isAuthenticated` / `isLoading` come from `authClient.useSession()` directly.
|
|
25
|
+
* - `fetchAccessToken` is identity-stable (`useCallback([])`). The Convex
|
|
26
|
+
* client caches the JWT internally and re-calls only on expiry/forceRefresh.
|
|
27
|
+
* - In-flight calls de-dup via a ref, so multiple consumers can't fire
|
|
28
|
+
* parallel `/convex/token` requests.
|
|
29
|
+
*
|
|
30
|
+
* The OAuth one-time-token (`?ott=...`) handling in the upstream provider is
|
|
31
|
+
* a web-only path (`window === undefined` on native), so we don't replicate it.
|
|
32
|
+
*/
|
|
33
|
+
function useBetterAuthForConvex() {
|
|
34
|
+
const { data: session, isPending } = authClient.useSession();
|
|
35
|
+
const isAuthenticated = !!session?.session;
|
|
36
|
+
|
|
37
|
+
const inflightRef = useRef<Promise<string | null> | null>(null);
|
|
38
|
+
|
|
39
|
+
const fetchAccessToken = useCallback(
|
|
40
|
+
async ({ forceRefreshToken = false }: { forceRefreshToken?: boolean } = {}) => {
|
|
41
|
+
if (!forceRefreshToken && inflightRef.current) return inflightRef.current;
|
|
42
|
+
|
|
43
|
+
const promise = authClient.convex
|
|
44
|
+
.token({ fetchOptions: { throw: false } })
|
|
45
|
+
.then(({ data }) => data?.token ?? null)
|
|
46
|
+
.catch(() => null)
|
|
47
|
+
.finally(() => {
|
|
48
|
+
inflightRef.current = null;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
inflightRef.current = promise;
|
|
52
|
+
return promise;
|
|
53
|
+
},
|
|
54
|
+
[],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return useMemo(
|
|
58
|
+
() => ({
|
|
59
|
+
isLoading: isPending,
|
|
60
|
+
isAuthenticated,
|
|
61
|
+
fetchAccessToken,
|
|
62
|
+
}),
|
|
63
|
+
[isPending, isAuthenticated, fetchAccessToken],
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function BetterAuthConvexProvider({
|
|
68
|
+
children,
|
|
69
|
+
client,
|
|
70
|
+
}: {
|
|
71
|
+
children: ReactNode;
|
|
72
|
+
client: ConvexReactClient;
|
|
73
|
+
}) {
|
|
74
|
+
return (
|
|
75
|
+
<ConvexProviderWithAuth client={client} useAuth={useBetterAuthForConvex}>
|
|
76
|
+
{children}
|
|
77
|
+
</ConvexProviderWithAuth>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { parse, createURL } from "expo-linking";
|
|
2
|
+
|
|
3
|
+
export const ALLOWED_DEEP_LINK_PATHS = [
|
|
4
|
+
"/",
|
|
5
|
+
"/welcome",
|
|
6
|
+
"/settings",
|
|
7
|
+
"/about",
|
|
8
|
+
"/help",
|
|
9
|
+
"/privacy",
|
|
10
|
+
"/sign-in",
|
|
11
|
+
"/sign-up",
|
|
12
|
+
"/forgot-password",
|
|
13
|
+
"/reset-password",
|
|
14
|
+
"/linked",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export function isValidDeepLink(url: string): boolean {
|
|
18
|
+
if (!url || typeof url !== "string") return false;
|
|
19
|
+
if (url.includes("..")) return false;
|
|
20
|
+
|
|
21
|
+
const { scheme, path } = parse(url);
|
|
22
|
+
const isRelativePath = url.startsWith("/") && !url.startsWith("//");
|
|
23
|
+
if (!isRelativePath && !scheme) return false;
|
|
24
|
+
|
|
25
|
+
const normalizedPath = "/" + (path ?? "").replace(/^\//, "");
|
|
26
|
+
|
|
27
|
+
return ALLOWED_DEEP_LINK_PATHS.some(
|
|
28
|
+
(allowed) => normalizedPath === allowed || normalizedPath.startsWith(allowed + "/"),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ResolvedDeepLink = {
|
|
33
|
+
path: string | null;
|
|
34
|
+
params: Record<string, string>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pure helper that parses a deep link URL into `{ path, params }`.
|
|
39
|
+
*
|
|
40
|
+
* Returns `path: null` for invalid URLs, disallowed paths, or traversal attempts.
|
|
41
|
+
* Array query values are joined with commas; nullish values are dropped.
|
|
42
|
+
* Unit-testable: no React, no side effects.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveDeepLink(url: string): ResolvedDeepLink {
|
|
45
|
+
const empty: ResolvedDeepLink = { path: null, params: {} };
|
|
46
|
+
if (!url || typeof url !== "string") return empty;
|
|
47
|
+
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = parse(url);
|
|
51
|
+
} catch {
|
|
52
|
+
return empty;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!isValidDeepLink(url)) return empty;
|
|
56
|
+
|
|
57
|
+
const normalizedPath = "/" + (parsed.path ?? "").replace(/^\//, "").replace(/\/+$/, "");
|
|
58
|
+
const path = normalizedPath === "/" ? "/" : normalizedPath;
|
|
59
|
+
|
|
60
|
+
const params: Record<string, string> = {};
|
|
61
|
+
if (parsed.queryParams) {
|
|
62
|
+
for (const [key, value] of Object.entries(parsed.queryParams)) {
|
|
63
|
+
if (value == null) continue;
|
|
64
|
+
params[key] = Array.isArray(value) ? value.join(",") : value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { path, params };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { createURL };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as DevClient from "expo-dev-client";
|
|
2
|
+
import * as SecureStore from "expo-secure-store";
|
|
3
|
+
import * as Clipboard from "expo-clipboard";
|
|
4
|
+
import Constants from "expo-constants";
|
|
5
|
+
import { checkForUpdateAsync } from "expo-updates";
|
|
6
|
+
|
|
7
|
+
import { authClient } from "@/lib/auth-client";
|
|
8
|
+
import { checkForUpdate } from "@/lib/updates";
|
|
9
|
+
import { setTheme } from "@/hooks/use-theme";
|
|
10
|
+
import { reloadApp } from "./app";
|
|
11
|
+
|
|
12
|
+
type SessionResponse = { data?: { session?: { id?: string } } | null };
|
|
13
|
+
|
|
14
|
+
async function copyAuthSessionId() {
|
|
15
|
+
try {
|
|
16
|
+
const res = (await authClient.getSession()) as SessionResponse;
|
|
17
|
+
const id = res?.data?.session?.id;
|
|
18
|
+
if (!id) {
|
|
19
|
+
console.log("[DevMenu] No active auth session");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
await Clipboard.setStringAsync(id);
|
|
23
|
+
console.log("[DevMenu] Auth session ID copied:", id);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.log("[DevMenu] Failed to copy session ID:", err);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function showPublicEnv() {
|
|
30
|
+
const keys = Object.keys(process.env).filter((k) => k.startsWith("EXPO_PUBLIC_"));
|
|
31
|
+
const snapshot: Record<string, string | undefined> = {};
|
|
32
|
+
for (const k of keys) snapshot[k] = process.env[k];
|
|
33
|
+
console.log("[DevMenu] EXPO_PUBLIC_* env:", snapshot);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clearLocalStorage() {
|
|
37
|
+
const ls = (globalThis as { localStorage?: Storage }).localStorage;
|
|
38
|
+
if (!ls) {
|
|
39
|
+
console.log("[DevMenu] localStorage unavailable on native");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
ls.clear();
|
|
43
|
+
console.log("[DevMenu] localStorage cleared");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Registers custom dev menu items visible when shaking the device.
|
|
48
|
+
* Call once at app startup. No-op in production builds.
|
|
49
|
+
*/
|
|
50
|
+
export function registerDevMenuItems() {
|
|
51
|
+
if (!__DEV__) return;
|
|
52
|
+
|
|
53
|
+
DevClient.registerDevMenuItems([
|
|
54
|
+
{
|
|
55
|
+
name: "Clear Secure Storage",
|
|
56
|
+
callback: () => {
|
|
57
|
+
SecureStore.deleteItemAsync("better-auth_session_token").catch(() => {});
|
|
58
|
+
SecureStore.deleteItemAsync("better-auth_refresh_token").catch(() => {});
|
|
59
|
+
console.log("[DevMenu] Secure storage cleared");
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "Reset Theme",
|
|
64
|
+
callback: () => {
|
|
65
|
+
setTheme("system");
|
|
66
|
+
console.log("[DevMenu] Theme reset to system");
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "Copy Session ID",
|
|
71
|
+
callback: () => {
|
|
72
|
+
Clipboard.setStringAsync(Constants.sessionId);
|
|
73
|
+
console.log("[DevMenu] Session ID copied:", Constants.sessionId);
|
|
74
|
+
},
|
|
75
|
+
shouldCollapse: true,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "Copy Auth Session ID",
|
|
79
|
+
callback: () => {
|
|
80
|
+
void copyAuthSessionId();
|
|
81
|
+
},
|
|
82
|
+
shouldCollapse: true,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "Check for Updates",
|
|
86
|
+
callback: () => {
|
|
87
|
+
checkForUpdate()
|
|
88
|
+
.then((result) => console.log("[DevMenu] Update check:", result))
|
|
89
|
+
.catch((err) => console.log("[DevMenu] Update check unavailable:", err.message));
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "Force OTA Update Check",
|
|
94
|
+
callback: () => {
|
|
95
|
+
checkForUpdateAsync()
|
|
96
|
+
.then((result) => console.log("[DevMenu] Forced update check:", result))
|
|
97
|
+
.catch((err) =>
|
|
98
|
+
console.log("[DevMenu] Forced update check failed:", err?.message ?? err),
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "Clear localStorage",
|
|
104
|
+
callback: clearLocalStorage,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "Show Env",
|
|
108
|
+
callback: showPublicEnv,
|
|
109
|
+
shouldCollapse: true,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "Reload App",
|
|
113
|
+
callback: () => {
|
|
114
|
+
reloadApp();
|
|
115
|
+
},
|
|
116
|
+
shouldCollapse: true,
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import Constants, { ExecutionEnvironment } from "expo-constants";
|
|
2
|
+
|
|
3
|
+
/** Current execution environment: Bare, Standalone, or StoreClient. */
|
|
4
|
+
export const executionEnvironment = Constants.executionEnvironment;
|
|
5
|
+
|
|
6
|
+
/** Production/release build created with or without EAS Build. */
|
|
7
|
+
export const isStandalone = executionEnvironment === ExecutionEnvironment.Standalone;
|
|
8
|
+
|
|
9
|
+
/** Running in Expo Go or a development build with expo-dev-client. */
|
|
10
|
+
export const isStoreClient = executionEnvironment === ExecutionEnvironment.StoreClient;
|
|
11
|
+
|
|
12
|
+
/** True when running in debug mode (__DEV__). */
|
|
13
|
+
export const debugMode = Constants.debugMode;
|
|
14
|
+
|
|
15
|
+
/** Unique per app session. Changes on every fresh launch. */
|
|
16
|
+
export const sessionId = Constants.sessionId;
|
|
17
|
+
|
|
18
|
+
/** True if the app is running headless (background task, no UI). */
|
|
19
|
+
export const isHeadless = Constants.isHeadless;
|
|
20
|
+
|
|
21
|
+
/** Default status bar height in points. Does not account for calls or location tracking. */
|
|
22
|
+
export const statusBarHeight = Constants.statusBarHeight;
|
|
23
|
+
|
|
24
|
+
/** Runtime version string. Null on web. */
|
|
25
|
+
export const expoRuntimeVersion = Constants.expoRuntimeVersion;
|
|
26
|
+
|
|
27
|
+
/** Human-readable device name (e.g. "Ramon's iPhone"). */
|
|
28
|
+
export const deviceName = Constants.deviceName;
|
|
29
|
+
|
|
30
|
+
/** System font names available on this device. */
|
|
31
|
+
export const systemFonts = Constants.systemFonts;
|
|
32
|
+
|
|
33
|
+
/** EAS config object. Non-null when built with EAS Build. */
|
|
34
|
+
export const easConfig = Constants.easConfig;
|
|
35
|
+
|
|
36
|
+
/** iOS-specific manifest: buildNumber, model, systemVersion, etc. */
|
|
37
|
+
export const iosManifest = Constants.platform?.ios;
|
|
38
|
+
|
|
39
|
+
/** Resolves the user agent string a webview would send from this device. */
|
|
40
|
+
export const getWebViewUserAgentAsync = Constants.getWebViewUserAgentAsync;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useWindowDimensions } from "react-native";
|
|
3
|
+
import { font } from "@expo/ui/swift-ui/modifiers";
|
|
4
|
+
|
|
5
|
+
type FontParams = Parameters<typeof font>[0];
|
|
6
|
+
type Weight = NonNullable<FontParams["weight"]>;
|
|
7
|
+
type Design = NonNullable<FontParams["design"]>;
|
|
8
|
+
|
|
9
|
+
const GEIST_BY_WEIGHT: Record<Weight, string> = {
|
|
10
|
+
ultraLight: "Geist-Thin",
|
|
11
|
+
thin: "Geist-Thin",
|
|
12
|
+
light: "Geist-Light",
|
|
13
|
+
regular: "Geist-Regular",
|
|
14
|
+
medium: "Geist-Medium",
|
|
15
|
+
semibold: "Geist-SemiBold",
|
|
16
|
+
bold: "Geist-Bold",
|
|
17
|
+
heavy: "Geist-ExtraBold",
|
|
18
|
+
black: "Geist-Black",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const GEIST_MONO_BY_WEIGHT: Record<Weight, string> = {
|
|
22
|
+
ultraLight: "GeistMono-Regular",
|
|
23
|
+
thin: "GeistMono-Regular",
|
|
24
|
+
light: "GeistMono-Regular",
|
|
25
|
+
regular: "GeistMono-Regular",
|
|
26
|
+
medium: "GeistMono-Medium",
|
|
27
|
+
semibold: "GeistMono-Medium",
|
|
28
|
+
bold: "GeistMono-Bold",
|
|
29
|
+
heavy: "GeistMono-Bold",
|
|
30
|
+
black: "GeistMono-Bold",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function resolveFamily(weight: Weight | undefined, design: Design | undefined): string {
|
|
34
|
+
const w = weight ?? "regular";
|
|
35
|
+
if (design === "monospaced") return GEIST_MONO_BY_WEIGHT[w];
|
|
36
|
+
return GEIST_BY_WEIGHT[w];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useDynamicFont() {
|
|
40
|
+
const { fontScale } = useWindowDimensions();
|
|
41
|
+
return useCallback(
|
|
42
|
+
(params: FontParams) => {
|
|
43
|
+
const family = params.family ?? resolveFamily(params.weight, params.design);
|
|
44
|
+
const size = params.size != null ? params.size * fontScale : params.size;
|
|
45
|
+
return font({ ...params, family, size });
|
|
46
|
+
},
|
|
47
|
+
[fontScale],
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const convexUrl = process.env.EXPO_PUBLIC_CONVEX_URL;
|
|
2
|
+
const convexSiteUrl = process.env.EXPO_PUBLIC_CONVEX_SITE_URL;
|
|
3
|
+
|
|
4
|
+
if (!convexUrl) throw new Error("Missing required env var: EXPO_PUBLIC_CONVEX_URL");
|
|
5
|
+
if (!convexSiteUrl) throw new Error("Missing required env var: EXPO_PUBLIC_CONVEX_SITE_URL");
|
|
6
|
+
|
|
7
|
+
export const env = {
|
|
8
|
+
convexUrl,
|
|
9
|
+
convexSiteUrl,
|
|
10
|
+
} as const;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as Haptics from "expo-haptics";
|
|
2
|
+
|
|
3
|
+
import { preferences } from "@/lib/preferences";
|
|
4
|
+
|
|
5
|
+
export { Haptics };
|
|
6
|
+
|
|
7
|
+
const gate = (fn: () => Promise<void>) => () => {
|
|
8
|
+
if (!preferences.hapticsEnabled()) return Promise.resolve();
|
|
9
|
+
return fn();
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const haptics = {
|
|
13
|
+
selection: gate(() => Haptics.selectionAsync()),
|
|
14
|
+
|
|
15
|
+
light: gate(() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)),
|
|
16
|
+
medium: gate(() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)),
|
|
17
|
+
heavy: gate(() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)),
|
|
18
|
+
rigid: gate(() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Rigid)),
|
|
19
|
+
soft: gate(() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Soft)),
|
|
20
|
+
|
|
21
|
+
success: gate(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)),
|
|
22
|
+
warning: gate(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning)),
|
|
23
|
+
error: gate(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error)),
|
|
24
|
+
};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import * as Notifications from "expo-notifications";
|
|
2
|
+
import * as Device from "expo-device";
|
|
3
|
+
import * as TaskManager from "expo-task-manager";
|
|
4
|
+
import Constants from "expo-constants";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Background task
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export const BACKGROUND_NOTIFICATION_TASK = "BACKGROUND_NOTIFICATION";
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
TaskManager.defineTask<Notifications.NotificationTaskPayload>(
|
|
14
|
+
BACKGROUND_NOTIFICATION_TASK,
|
|
15
|
+
async ({ data, error }) => {
|
|
16
|
+
if (error) {
|
|
17
|
+
console.error("[Notification] Background task error:", error);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (__DEV__) console.log("[Notification] Background payload:", data);
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
} catch (e) {
|
|
24
|
+
if (__DEV__) console.warn("[Notification] defineTask failed:", e);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Foreground handler
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
interface ForegroundOptions {
|
|
32
|
+
shouldShowBanner?: boolean;
|
|
33
|
+
shouldShowList?: boolean;
|
|
34
|
+
shouldPlaySound?: boolean;
|
|
35
|
+
shouldSetBadge?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function setForegroundHandler(options?: ForegroundOptions) {
|
|
39
|
+
if (!Device.isDevice) return;
|
|
40
|
+
try {
|
|
41
|
+
Notifications.setNotificationHandler({
|
|
42
|
+
handleNotification: async () => ({
|
|
43
|
+
shouldShowBanner: options?.shouldShowBanner ?? true,
|
|
44
|
+
shouldShowList: options?.shouldShowList ?? true,
|
|
45
|
+
shouldPlaySound: options?.shouldPlaySound ?? true,
|
|
46
|
+
shouldSetBadge: options?.shouldSetBadge ?? false,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
} catch (e) {
|
|
50
|
+
if (__DEV__) console.warn("[Notification] setForegroundHandler failed:", e);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function registerBackgroundTask() {
|
|
55
|
+
if (!Device.isDevice) return;
|
|
56
|
+
try {
|
|
57
|
+
Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (__DEV__) console.warn("[Notification] registerTaskAsync failed:", e);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Permissions
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export async function getPermissionStatus() {
|
|
68
|
+
const settings = await Notifications.getPermissionsAsync();
|
|
69
|
+
return {
|
|
70
|
+
granted: settings.granted,
|
|
71
|
+
status: settings.status,
|
|
72
|
+
canAskAgain: settings.canAskAgain,
|
|
73
|
+
ios: settings.ios,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function requestPermission() {
|
|
78
|
+
const settings = await Notifications.requestPermissionsAsync({
|
|
79
|
+
ios: {
|
|
80
|
+
allowAlert: true,
|
|
81
|
+
allowBadge: true,
|
|
82
|
+
allowSound: true,
|
|
83
|
+
allowCriticalAlerts: false,
|
|
84
|
+
allowProvisional: false,
|
|
85
|
+
provideAppNotificationSettings: true,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
granted: settings.granted,
|
|
90
|
+
status: settings.status,
|
|
91
|
+
canAskAgain: settings.canAskAgain,
|
|
92
|
+
ios: settings.ios,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Push tokens
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
export async function getExpoPushToken(): Promise<string | null> {
|
|
101
|
+
if (!Device.isDevice) return null;
|
|
102
|
+
|
|
103
|
+
const projectId = Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId;
|
|
104
|
+
if (!projectId) {
|
|
105
|
+
console.warn("[Notification] EAS projectId not found");
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const { data } = await Notifications.getExpoPushTokenAsync({ projectId });
|
|
111
|
+
return data;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.error("[Notification] Failed to get Expo push token:", e);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function getDevicePushToken(): Promise<string | null> {
|
|
119
|
+
if (!Device.isDevice) return null;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const { data } = await Notifications.getDevicePushTokenAsync();
|
|
123
|
+
return data;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
console.error("[Notification] Failed to get device push token:", e);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Scheduling
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
type ContentInput = Notifications.NotificationContentInput;
|
|
135
|
+
type TriggerInput = Notifications.NotificationTriggerInput;
|
|
136
|
+
|
|
137
|
+
export function scheduleNotification(content: ContentInput, trigger: TriggerInput) {
|
|
138
|
+
return Notifications.scheduleNotificationAsync({ content, trigger });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function scheduleTimeInterval(content: ContentInput, seconds: number, repeats = false) {
|
|
142
|
+
return scheduleNotification(content, {
|
|
143
|
+
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
|
144
|
+
seconds,
|
|
145
|
+
repeats,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function scheduleDate(content: ContentInput, date: Date | number) {
|
|
150
|
+
return scheduleNotification(content, {
|
|
151
|
+
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
|
152
|
+
date,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function scheduleCalendar(
|
|
157
|
+
content: ContentInput,
|
|
158
|
+
dateComponents: Omit<Notifications.CalendarTriggerInput, "type" | "repeats">,
|
|
159
|
+
repeats = false,
|
|
160
|
+
) {
|
|
161
|
+
return scheduleNotification(content, {
|
|
162
|
+
type: Notifications.SchedulableTriggerInputTypes.CALENDAR,
|
|
163
|
+
...dateComponents,
|
|
164
|
+
repeats,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function scheduleDaily(content: ContentInput, hour: number, minute: number) {
|
|
169
|
+
return scheduleNotification(content, {
|
|
170
|
+
type: Notifications.SchedulableTriggerInputTypes.DAILY,
|
|
171
|
+
hour,
|
|
172
|
+
minute,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function scheduleWeekly(
|
|
177
|
+
content: ContentInput,
|
|
178
|
+
weekday: number,
|
|
179
|
+
hour: number,
|
|
180
|
+
minute: number,
|
|
181
|
+
) {
|
|
182
|
+
return scheduleNotification(content, {
|
|
183
|
+
type: Notifications.SchedulableTriggerInputTypes.WEEKLY,
|
|
184
|
+
weekday,
|
|
185
|
+
hour,
|
|
186
|
+
minute,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function scheduleMonthly(content: ContentInput, day: number, hour: number, minute: number) {
|
|
191
|
+
return scheduleNotification(content, {
|
|
192
|
+
type: Notifications.SchedulableTriggerInputTypes.MONTHLY,
|
|
193
|
+
day,
|
|
194
|
+
hour,
|
|
195
|
+
minute,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function scheduleYearly(
|
|
200
|
+
content: ContentInput,
|
|
201
|
+
month: number,
|
|
202
|
+
day: number,
|
|
203
|
+
hour: number,
|
|
204
|
+
minute: number,
|
|
205
|
+
) {
|
|
206
|
+
return scheduleNotification(content, {
|
|
207
|
+
type: Notifications.SchedulableTriggerInputTypes.YEARLY,
|
|
208
|
+
month,
|
|
209
|
+
day,
|
|
210
|
+
hour,
|
|
211
|
+
minute,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Schedule management
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
export function getAllScheduled() {
|
|
220
|
+
return Notifications.getAllScheduledNotificationsAsync();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function cancelScheduled(id: string) {
|
|
224
|
+
return Notifications.cancelScheduledNotificationAsync(id);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function cancelAllScheduled() {
|
|
228
|
+
return Notifications.cancelAllScheduledNotificationsAsync();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function getNextTriggerDate(
|
|
232
|
+
trigger: Notifications.SchedulableNotificationTriggerInput,
|
|
233
|
+
): Promise<Date | null> {
|
|
234
|
+
const timestamp = await Notifications.getNextTriggerDateAsync(trigger);
|
|
235
|
+
return timestamp ? new Date(timestamp) : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Badge
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
export function getBadgeCount() {
|
|
243
|
+
return Notifications.getBadgeCountAsync();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function setBadgeCount(count: number) {
|
|
247
|
+
return Notifications.setBadgeCountAsync(count);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Dismiss
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
export function dismissNotification(id: string) {
|
|
255
|
+
return Notifications.dismissNotificationAsync(id);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function dismissAllNotifications() {
|
|
259
|
+
return Notifications.dismissAllNotificationsAsync();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Presented notifications
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
export function getPresentedNotifications() {
|
|
267
|
+
return Notifications.getPresentedNotificationsAsync();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Last notification response
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
export function clearLastNotificationResponse() {
|
|
275
|
+
Notifications.clearLastNotificationResponse();
|
|
276
|
+
}
|