@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.
- package/README.md +4 -3
- package/lib/typescript/components/LineLoader.d.ts +8 -5
- package/lib/typescript/components/LineLoader.d.ts.map +1 -1
- package/lib/typescript/components/SocialButton.d.ts +8 -0
- package/lib/typescript/components/SocialButton.d.ts.map +1 -1
- package/lib/typescript/components/WorkspaceSwitcherPane.d.ts.map +1 -1
- package/lib/typescript/config/axios/constants.d.ts +4 -0
- package/lib/typescript/config/axios/constants.d.ts.map +1 -1
- package/lib/typescript/config/axios/groupingKey.d.ts.map +1 -1
- package/lib/typescript/config/bugsnag.d.ts.map +1 -1
- package/lib/typescript/constants/index.d.ts +1 -0
- package/lib/typescript/constants/index.d.ts.map +1 -1
- package/lib/typescript/helpers/notifications/constants.d.ts +1 -0
- package/lib/typescript/helpers/notifications/constants.d.ts.map +1 -1
- package/lib/typescript/helpers/notifications/displayedMessages.d.ts +12 -0
- package/lib/typescript/helpers/notifications/displayedMessages.d.ts.map +1 -0
- package/lib/typescript/helpers/notifications/registerBackgroundHandlers.d.ts.map +1 -1
- package/lib/typescript/helpers/notifications/usePushNotification.d.ts.map +1 -1
- package/lib/typescript/hooks/query/useAuth.d.ts.map +1 -1
- package/lib/typescript/screens/authentication/AuthOptions.d.ts.map +1 -1
- package/lib/typescript/screens/authentication/IncompleteOnboardingInfo.d.ts.map +1 -1
- package/lib/typescript/screens/authentication/Organization.d.ts +7 -1
- package/lib/typescript/screens/authentication/Organization.d.ts.map +1 -1
- package/lib/typescript/screens/authentication/SubDomainScreen.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/LineLoader.tsx +25 -8
- package/src/components/SocialButton.tsx +21 -7
- package/src/components/WorkspaceSwitcherPane.tsx +8 -1
- package/src/config/axios/constants.ts +29 -0
- package/src/config/axios/groupingKey.ts +46 -6
- package/src/config/bugsnag.ts +20 -2
- package/src/constants/index.ts +1 -0
- package/src/helpers/notifications/constants.ts +19 -10
- package/src/helpers/notifications/displayedMessages.ts +30 -0
- package/src/helpers/notifications/registerBackgroundHandlers.ts +17 -2
- package/src/helpers/notifications/usePushNotification.ts +46 -3
- package/src/hooks/query/useAuth.ts +57 -15
- package/src/localization/translations/en.json +6 -3
- package/src/screens/authentication/AuthOptions.tsx +74 -8
- package/src/screens/authentication/IncompleteOnboardingInfo.tsx +7 -25
- package/src/screens/authentication/Organization.tsx +12 -1
- 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
|
-
- [
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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;
|
|
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,
|
|
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,
|
|
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;
|
|
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":"
|
|
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,
|
|
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
|
|
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;
|
|
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":"
|
|
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":"
|
|
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;
|
|
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,
|
|
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;
|
|
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;
|
|
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;
|
|
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,23 +1,40 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
23
|
+
isLoading = false,
|
|
24
|
+
backgroundColor = lightTheme.colors.background.primary,
|
|
25
|
+
foregroundColor = lightTheme.colors.palette.base,
|
|
26
|
+
height = moderateScale(3),
|
|
21
27
|
}: LineLoaderProps) => (
|
|
22
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
39
|
-
disabled={disabled}
|
|
40
|
-
|
|
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
|
-
</
|
|
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 } =
|
|
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 {
|
|
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
|
-
|
|
4
|
-
|
|
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
|
|
22
|
-
.
|
|
23
|
-
|
|
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
|
};
|
package/src/config/bugsnag.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/constants/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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={
|
|
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
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
143
|
+
flex: 1,
|
|
140
144
|
},
|
|
141
145
|
contentContainerStyle: {
|
|
142
|
-
|
|
146
|
+
paddingBottom: moderateScale(20),
|
|
143
147
|
},
|
|
144
148
|
});
|