@bigbinary/neeto-commons-rn 2.2.0 → 2.2.4

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.
Files changed (56) hide show
  1. package/lib/typescript/components/PushPermissionGate.d.ts +20 -0
  2. package/lib/typescript/components/PushPermissionGate.d.ts.map +1 -0
  3. package/lib/typescript/components/SocialButton.d.ts.map +1 -1
  4. package/lib/typescript/components/index.d.ts +1 -0
  5. package/lib/typescript/components/index.d.ts.map +1 -1
  6. package/lib/typescript/config/axios/caseConversion.d.ts.map +1 -1
  7. package/lib/typescript/config/axios/constants.d.ts +3 -0
  8. package/lib/typescript/config/axios/constants.d.ts.map +1 -1
  9. package/lib/typescript/config/axios/index.d.ts.map +1 -1
  10. package/lib/typescript/config/axios/redact.d.ts +2 -0
  11. package/lib/typescript/config/axios/redact.d.ts.map +1 -0
  12. package/lib/typescript/config/bugsnag.d.ts +3 -2
  13. package/lib/typescript/config/bugsnag.d.ts.map +1 -1
  14. package/lib/typescript/constants/index.d.ts +11 -0
  15. package/lib/typescript/constants/index.d.ts.map +1 -1
  16. package/lib/typescript/helpers/notifications/index.d.ts +1 -0
  17. package/lib/typescript/helpers/notifications/index.d.ts.map +1 -1
  18. package/lib/typescript/helpers/notifications/permission.d.ts +40 -0
  19. package/lib/typescript/helpers/notifications/permission.d.ts.map +1 -0
  20. package/lib/typescript/helpers/notifications/registerBackgroundHandlers.d.ts.map +1 -1
  21. package/lib/typescript/helpers/notifications/usePushNotification.d.ts.map +1 -1
  22. package/lib/typescript/hooks/index.d.ts +2 -0
  23. package/lib/typescript/hooks/index.d.ts.map +1 -1
  24. package/lib/typescript/hooks/query/useAuth.d.ts.map +1 -1
  25. package/lib/typescript/hooks/useNotificationPermissionStatus.d.ts +16 -0
  26. package/lib/typescript/hooks/useNotificationPermissionStatus.d.ts.map +1 -0
  27. package/lib/typescript/hooks/usePushNotificationPermissions.d.ts +8 -0
  28. package/lib/typescript/hooks/usePushNotificationPermissions.d.ts.map +1 -1
  29. package/lib/typescript/hooks/useRequestNotificationPermission.d.ts +13 -0
  30. package/lib/typescript/hooks/useRequestNotificationPermission.d.ts.map +1 -0
  31. package/lib/typescript/providers/NeetoCommonsRNProvider.d.ts.map +1 -1
  32. package/lib/typescript/screens/authentication/AuthOptions.d.ts.map +1 -1
  33. package/lib/typescript/screens/authentication/OTPVerificationScreen.d.ts.map +1 -1
  34. package/package.json +32 -4
  35. package/src/components/PushPermissionGate.ts +25 -0
  36. package/src/components/SocialButton.tsx +2 -1
  37. package/src/components/index.ts +1 -0
  38. package/src/config/axios/caseConversion.ts +6 -2
  39. package/src/config/axios/constants.ts +19 -0
  40. package/src/config/axios/index.ts +7 -31
  41. package/src/config/axios/redact.ts +50 -0
  42. package/src/config/bugsnag.ts +86 -6
  43. package/src/constants/index.ts +12 -0
  44. package/src/helpers/notifications/index.ts +4 -0
  45. package/src/helpers/notifications/permission.ts +56 -0
  46. package/src/helpers/notifications/registerBackgroundHandlers.ts +17 -6
  47. package/src/helpers/notifications/usePushNotification.ts +20 -13
  48. package/src/hooks/index.ts +2 -0
  49. package/src/hooks/query/useAuth.ts +6 -1
  50. package/src/hooks/query/useNotification.ts +2 -2
  51. package/src/hooks/useNotificationPermissionStatus.ts +50 -0
  52. package/src/hooks/usePushNotificationPermissions.ts +24 -13
  53. package/src/hooks/useRequestNotificationPermission.ts +55 -0
  54. package/src/providers/NeetoCommonsRNProvider.tsx +11 -8
  55. package/src/screens/authentication/AuthOptions.tsx +5 -0
  56. package/src/screens/authentication/OTPVerificationScreen.tsx +7 -0
