@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.
- package/lib/typescript/components/PushPermissionGate.d.ts +20 -0
- package/lib/typescript/components/PushPermissionGate.d.ts.map +1 -0
- package/lib/typescript/components/SocialButton.d.ts.map +1 -1
- package/lib/typescript/components/index.d.ts +1 -0
- package/lib/typescript/components/index.d.ts.map +1 -1
- package/lib/typescript/config/axios/caseConversion.d.ts.map +1 -1
- package/lib/typescript/config/axios/constants.d.ts +3 -0
- package/lib/typescript/config/axios/constants.d.ts.map +1 -1
- package/lib/typescript/config/axios/index.d.ts.map +1 -1
- package/lib/typescript/config/axios/redact.d.ts +2 -0
- package/lib/typescript/config/axios/redact.d.ts.map +1 -0
- package/lib/typescript/config/bugsnag.d.ts +3 -2
- package/lib/typescript/config/bugsnag.d.ts.map +1 -1
- package/lib/typescript/constants/index.d.ts +11 -0
- package/lib/typescript/constants/index.d.ts.map +1 -1
- package/lib/typescript/helpers/notifications/index.d.ts +1 -0
- package/lib/typescript/helpers/notifications/index.d.ts.map +1 -1
- package/lib/typescript/helpers/notifications/permission.d.ts +40 -0
- package/lib/typescript/helpers/notifications/permission.d.ts.map +1 -0
- package/lib/typescript/helpers/notifications/registerBackgroundHandlers.d.ts.map +1 -1
- package/lib/typescript/helpers/notifications/usePushNotification.d.ts.map +1 -1
- package/lib/typescript/hooks/index.d.ts +2 -0
- package/lib/typescript/hooks/index.d.ts.map +1 -1
- package/lib/typescript/hooks/query/useAuth.d.ts.map +1 -1
- package/lib/typescript/hooks/useNotificationPermissionStatus.d.ts +16 -0
- package/lib/typescript/hooks/useNotificationPermissionStatus.d.ts.map +1 -0
- package/lib/typescript/hooks/usePushNotificationPermissions.d.ts +8 -0
- package/lib/typescript/hooks/usePushNotificationPermissions.d.ts.map +1 -1
- package/lib/typescript/hooks/useRequestNotificationPermission.d.ts +13 -0
- package/lib/typescript/hooks/useRequestNotificationPermission.d.ts.map +1 -0
- package/lib/typescript/providers/NeetoCommonsRNProvider.d.ts.map +1 -1
- package/lib/typescript/screens/authentication/AuthOptions.d.ts.map +1 -1
- package/lib/typescript/screens/authentication/OTPVerificationScreen.d.ts.map +1 -1
- package/package.json +32 -4
- package/src/components/PushPermissionGate.ts +25 -0
- package/src/components/SocialButton.tsx +2 -1
- package/src/components/index.ts +1 -0
- package/src/config/axios/caseConversion.ts +6 -2
- package/src/config/axios/constants.ts +19 -0
- package/src/config/axios/index.ts +7 -31
- package/src/config/axios/redact.ts +50 -0
- package/src/config/bugsnag.ts +86 -6
- package/src/constants/index.ts +12 -0
- package/src/helpers/notifications/index.ts +4 -0
- package/src/helpers/notifications/permission.ts +56 -0
- package/src/helpers/notifications/registerBackgroundHandlers.ts +17 -6
- package/src/helpers/notifications/usePushNotification.ts +20 -13
- package/src/hooks/index.ts +2 -0
- package/src/hooks/query/useAuth.ts +6 -1
- package/src/hooks/query/useNotification.ts +2 -2
- package/src/hooks/useNotificationPermissionStatus.ts +50 -0
- package/src/hooks/usePushNotificationPermissions.ts +24 -13
- package/src/hooks/useRequestNotificationPermission.ts +55 -0
- package/src/providers/NeetoCommonsRNProvider.tsx +11 -8
- package/src/screens/authentication/AuthOptions.tsx +5 -0
- package/src/screens/authentication/OTPVerificationScreen.tsx +7 -0
package/src/config/bugsnag.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import BugsnagPluginReactNavigation from "@bugsnag/plugin-react-navigation";
|
|
2
|
-
import Bugsnag, {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/constants/index.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
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
|
-
//
|
|
131
|
-
//
|
|
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
|
-
|
|
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
|
|
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(
|
|
204
|
+
bugsnagErrorHandler(toNotifiableError(error), "notification");
|
|
198
205
|
}
|
|
199
206
|
}, [createChannels, handleTokenRegistration]);
|
|
200
207
|
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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 {
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
54
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
},
|