@bigbinary/neeto-commons-rn 2.2.1 → 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 (42) 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/bugsnag.d.ts.map +1 -1
  7. package/lib/typescript/constants/index.d.ts +11 -0
  8. package/lib/typescript/constants/index.d.ts.map +1 -1
  9. package/lib/typescript/helpers/notifications/index.d.ts +1 -0
  10. package/lib/typescript/helpers/notifications/index.d.ts.map +1 -1
  11. package/lib/typescript/helpers/notifications/permission.d.ts +40 -0
  12. package/lib/typescript/helpers/notifications/permission.d.ts.map +1 -0
  13. package/lib/typescript/helpers/notifications/registerBackgroundHandlers.d.ts.map +1 -1
  14. package/lib/typescript/helpers/notifications/usePushNotification.d.ts.map +1 -1
  15. package/lib/typescript/hooks/index.d.ts +2 -0
  16. package/lib/typescript/hooks/index.d.ts.map +1 -1
  17. package/lib/typescript/hooks/useNotificationPermissionStatus.d.ts +16 -0
  18. package/lib/typescript/hooks/useNotificationPermissionStatus.d.ts.map +1 -0
  19. package/lib/typescript/hooks/usePushNotificationPermissions.d.ts +8 -0
  20. package/lib/typescript/hooks/usePushNotificationPermissions.d.ts.map +1 -1
  21. package/lib/typescript/hooks/useRequestNotificationPermission.d.ts +13 -0
  22. package/lib/typescript/hooks/useRequestNotificationPermission.d.ts.map +1 -0
  23. package/lib/typescript/providers/NeetoCommonsRNProvider.d.ts.map +1 -1
  24. package/lib/typescript/screens/authentication/AuthOptions.d.ts.map +1 -1
  25. package/lib/typescript/screens/authentication/OTPVerificationScreen.d.ts.map +1 -1
  26. package/package.json +32 -4
  27. package/src/components/PushPermissionGate.ts +25 -0
  28. package/src/components/SocialButton.tsx +2 -1
  29. package/src/components/index.ts +1 -0
  30. package/src/config/bugsnag.ts +12 -5
  31. package/src/constants/index.ts +12 -0
  32. package/src/helpers/notifications/index.ts +4 -0
  33. package/src/helpers/notifications/permission.ts +56 -0
  34. package/src/helpers/notifications/registerBackgroundHandlers.ts +17 -6
  35. package/src/helpers/notifications/usePushNotification.ts +17 -10
  36. package/src/hooks/index.ts +2 -0
  37. package/src/hooks/useNotificationPermissionStatus.ts +50 -0
  38. package/src/hooks/usePushNotificationPermissions.ts +20 -9
  39. package/src/hooks/useRequestNotificationPermission.ts +55 -0
  40. package/src/providers/NeetoCommonsRNProvider.tsx +11 -8
  41. package/src/screens/authentication/AuthOptions.tsx +5 -0
  42. package/src/screens/authentication/OTPVerificationScreen.tsx +7 -0
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Raises the notification permission prompt, once, and renders nothing.
3
+ *
4
+ * Mount it inside the signed-in branch of the navigator:
5
+ *
6
+ * {isLoggedIn && <PushPermissionGate />}
7
+ *
8
+ * A component rather than a hook call because the request happens as soon as
9
+ * it runs and hooks cannot be called conditionally -- mounting is what gates
10
+ * it on auth, and what keeps the prompt off the login screen. Unmounting on
11
+ * sign-out means the next account on a shared device is asked on its own
12
+ * first login rather than inheriting the previous session's state.
13
+ *
14
+ * This exists because every app that put the request on its Profile screen
15
+ * shipped the same bug: Profile is a lazily mounted tab, so a user who never
16
+ * opened it was never asked, while the app went on registering a device token
17
+ * for them. See neetozone/neeto-chat-rn#788.
18
+ */
19
+ export declare const PushPermissionGate: () => null;
20
+ //# sourceMappingURL=PushPermissionGate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PushPermissionGate.d.ts","sourceRoot":"","sources":["../../../src/components/PushPermissionGate.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,kBAAkB,YAI9B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"SocialButton.d.ts","sourceRoot":"","sources":["../../../src/components/SocialButton.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,EAAc,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAiB1E,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC5B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAKD,eAAO,MAAM,YAAY,GAAI,mDAM1B,iBAAiB,sBAyBnB,CAAC"}
1
+ {"version":3,"file":"SocialButton.d.ts","sourceRoot":"","sources":["../../../src/components/SocialButton.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,EAAc,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAkB1E,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,OAAO,GAAG,QAAQ,CAAC;IAC5B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAKD,eAAO,MAAM,YAAY,GAAI,mDAM1B,iBAAiB,sBAyBnB,CAAC"}
@@ -11,4 +11,5 @@ export { EmptyView } from "./EmptyView";
11
11
  export { TextWithUrl } from "./TextWithUrl";
12
12
  export { OfflineScreen } from "./OfflineScreen";
13
13
  export { WorkspaceSwitcherPane } from "./WorkspaceSwitcherPane";
14
+ export { PushPermissionGate } from "./PushPermissionGate";
14
15
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,eAAe,CAAC;AACvD,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACrD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAClC,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,eAAe,CAAC;AACvD,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"bugsnag.d.ts","sourceRoot":"","sources":["../../../src/config/bugsnag.ts"],"names":[],"mappings":"AACA,OAAgB,EACd,KAAK,KAAK,EACV,KAAK,eAAe,EACrB,MAAM,uBAAuB,CAAC;AAgD/B,eAAO,MAAM,iBAAiB,GAAI,OAAO,OAAO,KAAG,KAMlD,CAAC;AAEF,eAAO,MAAM,mBAAmB,GAC9B,KAAK,eAAe,EACpB,QAAQ,MAAM,EAGd,eAAe,MAAM,EAGrB,WAAU,KAAK,CAAC,UAAU,CAAa,SAkBxC,CAAC;AAEF,eAAO,MAAM,gBAAgB,YAgD5B,CAAC"}
1
+ {"version":3,"file":"bugsnag.d.ts","sourceRoot":"","sources":["../../../src/config/bugsnag.ts"],"names":[],"mappings":"AACA,OAAgB,EACd,KAAK,KAAK,EACV,KAAK,eAAe,EACrB,MAAM,uBAAuB,CAAC;AAgD/B,eAAO,MAAM,iBAAiB,GAAI,OAAO,OAAO,KAAG,KAMlD,CAAC;AAEF,eAAO,MAAM,mBAAmB,GAC9B,KAAK,eAAe,EACpB,QAAQ,MAAM,EAGd,eAAe,MAAM,EAGrB,WAAU,KAAK,CAAC,UAAU,CAAa,SAkBxC,CAAC;AAEF,eAAO,MAAM,gBAAgB,YAuD5B,CAAC"}
@@ -3,6 +3,16 @@ export declare const STORAGE_KEYS: {
3
3
  readonly DEVICE_TOKEN: "devicetoken";
4
4
  readonly IS_VISITED: "isVisited";
5
5
  readonly NOTIFICATION_PERMISSION_REQUESTED: "notificationPermissionRequested";
6
+ /**
7
+ * Read-only fallback. Before this key existed here, every app kept its own
8
+ * "have we asked yet?" flag under this exact string -- neeto-desk,
9
+ * neeto-cal, neeto-form and neeto-invoice all wrote it verbatim from their
10
+ * own constants. An app adopting the hooks here would otherwise start with
11
+ * a null flag and re-prompt everyone who had already declined, so the
12
+ * request path reads this when its own key is unset. Never written; drop it
13
+ * once installs have rolled over.
14
+ */
15
+ readonly LEGACY_APP_NOTIFICATION_PROMPT_DONE: "notification_first_prompt_done";
6
16
  };
