@bigbinary/neeto-commons-rn 2.3.0 → 2.3.1

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/README.md +4 -3
  2. package/lib/typescript/components/LineLoader.d.ts +8 -5
  3. package/lib/typescript/components/LineLoader.d.ts.map +1 -1
  4. package/lib/typescript/components/SocialButton.d.ts +8 -0
  5. package/lib/typescript/components/SocialButton.d.ts.map +1 -1
  6. package/lib/typescript/components/WorkspaceSwitcherPane.d.ts.map +1 -1
  7. package/lib/typescript/config/axios/constants.d.ts +4 -0
  8. package/lib/typescript/config/axios/constants.d.ts.map +1 -1
  9. package/lib/typescript/config/axios/groupingKey.d.ts.map +1 -1
  10. package/lib/typescript/config/bugsnag.d.ts.map +1 -1
  11. package/lib/typescript/constants/index.d.ts +1 -0
  12. package/lib/typescript/constants/index.d.ts.map +1 -1
  13. package/lib/typescript/helpers/notifications/constants.d.ts +1 -0
  14. package/lib/typescript/helpers/notifications/constants.d.ts.map +1 -1
  15. package/lib/typescript/helpers/notifications/displayedMessages.d.ts +12 -0
  16. package/lib/typescript/helpers/notifications/displayedMessages.d.ts.map +1 -0
  17. package/lib/typescript/helpers/notifications/registerBackgroundHandlers.d.ts.map +1 -1
  18. package/lib/typescript/helpers/notifications/usePushNotification.d.ts.map +1 -1
  19. package/lib/typescript/hooks/query/useAuth.d.ts.map +1 -1
  20. package/lib/typescript/screens/authentication/AuthOptions.d.ts.map +1 -1
  21. package/lib/typescript/screens/authentication/IncompleteOnboardingInfo.d.ts.map +1 -1
  22. package/lib/typescript/screens/authentication/Organization.d.ts +7 -1
  23. package/lib/typescript/screens/authentication/Organization.d.ts.map +1 -1
  24. package/lib/typescript/screens/authentication/SubDomainScreen.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/components/LineLoader.tsx +25 -8
  27. package/src/components/SocialButton.tsx +21 -7
  28. package/src/components/WorkspaceSwitcherPane.tsx +8 -1
  29. package/src/config/axios/constants.ts +29 -0
  30. package/src/config/axios/groupingKey.ts +46 -6
  31. package/src/config/bugsnag.ts +20 -2
  32. package/src/constants/index.ts +1 -0
  33. package/src/helpers/notifications/constants.ts +19 -10
  34. package/src/helpers/notifications/displayedMessages.ts +30 -0
  35. package/src/helpers/notifications/registerBackgroundHandlers.ts +17 -2
  36. package/src/helpers/notifications/usePushNotification.ts +46 -3
  37. package/src/hooks/query/useAuth.ts +57 -15
  38. package/src/localization/translations/en.json +6 -3
  39. package/src/screens/authentication/AuthOptions.tsx +74 -8
  40. package/src/screens/authentication/IncompleteOnboardingInfo.tsx +7 -25
  41. package/src/screens/authentication/Organization.tsx +12 -1
  42. package/src/screens/authentication/SubDomainScreen.tsx +13 -9
package/README.md CHANGED
@@ -58,7 +58,8 @@ yarn android:<environment>-release
58
58
  The project includes detailed documentation on various aspects of the
59
59
  application:
60
60
 
61
- - [Android Notifications](./docs/android-notifications.md) - Comprehensive guide
62
- to the notification system for Android, including handling for Android 13+
63
- permission requirements.
61
+ - [Push notifications](./docs/notifications.md) - Delivery, registration,
62
+ permission and display, for both platforms.
63
+ - [Mobile sign-in](./docs/authentication.md) - Email, Google and Apple, and how
64
+ google-signin is configured.
64
65
 
@@ -1,13 +1,16 @@
1
1
  import React from "react";
2
2
  export interface LineLoaderProps {
3
3
  isLoading?: boolean;
4
+ backgroundColor?: string;
5
+ foregroundColor?: string;
4
6
  height?: number;
5
7
  }
6
8
  /**
7
- * @deprecated Absorbed by the UI library's Skeleton
8
- * (<Skeleton variant="line" animated />). This shim keeps the v2 prop
9
- * contract for one deprecation cycle and disappears in the next commons
10
- * major.
9
+ * neetoui-rn v2's LineLoader. The v3 migration pointed this at the
10
+ * library's Skeleton, which only pulses a `grey200` strip invisible on a
11
+ * white screen. The strip always occupies `height`, so toggling `isLoading`
12
+ * never shifts layout, and `key` remounts the loader so the sweep restarts
13
+ * from the left on every load.
11
14
  */
12
- export declare const LineLoader: ({ isLoading, height, }: LineLoaderProps) => React.JSX.Element;
15
+ export declare const LineLoader: ({ isLoading, backgroundColor, foregroundColor, height, }: LineLoaderProps) => React.JSX.Element;
13
16
  //# sourceMappingURL=LineLoader.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"LineLoader.d.ts","sourceRoot":"","sources":["../../../src/components/LineLoader.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAM1B,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,eAAO,MAAM,UAAU,GAAI,wBAGxB,eAAe,sBAEjB,CAAC"}
