@unif/react-native-chat 2.1.1 → 2.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 (72) hide show
  1. package/README.md +6 -6
  2. package/lib/module/components/Attachments/AttachmentItem.js +3 -5
  3. package/lib/module/components/Attachments/AttachmentItem.js.map +1 -1
  4. package/lib/module/components/Message/MarkdownContent/MarkdownContent.js +16 -5
  5. package/lib/module/components/Message/MarkdownContent/MarkdownContent.js.map +1 -1
  6. package/lib/module/components/MessageList/MessageList.js +64 -156
  7. package/lib/module/components/MessageList/MessageList.js.map +1 -1
  8. package/lib/module/components/MessageList/MessageListRow.js +23 -0
  9. package/lib/module/components/MessageList/MessageListRow.js.map +1 -0
  10. package/lib/module/components/MessageList/useMessageListScroll.js +153 -0
  11. package/lib/module/components/MessageList/useMessageListScroll.js.map +1 -0
  12. package/lib/module/components/MessageList/webPrependAnchor/useWebPrependAnchor.web.js +35 -26
  13. package/lib/module/components/MessageList/webPrependAnchor/useWebPrependAnchor.web.js.map +1 -1
  14. package/lib/module/components/ProcessingProgress/ProcessingProgress.js +46 -0
  15. package/lib/module/components/ProcessingProgress/ProcessingProgress.js.map +1 -0
  16. package/lib/module/components/ProcessingProgress/ProcessingProgressPrefix.js +53 -0
  17. package/lib/module/components/ProcessingProgress/ProcessingProgressPrefix.js.map +1 -0
  18. package/lib/module/components/ProcessingProgress/constants.js +7 -0
  19. package/lib/module/components/ProcessingProgress/constants.js.map +1 -0
  20. package/lib/module/components/ProcessingProgress/index.js +4 -0
  21. package/lib/module/components/ProcessingProgress/index.js.map +1 -0
  22. package/lib/module/components/ProcessingProgress/styles.js +17 -0
  23. package/lib/module/components/ProcessingProgress/styles.js.map +1 -0
  24. package/lib/module/components/ProcessingProgress/types.js +2 -0
  25. package/lib/module/components/ProcessingProgress/types.js.map +1 -0
  26. package/lib/module/index.js +1 -0
  27. package/lib/module/index.js.map +1 -1
  28. package/lib/typescript/src/components/Attachments/AttachmentItem.d.ts.map +1 -1
  29. package/lib/typescript/src/components/Attachments/types.d.ts +1 -1
  30. package/lib/typescript/src/components/Attachments/types.d.ts.map +1 -1
  31. package/lib/typescript/src/components/Message/MarkdownContent/MarkdownContent.d.ts.map +1 -1
  32. package/lib/typescript/src/components/Message/MarkdownContent/types.d.ts +3 -0
  33. package/lib/typescript/src/components/Message/MarkdownContent/types.d.ts.map +1 -1
  34. package/lib/typescript/src/components/MessageList/MessageList.d.ts.map +1 -1
  35. package/lib/typescript/src/components/MessageList/MessageListRow.d.ts +11 -0
  36. package/lib/typescript/src/components/MessageList/MessageListRow.d.ts.map +1 -0
  37. package/lib/typescript/src/components/MessageList/useMessageListScroll.d.ts +21 -0
  38. package/lib/typescript/src/components/MessageList/useMessageListScroll.d.ts.map +1 -0
  39. package/lib/typescript/src/components/MessageList/webPrependAnchor/types.d.ts +2 -0
  40. package/lib/typescript/src/components/MessageList/webPrependAnchor/types.d.ts.map +1 -1
  41. package/lib/typescript/src/components/MessageList/webPrependAnchor/useWebPrependAnchor.web.d.ts.map +1 -1
  42. package/lib/typescript/src/components/ProcessingProgress/ProcessingProgress.d.ts +3 -0
  43. package/lib/typescript/src/components/ProcessingProgress/ProcessingProgress.d.ts.map +1 -0
  44. package/lib/typescript/src/components/ProcessingProgress/ProcessingProgressPrefix.d.ts +3 -0
  45. package/lib/typescript/src/components/ProcessingProgress/ProcessingProgressPrefix.d.ts.map +1 -0
  46. package/lib/typescript/src/components/ProcessingProgress/constants.d.ts +5 -0
  47. package/lib/typescript/src/components/ProcessingProgress/constants.d.ts.map +1 -0
  48. package/lib/typescript/src/components/ProcessingProgress/index.d.ts +3 -0
  49. package/lib/typescript/src/components/ProcessingProgress/index.d.ts.map +1 -0
  50. package/lib/typescript/src/components/ProcessingProgress/styles.d.ts +14 -0
  51. package/lib/typescript/src/components/ProcessingProgress/styles.d.ts.map +1 -0
  52. package/lib/typescript/src/components/ProcessingProgress/types.d.ts +9 -0
  53. package/lib/typescript/src/components/ProcessingProgress/types.d.ts.map +1 -0
  54. package/lib/typescript/src/index.d.ts +2 -0
  55. package/lib/typescript/src/index.d.ts.map +1 -1
  56. package/package.json +3 -3
  57. package/src/components/Attachments/AttachmentItem.tsx +2 -7
  58. package/src/components/Attachments/types.ts +1 -1
  59. package/src/components/Message/MarkdownContent/MarkdownContent.tsx +22 -5
  60. package/src/components/Message/MarkdownContent/types.ts +4 -0
  61. package/src/components/MessageList/MessageList.tsx +77 -176
  62. package/src/components/MessageList/MessageListRow.tsx +35 -0
  63. package/src/components/MessageList/useMessageListScroll.ts +216 -0
  64. package/src/components/MessageList/webPrependAnchor/types.ts +2 -0
  65. package/src/components/MessageList/webPrependAnchor/useWebPrependAnchor.web.ts +121 -87
  66. package/src/components/ProcessingProgress/ProcessingProgress.tsx +49 -0
  67. package/src/components/ProcessingProgress/ProcessingProgressPrefix.tsx +68 -0
  68. package/src/components/ProcessingProgress/constants.ts +4 -0
  69. package/src/components/ProcessingProgress/index.ts +2 -0
  70. package/src/components/ProcessingProgress/styles.ts +17 -0
  71. package/src/components/ProcessingProgress/types.ts +9 -0
  72. package/src/index.tsx +2 -0