@@ -1,5 +1,9 @@
1
1
  import BugsnagPluginReactNavigation from "@bugsnag/plugin-react-navigation";
2
- import Bugsnag, { type NotifiableError } from "@bugsnag/react-native";
2
+ import Bugsnag, {
3
+ type Event,
4
+ type NotifiableError,
5
+ } from "@bugsnag/react-native";
6
+ import Config from "react-native-config";
3
7
 
4
8
  import {
5
9
  isEventErrorsNetworkRelated,
@@ -9,20 +13,68 @@ import {
9
13
  import { getLogRocketSessionURL } from "../initializers/logRocket";
10
14
  import { Store } from "../store";
11
15
 
16
+ // Mirrors the stage the native layer reports. Host apps set ENVIRONMENT per
17
+ // flavour in their .env files and feed the same value to the native Bugsnag
18
+ // config; without it every build reported as "production" — the native
19
+ // default for a release build — so QA traffic from staging installs was
20
+ // indistinguishable from real user traffic in the dashboard.
21
+ const releaseStage = Config.ENVIRONMENT || "production";
22
+
23
+ // Errors we must see even though their message reads like network noise.
24
+ // isNetworkError matches /timeout/i, and FCM rejects token registration with
25
+ // "java.io.IOException: TIMEOUT" — so restoring a real message to these
26
+ // reports would otherwise make them disappear entirely, which is worse than
27
+ // the "{}" it replaced. The global onError filter runs *before* any
28
+ // per-notify callback (@bugsnag/core/client.js), so a callback cannot undo
29
+ // the drop; the marker has to travel on the error itself. A WeakSet keeps it
30
+ // off the reported payload.
31
+ const alwaysReported = new WeakSet<object>();
32
+
33
+ const isAlwaysReported = (error: unknown): boolean =>
34
+ typeof error === "object" && error !== null && alwaysReported.has(error);
35
+
36
+ // Survives a circular throw, and `undefined` rather than an empty message.
37
+ const describeThrown = (error: unknown): string => {
38
+ try {
39
+ return JSON.stringify(error) ?? String(error);
40
+ } catch {
41
+ return String(error);
42
+ }
43
+ };
44
+
45
+ // Normalizes a caught `unknown` before it reaches Bugsnag, and exempts it
46
+ // from the network filter above. Never hand a caught value to JSON.stringify
47
+ // on the way in: Bugsnag reads `message` and `stack` off an Error and both
48
+ // are non-enumerable, and NativeFirebaseError defines every one of its own
49
+ // fields (`code`, `userInfo`, `nativeErrorCode`) non-enumerable too. A
50
+ // stringified FCM rejection lands in the dashboard as the literal "{}" — no
51
+ // message, and a "(native)" line-0 frame that groups it with every other
52
+ // message-less report.
53
+ export const toNotifiableError = (error: unknown): Error => {
54
+ const normalized =
55
+ error instanceof Error ? error : new Error(describeThrown(error));
56
+ alwaysReported.add(normalized);
57
+
58
+ return normalized;
59
+ };
60
+
12
61
  export const bugsnagErrorHandler = (
13
62
  err: NotifiableError,
14
63
  origin: string,
15
64
  // Pass a hash for failures that would otherwise fragment into a group per
16
65
  // resource or per status code.
17
- groupingHash?: string
66
+ groupingHash?: string,
67
+ // Handled reports default to "warning"; pass "error" for a failure that
68
+ // breaks the feature rather than degrading it.
69
+ severity: Event["severity"] = "warning"
18
70
  ) => {
19
71
  if (!__DEV__) {
20
72
  Bugsnag.notify(err, event => {
21
- if (isNetworkError(err)) {
73
+ if (!isAlwaysReported(err) && isNetworkError(err)) {
22
74
  return false;
23
75
  }
24
76
 
25
- event.severity = "warning";
77
+ event.severity = severity;
26
78
  event.context = origin;
27
79
 
28
80
  if (groupingHash) {
@@ -36,6 +88,12 @@ export const bugsnagErrorHandler = (
36
88
 
37
89
  export const configureBugsnag = () => {
38
90
  if (!__DEV__) {
91
+ // releaseStage and enabledReleaseStages are deliberately absent: the RN
92
+ // notifier only accepts onError/onBreadcrumb/logger/metadata/user/
93
+ // context/plugins/codeBundleId/featureFlags here, because the native
94
+ // layer owns the stage so that native crashes are tagged correctly too.
95
+ // Host apps set it in AndroidManifest.xml and Info.plist; see
96
+ // docs/bugsnag-release-stages.md.
39
97
  Bugsnag.start({
40
98
  onError: event => {
41
99
  const auth = Store.getState().auth;
@@ -43,11 +101,33 @@ export const configureBugsnag = () => {
43
101
  event.setUser(auth.userId, auth.email, auth.name);
44
102
  }
45
103
 
46
- if (event.device?.emulator) {
104
+ // Only in production: QA runs release builds on emulators on purpose,
105
+ // and dropping those would hide the very failures they are hunting.
106
+ // Bugsnag's own flag is unreliable — sdk_gphone images self-report
107
+ // `emulator: false` — so also match the well-known emulator model
108
+ // names. Stage separation alone does not cover QA running
109
+ // production-stage builds on emulators.
110
+ const deviceModel = event.device?.model?.toLowerCase() ?? "";
111
+ const isKnownEmulator =
112
+ event.device?.emulator ||
113
+ deviceModel.startsWith("sdk_gphone") ||
114
+ deviceModel.startsWith("generic") ||
115
+ deviceModel.startsWith("emulator") ||
116
+ deviceModel.includes("android sdk built for");
117
+ if (releaseStage === "production" && isKnownEmulator) {
47
118
  return false;
48
119
  }
49
120
 
50
- if (isEventErrorsNetworkRelated(event)) {
121
+ // Handled axios noise only. This filter matches on message text
122
+ // (/timeout/i, /network error/i…), and an unhandled crash whose
123
+ // message happens to contain one of those words is still a crash —
124
+ // "Timeout of 30000ms exceeded while rendering TicketList" was being
125
+ // discarded outright.
126
+ if (
127
+ !event.unhandled &&
128
+ !isAlwaysReported(event.originalError) &&
129
+ isEventErrorsNetworkRelated(event)
130
+ ) {
51
131
  return false;
52
132
  }
53
133
 
@@ -4,6 +4,16 @@ export const STORAGE_KEYS = {
4
4
  DEVICE_TOKEN: "devicetoken",
5
5
  IS_VISITED: "isVisited",
6
6
  NOTIFICATION_PERMISSION_REQUESTED: "notificationPermissionRequested",
7
+ /**
8
+ * Read-only fallback. Before this key existed here, every app kept its own
9
+ * "have we asked yet?" flag under this exact string -- neeto-desk,
10
+ * neeto-cal, neeto-form and neeto-invoice all wrote it verbatim from their
11
+ * own constants. An app adopting the hooks here would otherwise start with
12
+ * a null flag and re-prompt everyone who had already declined, so the
13
+ * request path reads this when its own key is unset. Never written; drop it
14
+ * once installs have rolled over.
15
+ */
16
+ LEGACY_APP_NOTIFICATION_PROMPT_DONE: "notification_first_prompt_done",
7
17
  } as const;
8
18
 
9
19
  export const DEFAULT_PAGE_SIZE = 30;
@@ -18,5 +28,7 @@ export const APP_VERSION_ERROR_REPORT_INTERVAL = 60 * 60 * 1000;
18
28
  /** One Bugsnag group for every app_versions failure, whatever the status. */
19
29
  export const APP_VERSION_ERROR_GROUPING_HASH = "app-version-check-failed";
20
30
 
31
+ export const AUTH_BUTTON_HEIGHT = 48;
32
+
21
33
  /** Design baseline: a ~5" phone, the width every scaled size was drawn against. */
22
34
  export const GUIDELINE_BASE_WIDTH = 350;
@@ -2,3 +2,7 @@ export { usePushNotification } from "./usePushNotification";
2
2
  export { registerBackgroundHandlers } from "./registerBackgroundHandlers";
3
3
  export { NOTIFICATION_CHANNELS, NOTIFICATION_CHANNEL_IDS } from "./constants";
4
4
  export { getInitialFirebaseNotification } from "./firebaseMessaging";
5
+ export {
6
+ hasBeenPromptedForNotifications,
7
+ needsNotificationPermissionPrompt,
8
+ } from "./permission";
@@ -0,0 +1,56 @@
1
+ import { Platform } from "react-native";
2
+
3
+ import { STORAGE_KEYS } from "../../constants";
4
+ import { getItem } from "../asyncStore";
5
+
6
+ /**
7
+ * Whether this device has a notification permission worth asking for.
8
+ *
9
+ * Android only gained a runtime notification permission in 13 (API 33);
10
+ * below that the manifest entry is the whole story and there is nothing to
11
+ * prompt for. Every iOS version needs asking.
12
+ *
13
+ * Consuming apps used to hand-roll this as
14
+ * `Platform.OS !== "android" || Platform.Version < 33`, which returns early
15
+ * on iOS as well and so never asked there at all. neeto-desk shipped that
16
+ * for months; neeto-form and neeto-invoice still carry the same line. Owning
17
+ * it here is the point of this module.
18
+ *
19
+ * Note that `Platform.Version` is a number on Android and a string on iOS,
20
+ * so the version comparison is only meaningful once the OS is known to be
21
+ * Android. Coercing keeps a string version from silently comparing as NaN.
22
+ *
23
+ * Parameters are injectable so the platform matrix is testable without
24
+ * mutating the Platform module.
25
+ */
26
+ export const needsNotificationPermissionPrompt = (
27
+ os: typeof Platform.OS = Platform.OS,
28
+ version: typeof Platform.Version = Platform.Version
29
+ ) => os !== "android" || Number(version) >= 33;
30
+
31
+ /**
32
+ * Has this install already been through the prompt?
33
+ *
34
+ * Falls back to the app-local flag every consuming app used to keep under
35
+ * "notification_first_prompt_done". Without that read, an app adopting these
36
+ * hooks starts with a null flag, decides nobody has been asked, and
37
+ * re-prompts everyone whose status is still DENIED -- on Android 13+ that is
38
+ * every user who declined exactly once, since Android allows a second ask.
39
+ * iOS is unaffected either way: a prior denial reads BLOCKED, not DENIED.
40
+ *
41
+ * A fallback read rather than a migration, on purpose. A migration would have
42
+ * to finish before the requesting hook mounts -- it prompts on mount -- which
43
+ * means gating the gate on an async seed. Reading both keys has no ordering
44
+ * to get wrong, is idempotent, and covers every app at once because they all
45
+ * wrote the identical string.
46
+ */
47
+ export const hasBeenPromptedForNotifications = async () => {
48
+ const current = await getItem(STORAGE_KEYS.NOTIFICATION_PERMISSION_REQUESTED);
49
+ if (current !== null) return true;
50
+
51
+ const legacy = await getItem(
52
+ STORAGE_KEYS.LEGACY_APP_NOTIFICATION_PROMPT_DONE
53
+ );
54
+
55
+ return legacy !== null;
56
+ };
@@ -21,18 +21,29 @@ export const registerBackgroundHandlers = (
21
21
  // Data-only messages need manual display; notification-type messages
22
22
  // are displayed by the OS automatically.
23
23
  if (remoteMessage.data && !remoteMessage.notification) {
24
+ // Non-string values fail notifee's data validation — demo pushes from
25
+ // tower carry no url, for instance.
26
+ const data = Object.fromEntries(
27
+ Object.entries(remoteMessage.data).filter(
28
+ ([, value]) => typeof value === "string"
29
+ )
30
+ ) as Record<string, string>;
31
+
32
+ // MIUI and other OEM launchers read the numeric badge off the active
33
+ // notification (setNumber), not from any badge API.
34
+ const unreadCount = Number(data.unread_count);
35
+
24
36
  await notifee.displayNotification({
25
- // FCM types a data value as string | object | number; the neeto
26
- // notifications engine only ever sends strings.
27
- title: (remoteMessage.data.title as string) || "New Notification",
28
- body:
29
- (remoteMessage.data.message as string) || "You have a new message",
30
- data: remoteMessage.data,
37
+ title: data.title || "New Notification",
38
+ body: data.message || "You have a new message",
39
+ data,
31
40
  android: {
32
41
  channelId: "default",
33
42
  smallIcon: "ic_notification",
34
43
  sound: "default",
35
44
  pressAction: { id: "default" },
45
+ ...(Number.isFinite(unreadCount) &&
46
+ unreadCount > 0 && { badgeCount: unreadCount }),
36
47
  },
37
48
  });
38
49
  }
@@ -13,7 +13,7 @@ import {
13
13
  type PressedNotification,
14
14
  } from "./types";
15
15
 
16
- import { bugsnagErrorHandler } from "../../config/bugsnag";
16
+ import { bugsnagErrorHandler, toNotifiableError } from "../../config/bugsnag";
17
17
  import { STORAGE_KEYS } from "../../constants";
18
18
  import { useNotification } from "../../hooks/query/useNotification";
19
19
  import { useAppState } from "../../hooks/useAppState";
@@ -52,7 +52,7 @@ export const usePushNotification = (
52
52
  const { status } = await checkNotifications();
53
53
  setNotificationPermission(status === RESULTS.GRANTED);
54
54
  } catch (error) {
55
- bugsnagErrorHandler(JSON.stringify(error), "notification");
55
+ bugsnagErrorHandler(toNotifiableError(error), "notification");
56
56
  }
57
57
  };
58
58
 
@@ -127,30 +127,37 @@ export const usePushNotification = (
127
127
  if (Platform.OS !== "android") return undefined;
128
128
 
129
129
  const unsubscribe = messaging().onMessage(async remoteMessage => {
130
- // FCM types a data value as string | object | number; the neeto
131
- // notifications engine only ever sends strings.
130
+ // Non-string values (or a missing key sent as undefined) fail notifee's
131
+ // data validation demo pushes from tower carry no url, for instance.
132
+ const data = Object.fromEntries(
133
+ Object.entries(remoteMessage.data ?? {}).filter(
134
+ ([, value]) => typeof value === "string"
135
+ )
136
+ ) as Record<string, string>;
137
+
132
138
  const title =
133
- remoteMessage.notification?.title ||
134
- (remoteMessage.data?.title as string) ||
135
- "New Notification";
139
+ remoteMessage.notification?.title || data.title || "New Notification";
136
140
 
137
141
  const body =
138
142
  remoteMessage.notification?.body ||
139
- (remoteMessage.data?.message as string) ||
143
+ data.message ||
140
144
  "You have a new message";
141
145
 
146
+ // MIUI and other OEM launchers read the numeric badge off the active
147
+ // notification (setNumber), not from any badge API.
148
+ const unreadCount = Number(data.unread_count);
149
+
142
150
  await notifee.displayNotification({
143
151
  title,
144
152
  body,
145
- data: { url: remoteMessage.data?.url, ...remoteMessage.data } as Record<
146
- string,
147
- string | number | object
148
- >,
153
+ data,
149
154
  android: {
150
155
  channelId: "default",
151
156
  smallIcon: "ic_notification",
152
157
  sound: "default",
153
158
  pressAction: { id: "default" },
159
+ ...(Number.isFinite(unreadCount) &&
160
+ unreadCount > 0 && { badgeCount: unreadCount }),
154
161
  },
155
162
  });
156
163
  });
@@ -194,7 +201,7 @@ export const usePushNotification = (
194
201
  }
195
202
  await handleTokenRegistration(token);
196
203
  } catch (error) {
197
- bugsnagErrorHandler(JSON.stringify(error), "notification");
204
+ bugsnagErrorHandler(toNotifiableError(error), "notification");
198
205
  }
199
206
  }, [createChannels, handleTokenRegistration]);
200
207
 
@@ -1,2 +1,4 @@
1
1
  export { useLocalization } from "../contexts/localization";
2
2
  export { usePushNotificationPermissions } from "./usePushNotificationPermissions";
3
+ export { useNotificationPermissionStatus } from "./useNotificationPermissionStatus";
4
+ export { useRequestNotificationPermission } from "./useRequestNotificationPermission";
@@ -166,7 +166,12 @@ export const useAuth = () => {
166
166
  text2: getErrorMessage(error?.response?.data, error),
167
167
  });
168
168
 
169
- bugsnagErrorHandler(error, "verify_otp_error");
169
+ // A 401 here is a wrong or expired OTP — a user typo, not a defect.
170
+ // Reporting it made this the single loudest error in the project. The
171
+ // axios interceptor already skips 401s; this path bypassed it.
172
+ if (status !== 401) {
173
+ bugsnagErrorHandler(error, "verify_otp_error");
174
+ }
170
175
  }
171
176
  };
172
177
 
@@ -16,7 +16,7 @@ import {
16
16
  fetchNotificationsAPI,
17
17
  markAllNotificationsReadAPI,
18
18
  } from "../../api/notifications";
19
- import { bugsnagErrorHandler } from "../../config/bugsnag";
19
+ import { bugsnagErrorHandler, toNotifiableError } from "../../config/bugsnag";
20
20
  import { STORAGE_KEYS, DEFAULT_PAGE_SIZE } from "../../constants";
21
21
  import { removeItem, setItem } from "../../helpers/asyncStore";
22
22
  import { removeSecureValue } from "../../helpers/keychain";
@@ -35,7 +35,7 @@ export const useNotification = () => {
35
35
  setItem(STORAGE_KEYS.DEVICE_TOKEN, response.device.device_token);
36
36
  },
37
37
  onError: error => {
38
- bugsnagErrorHandler(JSON.stringify(error), "notification");
38
+ bugsnagErrorHandler(toNotifiableError(error), "notification");
39
39
  },
40
40
  }),
41
41
  useFetchNotifications: () => {
@@ -0,0 +1,50 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { AppState } from "react-native";
3
+
4
+ import { RESULTS, checkNotifications } from "react-native-permissions";
5
+
6
+ import { needsNotificationPermissionPrompt } from "../helpers/notifications/permission";
7
+
8
+ /**
9
+ * Read-only view of the notification permission.
10
+ *
11
+ * Deliberately never requests. A screen that merely displays whether
12
+ * notifications are on -- every app's Profile -- must not be the thing that
13
+ * asks, or users who never open it are never asked at all. That is exactly
14
+ * what usePushNotificationPermissions caused by combining the two: it was
15
+ * called from Profile because Profile is where the status is shown, which
16
+ * made a settings screen the app's only requester. Use PushPermissionGate
17
+ * for the asking.
18
+ *
19
+ * Re-checks on foreground, because the only way back from a denial is the
20
+ * system Settings app, which means leaving the app and returning.
21
+ */
22
+ export const useNotificationPermissionStatus = () => {
23
+ const [isGranted, setIsGranted] = useState(false);
24
+
25
+ const check = useCallback(async () => {
26
+ // Below Android 13 there is no runtime permission to be missing, so the
27
+ // honest answer is "granted" rather than whatever checkNotifications
28
+ // reports.
29
+ if (!needsNotificationPermissionPrompt()) {
30
+ setIsGranted(true);
31
+
32
+ return;
33
+ }
34
+
35
+ const { status } = await checkNotifications();
36
+ setIsGranted(status === RESULTS.GRANTED);
37
+ }, []);
38
+
39
+ useEffect(() => {
40
+ check();
41
+
42
+ const subscription = AppState.addEventListener("change", nextState => {
43
+ if (nextState === "active") check();
44
+ });
45
+
46
+ return () => subscription.remove();
47
+ }, [check]);
48
+
49
+ return isGranted;
50
+ };
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState, useCallback } from "react";
2
- import { AppState, Platform } from "react-native";
2
+ import { AppState } from "react-native";
3
3
 
4
4
  import {
5
5
  RESULTS,
@@ -8,10 +8,22 @@ import {
8
8
  type PermissionStatus,
9
9
  } from "react-native-permissions";
10
10
 
11
- import { bugsnagErrorHandler } from "../config/bugsnag";
11
+ import { bugsnagErrorHandler, toNotifiableError } from "../config/bugsnag";
12
12
  import { STORAGE_KEYS } from "../constants";
13
- import { getItem, setItem } from "../helpers/asyncStore";
14
-
13
+ import { setItem } from "../helpers/asyncStore";
14
+ import {
15
+ hasBeenPromptedForNotifications,
16
+ needsNotificationPermissionPrompt,
17
+ } from "../helpers/notifications/permission";
18
+
19
+ /**
20
+ * @deprecated Checks and requests in one call, which is why every app that
21
+ * wanted to *display* the status ended up making its Profile screen the only
22
+ * thing that ever *asked* -- see neetozone/neeto-chat-rn#788. Prefer
23
+ * `useNotificationPermissionStatus()` to read and `<PushPermissionGate />`
24
+ * (or `useRequestNotificationPermission()`) to ask. Kept working for
25
+ * consumers that have not moved yet.
26
+ */
15
27
  export const usePushNotificationPermissions = () => {
16
28
  const [appState, setAppState] = useState(AppState.currentState);
17
29
  const [notificationPermission, setNotificationPermission] = useState(false);
@@ -31,7 +43,7 @@ export const usePushNotificationPermissions = () => {
31
43
  status?: PermissionStatus;
32
44
  }> => {
33
45
  try {
34
- if (Platform.OS === "android" && Platform.Version < 33) {
46
+ if (!needsNotificationPermissionPrompt()) {
35
47
  return { isGranted: true };
36
48
  }
37
49
 
@@ -42,7 +54,7 @@ export const usePushNotificationPermissions = () => {
42
54
  status,
43
55
  };
44
56
  } catch (error) {
45
- bugsnagErrorHandler(JSON.stringify(error), "notification");
57
+ bugsnagErrorHandler(toNotifiableError(error), "notification");
46
58
 
47
59
  return { isGranted: false };
48
60
  }
@@ -50,11 +62,10 @@ export const usePushNotificationPermissions = () => {
50
62
 
51
63
  const requestNotificationPermission = useCallback(async () => {
52
64
  try {
53
- const permissionRequested = await getItem(
54
- STORAGE_KEYS.NOTIFICATION_PERMISSION_REQUESTED
55
- );
56
-
57
- if (permissionRequested !== null) {
65
+ // Reads the app-local legacy flag too, so an app that has been
66
+ // writing "notification_first_prompt_done" does not re-prompt everyone
67
+ // who already declined.
68
+ if (await hasBeenPromptedForNotifications()) {
58
69
  return false;
59
70
  }
60
71
 
@@ -73,7 +84,7 @@ export const usePushNotificationPermissions = () => {
73
84
 
74
85
  return false;
75
86
  } catch (error) {
76
- bugsnagErrorHandler(JSON.stringify(error), "notification");
87
+ bugsnagErrorHandler(toNotifiableError(error), "notification");
77
88
 
78
89
  return false;
79
90
  }
@@ -97,7 +108,7 @@ export const usePushNotificationPermissions = () => {
97
108
  setNotificationPermission(false);
98
109
  }
99
110
  } catch (error) {
100
- bugsnagErrorHandler(JSON.stringify(error), "notification");
111
+ bugsnagErrorHandler(toNotifiableError(error), "notification");
101
112
  setNotificationPermission(false);
102
113
  }
103
114
  };
@@ -0,0 +1,55 @@
1
+ import { useEffect } from "react";
2
+
3
+ import {
4
+ RESULTS,
5
+ checkNotifications,
6
+ requestNotifications,
7
+ } from "react-native-permissions";
8
+
9
+ import { bugsnagErrorHandler, toNotifiableError } from "../config/bugsnag";
10
+ import { STORAGE_KEYS } from "../constants";
11
+ import { setItem } from "../helpers/asyncStore";
12
+ import {
13
+ hasBeenPromptedForNotifications,
14
+ needsNotificationPermissionPrompt,
15
+ } from "../helpers/notifications/permission";
16
+
17
+ /**
18
+ * Asks for notification permission once per install, and nothing else.
19
+ *
20
+ * Prefer mounting PushPermissionGate inside the signed-in branch of the
21
+ * navigator; this hook is what it wraps, exported for apps that need to
22
+ * trigger the request from somewhere other than a mount.
23
+ *
24
+ * Only asks when the status is DENIED. GRANTED needs nothing and BLOCKED
25
+ * cannot be reopened from inside the app -- sending a blocked user to
26
+ * Settings is denied-state recovery, which belongs to the UI, not here.
27
+ */
28
+ export const useRequestNotificationPermission = (enabled = true) => {
29
+ useEffect(() => {
30
+ const request = async () => {
31
+ try {
32
+ if (!enabled || !needsNotificationPermissionPrompt()) return;
33
+
34
+ if (await hasBeenPromptedForNotifications()) return;
35
+
36
+ const { status } = await checkNotifications();
37
+ if (status !== RESULTS.DENIED) return;
38
+
39
+ const result = await requestNotifications(["alert", "sound", "badge"]);
40
+
41
+ await setItem(
42
+ STORAGE_KEYS.NOTIFICATION_PERMISSION_REQUESTED,
43
+ result.status === RESULTS.GRANTED
44
+ );
45
+ } catch (error) {
46
+ // Written even on failure: without it a throwing request retries on
47
+ // every foreground, which is a prompt loop rather than one prompt.
48
+ await setItem(STORAGE_KEYS.NOTIFICATION_PERMISSION_REQUESTED, false);
49
+ bugsnagErrorHandler(toNotifiableError(error), "notification");
50
+ }
51
+ };
52
+
53
+ request();
54
+ }, [enabled]);
55
+ };
@@ -68,14 +68,17 @@ export const NeetoCommonsRNProvider = ({
68
68
  }: NeetoCommonsRNProviderProps) => {
69
69
  const prevReducersRef = useRef<Record<string, Reducer> | null>(null);
70
70
  const prevBlacklistRef = useRef<string[] | undefined>(undefined);
71
- if (
72
- prevReducersRef.current !== hostRootReducer ||
73
- prevBlacklistRef.current !== blacklistedReducers
74
- ) {
75
- prevReducersRef.current = hostRootReducer;
76
- prevBlacklistRef.current = blacklistedReducers;
77
- updateRootReducer({ reducers: hostRootReducer, blacklistedReducers });
78
- }
71
+
72
+ useLayoutEffect(() => {
73
+ if (
74
+ prevReducersRef.current !== hostRootReducer ||
75
+ prevBlacklistRef.current !== blacklistedReducers
76
+ ) {
77
+ prevReducersRef.current = hostRootReducer;
78
+ prevBlacklistRef.current = blacklistedReducers;
79
+ updateRootReducer({ reducers: hostRootReducer, blacklistedReducers });
80
+ }
81
+ }, [hostRootReducer, blacklistedReducers]);
79
82
 
80
83
  useLayoutEffect(() => {
81
84
  setHostNavigationRef(hostNavigationRef);
@@ -24,6 +24,7 @@ import {
24
24
 
25
25
  import { SocialButton } from "../../components/SocialButton";
26
26
  import { bugsnagErrorHandler } from "../../config/bugsnag";
27
+ import { AUTH_BUTTON_HEIGHT } from "../../constants";
27
28
  import { useLocalization } from "../../contexts/localization";
28
29
  import { removeSecureValue } from "../../helpers/keychain";
29
30
  import { moderateScale } from "../../helpers/scale";
@@ -196,6 +197,7 @@ export const AuthOptions = ({
196
197
  <Button
197
198
  isLoading={buttonLoading}
198
199
  label={strings.commons.buttons.continue}
200
+ style={styles.primaryButton}
199
201
  onPress={() => handleOnLogin("email")}
200
202
  />
201
203
  <Container style={styles.separatorRow}>
@@ -235,6 +237,9 @@ export const AuthOptions = ({
235
237
  };
236
238
 
237
239
  const styles = StyleSheet.create({
240
+ primaryButton: {
241
+ height: moderateScale(AUTH_BUTTON_HEIGHT),
242
+ },
238
243
  appleButton: {
239
244
  marginTop: moderateScale(24),
240
245
  },
@@ -13,7 +13,9 @@ import {
13
13
  } from "@bigbinary/neetoui-rn";
14
14
  import { useFocusEffect } from "@react-navigation/native";
15
15
 
16
+ import { AUTH_BUTTON_HEIGHT } from "../../constants";
16
17
  import { useLocalization } from "../../contexts/localization";
18
+ import { moderateScale } from "../../helpers/scale";
17
19
  import { useAuth } from "../../hooks/query/useAuth";
18
20
  import { type CommonsScreenProps } from "../../navigation/types";
19
21
 
@@ -151,6 +153,7 @@ export const OTPVerificationScreen = ({
151
153
  <Button
152
154
  disabled={Boolean(resendTimer)}
153
155
  isLoading={isGeneratingOTP}
156
+ style={styles.authButton}
154
157
  variant="text"
155
158
  // `formatString` returns a plain string whenever every argument is
156
159
  // one, which is the case here.
@@ -172,6 +175,7 @@ export const OTPVerificationScreen = ({
172
175
  disabled={code.length !== 6}
173
176
  isLoading={isVerifyingOTP}
174
177
  label={strings.commons.buttons.continue}
178
+ style={styles.authButton}
175
179
  onPress={() => verifyOTP(code)}
176
180
  />
177
181
  </Container>
@@ -181,6 +185,9 @@ export const OTPVerificationScreen = ({
181
185
  };
182
186
 
183
187
  const styles = StyleSheet.create({
188
+ authButton: {
189
+ height: moderateScale(AUTH_BUTTON_HEIGHT),
190
+ },
184
191
  screen: {
185
192
  flex: 1,
186
193
  },