1
+ {"version":3,"file":"LineLoader.d.ts","sourceRoot":"","sources":["../../../src/components/LineLoader.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAO1B,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,UAAU,GAAI,0DAKxB,eAAe,sBAajB,CAAC"}
@@ -7,5 +7,13 @@ export interface SocialButtonProps {
7
7
  style?: StyleProp<ViewStyle>;
8
8
  onPress: () => void;
9
9
  }
10
+ /**
11
+ * Pressable rather than the library's Touchable: Touchable always sets an
12
+ * `android_ripple`, and the `grey800` (#1f1f1f) colour this passed painted an
13
+ * opaque near-black wash over the whole button on Android. iOS never showed
14
+ * it, because `android_ripple` is a no-op there. neetoui-rn's own Button is
15
+ * a bare Pressable for the same reason, so the press feedback is the opacity
16
+ * dip on both platforms.
17
+ */
10
18
  export declare const SocialButton: ({ variant, disabled, isLoading, style, onPress, }: SocialButtonProps) => React.JSX.Element;
11
19
  //# sourceMappingURL=SocialButton.d.ts.map
@@ -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;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"}
1
+ {"version":3,"file":"SocialButton.d.ts","sourceRoot":"","sources":["../../../src/components/SocialButton.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,EAGL,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,cAAc,CAAC;AAiBtB,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;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,GAAI,mDAM1B,iBAAiB,sBAwBnB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"WorkspaceSwitcherPane.d.ts","sourceRoot":"","sources":["../../../src/components/WorkspaceSwitcherPane.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAkB1B,MAAM,WAAW,0BAA0B;IACzC,6EAA6E;IAC7E,UAAU,EAAE;QAAE,WAAW,EAAE,MAAM,IAAI,CAAA;KAAE,CAAC;CACzC;AAED,eAAO,MAAM,qBAAqB,GAAI,iBAEnC,0BAA0B,sBAyD5B,CAAC"}
1
+ {"version":3,"file":"WorkspaceSwitcherPane.d.ts","sourceRoot":"","sources":["../../../src/components/WorkspaceSwitcherPane.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAkB1B,MAAM,WAAW,0BAA0B;IACzC,6EAA6E;IAC7E,UAAU,EAAE;QAAE,WAAW,EAAE,MAAM,IAAI,CAAA;KAAE,CAAC;CACzC;AAED,eAAO,MAAM,qBAAqB,GAAI,iBAEnC,0BAA0B,sBAgE5B,CAAC"}
@@ -1,6 +1,10 @@
1
1
  import { type CaseConversionOptions } from "./types";
2
2
  export declare const DEFAULT_CASE_CONVERSION: boolean;
3
3
  export declare const UUID_SEGMENT: RegExp;
4
+ export declare const HEX_ID_SEGMENT: RegExp;
5
+ export declare const OPAQUE_ID_SEGMENT: RegExp;
6
+ export declare const RESERVED_URL_SEGMENTS: Set<string>;
7
+ export declare const MIN_SLUG_LENGTH = 5;
4
8
  export declare const shouldEnableCaseConversion: (options?: CaseConversionOptions) => boolean;
5
9
  export declare const SENSITIVE_KEY_PATTERNS: RegExp[];
6
10
  export declare const REDACTED = "<redacted>";
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../../src/config/axios/constants.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAErD,eAAO,MAAM,uBAAuB,SAA2C,CAAC;AAEhF,eAAO,MAAM,YAAY,QAC0C,CAAC;AAEpE,eAAO,MAAM,0BAA0B,GACrC,UAAS,qBAA0B,YASpC,CAAC;AAOF,eAAO,MAAM,sBAAsB,UAQlC,CAAC;AAEF,eAAO,MAAM,QAAQ,eAAe,CAAC;AAErC,eAAO,MAAM,gBAAgB,IAAI,CAAC"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../../src/config/axios/constants.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAErD,eAAO,MAAM,uBAAuB,SAA2C,CAAC;AAEhF,eAAO,MAAM,YAAY,QAC0C,CAAC;AAKpE,eAAO,MAAM,cAAc,QAAqB,CAAC;AAIjD,eAAO,MAAM,iBAAiB,QAAwC,CAAC;AAKvE,eAAO,MAAM,qBAAqB,aAUhC,CAAC;AAKH,eAAO,MAAM,eAAe,IAAI,CAAC;AAEjC,eAAO,MAAM,0BAA0B,GACrC,UAAS,qBAA0B,YASpC,CAAC;AAOF,eAAO,MAAM,sBAAsB,UAQlC,CAAC;AAEF,eAAO,MAAM,QAAQ,eAAe,CAAC;AAErC,eAAO,MAAM,gBAAgB,IAAI,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"groupingKey.d.ts","sourceRoot":"","sources":["../../../../src/config/axios/groupingKey.ts"],"names":[],"mappings":"AAKA;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,GAAI,MAAM,MAAM,WASnD,CAAC"}
1
+ {"version":3,"file":"groupingKey.d.ts","sourceRoot":"","sources":["../../../../src/config/axios/groupingKey.ts"],"names":[],"mappings":"AAyCA;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,GAAI,MAAM,MAAM,WAanD,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,YAuD5B,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,YAyE5B,CAAC"}
@@ -3,6 +3,7 @@ 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
+ readonly DISPLAYED_MESSAGE_IDS: "displayedMessageIds";
6
7
  /**
7
8
  * Read-only fallback. Before this key existed here, every app kept its own
8
9
  * "have we asked yet?" flag under this exact string -- neeto-desk,
@@ -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;;;;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"}
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;;;;;IAKvB;;;;;;;;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 @@ import { type AndroidChannel } from "react-native-notify-kit";
2
2
  export declare const NOTIFICATION_CHANNELS: AndroidChannel[];
3
3
  export declare const NOTIFICATION_CHANNEL_IDS: string[];
4
4
  export declare const DEVICE_TOKEN_TIMEOUT_MS = 10000;
5
+ export declare const DISPLAYED_MESSAGE_ID_LIMIT = 100;
5
6
  //# sourceMappingURL=constants.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../../src/helpers/notifications/constants.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EACpB,MAAM,yBAAyB,CAAC;AAIjC,eAAO,MAAM,qBAAqB,EAAE,cAAc,EAkBjD,CAAC;AAEF,eAAO,MAAM,wBAAwB,UAEpC,CAAC;AAIF,eAAO,MAAM,uBAAuB,QAAQ,CAAC"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../../../src/helpers/notifications/constants.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EACpB,MAAM,yBAAyB,CAAC;AAiBjC,eAAO,MAAM,qBAAqB,EAAE,cAAc,EAUjD,CAAC;AAEF,eAAO,MAAM,wBAAwB,UAEpC,CAAC;AAIF,eAAO,MAAM,uBAAuB,QAAQ,CAAC;AAI7C,eAAO,MAAM,0BAA0B,MAAM,CAAC"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * GmsCore redelivers a message the app never acknowledged on the next process
3
+ * start. On MIUI that is the headless boot a notification dismissal triggers,
4
+ * which re-displayed stale messages as ghost duplicates. The message id
5
+ * survives redelivery, so it is recorded before display and a repeat is
6
+ * dropped. An age cutoff was tried first; it also dropped every message FCM
7
+ * had queued while the device was offline.
8
+ *
9
+ * Returns `false` when the message was already displayed.
10
+ */
11
+ export declare const claimMessageForDisplay: (messageId?: string) => Promise<boolean>;
12
+ //# sourceMappingURL=displayedMessages.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"displayedMessages.d.ts","sourceRoot":"","sources":["../../../../src/helpers/notifications/displayedMessages.ts"],"names":[],"mappings":"AAKA;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,GAAU,YAAY,MAAM,qBAc9D,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,SA0D/C,CAAC"}
1
+ {"version":3,"file":"registerBackgroundHandlers.d.ts","sourceRoot":"","sources":["../../../../src/helpers/notifications/registerBackgroundHandlers.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,KAAK,wBAAwB,EAAE,MAAM,SAAS,CAAC;AAOxD,eAAO,MAAM,0BAA0B,GACrC,sBAAsB,wBAAwB,SAwE/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,YAwLrE,CAAC"}
1
+ {"version":3,"file":"usePushNotification.d.ts","sourceRoot":"","sources":["../../../../src/helpers/notifications/usePushNotification.ts"],"names":[],"mappings":"AAYA,OAAO,EACL,KAAK,wBAAwB,EAE9B,MAAM,SAAS,CAAC;AA2BjB,UAAU,uBAAuB;IAC/B,WAAW,EAAE,wBAAwB,CAAC;CACvC;AAED,eAAO,MAAM,mBAAmB,GAC9B,YAAY,OAAO,EACnB,kBAAiB,uBAAmD,YAiNrE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"useAuth.d.ts","sourceRoot":"","sources":["../../../../src/hooks/query/useAuth.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,OAAO,CAAC;AAUxC,OAAO,EAAE,KAAK,YAAY,EAAmB,MAAM,oBAAoB,CAAC;AASxE,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,mBAAmB,CAAC;AAE3B,eAAO,MAAM,OAAO;4CAQf;QACD,YAAY,EAAE,YAAY,CAAC;KAC5B;;eA8Ic,MAAM;4BAAsB,OAAO;;;cA/E1C,MAAM;gBACJ,MAAM;;;wBA0GyC,MAAM;;;2BAapC,MAAM;uBACV,MAAM;;;sBAYP,YAAY;;CAoBnC,CAAC"}
1
+ {"version":3,"file":"useAuth.d.ts","sourceRoot":"","sources":["../../../../src/hooks/query/useAuth.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,OAAO,CAAC;AAUxC,OAAO,EAAE,KAAK,YAAY,EAAmB,MAAM,oBAAoB,CAAC;AASxE,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,mBAAmB,CAAC;AAE3B,eAAO,MAAM,OAAO;4CAYf;QACD,YAAY,EAAE,YAAY,CAAC;KAC5B;;eA6Jc,MAAM;4BAAsB,OAAO;;;cA/E1C,MAAM;gBACJ,MAAM;;;wBA0GyC,MAAM;;;2BAapC,MAAM;uBACV,MAAM;;;sBAYP,YAAY;;CA2CnC,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;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
+ {"version":3,"file":"AuthOptions.d.ts","sourceRoot":"","sources":["../../../../src/screens/authentication/AuthOptions.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA2B,MAAM,OAAO,CAAC;AAoChD,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,MAAM,IAAI,CAAC;IACzB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,eAAO,MAAM,WAAW,GAAI,kCAGzB,gBAAgB,sBAkQlB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"IncompleteOnboardingInfo.d.ts","sourceRoot":"","sources":["../../../../src/screens/authentication/IncompleteOnboardingInfo.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAsB,MAAM,OAAO,CAAC;AAgB3C,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,wBAAwB,CAAC;AAEhC,eAAO,MAAM,wBAAwB,GAAI,wBAGtC,kBAAkB,CAAC,WAAW,CAAC,sBAkEjC,CAAC"}
1
+ {"version":3,"file":"IncompleteOnboardingInfo.d.ts","sourceRoot":"","sources":["../../../../src/screens/authentication/IncompleteOnboardingInfo.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAsB,MAAM,OAAO,CAAC;AAe3C,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,wBAAwB,CAAC;AAEhC,eAAO,MAAM,wBAAwB,GAAI,wBAGtC,kBAAkB,CAAC,WAAW,CAAC,sBAwDjC,CAAC"}
@@ -6,6 +6,12 @@ export interface OrganizationProps {
6
6
  organization: OrganizationEntity;
7
7
  }) => void;
8
8
  isSidePane?: boolean;
9
+ /**
10
+ * Blocks the row while a selection is already in flight. Without it a
11
+ * second tap starts a second selection, and the two races decide which
12
+ * workspace the session ends up authenticated against.
13
+ */
14
+ disabled?: boolean;
9
15
  }
10
- export declare const Organization: ({ organization, onOrganizationSelect, isSidePane, }: OrganizationProps) => React.JSX.Element;
16
+ export declare const Organization: ({ organization, onOrganizationSelect, isSidePane, disabled, }: OrganizationProps) => React.JSX.Element;
11
17
  //# sourceMappingURL=Organization.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Organization.d.ts","sourceRoot":"","sources":["../../../../src/screens/authentication/Organization.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAa1B,OAAO,EAAE,KAAK,YAAY,IAAI,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAE5E,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,kBAAkB,CAAC;IACjC,oBAAoB,EAAE,CAAC,IAAI,EAAE;QAAE,YAAY,EAAE,kBAAkB,CAAA;KAAE,KAAK,IAAI,CAAC;IAC3E,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,eAAO,MAAM,YAAY,GAAI,qDAI1B,iBAAiB,sBA4CnB,CAAC"}
1
+ {"version":3,"file":"Organization.d.ts","sourceRoot":"","sources":["../../../../src/screens/authentication/Organization.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAa1B,OAAO,EAAE,KAAK,YAAY,IAAI,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAE5E,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,kBAAkB,CAAC;IACjC,oBAAoB,EAAE,CAAC,IAAI,EAAE;QAAE,YAAY,EAAE,kBAAkB,CAAA;KAAE,KAAK,IAAI,CAAC;IAC3E,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,eAAO,MAAM,YAAY,GAAI,+DAK1B,iBAAiB,sBA6CnB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"SubDomainScreen.d.ts","sourceRoot":"","sources":["../../../../src/screens/authentication/SubDomainScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAgC,MAAM,OAAO,CAAC;AAkBrD,OAAO,EAAE,KAAK,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAGjE,eAAO,MAAM,eAAe,GAAI,wDAK7B,kBAAkB,CAAC,WAAW,CAAC,sBA6FjC,CAAC"}
1
+ {"version":3,"file":"SubDomainScreen.d.ts","sourceRoot":"","sources":["../../../../src/screens/authentication/SubDomainScreen.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAgC,MAAM,OAAO,CAAC;AAmBrD,OAAO,EAAE,KAAK,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAGjE,eAAO,MAAM,eAAe,GAAI,wDAK7B,kBAAkB,CAAC,WAAW,CAAC,sBAgGjC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigbinary/neeto-commons-rn",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "license": "UNLICENSED",
5
5
  "main": "./src/index.ts",
6
6
  "react-native": "./src/index.ts",
@@ -1,23 +1,40 @@
1
1
  import React from "react";
2
2
 
3
- import { Skeleton } from "@bigbinary/neetoui-rn";
3
+ import { lightTheme } from "@bigbinary/neetoui-rn";
4
+ import ContentLoader, { Rect } from "react-content-loader/native";
4
5
 
5
6
  import { moderateScale } from "../helpers/scale";
6
7
 
7
8
  export interface LineLoaderProps {
8
9
  isLoading?: boolean;
10
+ backgroundColor?: string;
11
+ foregroundColor?: string;
9
12
  height?: number;
10
13
  }
11
14
 
12
15
  /**
13
- * @deprecated Absorbed by the UI library's Skeleton
14
- * (<Skeleton variant="line" animated />). This shim keeps the v2 prop
15
- * contract for one deprecation cycle and disappears in the next commons
16
- * major.
16
+ * neetoui-rn v2's LineLoader. The v3 migration pointed this at the
17
+ * library's Skeleton, which only pulses a `grey200` strip invisible on a
18
+ * white screen. The strip always occupies `height`, so toggling `isLoading`
19
+ * never shifts layout, and `key` remounts the loader so the sweep restarts
20
+ * from the left on every load.
17
21
  */
18
22
  export const LineLoader = ({
19
- isLoading,
20
- height = moderateScale(1.5),
23
+ isLoading = false,
24
+ backgroundColor = lightTheme.colors.background.primary,
25
+ foregroundColor = lightTheme.colors.palette.base,
26
+ height = moderateScale(3),
21
27
  }: LineLoaderProps) => (
22
- <Skeleton animated={Boolean(isLoading)} height={height} variant="line" />
28
+ <ContentLoader
29
+ animate={isLoading}
30
+ backgroundColor={backgroundColor}
31
+ foregroundColor={foregroundColor}
32
+ height={height}
33
+ interval={0}
34
+ key={String(isLoading)}
35
+ speed={1}
36
+ width="100%"
37
+ >
38
+ <Rect height={height} width="100%" x={0} y={0} />
39
+ </ContentLoader>
23
40
  );
@@ -1,10 +1,14 @@
1
1
  import React from "react";
2
- import { StyleSheet, type StyleProp, type ViewStyle } from "react-native";
2
+ import {
3
+ Pressable,
4
+ StyleSheet,
5
+ type StyleProp,
6
+ type ViewStyle,
7
+ } from "react-native";
3
8
 
4
9
  import {
5
10
  Container,
6
11
  Loader,
7
- Touchable,
8
12
  Typography,
9
13
  lightTheme,
10
14
  } from "@bigbinary/neetoui-rn";
@@ -28,6 +32,14 @@ export interface SocialButtonProps {
28
32
  const capitalize = (value: string) =>
29
33
  `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
30
34
 
35
+ /**
36
+ * Pressable rather than the library's Touchable: Touchable always sets an
37
+ * `android_ripple`, and the `grey800` (#1f1f1f) colour this passed painted an
38
+ * opaque near-black wash over the whole button on Android. iOS never showed
39
+ * it, because `android_ripple` is a no-op there. neetoui-rn's own Button is
40
+ * a bare Pressable for the same reason, so the press feedback is the opacity
41
+ * dip on both platforms.
42
+ */
31
43
  export const SocialButton = ({
32
44
  variant,
33
45
  disabled,
@@ -35,10 +47,9 @@ export const SocialButton = ({
35
47
  style,
36
48
  onPress,
37
49
  }: SocialButtonProps) => (
38
- <Touchable
39
- disabled={disabled}
40
- rippleConfig={{ color: lightTheme.colors.palette.grey800 }}
41
- style={[styles.button, style]}
50
+ <Pressable
51
+ disabled={disabled || isLoading}
52
+ style={({ pressed }) => [styles.button, style, pressed && styles.pressed]}
42
53
  onPress={onPress}
43
54
  >
44
55
  {isLoading ? (
@@ -58,7 +69,7 @@ export const SocialButton = ({
58
69
  </Typography>
59
70
  </>
60
71
  )}
61
- </Touchable>
72
+ </Pressable>
62
73
  );
63
74
 
64
75
  const styles = StyleSheet.create({
@@ -72,6 +83,9 @@ const styles = StyleSheet.create({
72
83
  height: moderateScale(AUTH_BUTTON_HEIGHT),
73
84
  width: "100%",
74
85
  },
86
+ pressed: {
87
+ opacity: 0.7,
88
+ },
75
89
  logo: {
76
90
  position: "absolute",
77
91
  left: moderateScale(17),
@@ -27,13 +27,19 @@ export const WorkspaceSwitcherPane = ({
27
27
  const organizations = useAppSelector(state => state.auth.organizations);
28
28
 
29
29
  const { useSelectOrganizationMutation } = useAuth();
30
- const { mutate: selectOrganization } = useSelectOrganizationMutation();
30
+ const { mutate: selectOrganization, isPending: isSelectingOrganization } =
31
+ useSelectOrganizationMutation();
31
32
 
32
33
  const onOrganizationSelect = ({
33
34
  organization,
34
35
  }: {
35
36
  organization: OrganizationRecord;
36
37
  }) => {
38
+ // Same race as the sign-in workspace list: a second tap starts a second
39
+ // selection, and the two decide between them which workspace the session
40
+ // ends up authenticated against.
41
+ if (isSelectingOrganization) return;
42
+
37
43
  selectOrganization({ organization });
38
44
  navigation.closeDrawer();
39
45
  };
@@ -72,6 +78,7 @@ export const WorkspaceSwitcherPane = ({
72
78
  renderItem={({ item }) => (
73
79
  <Organization
74
80
  isSidePane
81
+ disabled={isSelectingOrganization}
75
82
  organization={item}
76
83
  onOrganizationSelect={onOrganizationSelect}
77
84
  />
@@ -7,6 +7,35 @@ export const DEFAULT_CASE_CONVERSION = Config.ENABLE_CASE_CONVERSION === "true";
7
7
  export const UUID_SEGMENT =
8
8
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
9
 
10
+ // Unhyphenated record ids (`6fceba301a43642c3b1f`). Sixteen is the shortest
11
+ // length no route word reaches while staying hex-only, so "faced" and
12
+ // "decade" cannot match.
13
+ export const HEX_ID_SEGMENT = /^[0-9a-f]{16,}$/i;
14
+
15
+ // Opaque short slugs (`wtzwge4`). Both a letter and a digit are required, which
16
+ // excludes every route word; the length floor of 6 excludes "v1" and "v2".
17
+ export const OPAQUE_ID_SEGMENT = /^(?=.*[a-z])(?=.*\d)[a-z0-9]{6,12}$/;
18
+
19
+ // Route words that read like ids: short lowercase alphanumerics, and actions
20
+ // that sit where a collection member would (`/tickets/trash`,
21
+ // `/direct_uploads/attach`).
22
+ export const RESERVED_URL_SEGMENTS = new Set([
23
+ "oauth2",
24
+ "base64",
25
+ "sha256",
26
+ "utf8",
27
+ "ipv4",
28
+ "ipv6",
29
+ "h264",
30
+ "trash",
31
+ "attach",
32
+ ]);
33
+
34
+ // Shortest a slug may be for the positional id rule below to claim it. Keeps
35
+ // the connective words out — `neeto_fields` is plural, so without the floor
36
+ // `/neeto_fields/api/v1` turned `api` into an id.
37
+ export const MIN_SLUG_LENGTH = 5;
38
+
10
39
  export const shouldEnableCaseConversion = (
11
40
  options: CaseConversionOptions = {}
12
41
  ) => {
@@ -1,7 +1,43 @@
1
- import { UUID_SEGMENT } from "./constants";
1
+ import {
2
+ HEX_ID_SEGMENT,
3
+ MIN_SLUG_LENGTH,
4
+ OPAQUE_ID_SEGMENT,
5
+ RESERVED_URL_SEGMENTS,
6
+ UUID_SEGMENT,
7
+ } from "./constants";
2
8
 
3
- const isIdSegment = (segment: string) =>
4
- /^\d+$/.test(segment) || UUID_SEGMENT.test(segment);
9
+ // Unambiguous on their own, wherever they appear in the path.
10
+ const looksLikeId = (segment: string) => {
11
+ if (/^\d+$/.test(segment) || UUID_SEGMENT.test(segment)) return true;
12
+
13
+ if (RESERVED_URL_SEGMENTS.has(segment.toLowerCase())) return false;
14
+
15
+ // Tenant-scoped record ids arrive as long hex strings —
16
+ // `/clients/6fceba301a43642c3b1f/projects/3d653bbc760187a6630e/...` filed 15
17
+ // separate NeetoInvoice groups for one broken timer.
18
+ if (HEX_ID_SEGMENT.test(segment)) return true;
19
+
20
+ // Slugs carrying both a letter and a digit (`wtzwge4`, `2kmffpd`). The mix
21
+ // is what makes them safe to match anywhere: no route word has it.
22
+ return OPAQUE_ID_SEGMENT.test(segment);
23
+ };
24
+
25
+ // A plural segment is a collection, so whatever follows it is that
26
+ // collection's member — the only way to recognise letter-only slugs like
27
+ // `xxnrdzg`, which are shaped exactly like the word `tickets`.
28
+ const isCollection = (segment = "") =>
29
+ segment.endsWith("s") && !/^\d+$/.test(segment);
30
+
31
+ // ...but a collection is also followed by sub-resources and actions
32
+ // (`/bookings/counts`, `/tickets/default_views`). Those are plural or
33
+ // snake_case; ids are neither.
34
+ const isMemberOfCollection = (segment: string, previous?: string) =>
35
+ isCollection(previous) &&
36
+ segment.length >= MIN_SLUG_LENGTH &&
37
+ !RESERVED_URL_SEGMENTS.has(segment.toLowerCase()) &&
38
+ !segment.includes("_") &&
39
+ !segment.includes("-") &&
40
+ !segment.endsWith("s");
5
41
 
6
42
  /**
7
43
  * Collapses a request URL to its route shape for Bugsnag grouping: ids become
@@ -17,9 +53,13 @@ export const normalizeUrlForGrouping = (url?: string) => {
17
53
  if (!url) return "";
18
54
 
19
55
  const [path] = url.split("?");
56
+ const segments = path.split("/");
20
57
 
21
- return path
22
- .split("/")
23
- .map(segment => (isIdSegment(segment) ? ":id" : segment))
58
+ return segments
59
+ .map((segment, index) =>
60
+ looksLikeId(segment) || isMemberOfCollection(segment, segments[index - 1])
61
+ ? ":id"
62
+ : segment
63
+ )
24
64
  .join("/");
25
65
  };
@@ -108,12 +108,30 @@ export const configureBugsnag = () => {
108
108
  // names. Stage separation alone does not cover QA running
109
109
  // production-stage builds on emulators.
110
110
  const deviceModel = event.device?.model?.toLowerCase() ?? "";
111
- const isKnownEmulator =
112
- event.device?.emulator ||
111
+ const isEmulatorModel =
113
112
  deviceModel.startsWith("sdk_gphone") ||
113
+ deviceModel.startsWith("sdk_google") ||
114
114
  deviceModel.startsWith("generic") ||
115
115
  deviceModel.startsWith("emulator") ||
116
+ deviceModel.startsWith("emu64") ||
116
117
  deviceModel.includes("android sdk built for");
118
+
119
+ // An AVD created from an AOSP system image reports a real model name —
120
+ // "Pixel 6 Pro" — so the model checks above miss it, and it filed the
121
+ // libreactnative.so SoLoader crashes against production. The build
122
+ // fingerprint still names the emulator image it booted
123
+ // ("sdk_phone_arm64-eng ... test-keys"). Matched on `sdk_` rather than
124
+ // `test-keys` alone, which a real device on a custom ROM also carries.
125
+ const osBuild = String(
126
+ event.device?.runtimeVersions?.osBuild ?? ""
127
+ ).toLowerCase();
128
+
129
+ const isEmulatorBuild =
130
+ osBuild.includes("sdk_") || osBuild.startsWith("sdk");
131
+
132
+ const isKnownEmulator =
133
+ event.device?.emulator || isEmulatorModel || isEmulatorBuild;
134
+
117
135
  if (releaseStage === "production" && isKnownEmulator) {
118
136
  return false;
119
137
  }
@@ -4,6 +4,7 @@ export const STORAGE_KEYS = {
4
4
  DEVICE_TOKEN: "devicetoken",
5
5
  IS_VISITED: "isVisited",
6
6
  NOTIFICATION_PERMISSION_REQUESTED: "notificationPermissionRequested",
7
+ DISPLAYED_MESSAGE_IDS: "displayedMessageIds",
7
8
  /**
8
9
  * Read-only fallback. Before this key existed here, every app kept its own
9
10
  * "have we asked yet?" flag under this exact string -- neeto-desk,
@@ -3,8 +3,21 @@ import {
3
3
  type AndroidChannel,
4
4
  } from "react-native-notify-kit";
5
5
 
6
- // Server payloads bake these channel ids into android_channel_id — the ids
7
- // must exist byte-identically on device.
6
+ // One channel, and both display paths name it explicitly.
7
+ //
8
+ // There used to be a second channel, `channel_01`. It was never ours: the old
9
+ // react-native-notifications stack hardcoded
10
+ // `DEFAULT_CHANNEL_ID = "channel_01"` and fell back to it whenever the payload
11
+ // named a channel the device did not have (PushNotification.java:162). The
12
+ // server names no channel, so every server push took that fallback and landed
13
+ // on a channel the library had auto-created at IMPORTANCE_DEFAULT — quiet, no
14
+ // heads-up banner. Android locks a channel's importance at creation, so the
15
+ // only way to fix it was to create `channel_01` first, with the settings we
16
+ // wanted (neetozone/neeto-commons-rn#289).
17
+ //
18
+ // Notifee has no such fallback and both display calls pass `channelId:
19
+ // "default"`, so nothing can route there any more. Devices that already have
20
+ // the channel keep it until the app is uninstalled; Android never removes one.
8
21
  export const NOTIFICATION_CHANNELS: AndroidChannel[] = [
9
22
  {
10
23
  id: "default",
@@ -15,14 +28,6 @@ export const NOTIFICATION_CHANNELS: AndroidChannel[] = [
15
28
  lights: true,
16
29
  badge: true,
17
30
  },
18
- {
19
- id: "channel_01",
20
- name: "Channel 01",
21
- importance: AndroidImportance.HIGH,
22
- lights: true,
23
- vibration: true,
24
- badge: true,
25
- },
26
31
  ];
27
32
 
28
33
  export const NOTIFICATION_CHANNEL_IDS = NOTIFICATION_CHANNELS.map(
@@ -32,3 +37,7 @@ export const NOTIFICATION_CHANNEL_IDS = NOTIFICATION_CHANNELS.map(
32
37
  // How long to wait for the APNs registration callback before treating the
33
38
  // token as unavailable (simulators without push entitlement never call back).
34
39
  export const DEVICE_TOKEN_TIMEOUT_MS = 10000;
40
+
41
+ // Enough to cover every message a redelivery burst can replay; older ids age
42
+ // out of the list, so a genuinely new message is never mistaken for a repeat.
43
+ export const DISPLAYED_MESSAGE_ID_LIMIT = 100;
@@ -0,0 +1,30 @@
1
+ import { DISPLAYED_MESSAGE_ID_LIMIT } from "./constants";
2
+
3
+ import { STORAGE_KEYS } from "../../constants";
4
+ import { getItem, setItem } from "../asyncStore";
5
+
6
+ /**
7
+ * GmsCore redelivers a message the app never acknowledged on the next process
8
+ * start. On MIUI that is the headless boot a notification dismissal triggers,
9
+ * which re-displayed stale messages as ghost duplicates. The message id
10
+ * survives redelivery, so it is recorded before display and a repeat is
11
+ * dropped. An age cutoff was tried first; it also dropped every message FCM
12
+ * had queued while the device was offline.
13
+ *
14
+ * Returns `false` when the message was already displayed.
15
+ */
16
+ export const claimMessageForDisplay = async (messageId?: string) => {
17
+ if (!messageId) return true;
18
+
19
+ const displayed =
20
+ (await getItem<string[]>(STORAGE_KEYS.DISPLAYED_MESSAGE_IDS)) ?? [];
21
+
22
+ if (displayed.includes(messageId)) return false;
23
+
24
+ await setItem(
25
+ STORAGE_KEYS.DISPLAYED_MESSAGE_IDS,
26
+ [...displayed, messageId].slice(-DISPLAYED_MESSAGE_ID_LIMIT)
27
+ );
28
+
29
+ return true;
30
+ };
@@ -3,6 +3,7 @@ import { Platform } from "react-native";
3
3
  import dayjs from "dayjs";
4
4
  import notifee, { EventType } from "react-native-notify-kit";
5
5
 
6
+ import { claimMessageForDisplay } from "./displayedMessages";
6
7
  import { messaging } from "./firebaseMessaging";
7
8
  import { type NotificationPressHandler } from "./types";
8
9
 
@@ -30,8 +31,22 @@ export const registerBackgroundHandlers = (
30
31
  ) as Record<string, string>;
31
32
 
32
33
  // 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);
34
+ // notification (setNumber), not from any badge API. The pusher sends
35
+ // the count as `badge`; `unread_count` kept as a fallback.
36
+ const unreadCount = Number(data.badge ?? data.unread_count);
37
+
38
+ // Housekeeping push with no user-facing copy (badge/unread sync):
39
+ // update the badge and stop. Displaying it renders the fallback
40
+ // "New Notification" ghost seen on MIUI.
41
+ if (!data.title && !data.message) {
42
+ if (Number.isFinite(unreadCount)) {
43
+ await notifee.setBadgeCount(Math.max(0, unreadCount));
44
+ }
45
+
46
+ return;
47
+ }
48
+
49
+ if (!(await claimMessageForDisplay(remoteMessage.messageId))) return;
35
50
 
36
51
  await notifee.displayNotification({
37
52
  title: data.title || "New Notification",
@@ -1,11 +1,13 @@
1
1
  import { useEffect, useCallback, useState } from "react";
2
2
  import { Platform } from "react-native";
3
3
 
4
+ import { type AxiosError } from "axios";
4
5
  import dayjs from "dayjs";
5
6
  import notifee, { EventType } from "react-native-notify-kit";
6
7
  import { RESULTS, checkNotifications } from "react-native-permissions";
7
8
 
8
9
  import { NOTIFICATION_CHANNELS } from "./constants";
10
+ import { claimMessageForDisplay } from "./displayedMessages";
9
11
  import { messaging } from "./firebaseMessaging";
10
12
  import { getDeviceToken } from "./getDeviceToken";
11
13
  import {
@@ -22,6 +24,22 @@ import { getItem, setItem } from "../asyncStore";
22
24
 
23
25
  let pendingRegistrationToken: string | null = null;
24
26
 
27
+ // Tokens the server refused for a reason retrying cannot change. NeetoInvoice
28
+ // logged 40,695 registration attempts from a single session: the request 422s
29
+ // on a duplicate `server_side_session_id`, the in-flight guard below was
30
+ // cleared on failure, and the next render fired it again ~twice a second for
31
+ // the rest of the session. A 4xx is the server's final answer, so remember it
32
+ // and stop asking; 5xx and network failures stay retryable.
33
+ const permanentlyRejectedTokens = new Set<string>();
34
+
35
+ const isRetryableFailure = (error: unknown) => {
36
+ const status = (error as AxiosError)?.response?.status;
37
+
38
+ // No response at all means the request never landed — a later attempt can
39
+ // still succeed.
40
+ return status === undefined || status >= 500;
41
+ };
42
+
25
43
  interface PushNotificationOptions {
26
44
  handleClick: NotificationPressHandler;
27
45
  }
@@ -75,6 +93,8 @@ export const usePushNotification = (
75
93
  if (!currentIsLoggedIn || !token) return;
76
94
 
77
95
  if (pendingRegistrationToken === token) return;
96
+
97
+ if (permanentlyRejectedTokens.has(token)) return;
78
98
  pendingRegistrationToken = token;
79
99
 
80
100
  const storedToken = await getItem<string>(STORAGE_KEYS.DEVICE_TOKEN);
@@ -96,7 +116,11 @@ export const usePushNotification = (
96
116
  await setItem(STORAGE_KEYS.DEVICE_TOKEN, token);
97
117
  pendingRegistrationToken = null;
98
118
  },
99
- onError: () => {
119
+ onError: error => {
120
+ if (!isRetryableFailure(error)) {
121
+ permanentlyRejectedTokens.add(token);
122
+ }
123
+
100
124
  pendingRegistrationToken = null;
101
125
  },
102
126
  });
@@ -144,8 +168,27 @@ export const usePushNotification = (
144
168
  "You have a new message";
145
169
 
146
170
  // 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);
171
+ // notification (setNumber), not from any badge API. The pusher sends
172
+ // the count as `badge`; `unread_count` kept as a fallback.
173
+ const unreadCount = Number(data.badge ?? data.unread_count);
174
+
175
+ // Housekeeping push with no user-facing copy (badge/unread sync):
176
+ // update the badge and stop. Displaying it renders the fallback
177
+ // "New Notification" ghost seen on MIUI.
178
+ if (
179
+ !remoteMessage.notification?.title &&
180
+ !remoteMessage.notification?.body &&
181
+ !data.title &&
182
+ !data.message
183
+ ) {
184
+ if (Number.isFinite(unreadCount)) {
185
+ await notifee.setBadgeCount(Math.max(0, unreadCount));
186
+ }
187
+
188
+ return;
189
+ }
190
+
191
+ if (!(await claimMessageForDisplay(remoteMessage.messageId))) return;
149
192
 
150
193
  await notifee.displayNotification({
151
194
  title,
@@ -34,6 +34,10 @@ export const useAuth = () => {
34
34
  const { strings } = useLocalization();
35
35
  const navigation = useNavigation<NavigationProp<CommonsParamList>>();
36
36
  const last504ReportTimeRef = useRef<number | null>(null);
37
+ // A ref rather than the mutation's isPending: two taps in the same tick
38
+ // would both pass a state check before React had re-rendered, and the two
39
+ // selections then race to decide which workspace the session belongs to.
40
+ const isSelectingOrganizationRef = useRef(false);
37
41
 
38
42
  const selectOrganization = async ({
39
43
  organization,
@@ -50,13 +54,28 @@ export const useAuth = () => {
50
54
  profile_image_url,
51
55
  } = organization;
52
56
 
57
+ // Every request the app makes after this point reads its `x-auth-token`
58
+ // back out of the keychain and its base URL out of `auth.subdomain`. If
59
+ // either half is missing the session still *looks* signed in — the app
60
+ // renders its tabs — but no screen can load, and because both redux and
61
+ // the keychain are persisted the dead session survives a restart. So the
62
+ // login is only dispatched once the credentials are actually stored.
63
+ if (!auth_token) {
64
+ throw new Error("Workspace is missing its auth token");
65
+ }
66
+
53
67
  const userAuth = {
54
68
  authToken: auth_token,
55
69
  email,
56
70
  };
57
71
 
58
72
  await setItem("email", email);
59
- await setSecureValue("userAuth", JSON.stringify(userAuth));
73
+ // setSecureValue swallows the keychain error and reports it as `false`.
74
+ const isStored = await setSecureValue("userAuth", JSON.stringify(userAuth));
75
+
76
+ if (isStored === false) {
77
+ throw new Error("Could not store the workspace credentials");
78
+ }
60
79
 
61
80
  dispatch(
62
81
  loginUserAction({
@@ -237,22 +256,45 @@ export const useAuth = () => {
237
256
  }: {
238
257
  organization: Organization;
239
258
  }) => {
240
- const { subdomain } = organization;
241
- const { data } = await fetchOrganizationDetailsAPI({ subdomain });
242
-
243
- // PATCH: For the apps which does not support organization details API updates
244
- // is_onboard will be set to true
245
- const is_onboard = data?.organization?.is_onboard ?? true;
246
-
247
- if (is_onboard) {
248
- await selectOrganization({ organization });
249
- } else {
250
- navigation.navigate(SCREEN_NAMES.UnBoarded, {
251
- name: organization.organization_name,
252
- subdomain: organization.subdomain,
253
- });
259
+ if (isSelectingOrganizationRef.current) return;
260
+ isSelectingOrganizationRef.current = true;
261
+
262
+ try {
263
+ const { subdomain } = organization;
264
+ const { data } = await fetchOrganizationDetailsAPI({ subdomain });
265
+
266
+ // PATCH: For the apps which does not support organization details API updates
267
+ // is_onboard will be set to true
268
+ const is_onboard = data?.organization?.is_onboard ?? true;
269
+
270
+ if (is_onboard) {
271
+ await selectOrganization({ organization });
272
+ } else {
273
+ navigation.navigate(SCREEN_NAMES.UnBoarded, {
274
+ name: organization.organization_name,
275
+ subdomain: organization.subdomain,
276
+ });
277
+ }
278
+ } finally {
279
+ isSelectingOrganizationRef.current = false;
254
280
  }
255
281
  },
282
+ onError: (error: Error) => {
283
+ // Request failures already reach the user through the axios
284
+ // response interceptor's toast. This covers the local ones — a
285
+ // workspace with no token, a refused keychain write — which
286
+ // previously failed silently and left the row looking inert.
287
+ if ((error as AxiosError).isAxiosError) return;
288
+
289
+ Toast.show({
290
+ type: "error",
291
+ text2:
292
+ strings.commons.errors.workspaceSelect ||
293
+ "Could not open that workspace. Please try again.",
294
+ });
295
+
296
+ bugsnagErrorHandler(error, "select_organization_error");
297
+ },
256
298
  }),
257
299
  };
258
300
  };
@@ -80,7 +80,8 @@
80
80
  "projects": "Projects",
81
81
  "subDomain": "Select your workspace",
82
82
  "login": "Login to your account",
83
- "otpVerification": "Verify your email"
83
+ "otpVerification": "Verify your email",
84
+ "unboardedOrganization": "Onboarding process is incomplete"
84
85
  },
85
86
  "timerScreen": {
86
87
  "cannotFindProject": "Can't find your project? Add from "
@@ -187,11 +188,13 @@
187
188
  "invalidAccount": "The selected account is invalid. Please choose a different account.",
188
189
  "resolutionRequired": "Additional steps are required to complete the sign-in process.",
189
190
  "apiNotConnected": "The API is not connected. Please check your configuration.",
190
- "developerError": "An error occurred due to misconfiguration. Please contact support."
191
+ "developerError": "An error occurred due to misconfiguration. Please contact support.",
192
+ "unsignedBuild": "This build was not installed from the Play Store, so Google does not recognise its signature. Google Sign-In only works on Play builds. Use another sign-in method here, or install from Play to test it."
191
193
  },
192
194
  "appleSignIn": {
193
195
  "failureTitle": "Apple Sign-In Failed",
194
196
  "default": "An error occurred during Apple Sign-In. Please try again."
195
- }
197
+ },
198
+ "workspaceSelect": "Could not open that workspace. Please try again."
196
199
  }
197
200
  }
@@ -1,4 +1,4 @@
1
- import React from "react";
1
+ import React, { useRef, useState } from "react";
2
2
  import { Platform, StyleSheet } from "react-native";
3
3
 
4
4
  import {
@@ -15,6 +15,8 @@ import {
15
15
  isErrorWithCode,
16
16
  isSuccessResponse,
17
17
  } from "@react-native-google-signin/google-signin";
18
+ import Config from "react-native-config";
19
+ import DeviceInfo from "react-native-device-info";
18
20
 
19
21
  import {
20
22
  GOOGLE_LOGIN_ERRORS,
@@ -51,7 +53,39 @@ export const AuthOptions = ({
51
53
  const { mutate: loginViaApple, isPending: isLoginViaAppleLoading } =
52
54
  useLoginViaAppleMutation();
53
55
 
56
+ // GoogleSignin.signIn() has no re-entrancy guard of its own, and the button's
57
+ // isLoading below reflects only the mutation that runs *after* it resolves —
58
+ // so the whole native flow left the button live. Tapping again while the
59
+ // first auth session was still opening made GIDSignIn fail with
60
+ // -1 "Unable to open Safari.", which is what six taps in four minutes looked
61
+ // like on the login screen in production.
62
+ // A build Play did not install keeps the upload or debug signature, and only
63
+ // the Play app-signing certificate is registered as an Android OAuth client —
64
+ // so Google answers DEVELOPER_ERROR. Expected for a sideload, and reporting it
65
+ // buried the real signal. Play is the only supported Android channel, so any
66
+ // other installer, a store that redistributes the artifact included, is
67
+ // treated as a sideload on purpose.
68
+ const isPlayInstall = () => {
69
+ if (Platform.OS !== "android") return true;
70
+
71
+ try {
72
+ return DeviceInfo.getInstallerPackageNameSync() === "com.android.vending";
73
+ } catch {
74
+ // Never let a diagnostic swallow a real report.
75
+ return true;
76
+ }
77
+ };
78
+
79
+ const isGoogleFlowActive = useRef(false);
80
+ const [isGooglePending, setIsGooglePending] = useState(false);
81
+
54
82
  const onGoogleButtonPress = async (retryCount = 0) => {
83
+ // A ref rather than the state below: two taps in the same tick would both
84
+ // pass a state check before React had re-rendered.
85
+ if (isGoogleFlowActive.current) return;
86
+ isGoogleFlowActive.current = true;
87
+ setIsGooglePending(true);
88
+
55
89
  try {
56
90
  const signedInUsingGoogle = GoogleSignin.hasPreviousSignIn();
57
91
  if (signedInUsingGoogle) {
@@ -70,8 +104,10 @@ export const AuthOptions = ({
70
104
  // Google types serverAuthCode as nullable. R.isEmpty(null) is false,
71
105
  // so the previous isEmpty guard let a null code through to the API.
72
106
  if (!serverAuthCode) {
107
+ // Message only — never attach authResponse. It carries the user's
108
+ // email, name and idToken, none of which belong in Bugsnag.
73
109
  bugsnagErrorHandler(
74
- JSON.stringify(authResponse),
110
+ "serverAuthCode missing in Google sign-in response",
75
111
  "Empty serverAuthCode in GoogleLogin"
76
112
  );
77
113
  } else {
@@ -129,22 +165,51 @@ export const AuthOptions = ({
129
165
  break;
130
166
  }
131
167
 
168
+ const isUnsignedBuild =
169
+ error.code === GOOGLE_LOGIN_ERRORS.DEVELOPER_ERROR &&
170
+ !isPlayInstall();
171
+
172
+ if (isUnsignedBuild) {
173
+ errorMessage = strings.commons.errors.googleSignIn.unsignedBuild;
174
+ }
175
+
132
176
  Alert.show({
133
177
  title: strings.commons.errors.googleSignIn.failureTitle,
134
178
  description: errorMessage,
135
179
  });
136
180
 
181
+ if (isUnsignedBuild) return;
182
+
183
+ // A readable Error instead of a JSON blob, grouped per code —
184
+ // message text varies per failure ("Unable to open Safari.",
185
+ // play-services prompts…) and fragmented into a group per message.
186
+ //
187
+ // DEVELOPER_ERROR means Google rejected the app's own configuration
188
+ // rather than anything the user did, and the report was previously
189
+ // silent about which configuration. The client ids are public, and
190
+ // their leading project number is the thing to compare against the
191
+ // project google-services.json was generated from — a mismatch
192
+ // between the two is the usual cause. Appended only for that code,
193
+ // so every other report stays a bare code and message.
194
+ const configuration =
195
+ error.code === GOOGLE_LOGIN_ERRORS.DEVELOPER_ERROR
196
+ ? ` (${Platform.OS}, webClientId=${Config.GOOGLE_WEB_CLIENT_ID}, iosClientId=${Config.IOS_CLIENT_ID})`
197
+ : "";
198
+
137
199
  bugsnagErrorHandler(
138
- JSON.stringify({
139
- code: error.code,
140
- message: error.message,
141
- }),
142
- "Google Sign-in API Error in GoogleLogin"
200
+ new Error(`[${error.code}] ${error.message}${configuration}`),
201
+ "Google Sign-in API Error in GoogleLogin",
202
+ `google-signin-${error.code}`
143
203
  );
144
204
  }
145
205
  } else {
146
206
  bugsnagErrorHandler(error as Error, "Error in GoogleLogin");
147
207
  }
208
+ } finally {
209
+ // Released before the retry alert's callback can fire, so a genuine
210
+ // retry is never blocked by this guard.
211
+ isGoogleFlowActive.current = false;
212
+ setIsGooglePending(false);
148
213
  }
149
214
  };
150
215
 
@@ -220,7 +285,8 @@ export const AuthOptions = ({
220
285
  />
221
286
  </Container>
222
287
  <SocialButton
223
- isLoading={isLoginViaGoogleLoading}
288
+ disabled={isGooglePending}
289
+ isLoading={isLoginViaGoogleLoading || isGooglePending}
224
290
  variant="google"
225
291
  onPress={() => handleOnLogin("google")}
226
292
  />
@@ -1,11 +1,10 @@
1
1
  import React, { useCallback } from "react";
2
2
  import { Linking, StyleSheet } from "react-native";
3
3
 
4
- import Left from "@bigbinary/neeto-icons-rn/icons/Left";
5
4
  import {
6
5
  Container,
6
+ Header,
7
7
  ParentView,
8
- Touchable,
9
8
  Typography,
10
9
  lightTheme,
11
10
  } from "@bigbinary/neetoui-rn";
@@ -25,7 +24,7 @@ export const IncompleteOnboardingInfo = ({
25
24
  }: CommonsScreenProps<"UnBoarded">) => {
26
25
  const {
27
26
  strings: {
28
- commons: { unboardedOrganization },
27
+ commons: { headers, unboardedOrganization },
29
28
  formatString,
30
29
  },
31
30
  } = useLocalization();
@@ -56,23 +55,13 @@ export const IncompleteOnboardingInfo = ({
56
55
  // asked for.
57
56
  return (
58
57
  <ParentView barStyle="dark-content">
59
- <Container style={styles.header}>
60
- {/* v3's ripple config dropped `isCentered`; nothing replaces it. */}
61
- <Touchable
62
- hitSlop={10}
63
- rippleConfig={{ shouldOverflowContainer: true }}
64
- style={styles.backButton}
65
- onPress={onBackPress}
66
- >
67
- <Left size={moderateScale(24)} />
68
- </Touchable>
69
- </Container>
58
+ <Header title={headers.unboardedOrganization} onBack={onBackPress} />
70
59
  <Container style={styles.body}>
71
60
  <Typography variant="modalHeader">
72
61
  {formatString(unboardedOrganization.title, name)}
73
62
  </Typography>
74
63
  <HyperlinkText
75
- linkStyleProps={{ color: lightTheme.colors.primary }}
64
+ linkStyleProps={styles.link}
76
65
  textStyleProps={{ style: styles.infoMessage }}
77
66
  linkMapping={{
78
67
  [helpLink]: unboardedOrganization.supportLabel,
@@ -91,20 +80,13 @@ export const IncompleteOnboardingInfo = ({
91
80
  };
92
81
 
93
82
  const styles = StyleSheet.create({
94
- header: {
95
- alignItems: "center",
96
- flexDirection: "row",
97
- height: moderateScale(44),
98
- paddingHorizontal: moderateScale(16),
99
- },
100
- backButton: {
101
- alignItems: "center",
102
- justifyContent: "center",
103
- },
104
83
  body: {
105
84
  marginHorizontal: moderateScale(16),
106
85
  },
107
86
  infoMessage: {
108
87
  marginTop: moderateScale(16),
109
88
  },
89
+ link: {
90
+ color: lightTheme.colors.primary,
91
+ },
110
92
  });
@@ -17,12 +17,19 @@ export interface OrganizationProps {
17
17
  organization: OrganizationEntity;
18
18
  onOrganizationSelect: (args: { organization: OrganizationEntity }) => void;
19
19
  isSidePane?: boolean;
20
+ /**
21
+ * Blocks the row while a selection is already in flight. Without it a
22
+ * second tap starts a second selection, and the two races decide which
23
+ * workspace the session ends up authenticated against.
24
+ */
25
+ disabled?: boolean;
20
26
  }
21
27
 
22
28
  export const Organization = ({
23
29
  organization,
24
30
  onOrganizationSelect,
25
31
  isSidePane = false,
32
+ disabled = false,
26
33
  }: OrganizationProps) => {
27
34
  const { organization_name, subdomain } = organization;
28
35
 
@@ -47,7 +54,8 @@ export const Organization = ({
47
54
 
48
55
  return (
49
56
  <Touchable
50
- style={styles.card}
57
+ disabled={disabled}
58
+ style={[styles.card, disabled && styles.cardDisabled]}
51
59
  onPress={() => onOrganizationSelect({ organization })}
52
60
  >
53
61
  {isSidePane ? (
@@ -84,6 +92,9 @@ const styles = StyleSheet.create({
84
92
  paddingHorizontal: 12,
85
93
  paddingVertical: 10,
86
94
  },
95
+ cardDisabled: {
96
+ opacity: 0.5,
97
+ },
87
98
  details: {
88
99
  justifyContent: "center",
89
100
  },
@@ -13,6 +13,7 @@ import {
13
13
 
14
14
  import { Organization } from "./Organization";
15
15
 
16
+ import { LineLoader } from "../../components/LineLoader";
16
17
  import { useLocalization } from "../../contexts/localization";
17
18
  import { moderateScale } from "../../helpers/scale";
18
19
  import { useAuth } from "../../hooks/query/useAuth";
@@ -42,6 +43,10 @@ export const SubDomainScreen = ({
42
43
  }: {
43
44
  organization: OrganizationEntity;
44
45
  }) => {
46
+ // The mutation guards itself too, but stopping the tap here is what keeps
47
+ // a second row from queueing behind the first.
48
+ if (isSelectingOrganization) return;
49
+
45
50
  selectOrganization({ organization });
46
51
  };
47
52
 
@@ -93,13 +98,11 @@ export const SubDomainScreen = ({
93
98
  barStyle="dark-content"
94
99
  style={styles.screen}
95
100
  >
96
- {/* Header carries the progress line the screen used to draw with its
97
- own LineLoader. */}
98
- <Header
99
- isLoading={isSelectingOrganization}
100
- title={strings.commons.headers.subDomain}
101
- onBack={onBackPress}
102
- />
101
+ {/* Header's own `isLoading` draws a grey200 Skeleton strip, which is
102
+ invisible on this white screen — the workspace list read as having
103
+ no loading state at all. LineLoader is the v2 progress line. */}
104
+ <Header title={strings.commons.headers.subDomain} onBack={onBackPress} />
105
+ <LineLoader isLoading={isSelectingOrganization} />
103
106
  <Container style={styles.body}>
104
107
  {renderIntro()}
105
108
  <FlashList
@@ -109,6 +112,7 @@ export const SubDomainScreen = ({
109
112
  style={styles.flatListStyle}
110
113
  renderItem={({ item }) => (
111
114
  <Organization
115
+ disabled={isSelectingOrganization}
112
116
  organization={item}
113
117
  onOrganizationSelect={onOrganizationSelect}
114
118
  />
@@ -136,9 +140,9 @@ const styles = StyleSheet.create({
136
140
  marginBottom: moderateScale(8),
137
141
  },
138
142
  flatListStyle: {
139
- marginBottom: 20,
143
+ flex: 1,
140
144
  },
141
145
  contentContainerStyle: {
142
- justifyContent: "center",
146
+ paddingBottom: moderateScale(20),
143
147
  },
144
148
  });