@appsupportkit/support-sdk 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/lib/commonjs/api.js +49 -15
  2. package/lib/commonjs/api.js.map +1 -1
  3. package/lib/commonjs/components/BottomSheet.js +1 -1
  4. package/lib/commonjs/components/BottomSheet.js.map +1 -1
  5. package/lib/commonjs/components/LauncherButton.js +4 -4
  6. package/lib/commonjs/components/LauncherButton.js.map +1 -1
  7. package/lib/commonjs/components/SupportButton.js +123 -97
  8. package/lib/commonjs/components/SupportButton.js.map +1 -1
  9. package/lib/commonjs/components/icons.js +81 -110
  10. package/lib/commonjs/components/icons.js.map +1 -1
  11. package/lib/commonjs/components/primitives.js +0 -153
  12. package/lib/commonjs/components/primitives.js.map +1 -1
  13. package/lib/commonjs/components/screens/ChatScreen.js +487 -0
  14. package/lib/commonjs/components/screens/ChatScreen.js.map +1 -0
  15. package/lib/commonjs/i18n/strings/de.js +3 -0
  16. package/lib/commonjs/i18n/strings/de.js.map +1 -1
  17. package/lib/commonjs/i18n/strings/en.js +3 -0
  18. package/lib/commonjs/i18n/strings/en.js.map +1 -1
  19. package/lib/commonjs/i18n/strings/es.js +3 -0
  20. package/lib/commonjs/i18n/strings/es.js.map +1 -1
  21. package/lib/commonjs/i18n/strings/fr.js +3 -0
  22. package/lib/commonjs/i18n/strings/fr.js.map +1 -1
  23. package/lib/commonjs/i18n/strings/hi.js +3 -0
  24. package/lib/commonjs/i18n/strings/hi.js.map +1 -1
  25. package/lib/commonjs/i18n/strings/it.js +3 -0
  26. package/lib/commonjs/i18n/strings/it.js.map +1 -1
  27. package/lib/commonjs/i18n/strings/ja.js +3 -0
  28. package/lib/commonjs/i18n/strings/ja.js.map +1 -1
  29. package/lib/commonjs/i18n/strings/ko.js +3 -0
  30. package/lib/commonjs/i18n/strings/ko.js.map +1 -1
  31. package/lib/commonjs/i18n/strings/nl.js +3 -0
  32. package/lib/commonjs/i18n/strings/nl.js.map +1 -1
  33. package/lib/commonjs/i18n/strings/pl.js +3 -0
  34. package/lib/commonjs/i18n/strings/pl.js.map +1 -1
  35. package/lib/commonjs/i18n/strings/pt.js +3 -0
  36. package/lib/commonjs/i18n/strings/pt.js.map +1 -1
  37. package/lib/commonjs/i18n/strings/ru.js +3 -0
  38. package/lib/commonjs/i18n/strings/ru.js.map +1 -1
  39. package/lib/commonjs/i18n/strings/tr.js +3 -0
  40. package/lib/commonjs/i18n/strings/tr.js.map +1 -1
  41. package/lib/commonjs/i18n/strings/zh.js +3 -0
  42. package/lib/commonjs/i18n/strings/zh.js.map +1 -1
  43. package/lib/commonjs/index.js +7 -0
  44. package/lib/commonjs/index.js.map +1 -1
  45. package/lib/commonjs/push.js +78 -3
  46. package/lib/commonjs/push.js.map +1 -1
  47. package/lib/commonjs/review.js +54 -0
  48. package/lib/commonjs/review.js.map +1 -0
  49. package/lib/commonjs/theme.js +142 -23
  50. package/lib/commonjs/theme.js.map +1 -1
  51. package/lib/commonjs/types.js +21 -3
  52. package/lib/commonjs/types.js.map +1 -1
  53. package/lib/commonjs/useSupportThreads.js +22 -15
  54. package/lib/commonjs/useSupportThreads.js.map +1 -1
  55. package/lib/commonjs/utils.js +13 -33
  56. package/lib/commonjs/utils.js.map +1 -1
  57. package/lib/commonjs/version.js +13 -0
  58. package/lib/commonjs/version.js.map +1 -0
  59. package/lib/module/api.js +46 -13
  60. package/lib/module/api.js.map +1 -1
  61. package/lib/module/components/BottomSheet.js +1 -1
  62. package/lib/module/components/BottomSheet.js.map +1 -1
  63. package/lib/module/components/LauncherButton.js +5 -5
  64. package/lib/module/components/LauncherButton.js.map +1 -1
  65. package/lib/module/components/SupportButton.js +128 -102
  66. package/lib/module/components/SupportButton.js.map +1 -1
  67. package/lib/module/components/icons.js +80 -107
  68. package/lib/module/components/icons.js.map +1 -1
  69. package/lib/module/components/primitives.js +2 -146
  70. package/lib/module/components/primitives.js.map +1 -1
  71. package/lib/module/components/screens/ChatScreen.js +485 -0
  72. package/lib/module/components/screens/ChatScreen.js.map +1 -0
  73. package/lib/module/i18n/strings/de.js +3 -0
  74. package/lib/module/i18n/strings/de.js.map +1 -1
  75. package/lib/module/i18n/strings/en.js +3 -0
  76. package/lib/module/i18n/strings/en.js.map +1 -1
  77. package/lib/module/i18n/strings/es.js +3 -0
  78. package/lib/module/i18n/strings/es.js.map +1 -1
  79. package/lib/module/i18n/strings/fr.js +3 -0
  80. package/lib/module/i18n/strings/fr.js.map +1 -1
  81. package/lib/module/i18n/strings/hi.js +3 -0
  82. package/lib/module/i18n/strings/hi.js.map +1 -1
  83. package/lib/module/i18n/strings/it.js +3 -0
  84. package/lib/module/i18n/strings/it.js.map +1 -1
  85. package/lib/module/i18n/strings/ja.js +3 -0
  86. package/lib/module/i18n/strings/ja.js.map +1 -1
  87. package/lib/module/i18n/strings/ko.js +3 -0
  88. package/lib/module/i18n/strings/ko.js.map +1 -1
  89. package/lib/module/i18n/strings/nl.js +3 -0
  90. package/lib/module/i18n/strings/nl.js.map +1 -1
  91. package/lib/module/i18n/strings/pl.js +3 -0
  92. package/lib/module/i18n/strings/pl.js.map +1 -1
  93. package/lib/module/i18n/strings/pt.js +3 -0
  94. package/lib/module/i18n/strings/pt.js.map +1 -1
  95. package/lib/module/i18n/strings/ru.js +3 -0
  96. package/lib/module/i18n/strings/ru.js.map +1 -1
  97. package/lib/module/i18n/strings/tr.js +3 -0
  98. package/lib/module/i18n/strings/tr.js.map +1 -1
  99. package/lib/module/i18n/strings/zh.js +3 -0
  100. package/lib/module/i18n/strings/zh.js.map +1 -1
  101. package/lib/module/index.js +1 -0
  102. package/lib/module/index.js.map +1 -1
  103. package/lib/module/push.js +77 -3
  104. package/lib/module/push.js.map +1 -1
  105. package/lib/module/review.js +48 -0
  106. package/lib/module/review.js.map +1 -0
  107. package/lib/module/theme.js +141 -23
  108. package/lib/module/theme.js.map +1 -1
  109. package/lib/module/types.js +21 -3
  110. package/lib/module/types.js.map +1 -1
  111. package/lib/module/useSupportThreads.js +22 -15
  112. package/lib/module/useSupportThreads.js.map +1 -1
  113. package/lib/module/utils.js +12 -32
  114. package/lib/module/utils.js.map +1 -1
  115. package/lib/module/version.js +9 -0
  116. package/lib/module/version.js.map +1 -0
  117. package/lib/typescript/api.d.ts +33 -12
  118. package/lib/typescript/api.d.ts.map +1 -1
  119. package/lib/typescript/components/SupportButton.d.ts +2 -2
  120. package/lib/typescript/components/SupportButton.d.ts.map +1 -1
  121. package/lib/typescript/components/icons.d.ts +5 -14
  122. package/lib/typescript/components/icons.d.ts.map +1 -1
  123. package/lib/typescript/components/primitives.d.ts +0 -31
  124. package/lib/typescript/components/primitives.d.ts.map +1 -1
  125. package/lib/typescript/components/screens/ChatScreen.d.ts +21 -0
  126. package/lib/typescript/components/screens/ChatScreen.d.ts.map +1 -0
  127. package/lib/typescript/i18n/strings/de.d.ts.map +1 -1
  128. package/lib/typescript/i18n/strings/en.d.ts +3 -0
  129. package/lib/typescript/i18n/strings/en.d.ts.map +1 -1
  130. package/lib/typescript/i18n/strings/es.d.ts.map +1 -1
  131. package/lib/typescript/i18n/strings/fr.d.ts.map +1 -1
  132. package/lib/typescript/i18n/strings/hi.d.ts.map +1 -1
  133. package/lib/typescript/i18n/strings/it.d.ts.map +1 -1
  134. package/lib/typescript/i18n/strings/ja.d.ts.map +1 -1
  135. package/lib/typescript/i18n/strings/ko.d.ts.map +1 -1
  136. package/lib/typescript/i18n/strings/nl.d.ts.map +1 -1
  137. package/lib/typescript/i18n/strings/pl.d.ts.map +1 -1
  138. package/lib/typescript/i18n/strings/pt.d.ts.map +1 -1
  139. package/lib/typescript/i18n/strings/ru.d.ts.map +1 -1
  140. package/lib/typescript/i18n/strings/tr.d.ts.map +1 -1
  141. package/lib/typescript/i18n/strings/zh.d.ts.map +1 -1
  142. package/lib/typescript/index.d.ts +3 -2
  143. package/lib/typescript/index.d.ts.map +1 -1
  144. package/lib/typescript/push.d.ts +14 -2
  145. package/lib/typescript/push.d.ts.map +1 -1
  146. package/lib/typescript/review.d.ts +15 -0
  147. package/lib/typescript/review.d.ts.map +1 -0
  148. package/lib/typescript/theme.d.ts +23 -5
  149. package/lib/typescript/theme.d.ts.map +1 -1
  150. package/lib/typescript/types.d.ts +54 -11
  151. package/lib/typescript/types.d.ts.map +1 -1
  152. package/lib/typescript/useSupportThreads.d.ts +9 -3
  153. package/lib/typescript/useSupportThreads.d.ts.map +1 -1
  154. package/lib/typescript/utils.d.ts +3 -2
  155. package/lib/typescript/utils.d.ts.map +1 -1
  156. package/lib/typescript/version.d.ts +7 -0
  157. package/lib/typescript/version.d.ts.map +1 -0
  158. package/package.json +1 -1
  159. package/src/api.ts +59 -30
  160. package/src/components/BottomSheet.tsx +1 -1
  161. package/src/components/LauncherButton.tsx +4 -4
  162. package/src/components/SupportButton.tsx +140 -111
  163. package/src/components/icons.tsx +60 -101
  164. package/src/components/primitives.tsx +1 -163
  165. package/src/components/screens/ChatScreen.tsx +541 -0
  166. package/src/i18n/strings/de.ts +3 -0
  167. package/src/i18n/strings/en.ts +3 -0
  168. package/src/i18n/strings/es.ts +3 -0
  169. package/src/i18n/strings/fr.ts +3 -0
  170. package/src/i18n/strings/hi.ts +3 -0
  171. package/src/i18n/strings/it.ts +3 -0
  172. package/src/i18n/strings/ja.ts +3 -0
  173. package/src/i18n/strings/ko.ts +3 -0
  174. package/src/i18n/strings/nl.ts +3 -0
  175. package/src/i18n/strings/pl.ts +3 -0
  176. package/src/i18n/strings/pt.ts +3 -0
  177. package/src/i18n/strings/ru.ts +3 -0
  178. package/src/i18n/strings/tr.ts +3 -0
  179. package/src/i18n/strings/zh.ts +3 -0
  180. package/src/index.tsx +5 -0
  181. package/src/push.ts +100 -2
  182. package/src/review.ts +54 -0
  183. package/src/theme.ts +182 -28
  184. package/src/types.ts +53 -11
  185. package/src/useSupportThreads.ts +31 -18
  186. package/src/utils.ts +14 -21
  187. package/src/version.ts +6 -0
  188. package/lib/commonjs/components/screens/ComposeScreen.js +0 -219
  189. package/lib/commonjs/components/screens/ComposeScreen.js.map +0 -1
  190. package/lib/commonjs/components/screens/HomeScreen.js +0 -334
  191. package/lib/commonjs/components/screens/HomeScreen.js.map +0 -1
  192. package/lib/commonjs/components/screens/ThreadScreen.js +0 -339
  193. package/lib/commonjs/components/screens/ThreadScreen.js.map +0 -1
  194. package/lib/module/components/screens/ComposeScreen.js +0 -217
  195. package/lib/module/components/screens/ComposeScreen.js.map +0 -1
  196. package/lib/module/components/screens/HomeScreen.js +0 -330
  197. package/lib/module/components/screens/HomeScreen.js.map +0 -1
  198. package/lib/module/components/screens/ThreadScreen.js +0 -337
  199. package/lib/module/components/screens/ThreadScreen.js.map +0 -1
  200. package/lib/typescript/components/screens/ComposeScreen.d.ts +0 -12
  201. package/lib/typescript/components/screens/ComposeScreen.d.ts.map +0 -1
  202. package/lib/typescript/components/screens/HomeScreen.d.ts +0 -19
  203. package/lib/typescript/components/screens/HomeScreen.d.ts.map +0 -1
  204. package/lib/typescript/components/screens/ThreadScreen.d.ts +0 -12
  205. package/lib/typescript/components/screens/ThreadScreen.d.ts.map +0 -1
  206. package/src/components/screens/ComposeScreen.tsx +0 -229
  207. package/src/components/screens/HomeScreen.tsx +0 -346
  208. package/src/components/screens/ThreadScreen.tsx +0 -424
