@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unif/react-native-chat",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Unif React Native 聊天组件:消息、输入、附件和交互展示",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -68,7 +68,7 @@
68
68
  "@sbaiahmed1/react-native-blur": "6.0.1",
69
69
  "@testing-library/react-native": "13.3.3",
70
70
  "@types/react": "^19.2.0",
71
- "@unif/react-native-design": "0.33.0",
71
+ "@unif/react-native-design": "0.34.0",
72
72
  "commitlint": "^21.2.1",
73
73
  "del-cli": "^7.0.0",
74
74
  "eslint": "^9.39.5",
@@ -96,7 +96,7 @@
96
96
  "typescript": "^6.0.3"
97
97
  },
98
98
  "peerDependencies": {
99
- "@unif/react-native-design": "^0.33.0",
99
+ "@unif/react-native-design": "^0.34.0",
100
100
  "react": ">=19.2.3 <20.0.0",
101
101
  "react-native": ">=0.86.0",
102
102
  "react-native-svg": ">=15"
@@ -1,6 +1,5 @@
1
1
  import { Text, View } from 'react-native';
2
2
  import {
3
- BorderBeam,
4
3
  Button,
5
4
  CircularProgress,
6
5
  Icon,
@@ -81,11 +80,7 @@ export function AttachmentItem({
81
80
 
82
81
  return (
83
82
  <View style={[styles.item, row ? styles.row : { width }]}>
84
- {busy && loadingVisual === 'border' ? (
85
- <BorderBeam>{media}</BorderBeam>
86
- ) : (
87
- media
88
- )}
83
+ {media}
89
84
  <View style={[styles.content, row && styles.rowContent]}>
90
85
  <Text style={styles.name}>{name}</Text>
91
86
  {item.meta ? <Text style={styles.meta}>{item.meta}</Text> : null}
@@ -98,7 +93,7 @@ export function AttachmentItem({
98
93
  </Text>
99
94
  ) : null}
100
95
  {busy &&
101
- loadingVisual !== 'center' &&
96
+ loadingVisual === 'caption' &&
102
97
  showProgressLabel &&
103
98
  progress !== undefined ? (
104
99
  <Text style={styles.meta}>{`${Math.round(progress * 100)}%`}</Text>
@@ -12,7 +12,7 @@ export interface ChatAttachmentItem {
12
12
  status?: ChatAttachmentStatus;
13
13
  statusText?: string;
14
14
  progress?: number;
15
- loadingVisual?: 'center' | 'border' | 'caption';
15
+ loadingVisual?: 'center' | 'caption';
16
16
  previewable?: boolean;
17
17
  removable?: boolean;
18
18
  removeDisabled?: boolean;
@@ -1,9 +1,15 @@
1
- import { Children, Fragment, useMemo } from 'react';
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ Fragment,
5
+ isValidElement,
6
+ useMemo,
7
+ } from 'react';
2
8
  import { Pressable, Text } from 'react-native';
3
9
  import { Renderer, useMarkdown } from 'react-native-marked';
4
10
  import { useTheme, useThemedStyles } from '@unif/react-native-design';
5
11
  import { createStyles } from './styles';
6
- import type { MarkdownContentProps } from './types';
12
+ import type { MarkdownContentProps, MarkdownSvgLabelProps } from './types';
7
13
  import { MarkdownImage } from './MarkdownImage/MarkdownImage';
8
14
 
9
15
  export function MarkdownContent({ text, onLinkPress }: MarkdownContentProps) {
@@ -16,9 +22,20 @@ export function MarkdownContent({ text, onLinkPress }: MarkdownContentProps) {
16
22
  instance.table = (...args) => (
17
23
  <Fragment key={instance.getKey()}>{renderTable(...args)}</Fragment>
18
24
  );
19
- instance.image = (uri, alt, _imageStyle, title) => (
20
- <MarkdownImage key={instance.getKey()} uri={uri} label={alt || title} />
21
- );
25
+ const renderImage = instance.image.bind(instance);
26
+ instance.image = (uri, alt, imageStyle, title) => {
27
+ // 沿用上游的 SVG 识别与加载,位图适配只避免重复测量。
28
+ if (uri.endsWith('.svg')) {
29
+ const image = renderImage(uri, alt, imageStyle, title);
30
+ // 上游 SVG 节点支持 alt,但 Renderer 未转交 Markdown 的标签。
31
+ return isValidElement<MarkdownSvgLabelProps>(image)
32
+ ? cloneElement(image, { alt: alt || title || undefined })
33
+ : image;
34
+ }
35
+ return (
36
+ <MarkdownImage key={instance.getKey()} uri={uri} label={alt || title} />
37
+ );
38
+ };
22
39
  instance.link = (children, href, linkStyle, title) => (
23
40
  <Text
24
41
  key={instance.getKey()}
@@ -2,3 +2,7 @@ export interface MarkdownContentProps {
2
2
  text: string;
3
3
  onLinkPress?(url: string): void;
4
4
  }
5
+
6
+ export interface MarkdownSvgLabelProps {
7
+ alt?: string;
8
+ }
@@ -1,17 +1,16 @@
1
- import { useImperativeHandle, useLayoutEffect, useRef, useState } from 'react';
1
+ import { useCallback, useImperativeHandle, useMemo, useRef } from 'react';
2
2
  import type { RefAttributes } from 'react';
3
3
  import { FlatList, View } from 'react-native';
4
- import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
4
+ import type { ListRenderItemInfo } from 'react-native';
5
5
  import { Button } from '@unif/react-native-design';
6
6
  import { DEFAULT_END_THRESHOLD } from './constants';
7
7
  import { styles } from './styles';
8
8
  import { useWebPrependAnchor } from './webPrependAnchor';
9
- import type {
10
- ListMeasurements,
11
- MessageListHandle,
12
- MessageListProps,
13
- MessageListScrollOptions,
14
- } from './types';
9
+ import { useMessageListScroll } from './useMessageListScroll';
10
+ import { MessageListRow } from './MessageListRow';
11
+ import type { MessageListHandle, MessageListProps } from './types';
12
+
13
+ const MAINTAIN_VISIBLE_POSITION = { minIndexForVisible: 0 };
15
14
 
16
15
  export function MessageList<T>({
17
16
  items,
@@ -36,128 +35,72 @@ export function MessageList<T>({
36
35
  ref,
37
36
  }: MessageListProps<T> & RefAttributes<MessageListHandle>) {
38
37
  const listRef = useRef<FlatList<T>>(null);
39
- const measurements = useRef<ListMeasurements>({
40
- viewport: 0,
41
- content: 0,
42
- offset: 0,
43
- hasContentSize: false,
44
- });
45
- const initial = useRef(initialPosition);
46
- const initialized = useRef(false);
47
- const previousKeys = useRef<readonly string[]>([]);
48
- const prependPending = useRef(false);
49
- const pendingScroll = useRef<MessageListScrollOptions | undefined>(undefined);
50
- const userScrolling = useRef(false);
51
- const atEndRef = useRef<boolean | undefined>(undefined);
52
- const [atEnd, setAtEnd] = useState(true);
53
- const threshold = Number.isFinite(endThreshold)
54
- ? Math.max(0, endThreshold)
55
- : DEFAULT_END_THRESHOLD;
56
- const keys = items.map(keyExtractor);
38
+ const keys = useMemo(() => items.map(keyExtractor), [items, keyExtractor]);
57
39
  const { getRowNativeID, preservingPrependRef, cancelPrependPreservation } =
58
40
  useWebPrependAnchor({ listRef, itemsIdentity: items, keys });
59
-
60
- useLayoutEffect(() => {
61
- const previous = previousKeys.current;
62
- const added = keys.length - previous.length;
63
- const onlyPrepended =
64
- previous.length > 0 &&
65
- added > 0 &&
66
- previous.every((key, index) => keys[index + added] === key);
67
- const changed =
68
- keys.length !== previous.length ||
69
- keys.some((key, index) => key !== previous[index]);
70
- if (changed) prependPending.current = onlyPrepended;
71
- previousKeys.current = keys;
41
+ const {
42
+ atEnd,
43
+ scrollToEnd,
44
+ onLayout,
45
+ onContentSizeChange,
46
+ onScroll,
47
+ onUserScrollBegin,
48
+ onScrollEndDrag,
49
+ onMomentumScrollEnd,
50
+ } = useMessageListScroll({
51
+ listRef,
52
+ keys,
53
+ initialPosition,
54
+ followOutput,
55
+ endThreshold,
56
+ onAtEndChange,
57
+ preservingPrependRef,
58
+ cancelPrependPreservation,
72
59
  });
60
+ useImperativeHandle(ref, () => ({ scrollToEnd }), [scrollToEnd]);
73
61
 
74
- function ready() {
75
- const current = measurements.current;
76
- return current.viewport > 0 && current.hasContentSize;
77
- }
78
-
79
- function publishPosition() {
80
- if (!ready()) return;
81
- const { viewport, content, offset } = measurements.current;
82
- const next = content - viewport - Math.max(0, offset) <= threshold;
83
- if (next !== atEndRef.current) {
84
- atEndRef.current = next;
85
- setAtEnd(next);
86
- onAtEndChange?.(next);
87
- }
88
- }
89
-
90
- function scrollToEnd(options: MessageListScrollOptions = {}) {
91
- cancelPrependPreservation();
92
- const request = { animated: options.animated ?? true };
93
- if (!ready()) {
94
- pendingScroll.current = request;
95
- return;
96
- }
97
- pendingScroll.current = undefined;
98
- listRef.current?.scrollToOffset({
99
- offset: Math.max(
100
- 0,
101
- measurements.current.content - measurements.current.viewport
102
- ),
103
- animated: request.animated,
104
- });
105
- if (measurements.current.content <= measurements.current.viewport)
106
- publishPosition();
107
- }
108
-
109
- useImperativeHandle(ref, () => ({ scrollToEnd }));
110
-
111
- function reconcileLayout(contentChanged: boolean) {
112
- if (!ready()) return;
113
- const request = pendingScroll.current;
114
- if (items.length > 0 && !initialized.current) {
115
- initialized.current = true;
116
- if (request || initial.current === 'end') {
117
- scrollToEnd(request ?? { animated: false });
118
- return;
119
- }
120
- publishPosition();
121
- return;
122
- }
123
- if (request) {
124
- scrollToEnd(request);
125
- return;
126
- }
127
- const prepended = prependPending.current;
128
- if (contentChanged) prependPending.current = false;
129
- const shouldFollow =
130
- initialized.current &&
131
- followOutput === 'whenAtEnd' &&
132
- !userScrolling.current &&
133
- !prepended &&
134
- !preservingPrependRef.current &&
135
- atEndRef.current;
136
- if (shouldFollow) scrollToEnd({ animated: false });
137
- else publishPosition();
138
- }
139
-
140
- function onScroll(event: NativeSyntheticEvent<NativeScrollEvent>) {
141
- const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
142
- const movedUp = contentOffset.y < measurements.current.offset;
143
- const resizingAtEnd =
144
- atEndRef.current &&
145
- !movedUp &&
146
- !userScrolling.current &&
147
- followOutput === 'whenAtEnd' &&
148
- (measurements.current.content !== contentSize.height ||
149
- measurements.current.viewport !== layoutMeasurement.height);
150
- measurements.current = {
151
- offset: contentOffset.y,
152
- content: contentSize.height,
153
- viewport: layoutMeasurement.height,
154
- hasContentSize: true,
155
- };
156
- if (!resizingAtEnd) publishPosition();
157
- }
62
+ const renderRow = useCallback(
63
+ ({ item, index }: ListRenderItemInfo<T>) => (
64
+ <MessageListRow
65
+ item={item}
66
+ index={index}
67
+ previous={renderSeparator ? items[index - 1] : undefined}
68
+ nativeID={getRowNativeID(keys[index]!)}
69
+ renderItem={renderItem}
70
+ renderSeparator={renderSeparator}
71
+ extraData={extraData}
72
+ />
73
+ ),
74
+ [extraData, getRowNativeID, items, keys, renderItem, renderSeparator]
75
+ );
158
76
 
159
77
  const history = hasEarlier && onRequestEarlier;
160
78
  const hasHeader = Boolean(header || history);
79
+ const listHeader = useMemo(
80
+ () =>
81
+ hasHeader ? (
82
+ <View>
83
+ {header}
84
+ {history ? (
85
+ <Button
86
+ label={loadingEarlier ? '正在加载…' : '查看更早消息'}
87
+ variant="text"
88
+ loading={loadingEarlier}
89
+ onPress={onRequestEarlier}
90
+ />
91
+ ) : null}
92
+ </View>
93
+ ) : undefined,
94
+ [hasHeader, header, history, loadingEarlier, onRequestEarlier]
95
+ );
96
+ const listFooter = useMemo(
97
+ () => (footer ? <View>{footer}</View> : undefined),
98
+ [footer]
99
+ );
100
+ const listEmpty = useMemo(
101
+ () => (empty ? <View>{empty}</View> : undefined),
102
+ [empty]
103
+ );
161
104
  return (
162
105
  <View style={[styles.root, style]} testID={testID}>
163
106
  <FlatList
@@ -167,63 +110,21 @@ export function MessageList<T>({
167
110
  data={items}
168
111
  keyExtractor={keyExtractor}
169
112
  extraData={extraData}
170
- renderItem={({ item, index }) => (
171
- <View nativeID={getRowNativeID(keyExtractor(item))}>
172
- {index > 0 && renderSeparator
173
- ? renderSeparator(items[index - 1]!, item)
174
- : null}
175
- {renderItem(item, index)}
176
- </View>
177
- )}
178
- ListHeaderComponent={
179
- hasHeader ? (
180
- <View>
181
- {header}
182
- {history ? (
183
- <Button
184
- label={loadingEarlier ? '正在加载…' : '查看更早消息'}
185
- variant="text"
186
- loading={loadingEarlier}
187
- onPress={onRequestEarlier}
188
- />
189
- ) : null}
190
- </View>
191
- ) : undefined
192
- }
193
- ListFooterComponent={footer ? <View>{footer}</View> : undefined}
194
- ListEmptyComponent={empty ? <View>{empty}</View> : undefined}
195
- maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
113
+ renderItem={renderRow}
114
+ ListHeaderComponent={listHeader}
115
+ ListFooterComponent={listFooter}
116
+ ListEmptyComponent={listEmpty}
117
+ maintainVisibleContentPosition={MAINTAIN_VISIBLE_POSITION}
196
118
  keyboardDismissMode={keyboardDismissMode}
197
119
  keyboardShouldPersistTaps="handled"
198
120
  scrollEventThrottle={16}
199
- onLayout={({ nativeEvent: { layout } }) => {
200
- measurements.current.viewport = layout.height;
201
- reconcileLayout(false);
202
- }}
203
- onContentSizeChange={(_width, height) => {
204
- measurements.current.content = height;
205
- measurements.current.hasContentSize = true;
206
- reconcileLayout(true);
207
- }}
121
+ onLayout={onLayout}
122
+ onContentSizeChange={onContentSizeChange}
208
123
  onScroll={onScroll}
209
- onScrollBeginDrag={() => {
210
- userScrolling.current = true;
211
- }}
212
- onMomentumScrollBegin={() => {
213
- userScrolling.current = true;
214
- }}
215
- onScrollEndDrag={(event) => {
216
- onScroll(event);
217
- if (!event.nativeEvent.velocity?.y) {
218
- userScrolling.current = false;
219
- publishPosition();
220
- }
221
- }}
222
- onMomentumScrollEnd={(event) => {
223
- onScroll(event);
224
- userScrolling.current = false;
225
- publishPosition();
226
- }}
124
+ onScrollBeginDrag={onUserScrollBegin}
125
+ onMomentumScrollBegin={onUserScrollBegin}
126
+ onScrollEndDrag={onScrollEndDrag}
127
+ onMomentumScrollEnd={onMomentumScrollEnd}
227
128
  />
228
129
  {showScrollToEnd && items.length > 0 && !atEnd ? (
229
130
  <Button
@@ -0,0 +1,35 @@
1
+ import { memo } from 'react';
2
+ import { View } from 'react-native';
3
+ import type { MessageListProps } from './types';
4
+
5
+ interface MessageListRowProps<T> extends Pick<
6
+ MessageListProps<T>,
7
+ 'renderItem' | 'renderSeparator' | 'extraData'
8
+ > {
9
+ item: T;
10
+ previous: T | undefined;
11
+ index: number;
12
+ nativeID: string;
13
+ }
14
+
15
+ function MessageListRowContent<T>({
16
+ item,
17
+ previous,
18
+ index,
19
+ nativeID,
20
+ renderItem,
21
+ renderSeparator,
22
+ }: MessageListRowProps<T>) {
23
+ return (
24
+ <View nativeID={nativeID}>
25
+ {index > 0 && renderSeparator ? renderSeparator(previous!, item) : null}
26
+ {renderItem(item, index)}
27
+ </View>
28
+ );
29
+ }
30
+
31
+ // extraData participates in memo's shallow comparison even though renderers
32
+ // read that external display state through their own closures.
33
+ export const MessageListRow = memo(
34
+ MessageListRowContent
35
+ ) as typeof MessageListRowContent;
@@ -0,0 +1,216 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState } from 'react';
2
+ import type { RefObject } from 'react';
3
+ import type {
4
+ FlatList,
5
+ LayoutChangeEvent,
6
+ NativeScrollEvent,
7
+ NativeSyntheticEvent,
8
+ } from 'react-native';
9
+ import { DEFAULT_END_THRESHOLD } from './constants';
10
+ import type {
11
+ ListMeasurements,
12
+ MessageListProps,
13
+ MessageListScrollOptions,
14
+ } from './types';
15
+
16
+ interface MessageListScrollInput<T> extends Pick<
17
+ MessageListProps<T>,
18
+ 'initialPosition' | 'followOutput' | 'endThreshold' | 'onAtEndChange'
19
+ > {
20
+ listRef: RefObject<FlatList<T> | null>;
21
+ keys: readonly string[];
22
+ preservingPrependRef: RefObject<boolean>;
23
+ cancelPrependPreservation(): void;
24
+ }
25
+
26
+ export function useMessageListScroll<T>({
27
+ listRef,
28
+ keys,
29
+ initialPosition,
30
+ followOutput,
31
+ endThreshold = DEFAULT_END_THRESHOLD,
32
+ onAtEndChange,
33
+ preservingPrependRef,
34
+ cancelPrependPreservation,
35
+ }: MessageListScrollInput<T>) {
36
+ const measurements = useRef<ListMeasurements>({
37
+ viewport: 0,
38
+ content: 0,
39
+ offset: 0,
40
+ hasContentSize: false,
41
+ });
42
+ const initial = useRef(initialPosition);
43
+ const initialized = useRef(false);
44
+ const previousKeys = useRef<readonly string[]>([]);
45
+ const prependPending = useRef(false);
46
+ const pendingScroll = useRef<MessageListScrollOptions | undefined>(undefined);
47
+ const userScrolling = useRef(false);
48
+ const atEndRef = useRef<boolean | undefined>(undefined);
49
+ const [atEnd, setAtEnd] = useState(true);
50
+ const threshold = Number.isFinite(endThreshold)
51
+ ? Math.max(0, endThreshold)
52
+ : DEFAULT_END_THRESHOLD;
53
+
54
+ useLayoutEffect(() => {
55
+ const previous = previousKeys.current;
56
+ const added = keys.length - previous.length;
57
+ const onlyPrepended =
58
+ previous.length > 0 &&
59
+ added > 0 &&
60
+ previous.every((key, index) => keys[index + added] === key);
61
+ const changed =
62
+ keys.length !== previous.length ||
63
+ keys.some((key, index) => key !== previous[index]);
64
+ if (changed) prependPending.current = onlyPrepended;
65
+ previousKeys.current = keys;
66
+ }, [keys]);
67
+
68
+ const ready = useCallback(() => {
69
+ const current = measurements.current;
70
+ return current.viewport > 0 && current.hasContentSize;
71
+ }, []);
72
+
73
+ const publishPosition = useCallback(() => {
74
+ if (!ready()) return;
75
+ const { viewport, content, offset } = measurements.current;
76
+ const next = content - viewport - Math.max(0, offset) <= threshold;
77
+ if (next !== atEndRef.current) {
78
+ atEndRef.current = next;
79
+ setAtEnd(next);
80
+ onAtEndChange?.(next);
81
+ }
82
+ }, [onAtEndChange, ready, threshold]);
83
+
84
+ const scrollToEnd = useCallback(
85
+ (options: MessageListScrollOptions = {}) => {
86
+ cancelPrependPreservation();
87
+ const request = { animated: options.animated ?? true };
88
+ if (!ready()) {
89
+ pendingScroll.current = request;
90
+ return;
91
+ }
92
+ pendingScroll.current = undefined;
93
+ listRef.current?.scrollToOffset({
94
+ offset: Math.max(
95
+ 0,
96
+ measurements.current.content - measurements.current.viewport
97
+ ),
98
+ animated: request.animated,
99
+ });
100
+ if (measurements.current.content <= measurements.current.viewport)
101
+ publishPosition();
102
+ },
103
+ [cancelPrependPreservation, listRef, publishPosition, ready]
104
+ );
105
+
106
+ const itemCount = keys.length;
107
+ const reconcileLayout = useCallback(
108
+ (contentChanged: boolean) => {
109
+ if (!ready()) return;
110
+ const request = pendingScroll.current;
111
+ if (itemCount > 0 && !initialized.current) {
112
+ initialized.current = true;
113
+ if (request || initial.current === 'end') {
114
+ scrollToEnd(request ?? { animated: false });
115
+ return;
116
+ }
117
+ publishPosition();
118
+ return;
119
+ }
120
+ if (request) {
121
+ scrollToEnd(request);
122
+ return;
123
+ }
124
+ const prepended = prependPending.current;
125
+ if (contentChanged) prependPending.current = false;
126
+ const shouldFollow =
127
+ initialized.current &&
128
+ followOutput === 'whenAtEnd' &&
129
+ !userScrolling.current &&
130
+ !prepended &&
131
+ !preservingPrependRef.current &&
132
+ atEndRef.current;
133
+ if (shouldFollow) scrollToEnd({ animated: false });
134
+ else publishPosition();
135
+ },
136
+ [
137
+ followOutput,
138
+ itemCount,
139
+ preservingPrependRef,
140
+ publishPosition,
141
+ ready,
142
+ scrollToEnd,
143
+ ]
144
+ );
145
+
146
+ const onScroll = useCallback(
147
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
148
+ const { contentOffset, contentSize, layoutMeasurement } =
149
+ event.nativeEvent;
150
+ const movedUp = contentOffset.y < measurements.current.offset;
151
+ const resizingAtEnd =
152
+ atEndRef.current &&
153
+ !movedUp &&
154
+ !userScrolling.current &&
155
+ followOutput === 'whenAtEnd' &&
156
+ (measurements.current.content !== contentSize.height ||
157
+ measurements.current.viewport !== layoutMeasurement.height);
158
+ measurements.current = {
159
+ offset: contentOffset.y,
160
+ content: contentSize.height,
161
+ viewport: layoutMeasurement.height,
162
+ hasContentSize: true,
163
+ };
164
+ if (!resizingAtEnd) publishPosition();
165
+ },
166
+ [followOutput, publishPosition]
167
+ );
168
+
169
+ const onLayout = useCallback(
170
+ ({ nativeEvent: { layout } }: LayoutChangeEvent) => {
171
+ measurements.current.viewport = layout.height;
172
+ reconcileLayout(false);
173
+ },
174
+ [reconcileLayout]
175
+ );
176
+ const onContentSizeChange = useCallback(
177
+ (_width: number, height: number) => {
178
+ measurements.current.content = height;
179
+ measurements.current.hasContentSize = true;
180
+ reconcileLayout(true);
181
+ },
182
+ [reconcileLayout]
183
+ );
184
+ const onUserScrollBegin = useCallback(() => {
185
+ userScrolling.current = true;
186
+ }, []);
187
+ const onScrollEndDrag = useCallback(
188
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
189
+ onScroll(event);
190
+ if (!event.nativeEvent.velocity?.y) {
191
+ userScrolling.current = false;
192
+ publishPosition();
193
+ }
194
+ },
195
+ [onScroll, publishPosition]
196
+ );
197
+ const onMomentumScrollEnd = useCallback(
198
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
199
+ onScroll(event);
200
+ userScrolling.current = false;
201
+ publishPosition();
202
+ },
203
+ [onScroll, publishPosition]
204
+ );
205
+
206
+ return {
207
+ atEnd,
208
+ scrollToEnd,
209
+ onLayout,
210
+ onContentSizeChange,
211
+ onScroll,
212
+ onUserScrollBegin,
213
+ onScrollEndDrag,
214
+ onMomentumScrollEnd,
215
+ };
216
+ }
@@ -14,6 +14,8 @@ export interface WebElementLike {
14
14
  id: string;
15
15
  scrollTop: number;
16
16
  scrollHeight: number;
17
+ readonly children?: ArrayLike<unknown>;
18
+ getInnerViewNode?(): unknown;
17
19
  getBoundingClientRect(): WebRectLike;
18
20
  querySelectorAll(selector: string): ArrayLike<unknown>;
19
21
  contains(candidate: unknown): boolean;