@appsupportkit/support-sdk 0.1.2 → 0.2.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 (94) hide show
  1. package/lib/commonjs/components/BottomSheet.js +67 -47
  2. package/lib/commonjs/components/BottomSheet.js.map +1 -1
  3. package/lib/commonjs/components/LauncherButton.js +177 -0
  4. package/lib/commonjs/components/LauncherButton.js.map +1 -0
  5. package/lib/commonjs/components/SupportButton.js +54 -176
  6. package/lib/commonjs/components/SupportButton.js.map +1 -1
  7. package/lib/commonjs/components/icons.js +74 -23
  8. package/lib/commonjs/components/icons.js.map +1 -1
  9. package/lib/commonjs/components/primitives.js +183 -38
  10. package/lib/commonjs/components/primitives.js.map +1 -1
  11. package/lib/commonjs/components/screens/ComposeScreen.js +115 -58
  12. package/lib/commonjs/components/screens/ComposeScreen.js.map +1 -1
  13. package/lib/commonjs/components/screens/HomeScreen.js +170 -115
  14. package/lib/commonjs/components/screens/HomeScreen.js.map +1 -1
  15. package/lib/commonjs/components/screens/ThreadScreen.js +222 -108
  16. package/lib/commonjs/components/screens/ThreadScreen.js.map +1 -1
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/theme.js +136 -17
  19. package/lib/commonjs/theme.js.map +1 -1
  20. package/lib/commonjs/types.js +7 -0
  21. package/lib/commonjs/types.js.map +1 -1
  22. package/lib/commonjs/unread.js +92 -0
  23. package/lib/commonjs/unread.js.map +1 -0
  24. package/lib/commonjs/useSupportThreads.js +286 -0
  25. package/lib/commonjs/useSupportThreads.js.map +1 -0
  26. package/lib/commonjs/utils.js +33 -0
  27. package/lib/commonjs/utils.js.map +1 -1
  28. package/lib/module/components/BottomSheet.js +68 -48
  29. package/lib/module/components/BottomSheet.js.map +1 -1
  30. package/lib/module/components/LauncherButton.js +172 -0
  31. package/lib/module/components/LauncherButton.js.map +1 -0
  32. package/lib/module/components/SupportButton.js +57 -179
  33. package/lib/module/components/SupportButton.js.map +1 -1
  34. package/lib/module/components/icons.js +72 -22
  35. package/lib/module/components/icons.js.map +1 -1
  36. package/lib/module/components/primitives.js +179 -39
  37. package/lib/module/components/primitives.js.map +1 -1
  38. package/lib/module/components/screens/ComposeScreen.js +118 -61
  39. package/lib/module/components/screens/ComposeScreen.js.map +1 -1
  40. package/lib/module/components/screens/HomeScreen.js +172 -117
  41. package/lib/module/components/screens/HomeScreen.js.map +1 -1
  42. package/lib/module/components/screens/ThreadScreen.js +228 -112
  43. package/lib/module/components/screens/ThreadScreen.js.map +1 -1
  44. package/lib/module/index.js.map +1 -1
  45. package/lib/module/theme.js +135 -16
  46. package/lib/module/theme.js.map +1 -1
  47. package/lib/module/types.js +7 -0
  48. package/lib/module/types.js.map +1 -1
  49. package/lib/module/unread.js +83 -0
  50. package/lib/module/unread.js.map +1 -0
  51. package/lib/module/useSupportThreads.js +281 -0
  52. package/lib/module/useSupportThreads.js.map +1 -0
  53. package/lib/module/utils.js +30 -0
  54. package/lib/module/utils.js.map +1 -1
  55. package/lib/typescript/components/BottomSheet.d.ts.map +1 -1
  56. package/lib/typescript/components/LauncherButton.d.ts +14 -0
  57. package/lib/typescript/components/LauncherButton.d.ts.map +1 -0
  58. package/lib/typescript/components/SupportButton.d.ts +3 -3
  59. package/lib/typescript/components/SupportButton.d.ts.map +1 -1
  60. package/lib/typescript/components/icons.d.ts +13 -2
  61. package/lib/typescript/components/icons.d.ts.map +1 -1
  62. package/lib/typescript/components/primitives.d.ts +35 -4
  63. package/lib/typescript/components/primitives.d.ts.map +1 -1
  64. package/lib/typescript/components/screens/ComposeScreen.d.ts.map +1 -1
  65. package/lib/typescript/components/screens/HomeScreen.d.ts +3 -2
  66. package/lib/typescript/components/screens/HomeScreen.d.ts.map +1 -1
  67. package/lib/typescript/components/screens/ThreadScreen.d.ts.map +1 -1
  68. package/lib/typescript/index.d.ts +1 -1
  69. package/lib/typescript/index.d.ts.map +1 -1
  70. package/lib/typescript/theme.d.ts +44 -3
  71. package/lib/typescript/theme.d.ts.map +1 -1
  72. package/lib/typescript/types.d.ts +22 -0
  73. package/lib/typescript/types.d.ts.map +1 -1
  74. package/lib/typescript/unread.d.ts +22 -0
  75. package/lib/typescript/unread.d.ts.map +1 -0
  76. package/lib/typescript/useSupportThreads.d.ts +28 -0
  77. package/lib/typescript/useSupportThreads.d.ts.map +1 -0
  78. package/lib/typescript/utils.d.ts +5 -0
  79. package/lib/typescript/utils.d.ts.map +1 -1
  80. package/package.json +2 -2
  81. package/src/components/BottomSheet.tsx +61 -43
  82. package/src/components/LauncherButton.tsx +217 -0
  83. package/src/components/SupportButton.tsx +52 -196
  84. package/src/components/icons.tsx +77 -19
  85. package/src/components/primitives.tsx +217 -36
  86. package/src/components/screens/ComposeScreen.tsx +131 -61
  87. package/src/components/screens/HomeScreen.tsx +168 -117
  88. package/src/components/screens/ThreadScreen.tsx +289 -119
  89. package/src/index.tsx +1 -0
  90. package/src/theme.ts +165 -18
  91. package/src/types.ts +24 -0
  92. package/src/unread.ts +91 -0
  93. package/src/useSupportThreads.ts +372 -0
  94. package/src/utils.ts +33 -0