@@ -1,4 +1,4 @@
1
- import { useId, useLayoutEffect, useRef } from 'react';
1
+ import { useCallback, useId, useLayoutEffect, useRef } from 'react';
2
2
  import { Platform } from 'react-native';
3
3
  import { USER_INTERACTION_EVENTS } from './constants';
4
4
  import type {
@@ -26,8 +26,9 @@ function isWebElementLike(value: unknown): value is WebElementLike {
26
26
 
27
27
  function sameKeys(left: readonly string[], right: readonly string[]): boolean {
28
28
  return (
29
- left.length === right.length &&
30
- left.every((key, index) => key === right[index])
29
+ left === right ||
30
+ (left.length === right.length &&
31
+ left.every((key, index) => key === right[index]))
31
32
  );
32
33
  }
33
34
 
@@ -79,111 +80,135 @@ export function useWebPrependAnchor({
79
80
 
80
81
  const globals = globalThis as unknown as WebGlobals;
81
82
 
82
- const getRowNativeID = (key: string) =>
83
- `${rowNativeIDPrefix}-${encodeURIComponent(key)}`;
83
+ const getRowNativeID = useCallback(
84
+ (key: string) => `${rowNativeIDPrefix}-${encodeURIComponent(key)}`,
85
+ [rowNativeIDPrefix]
86
+ );
84
87
 
85
- const getRows = (scroller: WebElementLike): WebElementLike[] =>
86
- Array.from(scroller.querySelectorAll('[id]')).filter(
87
- (candidate): candidate is WebElementLike =>
88
- isWebElementLike(candidate) &&
89
- candidate.id.startsWith(`${rowNativeIDPrefix}-`)
90
- );
88
+ const getRows = useCallback(
89
+ (scroller: WebElementLike): WebElementLike[] =>
90
+ Array.from(scroller.querySelectorAll('[id]')).filter(
91
+ (candidate): candidate is WebElementLike =>
92
+ isWebElementLike(candidate) &&
93
+ candidate.id.startsWith(`${rowNativeIDPrefix}-`)
94
+ ),
95
+ [rowNativeIDPrefix]
96
+ );
91
97
 
92
- const findRow = (
93
- scroller: WebElementLike,
94
- id: string
95
- ): WebElementLike | undefined => {
96
- const candidate = globals.document?.getElementById(id);
97
- return isWebElementLike(candidate) && scroller.contains(candidate)
98
- ? candidate
99
- : undefined;
100
- };
98
+ const findRow = useCallback(
99
+ (scroller: WebElementLike, id: string): WebElementLike | undefined => {
100
+ const candidate = globals.document?.getElementById(id);
101
+ return isWebElementLike(candidate) && scroller.contains(candidate)
102
+ ? candidate
103
+ : undefined;
104
+ },
105
+ [globals]
106
+ );
101
107
 
102
- const captureAnchor = (connection: WebPrependAnchorConnection) => {
103
- const viewport = connection.scroller.getBoundingClientRect();
104
- const firstVisible = getRows(connection.scroller).find((row) => {
105
- const rect = row.getBoundingClientRect();
106
- return rect.bottom > viewport.top && rect.top < viewport.bottom;
107
- });
108
- if (!firstVisible) return;
109
- lastAnchorRef.current = {
110
- id: firstVisible.id,
111
- top: firstVisible.getBoundingClientRect().top - viewport.top,
112
- scrollHeight: connection.scroller.scrollHeight,
113
- scrollTop: connection.scroller.scrollTop,
114
- };
115
- };
108
+ const captureAnchor = useCallback(
109
+ (connection: WebPrependAnchorConnection) => {
110
+ const viewport = connection.scroller.getBoundingClientRect();
111
+ const firstVisible = getRows(connection.scroller).find((row) => {
112
+ const rect = row.getBoundingClientRect();
113
+ return rect.bottom > viewport.top && rect.top < viewport.bottom;
114
+ });
115
+ if (!firstVisible) return;
116
+ lastAnchorRef.current = {
117
+ id: firstVisible.id,
118
+ top: firstVisible.getBoundingClientRect().top - viewport.top,
119
+ scrollHeight: connection.scroller.scrollHeight,
120
+ scrollTop: connection.scroller.scrollTop,
121
+ };
122
+ },
123
+ [getRows]
124
+ );
116
125
 
117
- const refreshResizeTargets = (connection: WebPrependAnchorConnection) => {
118
- const resizeObserver = connection.resizeObserver;
119
- if (!resizeObserver) return;
120
- const nextTargets = new Set([
121
- connection.scroller,
122
- ...getRows(connection.scroller),
123
- ]);
124
- connection.resizeTargets.forEach((target) => {
125
- if (!nextTargets.has(target)) resizeObserver.unobserve?.(target);
126
- });
127
- nextTargets.forEach((target) => {
128
- if (!connection.resizeTargets.has(target)) resizeObserver.observe(target);
129
- });
130
- connection.resizeTargets = nextTargets;
131
- };
126
+ const refreshResizeTargets = useCallback(
127
+ (connection: WebPrependAnchorConnection) => {
128
+ const resizeObserver = connection.resizeObserver;
129
+ if (!resizeObserver) return;
130
+ const nextTargets = new Set([
131
+ connection.scroller,
132
+ ...getRows(connection.scroller),
133
+ ]);
134
+ // RN Web exposes its content node here. Observe direct wrappers too:
135
+ // opposite header/footer resizes can leave the total height unchanged.
136
+ const content = connection.scroller.getInnerViewNode?.();
137
+ if (isWebElementLike(content)) {
138
+ nextTargets.add(content);
139
+ for (const child of Array.from(content.children ?? [])) {
140
+ if (isWebElementLike(child)) nextTargets.add(child);
141
+ }
142
+ }
143
+ connection.resizeTargets.forEach((target) => {
144
+ if (!nextTargets.has(target)) resizeObserver.unobserve?.(target);
145
+ });
146
+ nextTargets.forEach((target) => {
147
+ if (!connection.resizeTargets.has(target))
148
+ resizeObserver.observe(target);
149
+ });
150
+ connection.resizeTargets = nextTargets;
151
+ },
152
+ [getRows]
153
+ );
132
154
 
133
- const correctPrepend = (connection: WebPrependAnchorConnection) => {
134
- const preservation = preservationRef.current;
135
- if (!preservation) return;
136
- const anchor = findRow(connection.scroller, preservation.id);
137
- if (anchor) {
138
- const viewport = connection.scroller.getBoundingClientRect();
139
- const currentTop = anchor.getBoundingClientRect().top - viewport.top;
140
- const delta = currentTop - preservation.top;
141
- if (Math.abs(delta) > 0.5) {
155
+ const correctPrepend = useCallback(
156
+ (connection: WebPrependAnchorConnection) => {
157
+ const preservation = preservationRef.current;
158
+ if (!preservation) return;
159
+ const anchor = findRow(connection.scroller, preservation.id);
160
+ if (anchor) {
161
+ const viewport = connection.scroller.getBoundingClientRect();
162
+ const currentTop = anchor.getBoundingClientRect().top - viewport.top;
163
+ const delta = currentTop - preservation.top;
164
+ if (Math.abs(delta) > 0.5) {
165
+ connection.list.scrollToOffset({
166
+ offset: Math.max(0, connection.scroller.scrollTop + delta),
167
+ animated: false,
168
+ });
169
+ }
170
+ return;
171
+ }
172
+
173
+ const heightDelta = Math.max(
174
+ 0,
175
+ connection.scroller.scrollHeight - preservation.scrollHeight
176
+ );
177
+ if (heightDelta > 0) {
142
178
  connection.list.scrollToOffset({
143
- offset: Math.max(0, connection.scroller.scrollTop + delta),
179
+ offset: Math.max(0, preservation.scrollTop + heightDelta),
144
180
  animated: false,
145
181
  });
146
182
  }
147
- return;
148
- }
149
-
150
- const heightDelta = Math.max(
151
- 0,
152
- connection.scroller.scrollHeight - preservation.scrollHeight
153
- );
154
- if (heightDelta > 0) {
155
- connection.list.scrollToOffset({
156
- offset: Math.max(0, preservation.scrollTop + heightDelta),
157
- animated: false,
158
- });
159
- }
160
- };
183
+ },
184
+ [findRow]
185
+ );
161
186
 
162
- const runFrame = () => {
187
+ const runFrame = useCallback(() => {
163
188
  frameRef.current = undefined;
164
189
  const connection = connectionRef.current;
165
190
  if (!connection) return;
166
191
  refreshResizeTargets(connection);
167
192
  if (preservingPrependRef.current) correctPrepend(connection);
168
193
  else captureAnchor(connection);
169
- };
194
+ }, [captureAnchor, correctPrepend, refreshResizeTargets]);
170
195
 
171
- const scheduleFrame = () => {
196
+ const scheduleFrame = useCallback(() => {
172
197
  if (frameRef.current !== undefined) return;
173
198
  if (globals.requestAnimationFrame) {
174
199
  frameRef.current = globals.requestAnimationFrame(runFrame);
175
200
  } else {
176
201
  runFrame();
177
202
  }
178
- };
203
+ }, [globals, runFrame]);
179
204
 
180
- const stopPreserving = () => {
205
+ const stopPreserving = useCallback(() => {
181
206
  preservingPrependRef.current = false;
182
207
  preservationRef.current = undefined;
183
208
  lastAnchorRef.current = undefined;
184
- };
209
+ }, []);
185
210
 
186
- const disconnect = () => {
211
+ const disconnect = useCallback(() => {
187
212
  const connection = connectionRef.current;
188
213
  disconnectConnection(connection);
189
214
  connectionRef.current = undefined;
@@ -191,9 +216,9 @@ export function useWebPrependAnchor({
191
216
  globals.cancelAnimationFrame?.(frameRef.current);
192
217
  frameRef.current = undefined;
193
218
  }
194
- };
219
+ }, [globals]);
195
220
 
196
- const ensureConnected = () => {
221
+ const ensureConnected = useCallback(() => {
197
222
  if (Platform.OS !== 'web') return;
198
223
  const list = listRef.current;
199
224
  const scroller = list ? list.getScrollableNode() : undefined;
@@ -241,12 +266,20 @@ export function useWebPrependAnchor({
241
266
  scroller.addEventListener('scroll', scroll, { passive: true });
242
267
  refreshResizeTargets(connection);
243
268
  scheduleFrame();
244
- };
269
+ }, [
270
+ captureAnchor,
271
+ disconnect,
272
+ globals,
273
+ listRef,
274
+ refreshResizeTargets,
275
+ scheduleFrame,
276
+ stopPreserving,
277
+ ]);
245
278
 
246
- const cancelPrependPreservation = () => {
279
+ const cancelPrependPreservation = useCallback(() => {
247
280
  stopPreserving();
248
281
  scheduleFrame();
249
- };
282
+ }, [scheduleFrame, stopPreserving]);
250
283
 
251
284
  useLayoutEffect(() => {
252
285
  ensureConnected();
@@ -266,8 +299,9 @@ export function useWebPrependAnchor({
266
299
  }
267
300
  }
268
301
  previousIdentityRef.current = itemsIdentity;
269
- previousKeysRef.current = [...keys];
270
- scheduleFrame();
302
+ previousKeysRef.current = keys;
303
+ if (!previousKeys || previousKeys !== keys || identityChanged)
304
+ scheduleFrame();
271
305
  });
272
306
 
273
307
  useLayoutEffect(
@@ -0,0 +1,49 @@
1
+ import { Text, View } from 'react-native';
2
+ import {
3
+ usePrefersReducedMotion,
4
+ useThemedStyles,
5
+ } from '@unif/react-native-design';
6
+ import { PROCESSING_PROGRESS_SEPARATOR } from './constants';
7
+ import { ProcessingProgressPrefix } from './ProcessingProgressPrefix';
8
+ import { createStyles } from './styles';
9
+ import type { ProcessingProgressProps } from './types';
10
+
11
+ export function ProcessingProgress({ progress }: ProcessingProgressProps) {
12
+ const styles = useThemedStyles(createStyles);
13
+ const reducedMotion = usePrefersReducedMotion();
14
+ const separatorIndex = progress.indexOf(PROCESSING_PROGRESS_SEPARATOR);
15
+ const prefix =
16
+ separatorIndex === -1 ? progress : progress.slice(0, separatorIndex);
17
+ const suffix = separatorIndex === -1 ? '' : progress.slice(separatorIndex);
18
+
19
+ return (
20
+ <View
21
+ style={styles.root}
22
+ accessible
23
+ accessibilityRole="text"
24
+ accessibilityLabel={progress}
25
+ accessibilityState={{ busy: true }}
26
+ aria-busy
27
+ accessibilityLiveRegion="polite"
28
+ >
29
+ <Text
30
+ testID="processing-progress-line"
31
+ style={styles.text}
32
+ numberOfLines={1}
33
+ ellipsizeMode="tail"
34
+ accessible={false}
35
+ aria-hidden
36
+ >
37
+ <ProcessingProgressPrefix
38
+ // Each prefix/motion generation owns its value, including late frames.
39
+ key={`${reducedMotion}:${prefix}`}
40
+ text={prefix}
41
+ reducedMotion={reducedMotion}
42
+ />
43
+ {suffix ? (
44
+ <Text testID="processing-progress-suffix">{suffix}</Text>
45
+ ) : null}
46
+ </Text>
47
+ </View>
48
+ );
49
+ }
@@ -0,0 +1,68 @@
1
+ import { memo, useEffect, useRef } from 'react';
2
+ import { Animated, Easing, Platform, Text } from 'react-native';
3
+ import { useTheme } from '@unif/react-native-design';
4
+ import {
5
+ PROCESSING_PROGRESS_SHINE_DURATION_MS,
6
+ PROCESSING_PROGRESS_SHINE_PEAK,
7
+ PROCESSING_PROGRESS_SHINE_RADIUS,
8
+ } from './constants';
9
+ import type { ProcessingProgressPrefixProps } from './types';
10
+
11
+ export const ProcessingProgressPrefix = memo(function Prefix({
12
+ text,
13
+ reducedMotion,
14
+ }: ProcessingProgressPrefixProps) {
15
+ const { colors, scheme } = useTheme();
16
+ const animation = useRef(
17
+ new Animated.Value(-PROCESSING_PROGRESS_SHINE_RADIUS)
18
+ ).current;
19
+ const characters = Array.from(text);
20
+
21
+ useEffect(() => {
22
+ if (reducedMotion || !characters.length) return undefined;
23
+ const loop = Animated.loop(
24
+ Animated.timing(animation, {
25
+ toValue: characters.length + PROCESSING_PROGRESS_SHINE_RADIUS,
26
+ duration: PROCESSING_PROGRESS_SHINE_DURATION_MS,
27
+ easing: Easing.linear,
28
+ useNativeDriver: Platform.OS !== 'web',
29
+ isInteraction: false,
30
+ })
31
+ );
32
+ loop.start();
33
+ return () => loop.stop();
34
+ }, [animation, characters.length, reducedMotion]);
35
+
36
+ const baseColor =
37
+ scheme === 'dark' ? colors.foregroundMuted : colors.foreground;
38
+
39
+ if (reducedMotion) return <Text style={{ color: baseColor }}>{text}</Text>;
40
+
41
+ return (
42
+ <Text>
43
+ {characters.map((character, index) => (
44
+ <Animated.Text
45
+ key={`${index}:${character}`}
46
+ style={{
47
+ color: animation
48
+ .interpolate({
49
+ inputRange: [
50
+ index - PROCESSING_PROGRESS_SHINE_RADIUS,
51
+ index,
52
+ index + PROCESSING_PROGRESS_SHINE_RADIUS,
53
+ ],
54
+ outputRange: [0, PROCESSING_PROGRESS_SHINE_PEAK, 0],
55
+ extrapolate: 'clamp',
56
+ })
57
+ .interpolate({
58
+ inputRange: [0, 1],
59
+ outputRange: [baseColor, colors.onPrimary],
60
+ }),
61
+ }}
62
+ >
63
+ {character}
64
+ </Animated.Text>
65
+ ))}
66
+ </Text>
67
+ );
68
+ });
@@ -0,0 +1,4 @@
1
+ export const PROCESSING_PROGRESS_SEPARATOR = '·';
2
+ export const PROCESSING_PROGRESS_SHINE_DURATION_MS = 1800;
3
+ export const PROCESSING_PROGRESS_SHINE_RADIUS = 2;
4
+ export const PROCESSING_PROGRESS_SHINE_PEAK = 0.85;
@@ -0,0 +1,2 @@
1
+ export { ProcessingProgress } from './ProcessingProgress';
2
+ export type { ProcessingProgressProps } from './types';
@@ -0,0 +1,17 @@
1
+ import { StyleSheet } from 'react-native';
2
+ import { r, space, type } from '@unif/react-native-design';
3
+ import type { ColorTokens } from '@unif/react-native-design';
4
+
5
+ export const createStyles = (colors: ColorTokens) =>
6
+ StyleSheet.create({
7
+ root: {
8
+ minWidth: 0,
9
+ paddingHorizontal: space[4],
10
+ paddingVertical: space[3],
11
+ },
12
+ text: {
13
+ color: colors.foregroundMuted,
14
+ fontSize: type.xs,
15
+ lineHeight: r(20),
16
+ },
17
+ });
@@ -0,0 +1,9 @@
1
+ export interface ProcessingProgressProps {
2
+ /** 当前公开进度;文案和展示结束时机由调用方提供。 */
3
+ progress: string;
4
+ }
5
+
6
+ export interface ProcessingProgressPrefixProps {
7
+ text: string;
8
+ reducedMotion: boolean;
9
+ }
package/src/index.tsx CHANGED
@@ -53,6 +53,8 @@ export type {
53
53
  ProcessStep,
54
54
  ProcessStepStatus,
55
55
  } from './components/Process';
56
+ export { ProcessingProgress } from './components/ProcessingProgress';
57
+ export type { ProcessingProgressProps } from './components/ProcessingProgress';
56
58
  export { Feedback } from './components/Feedback';
57
59
  export type { FeedbackProps, FeedbackTone } from './components/Feedback';
58
60
  export { Sources } from './components/Sources';