@appsupportkit/support-sdk 0.1.2 → 0.3.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 (224) hide show
  1. package/lib/commonjs/api.js +17 -2
  2. package/lib/commonjs/api.js.map +1 -1
  3. package/lib/commonjs/components/BottomSheet.js +67 -47
  4. package/lib/commonjs/components/BottomSheet.js.map +1 -1
  5. package/lib/commonjs/components/LauncherButton.js +177 -0
  6. package/lib/commonjs/components/LauncherButton.js.map +1 -0
  7. package/lib/commonjs/components/SupportButton.js +168 -190
  8. package/lib/commonjs/components/SupportButton.js.map +1 -1
  9. package/lib/commonjs/components/icons.js +110 -23
  10. package/lib/commonjs/components/icons.js.map +1 -1
  11. package/lib/commonjs/components/primitives.js +191 -44
  12. package/lib/commonjs/components/primitives.js.map +1 -1
  13. package/lib/commonjs/components/screens/ComposeScreen.js +108 -60
  14. package/lib/commonjs/components/screens/ComposeScreen.js.map +1 -1
  15. package/lib/commonjs/components/screens/HomeScreen.js +221 -118
  16. package/lib/commonjs/components/screens/HomeScreen.js.map +1 -1
  17. package/lib/commonjs/components/screens/LanguageScreen.js +150 -0
  18. package/lib/commonjs/components/screens/LanguageScreen.js.map +1 -0
  19. package/lib/commonjs/components/screens/ThreadScreen.js +237 -111
  20. package/lib/commonjs/components/screens/ThreadScreen.js.map +1 -1
  21. package/lib/commonjs/i18n/index.js +101 -0
  22. package/lib/commonjs/i18n/index.js.map +1 -0
  23. package/lib/commonjs/i18n/languages.js +95 -0
  24. package/lib/commonjs/i18n/languages.js.map +1 -0
  25. package/lib/commonjs/i18n/strings/de.js +72 -0
  26. package/lib/commonjs/i18n/strings/de.js.map +1 -0
  27. package/lib/commonjs/i18n/strings/en.js +88 -0
  28. package/lib/commonjs/i18n/strings/en.js.map +1 -0
  29. package/lib/commonjs/i18n/strings/es.js +72 -0
  30. package/lib/commonjs/i18n/strings/es.js.map +1 -0
  31. package/lib/commonjs/i18n/strings/fr.js +72 -0
  32. package/lib/commonjs/i18n/strings/fr.js.map +1 -0
  33. package/lib/commonjs/i18n/strings/hi.js +72 -0
  34. package/lib/commonjs/i18n/strings/hi.js.map +1 -0
  35. package/lib/commonjs/i18n/strings/it.js +72 -0
  36. package/lib/commonjs/i18n/strings/it.js.map +1 -0
  37. package/lib/commonjs/i18n/strings/ja.js +72 -0
  38. package/lib/commonjs/i18n/strings/ja.js.map +1 -0
  39. package/lib/commonjs/i18n/strings/ko.js +72 -0
  40. package/lib/commonjs/i18n/strings/ko.js.map +1 -0
  41. package/lib/commonjs/i18n/strings/nl.js +72 -0
  42. package/lib/commonjs/i18n/strings/nl.js.map +1 -0
  43. package/lib/commonjs/i18n/strings/pl.js +72 -0
  44. package/lib/commonjs/i18n/strings/pl.js.map +1 -0
  45. package/lib/commonjs/i18n/strings/pt.js +72 -0
  46. package/lib/commonjs/i18n/strings/pt.js.map +1 -0
  47. package/lib/commonjs/i18n/strings/ru.js +72 -0
  48. package/lib/commonjs/i18n/strings/ru.js.map +1 -0
  49. package/lib/commonjs/i18n/strings/tr.js +72 -0
  50. package/lib/commonjs/i18n/strings/tr.js.map +1 -0
  51. package/lib/commonjs/i18n/strings/zh.js +72 -0
  52. package/lib/commonjs/i18n/strings/zh.js.map +1 -0
  53. package/lib/commonjs/index.js +31 -0
  54. package/lib/commonjs/index.js.map +1 -1
  55. package/lib/commonjs/theme.js +136 -17
  56. package/lib/commonjs/theme.js.map +1 -1
  57. package/lib/commonjs/types.js +18 -0
  58. package/lib/commonjs/types.js.map +1 -1
  59. package/lib/commonjs/unread.js +92 -0
  60. package/lib/commonjs/unread.js.map +1 -0
  61. package/lib/commonjs/useSupportThreads.js +296 -0
  62. package/lib/commonjs/useSupportThreads.js.map +1 -0
  63. package/lib/commonjs/utils.js +58 -10
  64. package/lib/commonjs/utils.js.map +1 -1
  65. package/lib/module/api.js +16 -2
  66. package/lib/module/api.js.map +1 -1
  67. package/lib/module/components/BottomSheet.js +68 -48
  68. package/lib/module/components/BottomSheet.js.map +1 -1
  69. package/lib/module/components/LauncherButton.js +172 -0
  70. package/lib/module/components/LauncherButton.js.map +1 -0
  71. package/lib/module/components/SupportButton.js +172 -194
  72. package/lib/module/components/SupportButton.js.map +1 -1
  73. package/lib/module/components/icons.js +107 -22
  74. package/lib/module/components/icons.js.map +1 -1
  75. package/lib/module/components/primitives.js +187 -45
  76. package/lib/module/components/primitives.js.map +1 -1
  77. package/lib/module/components/screens/ComposeScreen.js +114 -64
  78. package/lib/module/components/screens/ComposeScreen.js.map +1 -1
  79. package/lib/module/components/screens/HomeScreen.js +224 -121
  80. package/lib/module/components/screens/HomeScreen.js.map +1 -1
  81. package/lib/module/components/screens/LanguageScreen.js +147 -0
  82. package/lib/module/components/screens/LanguageScreen.js.map +1 -0
  83. package/lib/module/components/screens/ThreadScreen.js +243 -115
  84. package/lib/module/components/screens/ThreadScreen.js.map +1 -1
  85. package/lib/module/i18n/index.js +56 -0
  86. package/lib/module/i18n/index.js.map +1 -0
  87. package/lib/module/i18n/languages.js +88 -0
  88. package/lib/module/i18n/languages.js.map +1 -0
  89. package/lib/module/i18n/strings/de.js +68 -0
  90. package/lib/module/i18n/strings/de.js.map +1 -0
  91. package/lib/module/i18n/strings/en.js +84 -0
  92. package/lib/module/i18n/strings/en.js.map +1 -0
  93. package/lib/module/i18n/strings/es.js +68 -0
  94. package/lib/module/i18n/strings/es.js.map +1 -0
  95. package/lib/module/i18n/strings/fr.js +68 -0
  96. package/lib/module/i18n/strings/fr.js.map +1 -0
  97. package/lib/module/i18n/strings/hi.js +68 -0
  98. package/lib/module/i18n/strings/hi.js.map +1 -0
  99. package/lib/module/i18n/strings/it.js +68 -0
  100. package/lib/module/i18n/strings/it.js.map +1 -0
  101. package/lib/module/i18n/strings/ja.js +68 -0
  102. package/lib/module/i18n/strings/ja.js.map +1 -0
  103. package/lib/module/i18n/strings/ko.js +68 -0
  104. package/lib/module/i18n/strings/ko.js.map +1 -0
  105. package/lib/module/i18n/strings/nl.js +68 -0
  106. package/lib/module/i18n/strings/nl.js.map +1 -0
  107. package/lib/module/i18n/strings/pl.js +68 -0
  108. package/lib/module/i18n/strings/pl.js.map +1 -0
  109. package/lib/module/i18n/strings/pt.js +68 -0
  110. package/lib/module/i18n/strings/pt.js.map +1 -0
  111. package/lib/module/i18n/strings/ru.js +68 -0
  112. package/lib/module/i18n/strings/ru.js.map +1 -0
  113. package/lib/module/i18n/strings/tr.js +68 -0
  114. package/lib/module/i18n/strings/tr.js.map +1 -0
  115. package/lib/module/i18n/strings/zh.js +68 -0
  116. package/lib/module/i18n/strings/zh.js.map +1 -0
  117. package/lib/module/index.js +1 -0
  118. package/lib/module/index.js.map +1 -1
  119. package/lib/module/theme.js +135 -16
  120. package/lib/module/theme.js.map +1 -1
  121. package/lib/module/types.js +18 -0
  122. package/lib/module/types.js.map +1 -1
  123. package/lib/module/unread.js +83 -0
  124. package/lib/module/unread.js.map +1 -0
  125. package/lib/module/useSupportThreads.js +291 -0
  126. package/lib/module/useSupportThreads.js.map +1 -0
  127. package/lib/module/utils.js +55 -10
  128. package/lib/module/utils.js.map +1 -1
  129. package/lib/typescript/api.d.ts +9 -0
  130. package/lib/typescript/api.d.ts.map +1 -1
  131. package/lib/typescript/components/BottomSheet.d.ts.map +1 -1
  132. package/lib/typescript/components/LauncherButton.d.ts +14 -0
  133. package/lib/typescript/components/LauncherButton.d.ts.map +1 -0
  134. package/lib/typescript/components/SupportButton.d.ts +3 -3
  135. package/lib/typescript/components/SupportButton.d.ts.map +1 -1
  136. package/lib/typescript/components/icons.d.ts +18 -2
  137. package/lib/typescript/components/icons.d.ts.map +1 -1
  138. package/lib/typescript/components/primitives.d.ts +42 -7
  139. package/lib/typescript/components/primitives.d.ts.map +1 -1
  140. package/lib/typescript/components/screens/ComposeScreen.d.ts +3 -1
  141. package/lib/typescript/components/screens/ComposeScreen.d.ts.map +1 -1
  142. package/lib/typescript/components/screens/HomeScreen.d.ts +8 -2
  143. package/lib/typescript/components/screens/HomeScreen.d.ts.map +1 -1
  144. package/lib/typescript/components/screens/LanguageScreen.d.ts +30 -0
  145. package/lib/typescript/components/screens/LanguageScreen.d.ts.map +1 -0
  146. package/lib/typescript/components/screens/ThreadScreen.d.ts +3 -1
  147. package/lib/typescript/components/screens/ThreadScreen.d.ts.map +1 -1
  148. package/lib/typescript/i18n/index.d.ts +15 -0
  149. package/lib/typescript/i18n/index.d.ts.map +1 -0
  150. package/lib/typescript/i18n/languages.d.ts +36 -0
  151. package/lib/typescript/i18n/languages.d.ts.map +1 -0
  152. package/lib/typescript/i18n/strings/de.d.ts +3 -0
  153. package/lib/typescript/i18n/strings/de.d.ts.map +1 -0
  154. package/lib/typescript/i18n/strings/en.d.ts +83 -0
  155. package/lib/typescript/i18n/strings/en.d.ts.map +1 -0
  156. package/lib/typescript/i18n/strings/es.d.ts +3 -0
  157. package/lib/typescript/i18n/strings/es.d.ts.map +1 -0
  158. package/lib/typescript/i18n/strings/fr.d.ts +3 -0
  159. package/lib/typescript/i18n/strings/fr.d.ts.map +1 -0
  160. package/lib/typescript/i18n/strings/hi.d.ts +3 -0
  161. package/lib/typescript/i18n/strings/hi.d.ts.map +1 -0
  162. package/lib/typescript/i18n/strings/it.d.ts +3 -0
  163. package/lib/typescript/i18n/strings/it.d.ts.map +1 -0
  164. package/lib/typescript/i18n/strings/ja.d.ts +3 -0
  165. package/lib/typescript/i18n/strings/ja.d.ts.map +1 -0
  166. package/lib/typescript/i18n/strings/ko.d.ts +3 -0
  167. package/lib/typescript/i18n/strings/ko.d.ts.map +1 -0
  168. package/lib/typescript/i18n/strings/nl.d.ts +3 -0
  169. package/lib/typescript/i18n/strings/nl.d.ts.map +1 -0
  170. package/lib/typescript/i18n/strings/pl.d.ts +3 -0
  171. package/lib/typescript/i18n/strings/pl.d.ts.map +1 -0
  172. package/lib/typescript/i18n/strings/pt.d.ts +3 -0
  173. package/lib/typescript/i18n/strings/pt.d.ts.map +1 -0
  174. package/lib/typescript/i18n/strings/ru.d.ts +3 -0
  175. package/lib/typescript/i18n/strings/ru.d.ts.map +1 -0
  176. package/lib/typescript/i18n/strings/tr.d.ts +3 -0
  177. package/lib/typescript/i18n/strings/tr.d.ts.map +1 -0
  178. package/lib/typescript/i18n/strings/zh.d.ts +3 -0
  179. package/lib/typescript/i18n/strings/zh.d.ts.map +1 -0
  180. package/lib/typescript/index.d.ts +2 -1
  181. package/lib/typescript/index.d.ts.map +1 -1
  182. package/lib/typescript/theme.d.ts +44 -3
  183. package/lib/typescript/theme.d.ts.map +1 -1
  184. package/lib/typescript/types.d.ts +54 -2
  185. package/lib/typescript/types.d.ts.map +1 -1
  186. package/lib/typescript/unread.d.ts +22 -0
  187. package/lib/typescript/unread.d.ts.map +1 -0
  188. package/lib/typescript/useSupportThreads.d.ts +38 -0
  189. package/lib/typescript/useSupportThreads.d.ts.map +1 -0
  190. package/lib/typescript/utils.d.ts +8 -2
  191. package/lib/typescript/utils.d.ts.map +1 -1
  192. package/package.json +3 -3
  193. package/src/api.ts +24 -1
  194. package/src/components/BottomSheet.tsx +61 -43
  195. package/src/components/LauncherButton.tsx +217 -0
  196. package/src/components/SupportButton.tsx +195 -210
  197. package/src/components/icons.tsx +113 -19
  198. package/src/components/primitives.tsx +230 -39
  199. package/src/components/screens/ComposeScreen.tsx +129 -63
  200. package/src/components/screens/HomeScreen.tsx +230 -120
  201. package/src/components/screens/LanguageScreen.tsx +172 -0
  202. package/src/components/screens/ThreadScreen.tsx +319 -122
  203. package/src/i18n/index.ts +81 -0
  204. package/src/i18n/languages.ts +106 -0
  205. package/src/i18n/strings/de.ts +78 -0
  206. package/src/i18n/strings/en.ts +94 -0
  207. package/src/i18n/strings/es.ts +77 -0
  208. package/src/i18n/strings/fr.ts +78 -0
  209. package/src/i18n/strings/hi.ts +77 -0
  210. package/src/i18n/strings/it.ts +77 -0
  211. package/src/i18n/strings/ja.ts +77 -0
  212. package/src/i18n/strings/ko.ts +76 -0
  213. package/src/i18n/strings/nl.ts +77 -0
  214. package/src/i18n/strings/pl.ts +77 -0
  215. package/src/i18n/strings/pt.ts +77 -0
  216. package/src/i18n/strings/ru.ts +77 -0
  217. package/src/i18n/strings/tr.ts +77 -0
  218. package/src/i18n/strings/zh.ts +75 -0
  219. package/src/index.tsx +9 -0
  220. package/src/theme.ts +165 -18
  221. package/src/types.ts +56 -2
  222. package/src/unread.ts +91 -0
  223. package/src/useSupportThreads.ts +392 -0
  224. package/src/utils.ts +48 -10
