@unif/react-native-chat 2.1.1 → 2.1.2

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 (29) hide show
  1. package/lib/module/components/Message/MarkdownContent/MarkdownContent.js +16 -5
  2. package/lib/module/components/Message/MarkdownContent/MarkdownContent.js.map +1 -1
  3. package/lib/module/components/MessageList/MessageList.js +64 -156
  4. package/lib/module/components/MessageList/MessageList.js.map +1 -1
  5. package/lib/module/components/MessageList/MessageListRow.js +23 -0
  6. package/lib/module/components/MessageList/MessageListRow.js.map +1 -0
  7. package/lib/module/components/MessageList/useMessageListScroll.js +153 -0
  8. package/lib/module/components/MessageList/useMessageListScroll.js.map +1 -0
  9. package/lib/module/components/MessageList/webPrependAnchor/useWebPrependAnchor.web.js +35 -26
  10. package/lib/module/components/MessageList/webPrependAnchor/useWebPrependAnchor.web.js.map +1 -1
  11. package/lib/typescript/src/components/Message/MarkdownContent/MarkdownContent.d.ts.map +1 -1
  12. package/lib/typescript/src/components/Message/MarkdownContent/types.d.ts +3 -0
  13. package/lib/typescript/src/components/Message/MarkdownContent/types.d.ts.map +1 -1
  14. package/lib/typescript/src/components/MessageList/MessageList.d.ts.map +1 -1
  15. package/lib/typescript/src/components/MessageList/MessageListRow.d.ts +11 -0
  16. package/lib/typescript/src/components/MessageList/MessageListRow.d.ts.map +1 -0
  17. package/lib/typescript/src/components/MessageList/useMessageListScroll.d.ts +21 -0
  18. package/lib/typescript/src/components/MessageList/useMessageListScroll.d.ts.map +1 -0
  19. package/lib/typescript/src/components/MessageList/webPrependAnchor/types.d.ts +2 -0
  20. package/lib/typescript/src/components/MessageList/webPrependAnchor/types.d.ts.map +1 -1
  21. package/lib/typescript/src/components/MessageList/webPrependAnchor/useWebPrependAnchor.web.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/components/Message/MarkdownContent/MarkdownContent.tsx +22 -5
  24. package/src/components/Message/MarkdownContent/types.ts +4 -0
  25. package/src/components/MessageList/MessageList.tsx +77 -176
  26. package/src/components/MessageList/MessageListRow.tsx +35 -0
  27. package/src/components/MessageList/useMessageListScroll.ts +216 -0
  28. package/src/components/MessageList/webPrependAnchor/types.ts +2 -0
  29. package/src/components/MessageList/webPrependAnchor/useWebPrependAnchor.web.ts +121 -87
@@ -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;
@@ -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(