7
17
  export declare const DEFAULT_PAGE_SIZE = 30;
8
18
  /**
@@ -13,6 +23,7 @@ export declare const DEFAULT_PAGE_SIZE = 30;
13
23
  export declare const APP_VERSION_ERROR_REPORT_INTERVAL: number;
14
24
  /** One Bugsnag group for every app_versions failure, whatever the status. */
15
25
  export declare const APP_VERSION_ERROR_GROUPING_HASH = "app-version-check-failed";
26
+ export declare const AUTH_BUTTON_HEIGHT = 48;
16
27
  /** Design baseline: a ~5" phone, the width every scaled size was drawn against. */
17
28
  export declare const GUIDELINE_BASE_WIDTH = 350;
18
29
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/constants/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAElD,eAAO,MAAM,YAAY;;;;CAIf,CAAC;AAEX,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC;;;;GAIG;AACH,eAAO,MAAM,iCAAiC,QAAiB,CAAC;AAEhE,6EAA6E;AAC7E,eAAO,MAAM,+BAA+B,6BAA6B,CAAC;AAE1E,mFAAmF;AACnF,eAAO,MAAM,oBAAoB,MAAM,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/constants/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAElD,eAAO,MAAM,YAAY;;;;IAIvB;;;;;;;;OAQG;;CAEK,CAAC;AAEX,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC;;;;GAIG;AACH,eAAO,MAAM,iCAAiC,QAAiB,CAAC;AAEhE,6EAA6E;AAC7E,eAAO,MAAM,+BAA+B,6BAA6B,CAAC;AAE1E,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC,mFAAmF;AACnF,eAAO,MAAM,oBAAoB,MAAM,CAAC"}
@@ -2,4 +2,5 @@ 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 { hasBeenPromptedForNotifications, needsNotificationPermissionPrompt, } from "./permission";
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/helpers/notifications/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,EAAE,0BAA0B,EAAE,MAAM,8BAA8B,CAAC;AAC1E,OAAO,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAC;AAC9E,OAAO,EAAE,8BAA8B,EAAE,MAAM,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/helpers/notifications/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,EAAE,0BAA0B,EAAE,MAAM,8BAA8B,CAAC;AAC1E,OAAO,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAC;AAC9E,OAAO,EAAE,8BAA8B,EAAE,MAAM,qBAAqB,CAAC;AACrE,OAAO,EACL,+BAA+B,EAC/B,iCAAiC,GAClC,MAAM,cAAc,CAAC"}
@@ -0,0 +1,40 @@
1
+ import { Platform } from "react-native";
2
+ /**
3
+ * Whether this device has a notification permission worth asking for.
4
+ *
5
+ * Android only gained a runtime notification permission in 13 (API 33);
6
+ * below that the manifest entry is the whole story and there is nothing to
7
+ * prompt for. Every iOS version needs asking.
8
+ *
9
+ * Consuming apps used to hand-roll this as
10
+ * `Platform.OS !== "android" || Platform.Version < 33`, which returns early
11
+ * on iOS as well and so never asked there at all. neeto-desk shipped that
12
+ * for months; neeto-form and neeto-invoice still carry the same line. Owning
13
+ * it here is the point of this module.
14
+ *
15
+ * Note that `Platform.Version` is a number on Android and a string on iOS,
16
+ * so the version comparison is only meaningful once the OS is known to be
17
+ * Android. Coercing keeps a string version from silently comparing as NaN.
18
+ *
19
+ * Parameters are injectable so the platform matrix is testable without
20
+ * mutating the Platform module.
21
+ */
22
+ export declare const needsNotificationPermissionPrompt: (os?: typeof Platform.OS, version?: typeof Platform.Version) => boolean;
23
+ /**
24
+ * Has this install already been through the prompt?
25
+ *
26
+ * Falls back to the app-local flag every consuming app used to keep under
27
+ * "notification_first_prompt_done". Without that read, an app adopting these
28
+ * hooks starts with a null flag, decides nobody has been asked, and
29
+ * re-prompts everyone whose status is still DENIED -- on Android 13+ that is
30
+ * every user who declined exactly once, since Android allows a second ask.
31
+ * iOS is unaffected either way: a prior denial reads BLOCKED, not DENIED.
32
+ *
33
+ * A fallback read rather than a migration, on purpose. A migration would have
34
+ * to finish before the requesting hook mounts -- it prompts on mount -- which
35
+ * means gating the gate on an async seed. Reading both keys has no ordering
36
+ * to get wrong, is idempotent, and covers every app at once because they all
37
+ * wrote the identical string.
38
+ */
39
+ export declare const hasBeenPromptedForNotifications: () => Promise<boolean>;
40
+ //# sourceMappingURL=permission.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permission.d.ts","sourceRoot":"","sources":["../../../../src/helpers/notifications/permission.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAKxC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,iCAAiC,GAC5C,KAAI,OAAO,QAAQ,CAAC,EAAgB,EACpC,UAAS,OAAO,QAAQ,CAAC,OAA0B,YACP,CAAC;AAE/C;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,wBAS3C,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"registerBackgroundHandlers.d.ts","sourceRoot":"","sources":["../../../../src/helpers/notifications/registerBackgroundHandlers.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,wBAAwB,EAAE,MAAM,SAAS,CAAC;AAOxD,eAAO,MAAM,0BAA0B,GACrC,sBAAsB,wBAAwB,SA+C/C,CAAC"}
1
+ {"version":3,"file":"registerBackgroundHandlers.d.ts","sourceRoot":"","sources":["../../../../src/helpers/notifications/registerBackgroundHandlers.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,wBAAwB,EAAE,MAAM,SAAS,CAAC;AAOxD,eAAO,MAAM,0BAA0B,GACrC,sBAAsB,wBAAwB,SA0D/C,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"usePushNotification.d.ts","sourceRoot":"","sources":["../../../../src/helpers/notifications/usePushNotification.ts"],"names":[],"mappings":"AAUA,OAAO,EACL,KAAK,wBAAwB,EAE9B,MAAM,SAAS,CAAC;AAWjB,UAAU,uBAAuB;IAC/B,WAAW,EAAE,wBAAwB,CAAC;CACvC;AAED,eAAO,MAAM,mBAAmB,GAC9B,YAAY,OAAO,EACnB,kBAAiB,uBAAmD,YAiLrE,CAAC"}
1
+ {"version":3,"file":"usePushNotification.d.ts","sourceRoot":"","sources":["../../../../src/helpers/notifications/usePushNotification.ts"],"names":[],"mappings":"AAUA,OAAO,EACL,KAAK,wBAAwB,EAE9B,MAAM,SAAS,CAAC;AAWjB,UAAU,uBAAuB;IAC/B,WAAW,EAAE,wBAAwB,CAAC;CACvC;AAED,eAAO,MAAM,mBAAmB,GAC9B,YAAY,OAAO,EACnB,kBAAiB,uBAAmD,YAwLrE,CAAC"}
@@ -1,3 +1,5 @@
1
1
  export { useLocalization } from "../contexts/localization";
2
2
  export { usePushNotificationPermissions } from "./usePushNotificationPermissions";
3
+ export { useNotificationPermissionStatus } from "./useNotificationPermissionStatus";
4
+ export { useRequestNotificationPermission } from "./useRequestNotificationPermission";
3
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,EAAE,8BAA8B,EAAE,MAAM,kCAAkC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,EAAE,8BAA8B,EAAE,MAAM,kCAAkC,CAAC;AAClF,OAAO,EAAE,+BAA+B,EAAE,MAAM,mCAAmC,CAAC;AACpF,OAAO,EAAE,gCAAgC,EAAE,MAAM,oCAAoC,CAAC"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Read-only view of the notification permission.
3
+ *
4
+ * Deliberately never requests. A screen that merely displays whether
5
+ * notifications are on -- every app's Profile -- must not be the thing that
6
+ * asks, or users who never open it are never asked at all. That is exactly
7
+ * what usePushNotificationPermissions caused by combining the two: it was
8
+ * called from Profile because Profile is where the status is shown, which
9
+ * made a settings screen the app's only requester. Use PushPermissionGate
10
+ * for the asking.
11
+ *
12
+ * Re-checks on foreground, because the only way back from a denial is the
13
+ * system Settings app, which means leaving the app and returning.
14
+ */
15
+ export declare const useNotificationPermissionStatus: () => boolean;
16
+ //# sourceMappingURL=useNotificationPermissionStatus.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useNotificationPermissionStatus.d.ts","sourceRoot":"","sources":["../../../src/hooks/useNotificationPermissionStatus.ts"],"names":[],"mappings":"AAOA;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,+BAA+B,eA4B3C,CAAC"}
@@ -1,2 +1,10 @@
1
+ /**
2
+ * @deprecated Checks and requests in one call, which is why every app that
3
+ * wanted to *display* the status ended up making its Profile screen the only
4
+ * thing that ever *asked* -- see neetozone/neeto-chat-rn#788. Prefer
5
+ * `useNotificationPermissionStatus()` to read and `<PushPermissionGate />`
6
+ * (or `useRequestNotificationPermission()`) to ask. Kept working for
7
+ * consumers that have not moved yet.
8
+ */
1
9
  export declare const usePushNotificationPermissions: () => boolean[];
2
10
  //# sourceMappingURL=usePushNotificationPermissions.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"usePushNotificationPermissions.d.ts","sourceRoot":"","sources":["../../../src/hooks/usePushNotificationPermissions.ts"],"names":[],"mappings":"AAcA,eAAO,MAAM,8BAA8B,iBAgG1C,CAAC"}
1
+ {"version":3,"file":"usePushNotificationPermissions.d.ts","sourceRoot":"","sources":["../../../src/hooks/usePushNotificationPermissions.ts"],"names":[],"mappings":"AAkBA;;;;;;;GAOG;AACH,eAAO,MAAM,8BAA8B,iBA+F1C,CAAC"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Asks for notification permission once per install, and nothing else.
3
+ *
4
+ * Prefer mounting PushPermissionGate inside the signed-in branch of the
5
+ * navigator; this hook is what it wraps, exported for apps that need to
6
+ * trigger the request from somewhere other than a mount.
7
+ *
8
+ * Only asks when the status is DENIED. GRANTED needs nothing and BLOCKED
9
+ * cannot be reopened from inside the app -- sending a blocked user to
10
+ * Settings is denied-state recovery, which belongs to the UI, not here.
11
+ */
12
+ export declare const useRequestNotificationPermission: (enabled?: boolean) => void;
13
+ //# sourceMappingURL=useRequestNotificationPermission.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useRequestNotificationPermission.d.ts","sourceRoot":"","sources":["../../../src/hooks/useRequestNotificationPermission.ts"],"names":[],"mappings":"AAgBA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gCAAgC,GAAI,iBAAc,SA2B9D,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"NeetoCommonsRNProvider.d.ts","sourceRoot":"","sources":["../../../src/providers/NeetoCommonsRNProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAIZ,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAEf,OAAO,EAAE,KAAK,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AACvE,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAEzD,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,OAAO,CAAC;AAIrC,OAAO,EAEL,KAAK,WAAW,EACjB,MAAM,0BAA0B,CAAC;AAyBlC,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,iBAAiB,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,sBAAsB,CACxD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CACnC,GAAG,IAAI,CAAC,CAAC;IACV,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzC,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B;;;;OAIG;IACH,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED,eAAO,MAAM,sBAAsB,GAAI,2GAQpC,2BAA2B,sBAgC7B,CAAC"}
1
+ {"version":3,"file":"NeetoCommonsRNProvider.d.ts","sourceRoot":"","sources":["../../../src/providers/NeetoCommonsRNProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAIZ,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAEf,OAAO,EAAE,KAAK,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AACvE,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAEzD,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,OAAO,CAAC;AAIrC,OAAO,EAEL,KAAK,WAAW,EACjB,MAAM,0BAA0B,CAAC;AAyBlC,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,iBAAiB,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,sBAAsB,CACxD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CACnC,GAAG,IAAI,CAAC,CAAC;IACV,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzC,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B;;;;OAIG;IACH,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED,eAAO,MAAM,sBAAsB,GAAI,2GAQpC,2BAA2B,sBAmC7B,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"AuthOptions.d.ts","sourceRoot":"","sources":["../../../../src/screens/authentication/AuthOptions.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAiC1B,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,eAAO,MAAM,WAAW,GAAI,kCAGzB,gBAAgB,sBAiMlB,CAAC"}
1
+ {"version":3,"file":"AuthOptions.d.ts","sourceRoot":"","sources":["../../../../src/screens/authentication/AuthOptions.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAkC1B,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,eAAO,MAAM,WAAW,GAAI,kCAGzB,gBAAgB,sBAkMlB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"OTPVerificationScreen.d.ts","sourceRoot":"","sources":["../../../../src/screens/authentication/OTPVerificationScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAiBxE,OAAO,EAAE,KAAK,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAKjE,eAAO,MAAM,qBAAqB,GAAI,oCAGnC,kBAAkB,CAAC,iBAAiB,CAAC,sBA2JvC,CAAC"}
1
+ {"version":3,"file":"OTPVerificationScreen.d.ts","sourceRoot":"","sources":["../../../../src/screens/authentication/OTPVerificationScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAmBxE,OAAO,EAAE,KAAK,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAKjE,eAAO,MAAM,qBAAqB,GAAI,oCAGnC,kBAAkB,CAAC,iBAAiB,CAAC,sBA6JvC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigbinary/neeto-commons-rn",
3
- "version": "2.2.1",
3
+ "version": "2.2.4",
4
4
  "license": "UNLICENSED",
5
5
  "main": "./src/index.ts",
6
6
  "react-native": "./src/index.ts",
@@ -121,6 +121,11 @@
121
121
  "react-native": "./src/components/OrganizationItem.tsx",
122
122
  "default": "./src/components/OrganizationItem.tsx"
123
123
  },
124
+ "./components/PushPermissionGate": {
125
+ "types": "./lib/typescript/components/PushPermissionGate.d.ts",
126
+ "react-native": "./src/components/PushPermissionGate.ts",
127
+ "default": "./src/components/PushPermissionGate.ts"
128
+ },
124
129
  "./components/SocialButton": {
125
130
  "types": "./lib/typescript/components/SocialButton.d.ts",
126
131
  "react-native": "./src/components/SocialButton.tsx",
@@ -181,6 +186,11 @@
181
186
  "react-native": "./src/config/axios/paramsSerializer.ts",
182
187
  "default": "./src/config/axios/paramsSerializer.ts"
183
188
  },
189
+ "./config/axios/redact": {
190
+ "types": "./lib/typescript/config/axios/redact.d.ts",
191
+ "react-native": "./src/config/axios/redact.ts",
192
+ "default": "./src/config/axios/redact.ts"
193
+ },
184
194
  "./config/axios/types": {
185
195
  "types": "./lib/typescript/config/axios/types.d.ts",
186
196
  "react-native": "./src/config/axios/types.ts",
@@ -261,6 +271,11 @@
261
271
  "react-native": "./src/helpers/notifications/index.ts",
262
272
  "default": "./src/helpers/notifications/index.ts"
263
273
  },
274
+ "./helpers/notifications/permission": {
275
+ "types": "./lib/typescript/helpers/notifications/permission.d.ts",
276
+ "react-native": "./src/helpers/notifications/permission.ts",
277
+ "default": "./src/helpers/notifications/permission.ts"
278
+ },
264
279
  "./helpers/notifications/registerBackgroundHandlers": {
265
280
  "types": "./lib/typescript/helpers/notifications/registerBackgroundHandlers.d.ts",
266
281
  "react-native": "./src/helpers/notifications/registerBackgroundHandlers.ts",
@@ -331,16 +346,31 @@
331
346
  "react-native": "./src/hooks/useAppState.ts",
332
347
  "default": "./src/hooks/useAppState.ts"
333
348
  },
349
+ "./hooks/useNotificationPermissionStatus": {
350
+ "types": "./lib/typescript/hooks/useNotificationPermissionStatus.d.ts",
351
+ "react-native": "./src/hooks/useNotificationPermissionStatus.ts",
352
+ "default": "./src/hooks/useNotificationPermissionStatus.ts"
353
+ },
334
354
  "./hooks/usePushNotificationPermissions": {
335
355
  "types": "./lib/typescript/hooks/usePushNotificationPermissions.d.ts",
336
356
  "react-native": "./src/hooks/usePushNotificationPermissions.ts",
337
357
  "default": "./src/hooks/usePushNotificationPermissions.ts"
338
358
  },
359
+ "./hooks/useRequestNotificationPermission": {
360
+ "types": "./lib/typescript/hooks/useRequestNotificationPermission.d.ts",
361
+ "react-native": "./src/hooks/useRequestNotificationPermission.ts",
362
+ "default": "./src/hooks/useRequestNotificationPermission.ts"
363
+ },
339
364
  ".": {
340
365
  "types": "./lib/typescript/index.d.ts",
341
366
  "react-native": "./src/index.ts",
342
367
  "default": "./src/index.ts"
343
368
  },
369
+ "./initializers/constants": {
370
+ "types": "./lib/typescript/initializers/constants.d.ts",
371
+ "react-native": "./src/initializers/constants.ts",
372
+ "default": "./src/initializers/constants.ts"
373
+ },
344
374
  "./initializers": {
345
375
  "types": "./lib/typescript/initializers/index.d.ts",
346
376
  "react-native": "./src/initializers/index.ts",
@@ -598,7 +628,6 @@
598
628
  },
599
629
  "peerDependencies": {
600
630
  "@babel/eslint-parser": "^7.19.1",
601
- "@bigbinary/app-version-control": "^1.1.6",
602
631
  "@bigbinary/neetoui-rn": ">=3.0.0",
603
632
  "@eslint/js": "^9.26.0",
604
633
  "@gorhom/bottom-sheet": ">=5.0.0",
@@ -652,7 +681,7 @@
652
681
  "@rozenite/redux-devtools-plugin": "2.1.0",
653
682
  "@rozenite/storage-plugin": "2.1.0",
654
683
  "@rozenite/tanstack-query-plugin": "2.1.0",
655
- "compare-versions": "^6.0.0-rc.1",
684
+ "compare-versions": "^6.1.1",
656
685
  "formik": "^2.2.9",
657
686
  "i18next": "^26.3.6",
658
687
  "qs": "^6.15.2",
@@ -670,7 +699,6 @@
670
699
  "@babel/core": "^7.29.6",
671
700
  "@babel/eslint-parser": "^7.19.1",
672
701
  "@babel/runtime": "^7.12.5",
673
- "@bigbinary/app-version-control": "^1.1.6",
674
702
  "@bigbinary/neetoui-rn": "^3.0.0",
675
703
  "@bugsnag/source-maps": "^2.3.1",
676
704
  "@eslint/js": "^9.26.0",
@@ -0,0 +1,25 @@
1
+ import { useRequestNotificationPermission } from "../hooks/useRequestNotificationPermission";
2
+
3
+ /**
4
+ * Raises the notification permission prompt, once, and renders nothing.
5
+ *
6
+ * Mount it inside the signed-in branch of the navigator:
7
+ *
8
+ * {isLoggedIn && <PushPermissionGate />}
9
+ *
10
+ * A component rather than a hook call because the request happens as soon as
11
+ * it runs and hooks cannot be called conditionally -- mounting is what gates
12
+ * it on auth, and what keeps the prompt off the login screen. Unmounting on
13
+ * sign-out means the next account on a shared device is asked on its own
14
+ * first login rather than inheriting the previous session's state.
15
+ *
16
+ * This exists because every app that put the request on its Profile screen
17
+ * shipped the same bug: Profile is a lazily mounted tab, so a user who never
18
+ * opened it was never asked, while the app went on registering a device token
19
+ * for them. See neetozone/neeto-chat-rn#788.
20
+ */
21
+ export const PushPermissionGate = () => {
22
+ useRequestNotificationPermission();
23
+
24
+ return null;
25
+ };
@@ -11,6 +11,7 @@ import {
11
11
 
12
12
  import AppleLogo from "../assets/images/apple-logo.svg";
13
13
  import GoogleLogo from "../assets/images/google-logo.svg";
14
+ import { AUTH_BUTTON_HEIGHT } from "../constants";
14
15
  import { moderateScale } from "../helpers/scale";
15
16
 
16
17
  // neetoui-rn v2 shipped this; v3 dropped it. Reproduced here against the v3
@@ -68,7 +69,7 @@ const styles = StyleSheet.create({
68
69
  borderColor: lightTheme.colors.palette.grey800,
69
70
  borderRadius: moderateScale(8),
70
71
  borderWidth: moderateScale(1),
71
- height: moderateScale(48),
72
+ height: moderateScale(AUTH_BUTTON_HEIGHT),
72
73
  width: "100%",
73
74
  },
74
75
  logo: {
@@ -11,3 +11,4 @@ export { EmptyView } from "./EmptyView";
11
11
  export { TextWithUrl } from "./TextWithUrl";
12
12
  export { OfflineScreen } from "./OfflineScreen";
13
13
  export { WorkspaceSwitcherPane } from "./WorkspaceSwitcherPane";
14
+ export { PushPermissionGate } from "./PushPermissionGate";
@@ -103,11 +103,18 @@ export const configureBugsnag = () => {
103
103
 
104
104
  // Only in production: QA runs release builds on emulators on purpose,
105
105
  // and dropping those would hide the very failures they are hunting.
106
- // Bugsnag's own flag is also unreliable — the Android 16 images QA
107
- // uses report `emulator: false` — so this catches less than it looks
108
- // like it does. Stage separation above is what actually keeps QA
109
- // traffic out of the production dashboard.
110
- if (releaseStage === "production" && event.device?.emulator) {
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) {
111
118
  return false;
112
119
  }
113
120
 
@@ -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
  }
@@ -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
  });
@@ -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";
@@ -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,
@@ -10,8 +10,20 @@ import {
10
10
 
11
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
 
@@ -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
 
@@ -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
  },