@@ -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,+BAwIA"}
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+IrD,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,OAAO,EACP,OAAO,EACP,UAAU,EACV,KAAK,EACL,SAAS,EACT,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,UAAU,EAAE,OAAO,CAAC;IACpB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,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,+BAyHA"}
@@ -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,gEAAgE;IAChE,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CACrD,+BAiMA"}
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,EAAiB,MAAM,aAAa,CAAC;AA2KzD,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,+BA4MA"}
@@ -2,6 +2,6 @@ export { SupportButton } from './components/SupportButton';
2
2
  export { supportDarkTheme, supportLightTheme, resolveTheme, type SupportTheme, } from './theme';
3
3
  export { SupportApiError } from './api';
4
4
  export { isPushAvailable } from './push';
5
- export type { MessageSender, PushPermissionStatus, QueryStatus, QueryType, SupportButtonProps, SupportUser, Thread, ThreadMessage, ThreadsResponse, ThemeName, } from './types';
5
+ export type { LauncherVariant, MessageSender, PushPermissionStatus, QueryStatus, QueryType, SupportButtonProps, SupportUser, Thread, ThreadMessage, ThreadsResponse, ThemeName, } from './types';
6
6
  export { QUERY_TYPE_LABEL, USER_MESSAGE_MAX, USER_MESSAGE_MIN, } from './types';
7
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,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"}
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,eAAe,EACf,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"}
@@ -1,13 +1,54 @@
1
- import { motion, radii, spacing, type ThemeColors } from '@appsupportkit/design-tokens';
1
+ import { motion, spacing, type ThemeColors } from '@appsupportkit/design-tokens';
2
+ import type { ViewStyle } from 'react-native';
2
3
  import type { ThemeName } from './types';
4
+ /** Rounder than the dashboard: touch targets read better with more radius. */
5
+ export declare const supportRadii: {
6
+ readonly control: 10;
7
+ readonly card: 14;
8
+ readonly cardLg: 18;
9
+ /** Message bubbles. */
10
+ readonly bubble: 18;
11
+ /** The tight corner on a bubble's sender side. */
12
+ readonly bubbleTail: 6;
13
+ readonly sheet: 24;
14
+ readonly pill: 999;
15
+ readonly full: 9999;
16
+ };
17
+ export interface SupportShadows {
18
+ /** Floating launcher button. */
19
+ launcher: ViewStyle;
20
+ /** The bottom sheet itself. */
21
+ sheet: ViewStyle;
22
+ /** Unread badge, so it lifts off the button. */
23
+ badge: ViewStyle;
24
+ }
25
+ /**
26
+ * The launcher sits on the host app's own background rather than on an SDK
27
+ * surface, so the two themes need different treatment: on near-black a
28
+ * solid fill needs a hairline to separate from the background and a black
29
+ * shadow, while on white the accent-tinted shadow does that work and a
30
+ * borderless mark has to be darkened to stay legible.
31
+ */
32
+ export interface LauncherPalette {
33
+ /** Hairline drawn over the solid fill. */
34
+ solidRing: string;
35
+ /** Mark color for the borderless (minimal) variant. */
36
+ minimalMark: string;
37
+ }
3
38
  export interface SupportTheme {
4
39
  name: ThemeName;
5
40
  colors: ThemeColors;
6
41
  spacing: typeof spacing;
7
- radii: typeof radii;
42
+ radii: typeof supportRadii;
8
43
  motion: typeof motion;
44
+ shadows: SupportShadows;
45
+ launcher: LauncherPalette;
9
46
  }