package/src/theme.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // The SDK consumes the same design tokens as the admin dashboard, so both
2
- // surfaces look like one product.
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
5
  import {
4
6
  darkColors,
5
7
  lightColors,
@@ -8,32 +10,177 @@ import {
8
10
  spacing,
9
11
  type ThemeColors,
10
12
  } from '@appsupportkit/design-tokens';
13
+ import type { ViewStyle } from 'react-native';
11
14
  import type { ThemeName } from './types';
12
15
 
16
+ /** Rounder than the dashboard: touch targets read better with more radius. */
17
+ export const supportRadii = {
18
+ ...radii,
19
+ control: 10,
20
+ card: 14,
21
+ cardLg: 18,
22
+ /** Message bubbles. */
23
+ bubble: 18,
24
+ /** The tight corner on a bubble's sender side. */
25
+ bubbleTail: 6,
26
+ sheet: 24,
27
+ pill: 999,
28
+ } as const;
29
+
30
+ export interface SupportShadows {
31
+ /** Floating launcher button. */
32
+ launcher: ViewStyle;
33
+ /** The bottom sheet itself. */
34
+ sheet: ViewStyle;
35
+ /** Unread badge, so it lifts off the button. */
36
+ badge: ViewStyle;
37
+ }
38
+
39
+ /**
40
+ * The launcher sits on the host app's own background rather than on an SDK
41
+ * surface, so the two themes need different treatment: on near-black a
42
+ * solid fill needs a hairline to separate from the background and a black
43
+ * shadow, while on white the accent-tinted shadow does that work and a
44
+ * borderless mark has to be darkened to stay legible.
45
+ */
46
+ export interface LauncherPalette {
47
+ /** Hairline drawn over the solid fill. */
48
+ solidRing: string;
49
+ /** Mark color for the borderless (minimal) variant. */
50
+ minimalMark: string;
51
+ }
52
+
13
53
  export interface SupportTheme {
14
54
  name: ThemeName;
15
55
  colors: ThemeColors;
16
56
  spacing: typeof spacing;
17
- radii: typeof radii;
57
+ radii: typeof supportRadii;
18
58
  motion: typeof motion;
59
+ shadows: SupportShadows;
60
+ launcher: LauncherPalette;
19
61
  }
20
62
 
21
- export const supportDarkTheme: SupportTheme = {
22
- name: 'dark',
23
- colors: darkColors,
24
- spacing,
25
- radii,
26
- motion,
27
- };
63
+ // Color helpers come first: the themes below are built at module load, so
64
+ // anything they reach for has to already be initialized. (A `const` used
65
+ // before its definition survives tsc but lands as `undefined` on Hermes.)
66
+ const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
28
67
 
29
- export const supportLightTheme: SupportTheme = {
30
- name: 'light',
31
- colors: lightColors,
32
- spacing,
33
- radii,
34
- motion,
35
- };
68
+ function parseHex(color: string): [number, number, number] | null {
69
+ const match = HEX.exec(color.trim());
70
+ const digits = match?.[1];
71
+ if (digits === undefined) return null;
72
+ const hex =
73
+ digits.length === 3
74
+ ? digits
75
+ .split('')
76
+ .map((c) => c + c)
77
+ .join('')
78
+ : digits;
79
+ return [
80
+ parseInt(hex.slice(0, 2), 16),
81
+ parseInt(hex.slice(2, 4), 16),
82
+ parseInt(hex.slice(4, 6), 16),
83
+ ];
84
+ }
85
+
86
+ function mix([r, g, b]: [number, number, number], target: number, amount: number) {
87
+ const channel = (c: number) => Math.round(c + (target - c) * amount);
88
+ return `rgb(${channel(r)}, ${channel(g)}, ${channel(b)})`;
89
+ }
90
+
91
+ function shadowsFor(name: ThemeName, accent: string): SupportShadows {
92
+ const dark = name === 'dark';
93
+ return {
94
+ launcher: {
95
+ // Tinting the launcher's shadow with the accent is what makes it read
96
+ // as lifted rather than pasted on.
97
+ shadowColor: dark ? '#000000' : accent,
98
+ shadowOpacity: dark ? 0.45 : 0.3,
99
+ shadowRadius: 14,
100
+ shadowOffset: { width: 0, height: 6 },
101
+ elevation: 8,
102
+ },
103
+ sheet: {
104
+ shadowColor: '#000000',
105
+ shadowOpacity: dark ? 0.5 : 0.16,
106
+ shadowRadius: 24,
107
+ shadowOffset: { width: 0, height: -6 },
108
+ elevation: 24,
109
+ },
110
+ badge: {
111
+ shadowColor: '#000000',
112
+ shadowOpacity: 0.25,
113
+ shadowRadius: 3,
114
+ shadowOffset: { width: 0, height: 1 },
115
+ elevation: 3,
116
+ },
117
+ };
118
+ }
119
+
120
+ function launcherFor(name: ThemeName, accent: string): LauncherPalette {
121
+ const rgb = parseHex(accent);
122
+ return {
123
+ solidRing:
124
+ name === 'dark' ? 'rgba(255, 255, 255, 0.14)' : 'rgba(0, 0, 0, 0.06)',
125
+ // On a dark app background the raw accent is too close to the surface
126
+ // to read as a glyph; lift it toward white.
127
+ minimalMark:
128
+ name === 'dark' && rgb !== null ? mix(rgb, 255, 0.35) : accent,
129
+ };
130
+ }
131
+
132
+ function buildTheme(name: ThemeName, colors: ThemeColors): SupportTheme {
133
+ return {
134
+ name,
135
+ colors,
136
+ spacing,
137
+ radii: supportRadii,
138
+ motion,
139
+ shadows: shadowsFor(name, colors.accent),
140
+ launcher: launcherFor(name, colors.accent),
141
+ };
142
+ }
143
+
144
+ export const supportDarkTheme: SupportTheme = buildTheme('dark', darkColors);
145
+ export const supportLightTheme: SupportTheme = buildTheme('light', lightColors);
146
+
147
+ /**
148
+ * 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).
151
+ */
152
+ function withAccent(theme: SupportTheme, accent: string): SupportTheme {
153
+ const rgb = parseHex(accent);
154
+ if (!rgb) return theme;
155
+ 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 = {
159
+ ...theme.colors,
160
+ accent,
161
+ accentHover: mix(rgb, theme.name === 'dark' ? 255 : 0, 0.12),
162
+ accentMuted: `rgba(${r}, ${g}, ${b}, 0.12)`,
163
+ accentForeground: luminance > 0.62 ? '#111113' : '#FFFFFF',
164
+ statusOpen: accent,
165
+ statusOpenBg: `rgba(${r}, ${g}, ${b}, 0.10)`,
166
+ focusRing: accent,
167
+ };
168
+ return {
169
+ ...theme,
170
+ colors,
171
+ shadows: shadowsFor(theme.name, accent),
172
+ launcher: launcherFor(theme.name, accent),
173
+ };
174
+ }
36
175
 
37
- export function resolveTheme(name: ThemeName | undefined): SupportTheme {
38
- return name === 'light' ? supportLightTheme : supportDarkTheme;
176
+ /**
177
+ * @param name "light" | "dark" (default dark).
178
+ * @param accentColor Optional brand color (hex) replacing the default accent.
179
+ */
180
+ export function resolveTheme(
181
+ name: ThemeName | undefined,
182
+ accentColor?: string,
183
+ ): SupportTheme {
184
+ const base = name === 'light' ? supportLightTheme : supportDarkTheme;
185
+ return accentColor ? withAccent(base, accentColor) : base;
39
186
  }
package/src/types.ts CHANGED
@@ -1,9 +1,20 @@
1
+ import type { StyleProp, ViewStyle } from 'react-native';
2
+ import type { Language } from './i18n/languages';
3
+
1
4
  export type QueryType = 'feedback' | 'feature_request' | 'bug_report';
2
5
  export type QueryStatus = 'open' | 'resolved';
3
6
  export type MessageSender = 'user' | 'admin';
4
7
  export type ThemeName = 'dark' | 'light';
5
8
  export type PushPermissionStatus = 'undetermined' | 'granted' | 'denied';
6
9
 
10
+ /**
11
+ * How the launcher renders:
12
+ * solid — accent fill, elevated. The floating action button.
13
+ * soft — app surface + hairline border. Sits in a header or toolbar.
14
+ * minimal — bare mark, no fill or border. Sits in a nav bar.
15
+ */
16
+ export type LauncherVariant = 'solid' | 'soft' | 'minimal';
17
+
7
18
  export interface SupportUser {
8
19
  /** Display name shown to the support team. */
9
20
  name: string;
@@ -30,6 +41,20 @@ export interface SupportButtonProps {
30
41
  supabaseUrl: string;
31
42
  /** Visual theme. Matches the admin dashboard tokens. Default "dark". */
32
43
  theme?: ThemeName;
44
+ /**
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.
48
+ */
49
+ accentColor?: string;
50
+ /** Launcher style. Default "solid" — the floating action button. */
51
+ variant?: LauncherVariant;
52
+ /** Launcher diameter in px (height when `label` is set). Default 52. */
53
+ size?: number;
54
+ /** Renders the launcher as a pill with this text beside the mark. */
55
+ label?: string;
56
+ /** Style applied to the launcher — position it, add margins, etc. */
57
+ style?: StyleProp<ViewStyle>;
33
58
  /**
34
59
  * Idle poll interval (ms) — how often the unread badge is refreshed while
35
60
  * the sheet is CLOSED, using a lightweight count-only endpoint. Default
@@ -47,23 +72,45 @@ export interface SupportButtonProps {
47
72
  * installed — it is an optional peer dependency.
48
73
  */
49
74
  expoProjectId?: string;
75
+ /**
76
+ * Pre-selects a language in the picker the first time a user opens support.
77
+ * Pass it when the host app already knows what the user reads — an in-app
78
+ * language setting beats the device locale, which is what the SDK falls
79
+ * back to. It is a default, not an override: the user still chooses, and a
80
+ * choice they have already made always wins.
81
+ */
82
+ defaultLanguage?: Language;
50
83
  }
51
84
 
52
85
  export interface ThreadMessage {
53
86
  id: string;
54
87
  sender: MessageSender;
88
+ /**
89
+ * Already in the user's language: their own messages as they wrote them,
90
+ * support replies translated. The server does the choosing — see the
91
+ * get-threads function.
92
+ */
55
93
  body: string;
56
94
  created_at: string;
57
- admin_name: string | null;
95
+ /**
96
+ * A support reply whose translation hasn't landed yet. `body` currently
97
+ * holds the English original; it will change under the reader shortly, so
98
+ * the UI says so rather than letting it flicker.
99
+ */
100
+ translation_pending: boolean;
58
101
  }
59
102
 
103
+ /**
104
+ * Note the absence of any admin identity: end users see one support team,
105
+ * not the people staffing it, so the names never leave the server. The
106
+ * dashboard still shows who wrote and who resolved what.
107
+ */
60
108
  export interface Thread {
61
109
  id: string;
62
110
  type: QueryType;
63
111
  status: QueryStatus;
64
112
  created_at: string;
65
113
  resolved_at: string | null;
66
- resolved_by_name: string | null;
67
114
  app_version: string;
68
115
  unread_count: number;
69
116
  last_message: {
@@ -77,8 +124,15 @@ export interface Thread {
77
124
  export interface ThreadsResponse {
78
125
  threads: Thread[];
79
126
  total_unread: number;
127
+ /** Null when this user has never picked one — the SDK then asks. */
128
+ language: string | null;
80
129
  }
81
130
 
131
+ /**
132
+ * English labels, kept for host apps that render their own entry points.
133
+ * Inside the sheet the labels come from the string table instead, so they
134
+ * follow the user's language.
135
+ */
82
136
  export const QUERY_TYPE_LABEL: Record<QueryType, string> = {
83
137
  feedback: 'Feedback',
84
138
  feature_request: 'Feature request',
package/src/unread.ts ADDED
@@ -0,0 +1,91 @@
1
+ // Unread accounting.
2
+ //
3
+ // The server counts a thread's unread as "admin messages newer than the
4
+ // user's read receipt". That number is authoritative, but it is always a
5
+ // little behind the device: between the moment the user opens a thread and
6
+ // the moment `mark-read` lands, every already-in-flight poll still reports
7
+ // the old count — which is what made the badge flicker back on after being
8
+ // cleared, and what made a thread the user was actively reading accumulate
9
+ // unread replies.
10
+ //
11
+ // So the client keeps its own watermark per thread: the `created_at` of the
12
+ // newest admin message the user has actually seen. Those are *server*
13
+ // timestamps, so nothing here compares against the device clock.
14
+ //
15
+ // Displayed unread is then the stricter of the two counts, which is correct
16
+ // in both directions:
17
+ //
18
+ // read here, receipt not written yet → local watermark wins (0)
19
+ // read on another device → server count wins (0)
20
+
21
+ import type { Thread } from './types';
22
+
23
+ /** Watermark map: thread id → `created_at` (ms) of the newest seen reply. */
24
+ export type ReadWatermarks = Map<string, number>;
25
+
26
+ /** `created_at` (ms) of the newest admin message, or 0 if there are none. */
27
+ export function latestAdminMessageAt(thread: Thread): number {
28
+ let latest = 0;
29
+ for (const message of thread.messages) {
30
+ if (message.sender !== 'admin') continue;
31
+ const at = Date.parse(message.created_at);
32
+ if (Number.isFinite(at) && at > latest) latest = at;
33
+ }
34
+ return latest;
35
+ }
36
+
37
+ /** Admin messages in `thread` newer than `watermark`. */
38
+ export function unreadSince(thread: Thread, watermark: number): number {
39
+ let count = 0;
40
+ for (const message of thread.messages) {
41
+ if (message.sender !== 'admin') continue;
42
+ const at = Date.parse(message.created_at);
43
+ if (Number.isFinite(at) && at > watermark) count += 1;
44
+ }
45
+ return count;
46
+ }
47
+
48
+ /**
49
+ * Returns `thread` with `unread_count` narrowed by what this device knows
50
+ * the user has read. Identity is preserved when nothing changes so lists
51
+ * stay referentially stable between polls.
52
+ */
53
+ export function reconcileThread(
54
+ thread: Thread,
55
+ watermarks: ReadWatermarks,
56
+ ): Thread {
57
+ const watermark = watermarks.get(thread.id);
58
+ if (watermark === undefined || thread.unread_count === 0) return thread;
59
+ const unread = Math.min(thread.unread_count, unreadSince(thread, watermark));
60
+ return unread === thread.unread_count ? thread : { ...thread, unread_count: unread };
61
+ }
62
+
63
+ export function reconcileThreads(
64
+ threads: Thread[],
65
+ watermarks: ReadWatermarks,
66
+ ): Thread[] {
67
+ return threads.map((thread) => reconcileThread(thread, watermarks));
68
+ }
69
+
70
+ export function sumUnread(threads: Thread[]): number {
71
+ return threads.reduce((total, thread) => total + thread.unread_count, 0);
72
+ }
73
+
74
+ /**
75
+ * Drops watermarks the server has caught up on (its own count is already 0)
76
+ * and ones for threads that no longer exist, so the map can't grow without
77
+ * bound over a long session.
78
+ */
79
+ export function pruneWatermarks(
80
+ watermarks: ReadWatermarks,
81
+ serverThreads: Thread[],
82
+ ): ReadWatermarks {
83
+ const keep: ReadWatermarks = new Map();
84
+ for (const thread of serverThreads) {
85
+ const watermark = watermarks.get(thread.id);
86
+ if (watermark !== undefined && thread.unread_count > 0) {
87
+ keep.set(thread.id, watermark);
88
+ }
89
+ }
90
+ return keep;
91
+ }