@appsupportkit/support-sdk 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/api.js +29 -0
- package/lib/commonjs/api.js.map +1 -1
- package/lib/commonjs/components/SupportButton.js +81 -6
- package/lib/commonjs/components/SupportButton.js.map +1 -1
- package/lib/commonjs/components/screens/ComposeScreen.js +7 -7
- package/lib/commonjs/components/screens/ComposeScreen.js.map +1 -1
- package/lib/commonjs/components/screens/HomeScreen.js +5 -1
- package/lib/commonjs/components/screens/HomeScreen.js.map +1 -1
- package/lib/commonjs/components/screens/ThreadScreen.js +7 -7
- package/lib/commonjs/components/screens/ThreadScreen.js.map +1 -1
- package/lib/commonjs/index.js +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/push.js +103 -0
- package/lib/commonjs/push.js.map +1 -0
- package/lib/commonjs/types.js.map +1 -1
- package/lib/module/api.js +27 -0
- package/lib/module/api.js.map +1 -1
- package/lib/module/components/SupportButton.js +83 -8
- package/lib/module/components/SupportButton.js.map +1 -1
- package/lib/module/components/screens/ComposeScreen.js +7 -7
- package/lib/module/components/screens/ComposeScreen.js.map +1 -1
- package/lib/module/components/screens/HomeScreen.js +5 -1
- package/lib/module/components/screens/HomeScreen.js.map +1 -1
- package/lib/module/components/screens/ThreadScreen.js +7 -7
- package/lib/module/components/screens/ThreadScreen.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/push.js +98 -0
- package/lib/module/push.js.map +1 -0
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/api.d.ts +17 -1
- package/lib/typescript/api.d.ts.map +1 -1
- package/lib/typescript/components/SupportButton.d.ts.map +1 -1
- package/lib/typescript/components/screens/ComposeScreen.d.ts +2 -2
- package/lib/typescript/components/screens/ComposeScreen.d.ts.map +1 -1
- package/lib/typescript/components/screens/HomeScreen.d.ts.map +1 -1
- package/lib/typescript/components/screens/ThreadScreen.d.ts +2 -2
- package/lib/typescript/components/screens/ThreadScreen.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +2 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/push.d.ts +18 -0
- package/lib/typescript/push.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +18 -0
- package/lib/typescript/types.d.ts.map +1 -1
- package/package.json +7 -1
- package/src/api.ts +41 -1
- package/src/components/SupportButton.tsx +108 -10
- package/src/components/screens/ComposeScreen.tsx +10 -10
- package/src/components/screens/HomeScreen.tsx +3 -1
- package/src/components/screens/ThreadScreen.tsx +10 -10
- package/src/index.tsx +2 -0
- package/src/push.ts +97 -0
- package/src/types.ts +18 -0
package/lib/typescript/api.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QueryType, ThreadsResponse } from './types';
|
|
1
|
+
import type { PushPermissionStatus, QueryType, ThreadsResponse } from './types';
|
|
2
2
|
export interface ApiConfig {
|
|
3
3
|
supabaseUrl: string;
|
|
4
4
|
apiKey: string;
|
|
@@ -11,6 +11,22 @@ export declare class SupportApiError extends Error {
|
|
|
11
11
|
constructor(message: string, status: number | null, offline?: boolean);
|
|
12
12
|
}
|
|
13
13
|
export declare function getThreads(config: ApiConfig, revenuecatId: string): Promise<ThreadsResponse>;
|
|
14
|
+
/**
|
|
15
|
+
* Badge-only endpoint (~18 bytes) used for idle polling, instead of
|
|
16
|
+
* re-fetching every thread and message just to check for new replies.
|
|
17
|
+
*/
|
|
18
|
+
export declare function getUnreadCount(config: ApiConfig, revenuecatId: string): Promise<{
|
|
19
|
+
total_unread: number;
|
|
20
|
+
}>;
|
|
21
|
+
/** Reports the OS notification permission outcome (including denials). */
|
|
22
|
+
export declare function registerPush(config: ApiConfig, input: {
|
|
23
|
+
revenuecatId: string;
|
|
24
|
+
permissionStatus: PushPermissionStatus;
|
|
25
|
+
expoPushToken: string | null;
|
|
26
|
+
asked: boolean;
|
|
27
|
+
}): Promise<{
|
|
28
|
+
ok: boolean;
|
|
29
|
+
}>;
|
|
14
30
|
export declare function submitQuery(config: ApiConfig, input: {
|
|
15
31
|
queryType: QueryType;
|
|
16
32
|
message: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/api.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/api.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,oBAAoB,EACpB,SAAS,EACT,eAAe,EAChB,MAAM,SAAS,CAAC;AAEjB,MAAM,WAAW,SAAS;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,eAAgB,SAAQ,KAAK;IACxC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,wDAAwD;IACxD,OAAO,EAAE,OAAO,CAAC;gBAEL,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,UAAQ;CAMpE;AA2CD,wBAAgB,UAAU,CACxB,MAAM,EAAE,SAAS,EACjB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,eAAe,CAAC,CAK1B;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,SAAS,EACjB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC;IAAE,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC,CAKnC;AAED,0EAA0E;AAC1E,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE;IACL,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,oBAAoB,CAAC;IACvC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,KAAK,EAAE,OAAO,CAAC;CAChB,GACA,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAA;CAAE,CAAC,CAW1B;AAED,wBAAgB,WAAW,CACzB,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE;IACL,SAAS,EAAE,SAAS,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;CACpB,GACA,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAa/B;AAED,wBAAgB,WAAW,CACzB,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAChE,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CASjC;AAED,wBAAgB,QAAQ,CACtB,MAAM,EAAE,SAAS,EACjB,KAAK,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAC/C,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAA;CAAE,CAAC,CAQ1B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SupportButton.d.ts","sourceRoot":"","sources":["../../../src/components/SupportButton.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"SupportButton.d.ts","sourceRoot":"","sources":["../../../src/components/SupportButton.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAa,kBAAkB,EAAU,MAAM,UAAU,CAAC;AAsBtE;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,sCAiUtD"}
|
|
@@ -4,7 +4,7 @@ export declare function ComposeScreen({ theme, type, onBack, onSubmit, }: {
|
|
|
4
4
|
theme: SupportTheme;
|
|
5
5
|
type: QueryType;
|
|
6
6
|
onBack: () => void;
|
|
7
|
-
/** Resolves
|
|
8
|
-
onSubmit: (message: string) => Promise<
|
|
7
|
+
/** Resolves null on success, or a user-facing error message. */
|
|
8
|
+
onSubmit: (message: string) => Promise<string | null>;
|
|
9
9
|
}): import("react").JSX.Element;
|
|
10
10
|
//# sourceMappingURL=ComposeScreen.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ComposeScreen.d.ts","sourceRoot":"","sources":["../../../../src/components/screens/ComposeScreen.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAK7C,wBAAgB,aAAa,CAAC,EAC5B,KAAK,EACL,IAAI,EACJ,MAAM,EACN,QAAQ,GACT,EAAE;IACD,KAAK,EAAE,YAAY,CAAC;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,
|
|
1
|
+
{"version":3,"file":"ComposeScreen.d.ts","sourceRoot":"","sources":["../../../../src/components/screens/ComposeScreen.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAK7C,wBAAgB,aAAa,CAAC,EAC5B,KAAK,EACL,IAAI,EACJ,MAAM,EACN,QAAQ,GACT,EAAE;IACD,KAAK,EAAE,YAAY,CAAC;IACpB,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,gEAAgE;IAChE,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CACvD,+BA+IA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"HomeScreen.d.ts","sourceRoot":"","sources":["../../../../src/components/screens/HomeScreen.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AA+ErD,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,OAAO,EACP,OAAO,EACP,KAAK,EACL,OAAO,EACP,SAAS,EACT,YAAY,EACZ,OAAO,GACR,EAAE;IACD,KAAK,EAAE,YAAY,CAAC;IACpB,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,CAAC;IACrC,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,+
|
|
1
|
+
{"version":3,"file":"HomeScreen.d.ts","sourceRoot":"","sources":["../../../../src/components/screens/HomeScreen.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AA+ErD,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,OAAO,EACP,OAAO,EACP,KAAK,EACL,OAAO,EACP,SAAS,EACT,YAAY,EACZ,OAAO,GACR,EAAE;IACD,KAAK,EAAE,YAAY,CAAC;IACpB,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,IAAI,CAAC;IACrC,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,+BAwIA"}
|
|
@@ -4,7 +4,7 @@ export declare function ThreadScreen({ theme, thread, onBack, onSend, }: {
|
|
|
4
4
|
theme: SupportTheme;
|
|
5
5
|
thread: Thread;
|
|
6
6
|
onBack: () => void;
|
|
7
|
-
/** Resolves
|
|
8
|
-
onSend: (message: string) => Promise<
|
|
7
|
+
/** Resolves null on success, or a user-facing error message. */
|
|
8
|
+
onSend: (message: string) => Promise<string | null>;
|
|
9
9
|
}): import("react").JSX.Element;
|
|
10
10
|
//# sourceMappingURL=ThreadScreen.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ThreadScreen.d.ts","sourceRoot":"","sources":["../../../../src/components/screens/ThreadScreen.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAY1C,wBAAgB,YAAY,CAAC,EAC3B,KAAK,EACL,MAAM,EACN,MAAM,EACN,MAAM,GACP,EAAE;IACD,KAAK,EAAE,YAAY,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,
|
|
1
|
+
{"version":3,"file":"ThreadScreen.d.ts","sourceRoot":"","sources":["../../../../src/components/screens/ThreadScreen.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAY1C,wBAAgB,YAAY,CAAC,EAC3B,KAAK,EACL,MAAM,EACN,MAAM,EACN,MAAM,GACP,EAAE;IACD,KAAK,EAAE,YAAY,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,gEAAgE;IAChE,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CACrD,+BAiMA"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { SupportButton } from './components/SupportButton';
|
|
2
2
|
export { supportDarkTheme, supportLightTheme, resolveTheme, type SupportTheme, } from './theme';
|
|
3
3
|
export { SupportApiError } from './api';
|
|
4
|
-
export
|
|
4
|
+
export { isPushAvailable } from './push';
|
|
5
|
+
export type { MessageSender, PushPermissionStatus, QueryStatus, QueryType, SupportButtonProps, SupportUser, Thread, ThreadMessage, ThreadsResponse, ThemeName, } from './types';
|
|
5
6
|
export { QUERY_TYPE_LABEL, USER_MESSAGE_MAX, USER_MESSAGE_MIN, } from './types';
|
|
6
7
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,YAAY,EACZ,KAAK,YAAY,GAClB,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,eAAe,EAAE,MAAM,OAAO,CAAC;AACxC,YAAY,EACV,aAAa,EACb,WAAW,EACX,SAAS,EACT,kBAAkB,EAClB,WAAW,EACX,MAAM,EACN,aAAa,EACb,eAAe,EACf,SAAS,GACV,MAAM,SAAS,CAAC;AACjB,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,YAAY,EACZ,KAAK,YAAY,GAClB,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,eAAe,EAAE,MAAM,OAAO,CAAC;AACxC,OAAO,EAAE,eAAe,EAAE,MAAM,QAAQ,CAAC;AACzC,YAAY,EACV,aAAa,EACb,oBAAoB,EACpB,WAAW,EACX,SAAS,EACT,kBAAkB,EAClB,WAAW,EACX,MAAM,EACN,aAAa,EACb,eAAe,EACf,SAAS,GACV,MAAM,SAAS,CAAC;AACjB,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,SAAS,CAAC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PushPermissionStatus } from './types';
|
|
2
|
+
/** True when the host app has expo-notifications installed. */
|
|
3
|
+
export declare const isPushAvailable: boolean;
|
|
4
|
+
export interface PushRegistration {
|
|
5
|
+
status: PushPermissionStatus;
|
|
6
|
+
/** Whether the OS prompt was actually presented during this call. */
|
|
7
|
+
asked: boolean;
|
|
8
|
+
token: string | null;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Ensures notification permission has been requested, and returns the
|
|
12
|
+
* outcome plus an Expo push token when one is obtainable.
|
|
13
|
+
*
|
|
14
|
+
* Never throws: any failure resolves to an 'undetermined' registration so
|
|
15
|
+
* callers can report it and move on.
|
|
16
|
+
*/
|
|
17
|
+
export declare function ensurePushPermission(expoProjectId?: string): Promise<PushRegistration>;
|
|
18
|
+
//# sourceMappingURL=push.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../../src/push.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAuBpD,+DAA+D;AAC/D,eAAO,MAAM,eAAe,SAAyB,CAAC;AAEtD,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,oBAAoB,CAAC;IAC7B,qEAAqE;IACrE,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAQD;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,gBAAgB,CAAC,CAoC3B"}
|
|
@@ -2,6 +2,7 @@ export type QueryType = 'feedback' | 'feature_request' | 'bug_report';
|
|
|
2
2
|
export type QueryStatus = 'open' | 'resolved';
|
|
3
3
|
export type MessageSender = 'user' | 'admin';
|
|
4
4
|
export type ThemeName = 'dark' | 'light';
|
|
5
|
+
export type PushPermissionStatus = 'undetermined' | 'granted' | 'denied';
|
|
5
6
|
export interface SupportUser {
|
|
6
7
|
/** Display name shown to the support team. */
|
|
7
8
|
name: string;
|
|
@@ -27,6 +28,23 @@ export interface SupportButtonProps {
|
|
|
27
28
|
supabaseUrl: string;
|
|
28
29
|
/** Visual theme. Matches the admin dashboard tokens. Default "dark". */
|
|
29
30
|
theme?: ThemeName;
|
|
31
|
+
/**
|
|
32
|
+
* Idle poll interval (ms) — how often the unread badge is refreshed while
|
|
33
|
+
* the sheet is CLOSED, using a lightweight count-only endpoint. Default
|
|
34
|
+
* 60000; set 0 to disable idle polling (mount / sheet-open / foreground
|
|
35
|
+
* refreshes still happen).
|
|
36
|
+
*
|
|
37
|
+
* While the sheet is open the SDK polls faster on its own: every 15s on
|
|
38
|
+
* the thread list and every 5s inside a thread, where latency is felt.
|
|
39
|
+
*/
|
|
40
|
+
pollIntervalMs?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Optional EAS project id, forwarded to expo-notifications when minting a
|
|
43
|
+
* push token. Only needed if the host app's config doesn't already
|
|
44
|
+
* provide one. Push is skipped entirely when expo-notifications isn't
|
|
45
|
+
* installed — it is an optional peer dependency.
|
|
46
|
+
*/
|
|
47
|
+
expoProjectId?: string;
|
|
30
48
|
}
|
|
31
49
|
export interface ThreadMessage {
|
|
32
50
|
id: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG,UAAU,GAAG,iBAAiB,GAAG,YAAY,CAAC;AACtE,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,UAAU,CAAC;AAC9C,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,CAAC;AAC7C,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG,UAAU,GAAG,iBAAiB,GAAG,YAAY,CAAC;AACtE,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,UAAU,CAAC;AAC9C,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,CAAC;AAC7C,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,OAAO,CAAC;AACzC,MAAM,MAAM,oBAAoB,GAAG,cAAc,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEzE,MAAM,WAAW,WAAW;IAC1B,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,0CAA0C;IAC1C,MAAM,EAAE,OAAO,CAAC;IAChB,8DAA8D;IAC9D,YAAY,EAAE,MAAM,CAAC;IACrB,sCAAsC;IACtC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,kBAAkB;IACjC,+DAA+D;IAC/D,OAAO,EAAE,MAAM,CAAC;IAChB,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,IAAI,EAAE,WAAW,CAAC;IAClB;;;;OAIG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,wEAAwE;IACxE,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,aAAa,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,MAAM;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,WAAW,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE;QACZ,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,aAAa,CAAC;QACtB,UAAU,EAAE,MAAM,CAAC;KACpB,GAAG,IAAI,CAAC;IACT,QAAQ,EAAE,aAAa,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,CAItD,CAAC;AAEF,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,eAAO,MAAM,gBAAgB,MAAM,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appsupportkit/support-sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "In-app customer support for React Native: feedback, feature requests, and bug reports with two-way threads.",
|
|
5
5
|
"main": "./lib/commonjs/index.js",
|
|
6
6
|
"module": "./lib/module/index.js",
|
|
@@ -22,10 +22,16 @@
|
|
|
22
22
|
"@appsupportkit/design-tokens": "^0.1.0"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
+
"expo-notifications": ">=0.27.0",
|
|
25
26
|
"react": ">=18.0.0",
|
|
26
27
|
"react-native": ">=0.73.0",
|
|
27
28
|
"react-native-safe-area-context": ">=4.0.0"
|
|
28
29
|
},
|
|
30
|
+
"peerDependenciesMeta": {
|
|
31
|
+
"expo-notifications": {
|
|
32
|
+
"optional": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
29
35
|
"devDependencies": {
|
|
30
36
|
"@types/react": "^19.0.2",
|
|
31
37
|
"react": "19.0.0",
|
package/src/api.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Thin client for the Supabase Edge Functions. Every call authenticates with
|
|
2
2
|
// the per-app x-api-key header; no Supabase JS client is needed on-device.
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
PushPermissionStatus,
|
|
5
|
+
QueryType,
|
|
6
|
+
ThreadsResponse,
|
|
7
|
+
} from './types';
|
|
4
8
|
|
|
5
9
|
export interface ApiConfig {
|
|
6
10
|
supabaseUrl: string;
|
|
@@ -72,6 +76,42 @@ export function getThreads(
|
|
|
72
76
|
});
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Badge-only endpoint (~18 bytes) used for idle polling, instead of
|
|
81
|
+
* re-fetching every thread and message just to check for new replies.
|
|
82
|
+
*/
|
|
83
|
+
export function getUnreadCount(
|
|
84
|
+
config: ApiConfig,
|
|
85
|
+
revenuecatId: string,
|
|
86
|
+
): Promise<{ total_unread: number }> {
|
|
87
|
+
const params = new URLSearchParams({ revenuecat_id: revenuecatId });
|
|
88
|
+
return request(config, `get-unread-count?${params.toString()}`, {
|
|
89
|
+
method: 'GET',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Reports the OS notification permission outcome (including denials). */
|
|
94
|
+
export function registerPush(
|
|
95
|
+
config: ApiConfig,
|
|
96
|
+
input: {
|
|
97
|
+
revenuecatId: string;
|
|
98
|
+
permissionStatus: PushPermissionStatus;
|
|
99
|
+
expoPushToken: string | null;
|
|
100
|
+
asked: boolean;
|
|
101
|
+
},
|
|
102
|
+
): Promise<{ ok: boolean }> {
|
|
103
|
+
return request(config, 'register-push', {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
app_name: config.appName,
|
|
107
|
+
revenuecat_id: input.revenuecatId,
|
|
108
|
+
permission_status: input.permissionStatus,
|
|
109
|
+
expo_push_token: input.expoPushToken,
|
|
110
|
+
asked: input.asked,
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
75
115
|
export function submitQuery(
|
|
76
116
|
config: ApiConfig,
|
|
77
117
|
input: {
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { Pressable, Text, View } from 'react-native';
|
|
3
|
-
import {
|
|
2
|
+
import { AppState, Pressable, Text, View } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
getThreads,
|
|
5
|
+
getUnreadCount,
|
|
6
|
+
markRead,
|
|
7
|
+
postMessage,
|
|
8
|
+
registerPush,
|
|
9
|
+
submitQuery,
|
|
10
|
+
SupportApiError,
|
|
11
|
+
type ApiConfig,
|
|
12
|
+
} from '../api';
|
|
13
|
+
import { ensurePushPermission, isPushAvailable } from '../push';
|
|
4
14
|
import { resolveTheme } from '../theme';
|
|
5
15
|
import type { QueryType, SupportButtonProps, Thread } from '../types';
|
|
6
16
|
import { validateProps } from '../utils';
|
|
@@ -15,6 +25,15 @@ type Screen =
|
|
|
15
25
|
| { name: 'compose'; type: QueryType }
|
|
16
26
|
| { name: 'thread'; threadId: string };
|
|
17
27
|
|
|
28
|
+
// Poll cadence by context: fast where latency is felt (staring at a thread
|
|
29
|
+
// waiting for a reply), slow when the sheet is closed — and the idle tick
|
|
30
|
+
// uses the count-only endpoint, so it costs ~18 bytes instead of the full
|
|
31
|
+
// thread list.
|
|
32
|
+
const THREAD_POLL_MS = 5_000;
|
|
33
|
+
const SHEET_POLL_MS = 15_000;
|
|
34
|
+
const IDLE_POLL_MS = 60_000;
|
|
35
|
+
const MIN_POLL_MS = 3_000;
|
|
36
|
+
|
|
18
37
|
/**
|
|
19
38
|
* The single entry point of the SDK: a button with an unread badge that
|
|
20
39
|
* opens the support bottom sheet. Polls get-threads on mount and whenever
|
|
@@ -69,16 +88,81 @@ export function SupportButton(props: SupportButtonProps) {
|
|
|
69
88
|
}
|
|
70
89
|
}, [config, props.user.revenuecatId, validationError]);
|
|
71
90
|
|
|
91
|
+
/** Badge-only refresh — the cheap endpoint used while idle. */
|
|
92
|
+
const refreshBadge = useCallback(async () => {
|
|
93
|
+
if (validationError) return;
|
|
94
|
+
try {
|
|
95
|
+
const data = await getUnreadCount(config, props.user.revenuecatId);
|
|
96
|
+
if (mounted.current) setTotalUnread(data.total_unread);
|
|
97
|
+
} catch {
|
|
98
|
+
// Best-effort: the next tick or the next sheet open reconciles.
|
|
99
|
+
}
|
|
100
|
+
}, [config, props.user.revenuecatId, validationError]);
|
|
101
|
+
|
|
72
102
|
// Poll on mount…
|
|
73
103
|
useEffect(() => {
|
|
74
104
|
void refresh();
|
|
75
105
|
}, [refresh]);
|
|
76
106
|
|
|
107
|
+
// …then adapt the cadence to what the user is looking at, and refresh
|
|
108
|
+
// whenever the app returns to the foreground.
|
|
109
|
+
const idlePollMs = props.pollIntervalMs ?? IDLE_POLL_MS;
|
|
110
|
+
const screenName = screen.name;
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const idle = !sheetOpen;
|
|
113
|
+
const intervalMs = idle
|
|
114
|
+
? idlePollMs
|
|
115
|
+
: screenName === 'thread'
|
|
116
|
+
? THREAD_POLL_MS
|
|
117
|
+
: SHEET_POLL_MS;
|
|
118
|
+
|
|
119
|
+
// While idle only the badge matters, so use the count-only endpoint.
|
|
120
|
+
const tick = () => void (idle ? refreshBadge() : refresh());
|
|
121
|
+
|
|
122
|
+
// Clamped so a typo like `pollIntervalMs={30}` can't hammer the API;
|
|
123
|
+
// 0 (or negative) disables idle polling entirely.
|
|
124
|
+
const timer =
|
|
125
|
+
intervalMs > 0 ? setInterval(tick, Math.max(intervalMs, MIN_POLL_MS)) : null;
|
|
126
|
+
|
|
127
|
+
const appStateSub = AppState.addEventListener('change', (state) => {
|
|
128
|
+
if (state === 'active') tick();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return () => {
|
|
132
|
+
if (timer !== null) clearInterval(timer);
|
|
133
|
+
appStateSub.remove();
|
|
134
|
+
};
|
|
135
|
+
}, [sheetOpen, screenName, idlePollMs, refresh, refreshBadge]);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Asks for notification permission the moment the sheet opens, then
|
|
139
|
+
* reports the outcome (including denials) so the dashboard knows whether
|
|
140
|
+
* this user is reachable. Deliberately fire-and-forget: nothing in the
|
|
141
|
+
* support flow waits on it or breaks if it fails.
|
|
142
|
+
*/
|
|
143
|
+
const syncPushPermission = useCallback(async () => {
|
|
144
|
+
if (validationError || !isPushAvailable) return;
|
|
145
|
+
try {
|
|
146
|
+
const registration = await ensurePushPermission(props.expoProjectId);
|
|
147
|
+
await registerPush(config, {
|
|
148
|
+
revenuecatId: props.user.revenuecatId,
|
|
149
|
+
permissionStatus: registration.status,
|
|
150
|
+
expoPushToken: registration.token,
|
|
151
|
+
asked: registration.asked,
|
|
152
|
+
});
|
|
153
|
+
} catch {
|
|
154
|
+
// Push is an enhancement — never surfaced to the user as an error.
|
|
155
|
+
}
|
|
156
|
+
}, [config, props.user.revenuecatId, props.expoProjectId, validationError]);
|
|
157
|
+
|
|
77
158
|
// …and on sheet open.
|
|
78
159
|
function openSheet() {
|
|
79
160
|
setScreen({ name: 'home' });
|
|
80
161
|
setSheetOpen(true);
|
|
81
162
|
void refresh();
|
|
163
|
+
// The OS prompt appears right as the sheet opens; if the user declines,
|
|
164
|
+
// everything below carries on exactly as before.
|
|
165
|
+
void syncPushPermission();
|
|
82
166
|
}
|
|
83
167
|
|
|
84
168
|
function closeSheet() {
|
|
@@ -129,7 +213,18 @@ export function SupportButton(props: SupportButtonProps) {
|
|
|
129
213
|
}
|
|
130
214
|
}
|
|
131
215
|
|
|
132
|
-
|
|
216
|
+
function writeErrorMessage(e: unknown): string {
|
|
217
|
+
if (e instanceof SupportApiError) {
|
|
218
|
+
if (e.status === 429) return 'Too many requests — try again later';
|
|
219
|
+
if (e.offline) return "You're offline — check your connection";
|
|
220
|
+
}
|
|
221
|
+
return "Couldn't send — try again";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function handleSubmit(
|
|
225
|
+
type: QueryType,
|
|
226
|
+
message: string,
|
|
227
|
+
): Promise<string | null> {
|
|
133
228
|
try {
|
|
134
229
|
await submitQuery(config, {
|
|
135
230
|
queryType: type,
|
|
@@ -140,13 +235,16 @@ export function SupportButton(props: SupportButtonProps) {
|
|
|
140
235
|
appVersion: props.user.appVersion,
|
|
141
236
|
});
|
|
142
237
|
void refresh();
|
|
143
|
-
return
|
|
144
|
-
} catch {
|
|
145
|
-
return
|
|
238
|
+
return null;
|
|
239
|
+
} catch (e) {
|
|
240
|
+
return writeErrorMessage(e);
|
|
146
241
|
}
|
|
147
242
|
}
|
|
148
243
|
|
|
149
|
-
async function handleSend(
|
|
244
|
+
async function handleSend(
|
|
245
|
+
threadId: string,
|
|
246
|
+
message: string,
|
|
247
|
+
): Promise<string | null> {
|
|
150
248
|
try {
|
|
151
249
|
await postMessage(config, {
|
|
152
250
|
queryId: threadId,
|
|
@@ -154,9 +252,9 @@ export function SupportButton(props: SupportButtonProps) {
|
|
|
154
252
|
message,
|
|
155
253
|
});
|
|
156
254
|
await refresh();
|
|
157
|
-
return
|
|
158
|
-
} catch {
|
|
159
|
-
return
|
|
255
|
+
return null;
|
|
256
|
+
} catch (e) {
|
|
257
|
+
return writeErrorMessage(e);
|
|
160
258
|
}
|
|
161
259
|
}
|
|
162
260
|
|
|
@@ -15,12 +15,12 @@ export function ComposeScreen({
|
|
|
15
15
|
theme: SupportTheme;
|
|
16
16
|
type: QueryType;
|
|
17
17
|
onBack: () => void;
|
|
18
|
-
/** Resolves
|
|
19
|
-
onSubmit: (message: string) => Promise<
|
|
18
|
+
/** Resolves null on success, or a user-facing error message. */
|
|
19
|
+
onSubmit: (message: string) => Promise<string | null>;
|
|
20
20
|
}) {
|
|
21
21
|
const [message, setMessage] = useState('');
|
|
22
22
|
const [submitting, setSubmitting] = useState(false);
|
|
23
|
-
const [
|
|
23
|
+
const [failedMessage, setFailedMessage] = useState<string | null>(null);
|
|
24
24
|
const [sent, setSent] = useState(false);
|
|
25
25
|
|
|
26
26
|
const trimmed = message.trim();
|
|
@@ -31,11 +31,11 @@ export function ComposeScreen({
|
|
|
31
31
|
async function handleSubmit() {
|
|
32
32
|
if (!valid || submitting) return;
|
|
33
33
|
setSubmitting(true);
|
|
34
|
-
|
|
35
|
-
const
|
|
34
|
+
setFailedMessage(null);
|
|
35
|
+
const errorMessage = await onSubmit(trimmed);
|
|
36
36
|
setSubmitting(false);
|
|
37
|
-
if (
|
|
38
|
-
else
|
|
37
|
+
if (errorMessage === null) setSent(true);
|
|
38
|
+
else setFailedMessage(errorMessage);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
if (sent) {
|
|
@@ -96,7 +96,7 @@ export function ComposeScreen({
|
|
|
96
96
|
value={message}
|
|
97
97
|
onChangeText={(v) => {
|
|
98
98
|
setMessage(v);
|
|
99
|
-
|
|
99
|
+
setFailedMessage(null);
|
|
100
100
|
}}
|
|
101
101
|
multiline
|
|
102
102
|
autoFocus
|
|
@@ -143,9 +143,9 @@ export function ComposeScreen({
|
|
|
143
143
|
At least {USER_MESSAGE_MIN} characters
|
|
144
144
|
</Text>
|
|
145
145
|
)}
|
|
146
|
-
{
|
|
146
|
+
{failedMessage !== null && (
|
|
147
147
|
<Text style={{ fontSize: 11, color: theme.colors.statusDanger }}>
|
|
148
|
-
|
|
148
|
+
{failedMessage}
|
|
149
149
|
</Text>
|
|
150
150
|
)}
|
|
151
151
|
<View style={{ marginLeft: 'auto' }}>
|
|
@@ -161,7 +161,9 @@ export function HomeScreen({
|
|
|
161
161
|
</View>
|
|
162
162
|
|
|
163
163
|
{/* Threads */}
|
|
164
|
-
{error ? (
|
|
164
|
+
{error && threads === null ? (
|
|
165
|
+
// Only take over the screen when there's nothing cached to show —
|
|
166
|
+
// a failed background poll shouldn't hide an already-loaded list.
|
|
165
167
|
<ErrorState message={error} onRetry={onRetry} theme={theme} />
|
|
166
168
|
) : loading && threads === null ? (
|
|
167
169
|
<View style={{ paddingHorizontal: 16, paddingTop: 24, gap: 14 }}>
|
|
@@ -29,12 +29,12 @@ export function ThreadScreen({
|
|
|
29
29
|
theme: SupportTheme;
|
|
30
30
|
thread: Thread;
|
|
31
31
|
onBack: () => void;
|
|
32
|
-
/** Resolves
|
|
33
|
-
onSend: (message: string) => Promise<
|
|
32
|
+
/** Resolves null on success, or a user-facing error message. */
|
|
33
|
+
onSend: (message: string) => Promise<string | null>;
|
|
34
34
|
}) {
|
|
35
35
|
const [draft, setDraft] = useState('');
|
|
36
36
|
const [sending, setSending] = useState(false);
|
|
37
|
-
const [
|
|
37
|
+
const [failedMessage, setFailedMessage] = useState<string | null>(null);
|
|
38
38
|
const scrollRef = useRef<ScrollView>(null);
|
|
39
39
|
|
|
40
40
|
const resolved = thread.status === 'resolved';
|
|
@@ -49,11 +49,11 @@ export function ThreadScreen({
|
|
|
49
49
|
async function handleSend() {
|
|
50
50
|
if (!valid || sending || resolved) return;
|
|
51
51
|
setSending(true);
|
|
52
|
-
|
|
53
|
-
const
|
|
52
|
+
setFailedMessage(null);
|
|
53
|
+
const errorMessage = await onSend(trimmed);
|
|
54
54
|
setSending(false);
|
|
55
|
-
if (
|
|
56
|
-
else
|
|
55
|
+
if (errorMessage === null) setDraft('');
|
|
56
|
+
else setFailedMessage(errorMessage);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
return (
|
|
@@ -168,7 +168,7 @@ export function ThreadScreen({
|
|
|
168
168
|
value={draft}
|
|
169
169
|
onChangeText={(v) => {
|
|
170
170
|
setDraft(v);
|
|
171
|
-
|
|
171
|
+
setFailedMessage(null);
|
|
172
172
|
}}
|
|
173
173
|
multiline
|
|
174
174
|
placeholder="Reply"
|
|
@@ -205,9 +205,9 @@ export function ThreadScreen({
|
|
|
205
205
|
>
|
|
206
206
|
{draft.length}/{USER_MESSAGE_MAX}
|
|
207
207
|
</Text>
|
|
208
|
-
{
|
|
208
|
+
{failedMessage !== null && (
|
|
209
209
|
<Text style={{ fontSize: 11, color: theme.colors.statusDanger }}>
|
|
210
|
-
|
|
210
|
+
{failedMessage}
|
|
211
211
|
</Text>
|
|
212
212
|
)}
|
|
213
213
|
<View style={{ marginLeft: 'auto' }}>
|
package/src/index.tsx
CHANGED
package/src/push.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Optional push-notification support.
|
|
2
|
+
//
|
|
3
|
+
// expo-notifications is an OPTIONAL peer dependency: if the host app has it
|
|
4
|
+
// installed we ask for permission and mint a token, otherwise every function
|
|
5
|
+
// here degrades to "undetermined" and the support flow is unaffected. The
|
|
6
|
+
// require is wrapped in try/catch so bundling succeeds either way.
|
|
7
|
+
//
|
|
8
|
+
// Nothing in the support experience is gated on the result — a user who
|
|
9
|
+
// denies notifications keeps full use of threads, replies, and unread
|
|
10
|
+
// badges. The status is reported purely so the dashboard can show whether
|
|
11
|
+
// the user is reachable.
|
|
12
|
+
|
|
13
|
+
import type { PushPermissionStatus } from './types';
|
|
14
|
+
|
|
15
|
+
/** The slice of expo-notifications this SDK uses. */
|
|
16
|
+
interface ExpoNotificationsModule {
|
|
17
|
+
getPermissionsAsync(): Promise<{ status: string; canAskAgain: boolean }>;
|
|
18
|
+
requestPermissionsAsync(): Promise<{ status: string; canAskAgain: boolean }>;
|
|
19
|
+
getExpoPushTokenAsync(options?: {
|
|
20
|
+
projectId?: string;
|
|
21
|
+
}): Promise<{ data: string }>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function loadNotifications(): ExpoNotificationsModule | null {
|
|
25
|
+
try {
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
27
|
+
const mod = require('expo-notifications') as ExpoNotificationsModule;
|
|
28
|
+
return typeof mod?.getPermissionsAsync === 'function' ? mod : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const notifications = loadNotifications();
|
|
35
|
+
|
|
36
|
+
/** True when the host app has expo-notifications installed. */
|
|
37
|
+
export const isPushAvailable = notifications !== null;
|
|
38
|
+
|
|
39
|
+
export interface PushRegistration {
|
|
40
|
+
status: PushPermissionStatus;
|
|
41
|
+
/** Whether the OS prompt was actually presented during this call. */
|
|
42
|
+
asked: boolean;
|
|
43
|
+
token: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeStatus(status: string): PushPermissionStatus {
|
|
47
|
+
if (status === 'granted') return 'granted';
|
|
48
|
+
if (status === 'denied') return 'denied';
|
|
49
|
+
return 'undetermined';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Ensures notification permission has been requested, and returns the
|
|
54
|
+
* outcome plus an Expo push token when one is obtainable.
|
|
55
|
+
*
|
|
56
|
+
* Never throws: any failure resolves to an 'undetermined' registration so
|
|
57
|
+
* callers can report it and move on.
|
|
58
|
+
*/
|
|
59
|
+
export async function ensurePushPermission(
|
|
60
|
+
expoProjectId?: string,
|
|
61
|
+
): Promise<PushRegistration> {
|
|
62
|
+
if (!notifications) {
|
|
63
|
+
return { status: 'undetermined', asked: false, token: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const current = await notifications.getPermissionsAsync();
|
|
68
|
+
let status = normalizeStatus(current.status);
|
|
69
|
+
let asked = false;
|
|
70
|
+
|
|
71
|
+
// Only prompt when the OS will actually show something. Re-prompting a
|
|
72
|
+
// user who already decided is impossible on iOS and annoying on Android.
|
|
73
|
+
if (status === 'undetermined' && current.canAskAgain) {
|
|
74
|
+
const requested = await notifications.requestPermissionsAsync();
|
|
75
|
+
status = normalizeStatus(requested.status);
|
|
76
|
+
asked = true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (status !== 'granted') {
|
|
80
|
+
return { status, asked, token: null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Token minting needs a dev/production build with push credentials —
|
|
84
|
+
// it fails in Expo Go. A granted permission with no token is still
|
|
85
|
+
// useful information for the dashboard, so report it either way.
|
|
86
|
+
try {
|
|
87
|
+
const token = await notifications.getExpoPushTokenAsync(
|
|
88
|
+
expoProjectId ? { projectId: expoProjectId } : undefined,
|
|
89
|
+
);
|
|
90
|
+
return { status, asked, token: token.data };
|
|
91
|
+
} catch {
|
|
92
|
+
return { status, asked, token: null };
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
return { status: 'undetermined', asked: false, token: null };
|
|
96
|
+
}
|
|
97
|
+
}
|