@appsupportkit/support-sdk 0.1.1 → 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 (110) hide show
  1. package/lib/commonjs/api.js +29 -0
  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 +76 -146
  8. package/lib/commonjs/components/SupportButton.js.map +1 -1
  9. package/lib/commonjs/components/icons.js +74 -23
  10. package/lib/commonjs/components/icons.js.map +1 -1
  11. package/lib/commonjs/components/primitives.js +183 -38
  12. package/lib/commonjs/components/primitives.js.map +1 -1
  13. package/lib/commonjs/components/screens/ComposeScreen.js +115 -58
  14. package/lib/commonjs/components/screens/ComposeScreen.js.map +1 -1
  15. package/lib/commonjs/components/screens/HomeScreen.js +170 -115
  16. package/lib/commonjs/components/screens/HomeScreen.js.map +1 -1
  17. package/lib/commonjs/components/screens/ThreadScreen.js +222 -108
  18. package/lib/commonjs/components/screens/ThreadScreen.js.map +1 -1
  19. package/lib/commonjs/index.js +7 -0
  20. package/lib/commonjs/index.js.map +1 -1
  21. package/lib/commonjs/push.js +103 -0
  22. package/lib/commonjs/push.js.map +1 -0
  23. package/lib/commonjs/theme.js +136 -17
  24. package/lib/commonjs/theme.js.map +1 -1
  25. package/lib/commonjs/types.js +7 -0
  26. package/lib/commonjs/types.js.map +1 -1
  27. package/lib/commonjs/unread.js +92 -0
  28. package/lib/commonjs/unread.js.map +1 -0
  29. package/lib/commonjs/useSupportThreads.js +286 -0
  30. package/lib/commonjs/useSupportThreads.js.map +1 -0
  31. package/lib/commonjs/utils.js +33 -0
  32. package/lib/commonjs/utils.js.map +1 -1
  33. package/lib/module/api.js +27 -0
  34. package/lib/module/api.js.map +1 -1
  35. package/lib/module/components/BottomSheet.js +68 -48
  36. package/lib/module/components/BottomSheet.js.map +1 -1
  37. package/lib/module/components/LauncherButton.js +172 -0
  38. package/lib/module/components/LauncherButton.js.map +1 -0
  39. package/lib/module/components/SupportButton.js +79 -149
  40. package/lib/module/components/SupportButton.js.map +1 -1
  41. package/lib/module/components/icons.js +72 -22
  42. package/lib/module/components/icons.js.map +1 -1
  43. package/lib/module/components/primitives.js +179 -39
  44. package/lib/module/components/primitives.js.map +1 -1
  45. package/lib/module/components/screens/ComposeScreen.js +118 -61
  46. package/lib/module/components/screens/ComposeScreen.js.map +1 -1
  47. package/lib/module/components/screens/HomeScreen.js +172 -117
  48. package/lib/module/components/screens/HomeScreen.js.map +1 -1
  49. package/lib/module/components/screens/ThreadScreen.js +228 -112
  50. package/lib/module/components/screens/ThreadScreen.js.map +1 -1
  51. package/lib/module/index.js +1 -0
  52. package/lib/module/index.js.map +1 -1
  53. package/lib/module/push.js +98 -0
  54. package/lib/module/push.js.map +1 -0
  55. package/lib/module/theme.js +135 -16
  56. package/lib/module/theme.js.map +1 -1
  57. package/lib/module/types.js +7 -0
  58. package/lib/module/types.js.map +1 -1
  59. package/lib/module/unread.js +83 -0
  60. package/lib/module/unread.js.map +1 -0
  61. package/lib/module/useSupportThreads.js +281 -0
  62. package/lib/module/useSupportThreads.js.map +1 -0
  63. package/lib/module/utils.js +30 -0
  64. package/lib/module/utils.js.map +1 -1
  65. package/lib/typescript/api.d.ts +17 -1
  66. package/lib/typescript/api.d.ts.map +1 -1
  67. package/lib/typescript/components/BottomSheet.d.ts.map +1 -1
  68. package/lib/typescript/components/LauncherButton.d.ts +14 -0
  69. package/lib/typescript/components/LauncherButton.d.ts.map +1 -0
  70. package/lib/typescript/components/SupportButton.d.ts +3 -3
  71. package/lib/typescript/components/SupportButton.d.ts.map +1 -1
  72. package/lib/typescript/components/icons.d.ts +13 -2
  73. package/lib/typescript/components/icons.d.ts.map +1 -1
  74. package/lib/typescript/components/primitives.d.ts +35 -4
  75. package/lib/typescript/components/primitives.d.ts.map +1 -1
  76. package/lib/typescript/components/screens/ComposeScreen.d.ts.map +1 -1
  77. package/lib/typescript/components/screens/HomeScreen.d.ts +3 -2
  78. package/lib/typescript/components/screens/HomeScreen.d.ts.map +1 -1
  79. package/lib/typescript/components/screens/ThreadScreen.d.ts.map +1 -1
  80. package/lib/typescript/index.d.ts +2 -1
  81. package/lib/typescript/index.d.ts.map +1 -1
  82. package/lib/typescript/push.d.ts +18 -0
  83. package/lib/typescript/push.d.ts.map +1 -0
  84. package/lib/typescript/theme.d.ts +44 -3
  85. package/lib/typescript/theme.d.ts.map +1 -1
  86. package/lib/typescript/types.d.ts +37 -4
  87. package/lib/typescript/types.d.ts.map +1 -1
  88. package/lib/typescript/unread.d.ts +22 -0
  89. package/lib/typescript/unread.d.ts.map +1 -0
  90. package/lib/typescript/useSupportThreads.d.ts +28 -0
  91. package/lib/typescript/useSupportThreads.d.ts.map +1 -0
  92. package/lib/typescript/utils.d.ts +5 -0
  93. package/lib/typescript/utils.d.ts.map +1 -1
  94. package/package.json +8 -2
  95. package/src/api.ts +41 -1
  96. package/src/components/BottomSheet.tsx +61 -43
  97. package/src/components/LauncherButton.tsx +217 -0
  98. package/src/components/SupportButton.tsx +81 -162
  99. package/src/components/icons.tsx +77 -19
  100. package/src/components/primitives.tsx +217 -36
  101. package/src/components/screens/ComposeScreen.tsx +131 -61
  102. package/src/components/screens/HomeScreen.tsx +168 -117
  103. package/src/components/screens/ThreadScreen.tsx +289 -119
  104. package/src/index.tsx +3 -0
  105. package/src/push.ts +97 -0
  106. package/src/theme.ts +165 -18
  107. package/src/types.ts +39 -4
  108. package/src/unread.ts +91 -0
  109. package/src/useSupportThreads.ts +372 -0
  110. package/src/utils.ts +33 -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
+ }