10
47
  export declare const supportDarkTheme: SupportTheme;
11
48
  export declare const supportLightTheme: SupportTheme;
12
- export declare function resolveTheme(name: ThemeName | undefined): SupportTheme;
49
+ /**
50
+ * @param name "light" | "dark" (default dark).
51
+ * @param accentColor Optional brand color (hex) replacing the default accent.
52
+ */
53
+ export declare function resolveTheme(name: ThemeName | undefined, accentColor?: string): SupportTheme;
13
54
  //# sourceMappingURL=theme.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../src/theme.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,MAAM,EACN,KAAK,EACL,OAAO,EACP,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEzC,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,WAAW,CAAC;IACpB,OAAO,EAAE,OAAO,OAAO,CAAC;IACxB,KAAK,EAAE,OAAO,KAAK,CAAC;IACpB,MAAM,EAAE,OAAO,MAAM,CAAC;CACvB;AAED,eAAO,MAAM,gBAAgB,EAAE,YAM9B,CAAC;AAEF,eAAO,MAAM,iBAAiB,EAAE,YAM/B,CAAC;AAEF,wBAAgB,YAAY,CAAC,IAAI,EAAE,SAAS,GAAG,SAAS,GAAG,YAAY,CAEtE"}
1
+ {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../../src/theme.ts"],"names":[],"mappings":"AAIA,OAAO,EAGL,MAAM,EAEN,OAAO,EACP,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEzC,8EAA8E;AAC9E,eAAO,MAAM,YAAY;;;;IAKvB,uBAAuB;;IAEvB,kDAAkD;;;;;CAI1C,CAAC;AAEX,MAAM,WAAW,cAAc;IAC7B,gCAAgC;IAChC,QAAQ,EAAE,SAAS,CAAC;IACpB,+BAA+B;IAC/B,KAAK,EAAE,SAAS,CAAC;IACjB,gDAAgD;IAChD,KAAK,EAAE,SAAS,CAAC;CAClB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,eAAe;IAC9B,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,WAAW,CAAC;IACpB,OAAO,EAAE,OAAO,OAAO,CAAC;IACxB,KAAK,EAAE,OAAO,YAAY,CAAC;IAC3B,MAAM,EAAE,OAAO,MAAM,CAAC;IACtB,OAAO,EAAE,cAAc,CAAC;IACxB,QAAQ,EAAE,eAAe,CAAC;CAC3B;AAmFD,eAAO,MAAM,gBAAgB,EAAE,YAA6C,CAAC;AAC7E,eAAO,MAAM,iBAAiB,EAAE,YAA+C,CAAC;AA+BhF;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,SAAS,GAAG,SAAS,EAC3B,WAAW,CAAC,EAAE,MAAM,GACnB,YAAY,CAGd"}
@@ -1,8 +1,16 @@
1
+ import type { StyleProp, ViewStyle } from 'react-native';
1
2
  export type QueryType = 'feedback' | 'feature_request' | 'bug_report';
2
3
  export type QueryStatus = 'open' | 'resolved';
3
4
  export type MessageSender = 'user' | 'admin';
4
5
  export type ThemeName = 'dark' | 'light';
5
6
  export type PushPermissionStatus = 'undetermined' | 'granted' | 'denied';
7
+ /**
8
+ * How the launcher renders:
9
+ * solid — accent fill, elevated. The floating action button.
10
+ * soft — app surface + hairline border. Sits in a header or toolbar.
11
+ * minimal — bare mark, no fill or border. Sits in a nav bar.
12
+ */
13
+ export type LauncherVariant = 'solid' | 'soft' | 'minimal';
6
14
  export interface SupportUser {
7
15
  /** Display name shown to the support team. */
8
16
  name: string;
@@ -28,6 +36,20 @@ export interface SupportButtonProps {
28
36
  supabaseUrl: string;
29
37
  /** Visual theme. Matches the admin dashboard tokens. Default "dark". */
30
38
  theme?: ThemeName;
39
+ /**
40
+ * Brand color (hex, e.g. "#FF5A1F") replacing the default accent across
41
+ * the launcher, buttons, and the user's own message bubbles. The pressed
42
+ * shade, the 12% wash, and a legible foreground are derived from it.
43
+ */
44
+ accentColor?: string;
45
+ /** Launcher style. Default "solid" — the floating action button. */
46
+ variant?: LauncherVariant;
47
+ /** Launcher diameter in px (height when `label` is set). Default 52. */
48
+ size?: number;
49
+ /** Renders the launcher as a pill with this text beside the mark. */
50
+ label?: string;
51
+ /** Style applied to the launcher — position it, add margins, etc. */
52
+ style?: StyleProp<ViewStyle>;
31
53
  /**
32
54
  * Idle poll interval (ms) — how often the unread badge is refreshed while
33
55
  * the sheet is CLOSED, using a lightweight count-only endpoint. Default
@@ -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;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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzD,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;;;;;GAKG;AACH,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,MAAM,GAAG,SAAS,CAAC;AAE3D,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;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oEAAoE;IACpE,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B;;;;;;;;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"}
@@ -0,0 +1,22 @@
1
+ import type { Thread } from './types';
2
+ /** Watermark map: thread id → `created_at` (ms) of the newest seen reply. */
3
+ export type ReadWatermarks = Map<string, number>;
4
+ /** `created_at` (ms) of the newest admin message, or 0 if there are none. */
5
+ export declare function latestAdminMessageAt(thread: Thread): number;
6
+ /** Admin messages in `thread` newer than `watermark`. */
7
+ export declare function unreadSince(thread: Thread, watermark: number): number;
8
+ /**
9
+ * Returns `thread` with `unread_count` narrowed by what this device knows
10
+ * the user has read. Identity is preserved when nothing changes so lists
11
+ * stay referentially stable between polls.
12
+ */
13
+ export declare function reconcileThread(thread: Thread, watermarks: ReadWatermarks): Thread;
14
+ export declare function reconcileThreads(threads: Thread[], watermarks: ReadWatermarks): Thread[];
15
+ export declare function sumUnread(threads: Thread[]): number;
16
+ /**
17
+ * Drops watermarks the server has caught up on (its own count is already 0)
18
+ * and ones for threads that no longer exist, so the map can't grow without
19
+ * bound over a long session.
20
+ */
21
+ export declare function pruneWatermarks(watermarks: ReadWatermarks, serverThreads: Thread[]): ReadWatermarks;
22
+ //# sourceMappingURL=unread.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unread.d.ts","sourceRoot":"","sources":["../../src/unread.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAEtC,6EAA6E;AAC7E,MAAM,MAAM,cAAc,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAEjD,6EAA6E;AAC7E,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAQ3D;AAED,yDAAyD;AACzD,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAQrE;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,EACd,UAAU,EAAE,cAAc,GACzB,MAAM,CAKR;AAED,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,MAAM,EAAE,EACjB,UAAU,EAAE,cAAc,GACzB,MAAM,EAAE,CAEV;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,MAAM,CAEnD;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,UAAU,EAAE,cAAc,EAC1B,aAAa,EAAE,MAAM,EAAE,GACtB,cAAc,CAShB"}
@@ -0,0 +1,28 @@
1
+ import { type ApiConfig } from './api';
2
+ import type { Thread } from './types';
3
+ export declare const IDLE_POLL_MS = 60000;
4
+ export interface SupportThreadsState {
5
+ /** Server threads with unread counts narrowed by local reads. */
6
+ threads: Thread[] | null;
7
+ totalUnread: number;
8
+ /** First load only — there is nothing cached to show yet. */
9
+ loading: boolean;
10
+ /** User-pulled refresh with content already on screen. */
11
+ refreshing: boolean;
12
+ /** Set only when there is nothing cached; background failures stay quiet. */
13
+ error: string | null;
14
+ /** User-initiated refresh (pull-to-refresh, retry button). */
15
+ refresh: () => void;
16
+ /** Awaited by send/submit handlers so the list reflects the write. */
17
+ reload: () => Promise<void>;
18
+ }
19
+ export declare function useSupportThreads({ config, revenuecatId, enabled, sheetOpen, activeThreadId, idlePollMs, }: {
20
+ config: ApiConfig;
21
+ revenuecatId: string;
22
+ /** False when props failed validation — no requests are made. */
23
+ enabled: boolean;
24
+ sheetOpen: boolean;
25
+ activeThreadId: string | null;
26
+ idlePollMs: number;
27
+ }): SupportThreadsState;
28
+ //# sourceMappingURL=useSupportThreads.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSupportThreads.d.ts","sourceRoot":"","sources":["../../src/useSupportThreads.ts"],"names":[],"mappings":"AAgBA,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAetC,eAAO,MAAM,YAAY,QAAS,CAAC;AA0CnC,MAAM,WAAW,mBAAmB;IAClC,iEAAiE;IACjE,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,OAAO,EAAE,OAAO,CAAC;IACjB,0DAA0D;IAC1D,UAAU,EAAE,OAAO,CAAC;IACpB,6EAA6E;IAC7E,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,8DAA8D;IAC9D,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,sEAAsE;IACtE,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B;AAED,wBAAgB,iBAAiB,CAAC,EAChC,MAAM,EACN,YAAY,EACZ,OAAO,EACP,SAAS,EACT,cAAc,EACd,UAAU,GACX,EAAE;IACD,MAAM,EAAE,SAAS,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,iEAAiE;IACjE,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;IACnB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,UAAU,EAAE,MAAM,CAAC;CACpB,GAAG,mBAAmB,CAoQtB"}
@@ -1,6 +1,11 @@
1
1
  /** "2h ago" relative time — mirrors the web dashboard's formatting. */
2
2
  export declare function relativeTime(iso: string): string;
3
3
  export declare function formatDate(iso: string): string;
4
+ /** "14:32" in the device's locale — shown under a group of messages. */
5
+ export declare function formatTime(iso: string): string;
6
+ export declare function isSameDay(a: string, b: string): boolean;
7
+ /** Date separator label: Today / Yesterday / "Mon, 5 Jan". */
8
+ export declare function dayLabel(iso: string): string;
4
9
  /** Dev-mode prop validation with actionable messages. */
5
10
  export declare function validateProps(props: {
6
11
  appName?: unknown;
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAehD;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM9C;AAED,yDAAyD;AACzD,wBAAgB,aAAa,CAAC,KAAK,EAAE;IACnC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB,GAAG,MAAM,GAAG,IAAI,CA8BhB"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"AAAA,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAehD;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM9C;AAED,wEAAwE;AACxE,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAK9C;AAED,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAQvD;AAED,8DAA8D;AAC9D,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAY5C;AAED,yDAAyD;AACzD,wBAAgB,aAAa,CAAC,KAAK,EAAE;IACnC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB,GAAG,MAAM,GAAG,IAAI,CA8BhB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsupportkit/support-sdk",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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",
@@ -19,7 +19,7 @@
19
19
  ],
20
20
  "license": "MIT",
21
21
  "dependencies": {
22
- "@appsupportkit/design-tokens": "^0.1.0"
22
+ "@appsupportkit/design-tokens": "^0.2.0"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "expo-notifications": ">=0.27.0",
@@ -1,10 +1,15 @@
1
1
  // Bottom sheet built on the built-in Modal + Animated APIs.
2
2
  //
3
3
  // Why no sheet library: the SDK needs exactly one detent, a drag handle, a
4
- // backdrop fade, and a 180ms ease-out slide — all achievable with core APIs.
4
+ // backdrop fade, and a short ease-out slide — all achievable with core APIs.
5
5
  // Skipping reanimated/gesture-handler keeps the package free of native
6
6
  // dependencies and install friction for consumers.
7
- import { useCallback, useEffect, useRef, type ReactNode } from 'react';
7
+ //
8
+ // `visible` drives a single animation path: the sheet stays mounted until
9
+ // its exit finishes, so closing from the header button, the backdrop, the
10
+ // hardware back button, or a drag all animate identically — and a reopen
11
+ // always starts from off-screen instead of appearing already in place.
12
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
8
13
  import {
9
14
  Animated,
10
15
  Easing,
@@ -17,7 +22,8 @@ import {
17
22
  import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
18
23
  import type { SupportTheme } from '../theme';
19
24
 
20
- const SLIDE_MS = 180; // motion.slow — ease-out, no springs, no bounce.
25
+ const SLIDE_MS = 260;
26
+ const FADE_MS = 200;
21
27
 
22
28
  export function BottomSheet({
23
29
  visible,
@@ -31,57 +37,67 @@ export function BottomSheet({
31
37
  children: ReactNode;
32
38
  }) {
33
39
  const { height: windowHeight } = useWindowDimensions();
34
- const sheetHeight = Math.min(windowHeight * 0.82, 640);
40
+ const sheetHeight = Math.min(windowHeight * 0.88, 720);
35
41
 
42
+ const [rendered, setRendered] = useState(false);
36
43
  const translateY = useRef(new Animated.Value(sheetHeight)).current;
37
44
  const backdrop = useRef(new Animated.Value(0)).current;
45
+ /** Whether the entrance has already played for the current open. */
46
+ const shown = useRef(false);
38
47
 
39
- const animateIn = useCallback(() => {
40
- Animated.parallel([
41
- Animated.timing(translateY, {
42
- toValue: 0,
43
- duration: SLIDE_MS,
44
- easing: Easing.out(Easing.cubic),
45
- useNativeDriver: true,
46
- }),
47
- Animated.timing(backdrop, {
48
- toValue: 1,
49
- duration: SLIDE_MS,
50
- easing: Easing.out(Easing.cubic),
51
- useNativeDriver: true,
52
- }),
53
- ]).start();
54
- }, [translateY, backdrop]);
48
+ useEffect(() => {
49
+ if (visible) setRendered(true);
50
+ }, [visible]);
55
51
 
56
- const animateOut = useCallback(
57
- (done: () => void) => {
52
+ useEffect(() => {
53
+ if (!rendered) return;
54
+
55
+ if (visible && !shown.current) {
56
+ shown.current = true;
57
+ translateY.setValue(sheetHeight);
58
58
  Animated.parallel([
59
59
  Animated.timing(translateY, {
60
- toValue: sheetHeight,
60
+ toValue: 0,
61
61
  duration: SLIDE_MS,
62
+ easing: Easing.bezier(0.16, 1, 0.3, 1),
63
+ useNativeDriver: true,
64
+ }),
65
+ Animated.timing(backdrop, {
66
+ toValue: 1,
67
+ duration: FADE_MS,
62
68
  easing: Easing.out(Easing.cubic),
63
69
  useNativeDriver: true,
64
70
  }),
71
+ ]).start();
72
+ return;
73
+ }
74
+
75
+ if (!visible && shown.current) {
76
+ shown.current = false;
77
+ // Starts from wherever the sheet is now, so a drag that crosses the
78
+ // dismiss threshold continues rather than jumping.
79
+ Animated.parallel([
80
+ Animated.timing(translateY, {
81
+ toValue: sheetHeight,
82
+ duration: theme.motion.slow,
83
+ easing: Easing.in(Easing.cubic),
84
+ useNativeDriver: true,
85
+ }),
65
86
  Animated.timing(backdrop, {
66
87
  toValue: 0,
67
- duration: SLIDE_MS,
68
- easing: Easing.out(Easing.cubic),
88
+ duration: theme.motion.slow,
89
+ easing: Easing.in(Easing.cubic),
69
90
  useNativeDriver: true,
70
91
  }),
71
92
  ]).start(({ finished }) => {
72
- if (finished) done();
93
+ if (finished) setRendered(false);
73
94
  });
74
- },
75
- [translateY, backdrop, sheetHeight],
76
- );
77
-
78
- useEffect(() => {
79
- if (visible) animateIn();
80
- }, [visible, animateIn]);
95
+ }
96
+ }, [rendered, visible, sheetHeight, translateY, backdrop, theme.motion.slow]);
81
97
 
82
98
  const requestClose = useCallback(() => {
83
- animateOut(onClose);
84
- }, [animateOut, onClose]);
99
+ onClose();
100
+ }, [onClose]);
85
101
 
86
102
  // Drag handle: follow the finger downward; past 25% (or a flick), dismiss.
87
103
  const panResponder = useRef(
@@ -97,7 +113,7 @@ export function BottomSheet({
97
113
  } else {
98
114
  Animated.timing(translateY, {
99
115
  toValue: 0,
100
- duration: SLIDE_MS,
116
+ duration: theme.motion.slow,
101
117
  easing: Easing.out(Easing.cubic),
102
118
  useNativeDriver: true,
103
119
  }).start();
@@ -106,7 +122,7 @@ export function BottomSheet({
106
122
  }),
107
123
  ).current;
108
124
 
109
- if (!visible) return null;
125
+ if (!rendered) return null;
110
126
 
111
127
  return (
112
128
  <Modal
@@ -125,7 +141,7 @@ export function BottomSheet({
125
141
  right: 0,
126
142
  bottom: 0,
127
143
  left: 0,
128
- backgroundColor: 'rgba(0,0,0,0.5)',
144
+ backgroundColor: 'rgba(0,0,0,0.55)',
129
145
  opacity: backdrop,
130
146
  }}
131
147
  >
@@ -139,25 +155,27 @@ export function BottomSheet({
139
155
  <Animated.View
140
156
  style={{
141
157
  height: sheetHeight,
142
- backgroundColor: theme.colors.overlay,
158
+ backgroundColor: theme.colors.bg,
143
159
  borderTopLeftRadius: theme.radii.sheet,
144
160
  borderTopRightRadius: theme.radii.sheet,
145
161
  borderWidth: 1,
146
162
  borderBottomWidth: 0,
147
163
  borderColor: theme.colors.border,
164
+ overflow: 'hidden',
148
165
  transform: [{ translateY }],
166
+ ...theme.shadows.sheet,
149
167
  }}
150
168
  >
151
169
  {/* Drag handle */}
152
170
  <View
153
171
  {...panResponder.panHandlers}
154
- style={{ alignItems: 'center', paddingVertical: 10 }}
172
+ style={{ alignItems: 'center', paddingTop: 10, paddingBottom: 6 }}
155
173
  >
156
174
  <View
157
175
  style={{
158
- width: 32,
159
- height: 4,
160
- borderRadius: 2,
176
+ width: 40,
177
+ height: 5,
178
+ borderRadius: 3,
161
179
  backgroundColor: theme.colors.borderStrong,
162
180
  }}
163
181
  />
@@ -0,0 +1,217 @@
1
+ // The launcher: the one piece of the SDK that lives inside the host app's
2
+ // own UI, so it has to look intentional next to anything. Three variants
3
+ // cover the placements that actually occur — a floating action button, a
4
+ // bordered button in a header, and a bare icon in a toolbar.
5
+ import { useEffect, useRef } from 'react';
6
+ import {
7
+ Animated,
8
+ Easing,
9
+ Pressable,
10
+ Text,
11
+ View,
12
+ type StyleProp,
13
+ type ViewStyle,
14
+ } from 'react-native';
15
+ import type { SupportTheme } from '../theme';
16
+ import type { LauncherVariant } from '../types';
17
+ import { ChatBubbleMark } from './icons';
18
+
19
+ const PRESS_SCALE = 0.93;
20
+
21
+ /** Counts stay legible: three digits in a pill is not a design. */
22
+ function badgeLabel(unread: number): string {
23
+ return unread > 99 ? '99+' : String(unread);
24
+ }
25
+
26
+ export function LauncherButton({
27
+ theme,
28
+ unread,
29
+ variant = 'solid',
30
+ size = 52,
31
+ label,
32
+ style,
33
+ onPress,
34
+ }: {
35
+ theme: SupportTheme;
36
+ unread: number;
37
+ variant?: LauncherVariant;
38
+ size?: number;
39
+ /** Renders a pill with text next to the mark instead of a circle. */
40
+ label?: string;
41
+ style?: StyleProp<ViewStyle>;
42
+ onPress: () => void;
43
+ }) {
44
+ const scale = useRef(new Animated.Value(1)).current;
45
+ const pulse = useRef(new Animated.Value(0)).current;
46
+ const previousUnread = useRef(unread);
47
+
48
+ // A new reply pulses the halo twice, then stops. Enough to catch the eye
49
+ // on the way past; not a permanently animating element in someone's app.
50
+ useEffect(() => {
51
+ if (unread > previousUnread.current) {
52
+ pulse.setValue(0);
53
+ const animation = Animated.loop(
54
+ Animated.timing(pulse, {
55
+ toValue: 1,
56
+ duration: 1600,
57
+ easing: Easing.out(Easing.ease),
58
+ useNativeDriver: true,
59
+ }),
60
+ { iterations: 2 },
61
+ );
62
+ animation.start(() => pulse.setValue(0));
63
+ previousUnread.current = unread;
64
+ return () => animation.stop();
65
+ }
66
+ previousUnread.current = unread;
67
+ return undefined;
68
+ }, [unread, pulse]);
69
+
70
+ const press = (to: number) =>
71
+ Animated.timing(scale, {
72
+ toValue: to,
73
+ duration: to === 1 ? theme.motion.base : theme.motion.fast,
74
+ easing: Easing.out(Easing.cubic),
75
+ useNativeDriver: true,
76
+ }).start();
77
+
78
+ const solid = variant === 'solid';
79
+ const soft = variant === 'soft';
80
+ const fill = solid
81
+ ? theme.colors.accent
82
+ : soft
83
+ ? theme.colors.surface
84
+ : 'transparent';
85
+ const markColor = solid
86
+ ? theme.colors.accentForeground
87
+ : soft
88
+ ? theme.colors.accent
89
+ : theme.launcher.minimalMark;
90
+ // Dots are punched out of the bubble in whatever sits behind the mark —
91
+ // the button's own fill, or the app background when there isn't one.
92
+ const markHoleColor = solid
93
+ ? theme.colors.accent
94
+ : soft
95
+ ? theme.colors.surface
96
+ : theme.colors.bg;
97
+ // Dark: a hairline separates the fill from a near-black app background.
98
+ // Light: the accent-tinted shadow already does that, so the ring only has
99
+ // to keep the edge from looking soft.
100
+ const border = solid
101
+ ? { borderWidth: 1, borderColor: theme.launcher.solidRing }
102
+ : soft
103
+ ? { borderWidth: 1, borderColor: theme.colors.border }
104
+ : { borderWidth: 0, borderColor: 'transparent' };
105
+ const badgeBg = solid ? theme.colors.accentForeground : theme.colors.accent;
106
+ const badgeFg = solid ? theme.colors.accent : theme.colors.accentForeground;
107
+
108
+ const height = label ? Math.max(44, size * 0.84) : size;
109
+ const markSize = Math.round(size * (label ? 0.36 : 0.42));
110
+
111
+ return (
112
+ <Animated.View
113
+ style={[{ transform: [{ scale }] }, style]}
114
+ // The halo and badge sit outside the button's bounds.
115
+ pointerEvents="box-none"
116
+ >
117
+ {unread > 0 && (
118
+ <Animated.View
119
+ pointerEvents="none"
120
+ style={{
121
+ position: 'absolute',
122
+ top: 0,
123
+ left: 0,
124
+ width: label ? undefined : size,
125
+ right: label ? 0 : undefined,
126
+ height,
127
+ borderRadius: theme.radii.pill,
128
+ backgroundColor: theme.colors.accent,
129
+ opacity: pulse.interpolate({
130
+ inputRange: [0, 1],
131
+ outputRange: [0.28, 0],
132
+ }),
133
+ transform: [
134
+ {
135
+ scale: pulse.interpolate({
136
+ inputRange: [0, 1],
137
+ outputRange: [1, 1.5],
138
+ }),
139
+ },
140
+ ],
141
+ }}
142
+ />
143
+ )}
144
+
145
+ <Pressable
146
+ onPress={onPress}
147
+ onPressIn={() => press(PRESS_SCALE)}
148
+ onPressOut={() => press(1)}
149
+ hitSlop={8}
150
+ accessibilityRole="button"
151
+ accessibilityLabel={
152
+ unread > 0
153
+ ? `Support, ${unread} unread ${unread === 1 ? 'reply' : 'replies'}`
154
+ : 'Support'
155
+ }
156
+ style={{
157
+ height,
158
+ width: label ? undefined : size,
159
+ paddingHorizontal: label ? 18 : 0,
160
+ flexDirection: 'row',
161
+ alignItems: 'center',
162
+ justifyContent: 'center',
163
+ gap: 9,
164
+ borderRadius: theme.radii.pill,
165
+ backgroundColor: fill,
166
+ ...border,
167
+ ...(variant === 'minimal' ? null : theme.shadows.launcher),
168
+ }}
169
+ >
170
+ <ChatBubbleMark color={markColor} dotColor={markHoleColor} size={markSize} />
171
+ {label !== undefined && (
172
+ <Text
173
+ style={{
174
+ fontSize: 15,
175
+ fontWeight: '600',
176
+ letterSpacing: -0.1,
177
+ color: solid ? theme.colors.accentForeground : theme.colors.textPrimary,
178
+ }}
179
+ >
180
+ {label}
181
+ </Text>
182
+ )}
183
+ </Pressable>
184
+
185
+ {unread > 0 && (
186
+ <View
187
+ pointerEvents="none"
188
+ style={{
189
+ position: 'absolute',
190
+ top: -5,
191
+ right: -5,
192
+ minWidth: 19,
193
+ height: 19,
194
+ borderRadius: theme.radii.pill,
195
+ paddingHorizontal: 5,
196
+ backgroundColor: badgeBg,
197
+ alignItems: 'center',
198
+ justifyContent: 'center',
199
+ ...theme.shadows.badge,
200
+ }}
201
+ >
202
+ <Text
203
+ style={{
204
+ fontSize: 11,
205
+ lineHeight: 14,
206
+ fontWeight: '700',
207
+ color: badgeFg,
208
+ fontVariant: ['tabular-nums'],
209
+ }}
210
+ >
211
+ {badgeLabel(unread)}
212
+ </Text>
213
+ </View>
214
+ )}
215
+ </Animated.View>
216
+ );
217
+ }