@@ -42,6 +42,9 @@ export const zh: StringTable = {
42
42
  'thread.resolvedAt': '已解决 · {date}',
43
43
  'thread.readOnly': '此对话已解决,仅供查看',
44
44
  'thread.placeholder': '写下回复…',
45
+ 'chat.placeholder': '输入消息…',
46
+ 'review.appStore': '在 App Store 评分',
47
+ 'review.playStore': '在 Google Play 评分',
45
48
  'thread.sendA11y': '发送回复',
46
49
  'thread.sendingA11y': '发送中',
47
50
  'thread.translating': '翻译中…',
package/src/index.tsx CHANGED
@@ -3,9 +3,11 @@ export {
3
3
  supportDarkTheme,
4
4
  supportLightTheme,
5
5
  resolveTheme,
6
+ type SupportColors,
6
7
  type SupportTheme,
7
8
  } from './theme';
8
9
  export { SupportApiError } from './api';
10
+ export { SDK_VERSION } from './version';
9
11
  export { isPushAvailable } from './push';
10
12
  export {
11
13
  DEFAULT_LANGUAGE,
@@ -16,6 +18,7 @@ export {
16
18
  type Language,
17
19
  } from './i18n/languages';
18
20
  export type {
21
+ AccentColor,
19
22
  LauncherVariant,
20
23
  MessageSender,
21
24
  PushPermissionStatus,
@@ -27,6 +30,8 @@ export type {
27
30
  ThreadMessage,
28
31
  ThreadsResponse,
29
32
  ThemeName,
33
+ ThemePreference,
34
+ UserPlan,
30
35
  } from './types';
31
36
  export {
32
37
  QUERY_TYPE_LABEL,
package/src/push.ts CHANGED
@@ -12,6 +12,17 @@
12
12
 
13
13
  import type { PushPermissionStatus } from './types';
14
14
 
15
+ /** The slice of a NotificationResponse that tap handling reads. */
16
+ interface NotificationResponse {
17
+ actionIdentifier: string;
18
+ notification: {
19
+ request: {
20
+ identifier: string;
21
+ content: { data?: Record<string, unknown> | null };
22
+ };
23
+ };
24
+ }
25
+
15
26
  /** The slice of expo-notifications this SDK uses. */
16
27
  interface ExpoNotificationsModule {
17
28
  getPermissionsAsync(): Promise<{ status: string; canAskAgain: boolean }>;
@@ -19,6 +30,11 @@ interface ExpoNotificationsModule {
19
30
  getExpoPushTokenAsync(options?: {
20
31
  projectId?: string;
21
32
  }): Promise<{ data: string }>;
33
+ addNotificationResponseReceivedListener(
34
+ listener: (response: NotificationResponse) => void,
35
+ ): { remove(): void };
36
+ getLastNotificationResponseAsync(): Promise<NotificationResponse | null>;
37
+ DEFAULT_ACTION_IDENTIFIER?: string;
22
38
  }
23
39
 
24
40
  function loadNotifications(): ExpoNotificationsModule | null {
@@ -51,13 +67,16 @@ function normalizeStatus(status: string): PushPermissionStatus {
51
67
 
52
68
  /**
53
69
  * Ensures notification permission has been requested, and returns the
54
- * outcome plus an Expo push token when one is obtainable.
70
+ * outcome plus an Expo push token when one is obtainable. With
71
+ * `prompt: false` it only reads the current state — for refreshing the token
72
+ * at launch without ever showing the OS prompt outside the support sheet.
55
73
  *
56
74
  * Never throws: any failure resolves to an 'undetermined' registration so
57
75
  * callers can report it and move on.
58
76
  */
59
77
  export async function ensurePushPermission(
60
78
  expoProjectId?: string,
79
+ { prompt = true }: { prompt?: boolean } = {},
61
80
  ): Promise<PushRegistration> {
62
81
  if (!notifications) {
63
82
  return { status: 'undetermined', asked: false, token: null };
@@ -70,7 +89,7 @@ export async function ensurePushPermission(
70
89
 
71
90
  // Only prompt when the OS will actually show something. Re-prompting a
72
91
  // user who already decided is impossible on iOS and annoying on Android.
73
- if (status === 'undetermined' && current.canAskAgain) {
92
+ if (prompt && status === 'undetermined' && current.canAskAgain) {
74
93
  const requested = await notifications.requestPermissionsAsync();
75
94
  status = normalizeStatus(requested.status);
76
95
  asked = true;
@@ -95,3 +114,82 @@ export async function ensurePushPermission(
95
114
  return { status: 'undetermined', asked: false, token: null };
96
115
  }
97
116
  }
117
+
118
+ // ---------- opening the thread from a tapped notification ----------
119
+ //
120
+ // send-push tags every support reply with `{ type: 'support_reply',
121
+ // query_id }`. Tapping one should land the user in that thread, not wherever
122
+ // the host app happens to open.
123
+ //
124
+ // The observer starts when this module loads rather than when a launcher
125
+ // mounts, for two reasons: a cold start's tap is only reported once, early,
126
+ // and a host app may mount the launcher later (behind auth, on a settings
127
+ // screen). A tap seen before any launcher is listening is held for
128
+ // PENDING_TAP_TTL_MS and then dropped — opening the sheet minutes after the
129
+ // tap, the first time the user wanders onto that screen, would be a surprise.
130
+
131
+ const PENDING_TAP_TTL_MS = 60_000;
132
+
133
+ type SupportTapListener = (threadId: string) => void;
134
+
135
+ const tapListeners: SupportTapListener[] = [];
136
+ let pendingTap: { threadId: string; at: number } | null = null;
137
+ // Both the listener and getLastNotificationResponseAsync can report the same
138
+ // cold-start tap, and the latter keeps reporting it for the life of the
139
+ // process. Each tap is handled once.
140
+ const handledTaps = new Set<string>();
141
+
142
+ function supportReplyThreadId(response: NotificationResponse): string | null {
143
+ const defaultAction = notifications?.DEFAULT_ACTION_IDENTIFIER;
144
+ if (defaultAction && response.actionIdentifier !== defaultAction) return null;
145
+ const data = response.notification?.request?.content?.data;
146
+ if (data?.type !== 'support_reply') return null;
147
+ return typeof data.query_id === 'string' ? data.query_id : null;
148
+ }
149
+
150
+ function handleResponse(response: NotificationResponse | null) {
151
+ if (!response) return;
152
+ const threadId = supportReplyThreadId(response);
153
+ const tapId = response.notification?.request?.identifier;
154
+ if (threadId === null || !tapId || handledTaps.has(tapId)) return;
155
+ handledTaps.add(tapId);
156
+
157
+ // One sheet, not one per mounted launcher: the newest launcher takes it.
158
+ const listener = tapListeners[tapListeners.length - 1];
159
+ if (listener) listener(threadId);
160
+ else pendingTap = { threadId, at: Date.now() };
161
+ }
162
+
163
+ if (notifications) {
164
+ try {
165
+ notifications.addNotificationResponseReceivedListener(handleResponse);
166
+ notifications
167
+ .getLastNotificationResponseAsync()
168
+ .then(handleResponse)
169
+ .catch(() => {});
170
+ } catch {
171
+ // Same contract as the rest of this file: push never breaks support.
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Calls `listener` with the thread id whenever the user taps a support-reply
177
+ * notification — including, once, a tap from the last minute that arrived
178
+ * before anything was listening. Returns an unsubscribe function.
179
+ */
180
+ export function onSupportNotificationTap(
181
+ listener: SupportTapListener,
182
+ ): () => void {
183
+ tapListeners.push(listener);
184
+
185
+ const pending = pendingTap;
186
+ pendingTap = null;
187
+ if (pending && Date.now() - pending.at < PENDING_TAP_TTL_MS) {
188
+ listener(pending.threadId);
189
+ }
190
+
191
+ return () => {
192
+ const index = tapListeners.lastIndexOf(listener);
193
+ if (index !== -1) tapListeners.splice(index, 1);
194
+ };
195
+ }
package/src/review.ts ADDED
@@ -0,0 +1,54 @@
1
+ // Review requests: a support reply with a "Rate on the App Store / Google
2
+ // Play" button. The button opens the store's write-a-review page — the store
3
+ // app where there is one, its website otherwise. It is a link, not a prompt:
4
+ // Apple's guidelines reserve in-app rating prompts for SKStoreReviewController,
5
+ // and linking to the review page is what they document for everything else.
6
+
7
+ import { Linking, Platform } from 'react-native';
8
+ import type { StoreListing } from './types';
9
+
10
+ interface StoreLinks {
11
+ /** Opens the store app. */
12
+ app: string;
13
+ /** The same page on the web, if the store app won't open. */
14
+ web: string;
15
+ }
16
+
17
+ /** Links for this device's store, or null when the team hasn't set one up. */
18
+ export function storeLinks(store: StoreListing | null | undefined): StoreLinks | null {
19
+ if (Platform.OS === 'ios' && store?.app_store_id) {
20
+ const id = encodeURIComponent(store.app_store_id);
21
+ return {
22
+ app: `itms-apps://apps.apple.com/app/id${id}?action=write-review`,
23
+ web: `https://apps.apple.com/app/id${id}?action=write-review`,
24
+ };
25
+ }
26
+ if (Platform.OS === 'android' && store?.play_store_package) {
27
+ const pkg = encodeURIComponent(store.play_store_package);
28
+ return {
29
+ app: `market://details?id=${pkg}&showAllReviews=true`,
30
+ web: `https://play.google.com/store/apps/details?id=${pkg}&showAllReviews=true`,
31
+ };
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /** Opens the review page; resolves false if neither link would open. */
37
+ export async function openStoreReview(links: StoreLinks): Promise<boolean> {
38
+ try {
39
+ await Linking.openURL(links.app);
40
+ return true;
41
+ } catch {
42
+ try {
43
+ await Linking.openURL(links.web);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+ }
50
+
51
+ /** The platform the backend should know this user by, if it's a store one. */
52
+ export function storePlatform(): 'ios' | 'android' | undefined {
53
+ return Platform.OS === 'ios' || Platform.OS === 'android' ? Platform.OS : undefined;
54
+ }
package/src/theme.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  // The SDK consumes the same design tokens as the admin dashboard, so both
2
2
  // surfaces look like one product. On top of those it adds the few values
3
- // that only exist on mobile: sheet/bubble radii and the elevation used by
4
- // the launcher and the sheet (the web side gets those from CSS).
3
+ // that only exist on mobile: sheet/bubble radii, the elevation used by the
4
+ // launcher and the sheet (the web side gets those from CSS), and a re-cut of
5
+ // the surfaces and text ramp for a sheet held at arm's length (see
6
+ // SHEET_OVERRIDES).
5
7
  import {
6
8
  darkColors,
7
9
  lightColors,
@@ -11,7 +13,7 @@ import {
11
13
  type ThemeColors,
12
14
  } from '@appsupportkit/design-tokens';
13
15
  import type { ViewStyle } from 'react-native';
14
- import type { ThemeName } from './types';
16
+ import type { AccentColor, ThemeName } from './types';
15
17
 
16
18
  /** Rounder than the dashboard: touch targets read better with more radius. */
17
19
  export const supportRadii = {
@@ -27,6 +29,17 @@ export const supportRadii = {
27
29
  pill: 999,
28
30
  } as const;
29
31
 
32
+ export interface SupportColors extends ThemeColors {
33
+ /** The sheet's own background — the layer every screen is drawn on. */
34
+ sheet: string;
35
+ /**
36
+ * The accent where it is small text — status labels, the "New" divider.
37
+ * The fill color can sit at 3:1; text needs 4.5:1 on the sheet and on the
38
+ * cards over it, so this is the accent nudged as far as that takes.
39
+ */
40
+ accentText: string;
41
+ }
42
+
30
43
  export interface SupportShadows {
31
44
  /** Floating launcher button. */
32
45
  launcher: ViewStyle;
@@ -52,7 +65,7 @@ export interface LauncherPalette {
52
65
 
53
66
  export interface SupportTheme {
54
67
  name: ThemeName;
55
- colors: ThemeColors;
68
+ colors: SupportColors;
56
69
  spacing: typeof spacing;
57
70
  radii: typeof supportRadii;
58
71
  motion: typeof motion;
@@ -63,9 +76,15 @@ export interface SupportTheme {
63
76
  // Color helpers come first: the themes below are built at module load, so
64
77
  // anything they reach for has to already be initialized. (A `const` used
65
78
  // before its definition survives tsc but lands as `undefined` on Hermes.)
79
+ type Rgb = [number, number, number];
80
+
66
81
  const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
82
+ const WHITE: Rgb = [255, 255, 255];
83
+ /** Foreground for light accents — the dashboard's near-black, not pure black. */
84
+ const INK = '#111113';
85
+ const INK_RGB: Rgb = [17, 17, 19];
67
86
 
68
- function parseHex(color: string): [number, number, number] | null {
87
+ function parseHex(color: string): Rgb | null {
69
88
  const match = HEX.exec(color.trim());
70
89
  const digits = match?.[1];
71
90
  if (digits === undefined) return null;
@@ -83,11 +102,94 @@ function parseHex(color: string): [number, number, number] | null {
83
102
  ];
84
103
  }
85
104
 
86
- function mix([r, g, b]: [number, number, number], target: number, amount: number) {
105
+ function toHex(rgb: Rgb): string {
106
+ return `#${rgb.map((c) => c.toString(16).padStart(2, '0')).join('').toUpperCase()}`;
107
+ }
108
+
109
+ function mixRgb([r, g, b]: Rgb, target: number, amount: number): Rgb {
87
110
  const channel = (c: number) => Math.round(c + (target - c) * amount);
88
- return `rgb(${channel(r)}, ${channel(g)}, ${channel(b)})`;
111
+ return [channel(r), channel(g), channel(b)];
112
+ }
113
+
114
+ function mix(rgb: Rgb, target: number, amount: number): string {
115
+ return toHex(mixRgb(rgb, target, amount));
116
+ }
117
+
118
+ /** WCAG relative luminance. */
119
+ function luminance(rgb: Rgb): number {
120
+ const [r, g, b] = rgb.map((c) => {
121
+ const s = c / 255;
122
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
123
+ }) as Rgb;
124
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
125
+ }
126
+
127
+ /** WCAG contrast ratio, 1–21. */
128
+ function contrast(a: Rgb, b: Rgb): number {
129
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [
130
+ number,
131
+ number,
132
+ ];
133
+ return (hi + 0.05) / (lo + 0.05);
134
+ }
135
+
136
+ /**
137
+ * Nudges `color` toward white (dark theme) or black (light theme) in 5%
138
+ * steps until it reaches `ratio` against every background. The smallest
139
+ * step that works wins, so a brand color stays recognizably itself.
140
+ */
141
+ function legible(
142
+ color: string,
143
+ backgrounds: string[],
144
+ ratio: number,
145
+ name: ThemeName,
146
+ ): string {
147
+ const rgb = parseHex(color);
148
+ if (rgb === null) return color;
149
+ const against = backgrounds
150
+ .map(parseHex)
151
+ .filter((c): c is Rgb => c !== null);
152
+ const target = name === 'dark' ? 255 : 0;
153
+ for (let step = 0; step <= 20; step += 1) {
154
+ const candidate = mixRgb(rgb, target, step * 0.05);
155
+ if (against.every((bg) => contrast(candidate, bg) >= ratio)) {
156
+ return toHex(candidate);
157
+ }
158
+ }
159
+ return name === 'dark' ? '#FFFFFF' : '#000000';
89
160
  }
90
161
 
162
+ /**
163
+ * Where the SDK departs from the dashboard tokens.
164
+ *
165
+ * Dark: the sheet floats over the host app, so it takes an elevated gray the
166
+ * way iOS sheets do, and the cards and bubbles on it step lighter again. On
167
+ * the dashboard's near-black base, cards sat 1.05:1 from the background and
168
+ * the layering disappeared.
169
+ *
170
+ * Both: the text ramp is re-cut so every step clears WCAG AA (4.5:1) on the
171
+ * sheet and on the cards over it. Tertiary carries timestamps and labels at
172
+ * 11–12px and was 3.1:1 (dark) and 2.5:1 (light); secondary moves too, so
173
+ * the two stay a visible step apart.
174
+ */
175
+ const SHEET_OVERRIDES: Record<ThemeName, Partial<SupportColors>> = {
176
+ dark: {
177
+ sheet: '#161618',
178
+ surface: '#1F1F23',
179
+ surfaceHover: '#26262B',
180
+ surfaceActive: '#2D2D33',
181
+ border: '#2E2E35',
182
+ borderStrong: '#3C3C44',
183
+ textSecondary: '#A3A3AB', // 6.5:1 on surface
184
+ textTertiary: '#87878F', // 4.6:1 on surface, 5.1:1 on sheet
185
+ },
186
+ light: {
187
+ sheet: lightColors.bg,
188
+ textSecondary: '#5A5A62', // 6.5:1 on sheet
189
+ textTertiary: '#717179', // 4.6:1 on sheet, 4.8:1 on surface
190
+ },
191
+ };
192
+
91
193
  function shadowsFor(name: ThemeName, accent: string): SupportShadows {
92
194
  const dark = name === 'dark';
93
195
  return {
@@ -129,7 +231,21 @@ function launcherFor(name: ThemeName, accent: string): LauncherPalette {
129
231
  };
130
232
  }
131
233
 
132
- function buildTheme(name: ThemeName, colors: ThemeColors): SupportTheme {
234
+ function buildTheme(name: ThemeName, tokens: ThemeColors): SupportTheme {
235
+ const base = {
236
+ ...tokens,
237
+ ...SHEET_OVERRIDES[name],
238
+ } as SupportColors;
239
+ // Status tones double as 12px labels, so they get the text bar too.
240
+ const onSheet = [base.sheet, base.surface];
241
+ const colors: SupportColors = {
242
+ ...base,
243
+ statusOpen: legible(base.statusOpen, onSheet, 4.5, name),
244
+ statusProgress: legible(base.statusProgress, onSheet, 4.5, name),
245
+ statusResolved: legible(base.statusResolved, onSheet, 4.5, name),
246
+ statusDanger: legible(base.statusDanger, onSheet, 4.5, name),
247
+ accentText: legible(base.accent, onSheet, 4.5, name),
248
+ };
133
249
  return {
134
250
  name,
135
251
  colors,
@@ -146,41 +262,79 @@ export const supportLightTheme: SupportTheme = buildTheme('light', lightColors);
146
262
 
147
263
  /**
148
264
  * Derives the accent family from a single brand color: a pressed shade, the
149
- * 12% wash used behind chips and own-message bubbles, and a foreground that
150
- * stays legible on top (dark text on light brands, white on dark ones).
265
+ * 12% wash used behind chips, a text-safe variant, and whichever of white or
266
+ * ink reads better on top of it.
267
+ *
268
+ * `adapt` is for a color that has to serve both themes: it is lifted just
269
+ * far enough to read as a fill (3:1, the WCAG bar for UI components) on this
270
+ * theme's sheet — a navy brand would otherwise vanish in dark mode. A color
271
+ * given for this mode specifically is used as is.
151
272
  */
152
- function withAccent(theme: SupportTheme, accent: string): SupportTheme {
153
- const rgb = parseHex(accent);
154
- if (!rgb) return theme;
273
+ function withAccent(
274
+ theme: SupportTheme,
275
+ accent: string,
276
+ adapt: boolean,
277
+ ): SupportTheme {
278
+ if (parseHex(accent) === null) return theme;
279
+ const { name } = theme;
280
+ const fill = adapt
281
+ ? legible(accent, [theme.colors.sheet], 3, name)
282
+ : toHex(parseHex(accent) as Rgb);
283
+ const rgb = parseHex(fill) as Rgb;
155
284
  const [r, g, b] = rgb;
156
- // Relative luminance, sRGB coefficients — good enough to pick a foreground.
157
- const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
158
- const colors: ThemeColors = {
285
+ const accentText = legible(
286
+ fill,
287
+ [theme.colors.sheet, theme.colors.surface],
288
+ 4.5,
289
+ name,
290
+ );
291
+ const colors: SupportColors = {
159
292
  ...theme.colors,
160
- accent,
161
- accentHover: mix(rgb, theme.name === 'dark' ? 255 : 0, 0.12),
293
+ accent: fill,
294
+ accentHover: mix(rgb, name === 'dark' ? 255 : 0, 0.12),
162
295
  accentMuted: `rgba(${r}, ${g}, ${b}, 0.12)`,
163
- accentForeground: luminance > 0.62 ? '#111113' : '#FFFFFF',
164
- statusOpen: accent,
296
+ accentForeground:
297
+ contrast(rgb, WHITE) >= contrast(rgb, INK_RGB) ? '#FFFFFF' : INK,
298
+ accentText,
299
+ statusOpen: accentText,
165
300
  statusOpenBg: `rgba(${r}, ${g}, ${b}, 0.10)`,
166
- focusRing: accent,
301
+ focusRing: fill,
167
302
  };
168
303
  return {
169
304
  ...theme,
170
305
  colors,
171
- shadows: shadowsFor(theme.name, accent),
172
- launcher: launcherFor(theme.name, accent),
306
+ shadows: shadowsFor(name, fill),
307
+ launcher: launcherFor(name, fill),
173
308
  };
174
309
  }
175
310
 
176
311
  /**
177
- * @param name "light" | "dark" (default dark).
178
- * @param accentColor Optional brand color (hex) replacing the default accent.
312
+ * The theme for one resolved mode. `accent` is the color for this mode
313
+ * (already picked from a light/dark pair), and `adapt` says whether it was a
314
+ * single color that has to be made to work here.
315
+ */
316
+ export function themeFor(
317
+ name: ThemeName,
318
+ accent: string | undefined,
319
+ adapt: boolean,
320
+ ): SupportTheme {
321
+ const base = name === 'light' ? supportLightTheme : supportDarkTheme;
322
+ return accent ? withAccent(base, accent, adapt) : base;
323
+ }
324
+
325
+ /**
326
+ * @param name "light" | "dark" (default dark). SupportButton resolves its
327
+ * "system" default before calling this.
328
+ * @param accentColor Optional brand color: one hex for both modes (adjusted
329
+ * per mode where needed), or a `{ light, dark }` pair used as given.
179
330
  */
180
331
  export function resolveTheme(
181
332
  name: ThemeName | undefined,
182
- accentColor?: string,
333
+ accentColor?: AccentColor,
183
334
  ): SupportTheme {
184
- const base = name === 'light' ? supportLightTheme : supportDarkTheme;
185
- return accentColor ? withAccent(base, accentColor) : base;
335
+ const mode: ThemeName = name === 'light' ? 'light' : 'dark';
336
+ if (typeof accentColor === 'object' && accentColor !== null) {
337
+ return themeFor(mode, accentColor[mode], false);
338
+ }
339
+ return themeFor(mode, accentColor, true);
186
340
  }
package/src/types.ts CHANGED
@@ -1,10 +1,24 @@
1
1
  import type { StyleProp, ViewStyle } from 'react-native';
2
2
  import type { Language } from './i18n/languages';
3
3
 
4
- export type QueryType = 'feedback' | 'feature_request' | 'bug_report';
4
+ /**
5
+ * 'support' is the single chat (0.4+). The other three are the typed requests
6
+ * older builds file; their conversations still show in the chat.
7
+ */
8
+ export type QueryType = 'feedback' | 'feature_request' | 'bug_report' | 'support';
9
+ /** Shown to the support team, which can filter and message users by it. */
10
+ export type UserPlan = 'free' | 'trial' | 'pro';
5
11
  export type QueryStatus = 'open' | 'resolved';
6
12
  export type MessageSender = 'user' | 'admin';
7
13
  export type ThemeName = 'dark' | 'light';
14
+ /** What the host app asks for. "system" follows the app's light/dark appearance. */
15
+ export type ThemePreference = ThemeName | 'system';
16
+ /**
17
+ * A brand color: one hex for both modes, or a pair. A single color is
18
+ * lightened in dark mode (darkened in light) only as far as it takes to
19
+ * stay visible; a pair is used exactly as given.
20
+ */
21
+ export type AccentColor = string | { light: string; dark: string };
8
22
  export type PushPermissionStatus = 'undetermined' | 'granted' | 'denied';
9
23
 
10
24
  /**
@@ -18,8 +32,17 @@ export type LauncherVariant = 'solid' | 'soft' | 'minimal';
18
32
  export interface SupportUser {
19
33
  /** Display name shown to the support team. */
20
34
  name: string;
21
- /** Whether the user is on a paid plan. */
22
- isPaid: boolean;
35
+ /**
36
+ * The user's plan: "free", "trial" (in a free trial) or "pro" (paying).
37
+ * With RevenueCat: "trial" when the active entitlement's periodType is
38
+ * TRIAL, "pro" when it is active otherwise, else "free".
39
+ */
40
+ plan?: UserPlan;
41
+ /**
42
+ * @deprecated Pass `plan`, which can also say "trial". Still accepted:
43
+ * true reads as "pro", false as "free". One of the two is required.
44
+ */
45
+ isPaid?: boolean;
23
46
  /** RevenueCat app user id — identifies the user's threads. */
24
47
  revenuecatId: string;
25
48
  /** Host app version, e.g. "2.4.1". */
@@ -39,14 +62,19 @@ export interface SupportButtonProps {
39
62
  * Edge Functions.
40
63
  */
41
64
  supabaseUrl: string;
42
- /** Visual theme. Matches the admin dashboard tokens. Default "dark". */
43
- theme?: ThemeName;
44
65
  /**
45
- * Brand color (hex, e.g. "#FF5A1F") replacing the default accent across
46
- * the launcher, buttons, and the user's own message bubbles. The pressed
47
- * shade, the 12% wash, and a legible foreground are derived from it.
66
+ * Visual theme. Default "system": follows the host app's light/dark
67
+ * appearance (the device setting, unless the app pins one) and switches
68
+ * live when it changes. Pass "light" or "dark" to pin it.
69
+ */
70
+ theme?: ThemePreference;
71
+ /**
72
+ * Brand color replacing the default accent across the launcher, buttons,
73
+ * and the user's own message bubbles: a hex like "#FF5A1F", or
74
+ * `{ light, dark }` for a color per mode. The pressed shade, the 12% wash,
75
+ * a text-safe variant, and a legible foreground are derived from it.
48
76
  */
49
- accentColor?: string;
77
+ accentColor?: AccentColor;
50
78
  /** Launcher style. Default "solid" — the floating action button. */
51
79
  variant?: LauncherVariant;
52
80
  /** Launcher diameter in px (height when `label` is set). Default 52. */
@@ -98,6 +126,17 @@ export interface ThreadMessage {
98
126
  * the UI says so rather than letting it flicker.
99
127
  */
100
128
  translation_pending: boolean;
129
+ /**
130
+ * 'review_request': a support reply that carries a store review button.
131
+ * Absent from servers older than 0.4, where every message is text.
132
+ */
133
+ kind?: 'text' | 'review_request';
134
+ }
135
+
136
+ /** Where a review request's button points; set per app in the dashboard. */
137
+ export interface StoreListing {
138
+ app_store_id: string | null;
139
+ play_store_package: string | null;
101
140
  }
102
141
 
103
142
  /**
@@ -126,6 +165,8 @@ export interface ThreadsResponse {
126
165
  total_unread: number;
127
166
  /** Null when this user has never picked one — the SDK then asks. */
128
167
  language: string | null;
168
+ /** The app's store listings, for review requests. */
169
+ store?: StoreListing;
129
170
  }
130
171
 
131
172
  /**
@@ -137,7 +178,8 @@ export const QUERY_TYPE_LABEL: Record<QueryType, string> = {
137
178
  feedback: 'Feedback',
138
179
  feature_request: 'Feature request',
139
180
  bug_report: 'Bug report',
181
+ support: 'Support',
140
182
  };
141
183
 
142
- export const USER_MESSAGE_MIN = 10;
143
- export const USER_MESSAGE_MAX = 300;
184
+ export const USER_MESSAGE_MIN = 1;
185
+ export const USER_MESSAGE_MAX = 1000;