@droppii-org/chat-mobile 0.2.88 → 0.2.90
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.
- package/lib/module/components/ThreadCard/ThreadCard.js +0 -1
- package/lib/module/components/ThreadCard/ThreadCard.js.map +1 -1
- package/lib/module/components/messages/LinkifiedText.js +34 -0
- package/lib/module/components/messages/LinkifiedText.js.map +1 -0
- package/lib/module/components/messages/quotedMessage/index.js +14 -2
- package/lib/module/components/messages/quotedMessage/index.js.map +1 -1
- package/lib/module/components/messages/textMessage/index.js +15 -22
- package/lib/module/components/messages/textMessage/index.js.map +1 -1
- package/lib/module/core/useChatListener.js +2 -2
- package/lib/module/core/useChatListener.js.map +1 -1
- package/lib/module/hooks/message/useMessages.js +6 -0
- package/lib/module/hooks/message/useMessages.js.map +1 -1
- package/lib/module/hooks/message/useSendMessage.js +10 -3
- package/lib/module/hooks/message/useSendMessage.js.map +1 -1
- package/lib/module/screens/ResourceCenter/ResourceCenterTabScene.js.map +1 -1
- package/lib/module/screens/chat-detail/ChatDetail.js +2 -1
- package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
- package/lib/module/screens/chat-detail/ChatListLegend.js +235 -123
- package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
- package/lib/module/screens/chat-detail/PinnedMessagesSheet.js +4 -1
- package/lib/module/screens/chat-detail/PinnedMessagesSheet.js.map +1 -1
- package/lib/module/screens/chat-detail/PinnedMessagesSheetBackup.js +3 -1
- package/lib/module/screens/chat-detail/PinnedMessagesSheetBackup.js.map +1 -1
- package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +11 -9
- package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
- package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js +37 -4
- package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js.map +1 -1
- package/lib/module/services/resourceCenter.js +45 -22
- package/lib/module/services/resourceCenter.js.map +1 -1
- package/lib/module/store/message.js +8 -1
- package/lib/module/store/message.js.map +1 -1
- package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
- package/lib/typescript/src/components/messages/LinkifiedText.d.ts +11 -0
- package/lib/typescript/src/components/messages/LinkifiedText.d.ts.map +1 -0
- package/lib/typescript/src/components/messages/quotedMessage/index.d.ts +1 -0
- package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
- package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/message/useMessages.d.ts.map +1 -1
- package/lib/typescript/src/hooks/message/useSendMessage.d.ts.map +1 -1
- package/lib/typescript/src/screens/ResourceCenter/ResourceCenterTabScene.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts +1 -1
- package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheet.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheetBackup.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +5 -3
- package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts +2 -0
- package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts.map +1 -1
- package/lib/typescript/src/services/resourceCenter.d.ts.map +1 -1
- package/lib/typescript/src/store/message.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/ThreadCard/ThreadCard.tsx +0 -2
- package/src/components/messages/LinkifiedText.tsx +37 -0
- package/src/components/messages/quotedMessage/index.tsx +18 -2
- package/src/components/messages/textMessage/index.tsx +18 -27
- package/src/core/useChatListener.ts +2 -2
- package/src/hooks/message/useMessages.ts +6 -0
- package/src/hooks/message/useSendMessage.ts +7 -0
- package/src/screens/ResourceCenter/ResourceCenterTabScene.tsx +0 -1
- package/src/screens/chat-detail/ChatDetail.tsx +1 -0
- package/src/screens/chat-detail/ChatListLegend.tsx +236 -136
- package/src/screens/chat-detail/PinnedMessagesSheet.tsx +6 -1
- package/src/screens/chat-detail/PinnedMessagesSheetBackup.tsx +3 -1
- package/src/screens/chat-detail/legend/LegendChatMessage.tsx +16 -11
- package/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.tsx +47 -2
- package/src/services/resourceCenter.ts +77 -36
- package/src/store/message.ts +7 -1
|
@@ -23,7 +23,11 @@ import Animated, {
|
|
|
23
23
|
type SharedValue,
|
|
24
24
|
} from 'react-native-reanimated';
|
|
25
25
|
import { KeyboardAwareLegendList } from '@legendapp/list/keyboard';
|
|
26
|
-
import {
|
|
26
|
+
import { KeyboardEvents } from 'react-native-keyboard-controller';
|
|
27
|
+
import {
|
|
28
|
+
type LegendListRef,
|
|
29
|
+
type MaintainVisibleContentPositionConfig,
|
|
30
|
+
} from '@legendapp/list/react-native';
|
|
27
31
|
import {
|
|
28
32
|
KContainer,
|
|
29
33
|
KColors,
|
|
@@ -72,16 +76,18 @@ import ChatImageDimens, {
|
|
|
72
76
|
const ROW_MARGIN_CLUSTER_END = KSpacingValue['0.5rem'];
|
|
73
77
|
const ROW_MARGIN_IN_CLUSTER = KSpacingValue['0.25rem'];
|
|
74
78
|
|
|
79
|
+
const BUTTON_FADE_CONFIG = { duration: 200, easing: Easing.out(Easing.ease) };
|
|
80
|
+
|
|
81
|
+
// Long enough to swallow Android's willShow→willHide→willShow flapping
|
|
82
|
+
// (~1.3s period, measured), short enough that a real keyboard close still
|
|
83
|
+
// settles within one animation.
|
|
84
|
+
const KEYBOARD_HIDE_DEBOUNCE_MS = 300;
|
|
85
|
+
|
|
75
86
|
const getRowMarginBottom = (position: GroupPosition): number =>
|
|
76
87
|
position === 'last' || position === 'solo'
|
|
77
88
|
? ROW_MARGIN_CLUSTER_END
|
|
78
89
|
: ROW_MARGIN_IN_CLUSTER;
|
|
79
90
|
|
|
80
|
-
// KeyboardAwareLegendList auto scroll when modal opened
|
|
81
|
-
// TODO: Convert toolbox to absolute view
|
|
82
|
-
const ListComponent =
|
|
83
|
-
Platform.OS === 'ios' ? KeyboardAwareLegendList : LegendList;
|
|
84
|
-
|
|
85
91
|
interface ChatListLegendInternalProps extends ChatListProps {
|
|
86
92
|
listRef: RefObject<LegendListRef | null>;
|
|
87
93
|
freeze: SharedValue<boolean>;
|
|
@@ -101,7 +107,6 @@ export const ChatListLegend = memo(
|
|
|
101
107
|
moreOldLoading,
|
|
102
108
|
hasMoreOld,
|
|
103
109
|
onLoadMoreNew,
|
|
104
|
-
moreNewLoading,
|
|
105
110
|
hasMoreNew,
|
|
106
111
|
onMediaPress,
|
|
107
112
|
onPressUrl,
|
|
@@ -144,11 +149,37 @@ export const ChatListLegend = memo(
|
|
|
144
149
|
const buttonOpacity = useSharedValue(0);
|
|
145
150
|
const isAtBottomRef = useRef(true);
|
|
146
151
|
const scrollDebounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
152
|
+
// Current keyboard height, and the trigger for the re-anchor effect
|
|
153
|
+
// below — see the keyboard listener for how each platform uses it.
|
|
154
|
+
const [keyboardInset, setKeyboardInset] = useState(0);
|
|
155
|
+
const hasSeenFirstKeyboardInsetRef = useRef(false);
|
|
147
156
|
const rowRefsMap = useRef<Map<string, View | null>>(new Map());
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
157
|
+
// Set on a real touch-drag (`onScrollBeginDrag`), before onStartReached/
|
|
158
|
+
// onEndReached's threshold could fire. `onMomentumScrollBegin` was tried
|
|
159
|
+
// and reverted: it only fires after the finger lifts, so the first
|
|
160
|
+
// gesture's mid-drag threshold-crossing beats it, needing a wasted
|
|
161
|
+
// second gesture to actually unlock. See
|
|
162
|
+
// project_chatlistlegend_scroll_debugging for the full history.
|
|
163
|
+
const hasUserScrolledRef = useRef(false);
|
|
164
|
+
// Old Architecture defers recomputing `alignItemsAtEndPadding` when
|
|
165
|
+
// content shrinks while `initialScrollAtEnd` is active. `estimatedItemSize`
|
|
166
|
+
// below is a deliberate lower bound to mostly avoid that; this ref
|
|
167
|
+
// backstops the rest via one forced `scrollToEnd` on mount (`handleListLoad`).
|
|
168
|
+
const hasCorrectedInitialPaddingRef = useRef(false);
|
|
169
|
+
// `messages` starts empty, so `lastMessageId`'s first real value looks
|
|
170
|
+
// identical to a new message — tracking the previous id (not a one-shot
|
|
171
|
+
// flag) lets the effect below tell the two apart.
|
|
172
|
+
const previousLastMessageIdRef = useRef<string | null>(null);
|
|
173
|
+
// Read via ref (not as a dependency) by the `lastMessageId` effect below —
|
|
174
|
+
// a loading-state flip alone shouldn't re-run it.
|
|
175
|
+
const moreOldLoadingRef = useRef(moreOldLoading);
|
|
176
|
+
moreOldLoadingRef.current = moreOldLoading;
|
|
177
|
+
// Read via ref (not as a dependency) by the ScrollToMessage subscription
|
|
178
|
+
// below — the whole array changes on every new message and every old-
|
|
179
|
+
// history page, which would otherwise tear down and re-add both
|
|
180
|
+
// DeviceEventEmitter listeners on every one of those.
|
|
181
|
+
const messagesRef = useRef(messages);
|
|
182
|
+
messagesRef.current = messages;
|
|
152
183
|
const [showBadge, setShowBadge] = useState(false);
|
|
153
184
|
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
|
154
185
|
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
|
@@ -190,7 +221,7 @@ export const ChatListLegend = memo(
|
|
|
190
221
|
}, []);
|
|
191
222
|
|
|
192
223
|
const renderItem = useCallback(
|
|
193
|
-
({ item, index }:
|
|
224
|
+
({ item, index }: { item: MessageItem; index: number }) => {
|
|
194
225
|
try {
|
|
195
226
|
if (!item) return null;
|
|
196
227
|
|
|
@@ -247,17 +278,14 @@ export const ChatListLegend = memo(
|
|
|
247
278
|
>
|
|
248
279
|
<LegendChatMessage
|
|
249
280
|
message={message}
|
|
281
|
+
messageId={messageId}
|
|
250
282
|
isOutgoing={isOutgoing}
|
|
251
283
|
createdAtTime={createdAt}
|
|
252
284
|
onMediaPress={onMediaPress}
|
|
253
285
|
onPressUrl={onPressUrl}
|
|
254
286
|
onRetry={retryMessage}
|
|
255
|
-
onPress={
|
|
256
|
-
onLongPress={
|
|
257
|
-
onLongPress
|
|
258
|
-
? () => handleMessageLongPress(messageId, message)
|
|
259
|
-
: undefined
|
|
260
|
-
}
|
|
287
|
+
onPress={handleMessagePress}
|
|
288
|
+
onLongPress={onLongPress ? handleMessageLongPress : undefined}
|
|
261
289
|
customMessageItemFactory={customMessageItemFactory}
|
|
262
290
|
isHighlighted={highlightedId === messageId}
|
|
263
291
|
isTimeRevealed={revealedMessageId === messageId}
|
|
@@ -310,33 +338,21 @@ export const ChatListLegend = memo(
|
|
|
310
338
|
]
|
|
311
339
|
);
|
|
312
340
|
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
// mid-prepend (toggling forces an extra layout recompute). `data` anchoring keeps
|
|
329
|
-
// the viewport pinned to the item being read when older messages are added on top.
|
|
330
|
-
const maintainVisiblePositionConfig = useMemo(() => ({ data: true }), []);
|
|
331
|
-
|
|
332
|
-
// Buckets averaging (`averageSizes[itemType]`, internal to LegendList) per
|
|
333
|
-
// content type, so a batch of images landing early doesn't pollute the
|
|
334
|
-
// estimate used for still-unmeasured text rows and vice versa. Items
|
|
335
|
-
// rendered via `customMessageItemFactory` (host apps can override ANY
|
|
336
|
-
// content type with an arbitrary custom node — see `LegendChatMessage`'s
|
|
337
|
-
// `customNode` short-circuit, checked before its own contentType switch)
|
|
338
|
-
// get their own bucket per content type, so a custom node's real measured
|
|
339
|
-
// height never pollutes the native component's average for that same type.
|
|
341
|
+
// `maintainVisibleContentPosition` anchors a prepend to whatever native
|
|
342
|
+
// view sits at the top. Default `drawDistance` (250px) doesn't survive a
|
|
343
|
+
// ~1-1.5s history fetch if the user keeps scrolling away — the anchor
|
|
344
|
+
// unmounts and the native compensation silently drops (the "list drifts
|
|
345
|
+
// after loading old history" bug). `drawDistance` below is raised to
|
|
346
|
+
// outlast a realistic scroll during that wait (measured ~300-350px).
|
|
347
|
+
// `recycleItems` is safe again since @legendapp/list 3.3.7 fixed
|
|
348
|
+
// recycled rows flashing wrong content on prepend.
|
|
349
|
+
const maintainVisiblePositionConfig: MaintainVisibleContentPositionConfig =
|
|
350
|
+
useMemo(() => ({ data: true }), []);
|
|
351
|
+
|
|
352
|
+
// Buckets LegendList's size-averaging per content type so a batch of
|
|
353
|
+
// images landing early doesn't pollute the estimate for still-unmeasured
|
|
354
|
+
// text rows. `customMessageItemFactory` nodes get their own bucket per
|
|
355
|
+
// type too, so a custom node's real height doesn't pollute that type's average.
|
|
340
356
|
const getItemType = useCallback(
|
|
341
357
|
(item: MessageItem) => {
|
|
342
358
|
const isCustom = !!customMessageItemFactory?.(item);
|
|
@@ -345,14 +361,12 @@ export const ChatListLegend = memo(
|
|
|
345
361
|
[customMessageItemFactory]
|
|
346
362
|
);
|
|
347
363
|
|
|
348
|
-
// Exact size for content types
|
|
349
|
-
// `plans/260728-1400-chatlist-legend-fixed-item-size/plan.md
|
|
350
|
-
// `undefined` (defer to
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
// and once it resolves a number that item is never measured again, so
|
|
355
|
-
// guessing wrong here is a permanent regression, not just an approximation.
|
|
364
|
+
// Exact size for content types knowable ahead of render (see
|
|
365
|
+
// `plans/260728-1400-chatlist-legend-fixed-item-size/plan.md`). Returns
|
|
366
|
+
// `undefined` (defer to measurement) for anything unpredictable, or
|
|
367
|
+
// carrying chrome this function doesn't account for (day-divider, flow-
|
|
368
|
+
// input-buttons row) — a resolved number is never re-measured, so
|
|
369
|
+
// guessing wrong here is permanent, not just approximate.
|
|
356
370
|
const getFixedItemSize = useCallback(
|
|
357
371
|
(item: MessageItem, index: number): number | undefined => {
|
|
358
372
|
const message = item;
|
|
@@ -432,13 +446,10 @@ export const ChatListLegend = memo(
|
|
|
432
446
|
]
|
|
433
447
|
);
|
|
434
448
|
|
|
435
|
-
// LegendList
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
// message itself
|
|
439
|
-
// 'first' into a 'middle'. messageDataMap is memoised on exactly the inputs
|
|
440
|
-
// that can move those boundaries, so including it here is what keeps a
|
|
441
|
-
// stale tail from being left behind.
|
|
449
|
+
// LegendList only re-renders an item when [itemKey, item, extraData]
|
|
450
|
+
// changes. Cluster position depends on a message's NEIGHBOURS, which can
|
|
451
|
+
// shift (prepending history can turn a 'first' into a 'middle') without
|
|
452
|
+
// the message itself changing — messageDataMap here is what catches that.
|
|
442
453
|
const listExtraData = useMemo(
|
|
443
454
|
() => ({
|
|
444
455
|
messageDataMap,
|
|
@@ -457,51 +468,25 @@ export const ChatListLegend = memo(
|
|
|
457
468
|
);
|
|
458
469
|
|
|
459
470
|
const handleStartReached = useCallback(() => {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
: '[LegendList] onStartReached triggered',
|
|
466
|
-
{
|
|
467
|
-
conversationId,
|
|
468
|
-
messagesLength: messages.length,
|
|
469
|
-
firstMessageId:
|
|
470
|
-
messages[0]?.clientMsgID || messages[0]?.serverMsgID,
|
|
471
|
-
hasMoreOld,
|
|
472
|
-
moreOldLoading,
|
|
473
|
-
willLoad,
|
|
474
|
-
duringInitialSettle: !hasSettledRef.current,
|
|
475
|
-
}
|
|
476
|
-
);
|
|
477
|
-
}
|
|
478
|
-
if (willLoad) {
|
|
471
|
+
// Both thresholds are checked continuously, including at mount before
|
|
472
|
+
// any gesture — `hasUserScrolledRef` is what keeps that from firing a
|
|
473
|
+
// load-more the user never asked for.
|
|
474
|
+
if (!hasUserScrolledRef.current) return;
|
|
475
|
+
if (hasMoreOld && !useMessageStore.getState().moreOldLoading) {
|
|
479
476
|
onLoadMoreOld?.(true);
|
|
480
477
|
}
|
|
481
|
-
}, [
|
|
478
|
+
}, [hasMoreOld, onLoadMoreOld]);
|
|
482
479
|
|
|
483
480
|
const handleEndReached = useCallback(() => {
|
|
484
|
-
|
|
485
|
-
if (
|
|
486
|
-
console.log(
|
|
487
|
-
willLoad && !hasSettledRef.current
|
|
488
|
-
? '[LegendList] ⚠️ onEndReached SPURIOUS mount-time load'
|
|
489
|
-
: '[LegendList] onEndReached triggered',
|
|
490
|
-
{
|
|
491
|
-
conversationId,
|
|
492
|
-
messagesLength: messages.length,
|
|
493
|
-
lastMessageId: messages[messages.length - 1]?.clientMsgID,
|
|
494
|
-
hasMoreNew,
|
|
495
|
-
moreNewLoading,
|
|
496
|
-
willLoad,
|
|
497
|
-
duringInitialSettle: !hasSettledRef.current,
|
|
498
|
-
}
|
|
499
|
-
);
|
|
500
|
-
}
|
|
501
|
-
if (willLoad) {
|
|
481
|
+
if (!hasUserScrolledRef.current) return;
|
|
482
|
+
if (hasMoreNew && !useMessageStore.getState().moreNewLoading) {
|
|
502
483
|
onLoadMoreNew?.(true);
|
|
503
484
|
}
|
|
504
|
-
}, [
|
|
485
|
+
}, [hasMoreNew, onLoadMoreNew]);
|
|
486
|
+
|
|
487
|
+
const handleScrollBeginDrag = useCallback(() => {
|
|
488
|
+
hasUserScrolledRef.current = true;
|
|
489
|
+
}, []);
|
|
505
490
|
|
|
506
491
|
const handleScroll = useCallback(
|
|
507
492
|
(e: any) => {
|
|
@@ -518,10 +503,10 @@ export const ChatListLegend = memo(
|
|
|
518
503
|
scrollDebounceRef.current = setTimeout(() => {
|
|
519
504
|
if (isAtBottomRef.current !== isAtBottom) {
|
|
520
505
|
isAtBottomRef.current = isAtBottom;
|
|
521
|
-
buttonOpacity.value = withTiming(
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
506
|
+
buttonOpacity.value = withTiming(
|
|
507
|
+
isAtBottom ? 0 : 1,
|
|
508
|
+
BUTTON_FADE_CONFIG
|
|
509
|
+
);
|
|
525
510
|
}
|
|
526
511
|
if (isAtBottom) {
|
|
527
512
|
ChatMessageAPI.markConversationAsRead(conversationId || '');
|
|
@@ -534,15 +519,17 @@ export const ChatListLegend = memo(
|
|
|
534
519
|
const handleScrollToBottom = useCallback(() => {
|
|
535
520
|
if (hasMoreNew) {
|
|
536
521
|
onLoadMoreOld?.(false);
|
|
537
|
-
|
|
538
|
-
scrollMessageToEnd({ animated: true, closeKeyboard: false });
|
|
539
|
-
buttonOpacity.value = withTiming(0, {
|
|
540
|
-
duration: 200,
|
|
541
|
-
easing: Easing.out(Easing.ease),
|
|
542
|
-
});
|
|
543
|
-
isAtBottomRef.current = true;
|
|
544
|
-
setShowBadge(false);
|
|
522
|
+
return;
|
|
545
523
|
}
|
|
524
|
+
// `ChatEvent.ScrollToBottom` fires after every self-sent message, but
|
|
525
|
+
// the `lastMessageId` effect below already animates to it — re-scrolling
|
|
526
|
+
// here raced it and flickered. Only act when not already at bottom
|
|
527
|
+
// (FAB tap, or a send while scrolled away).
|
|
528
|
+
if (isAtBottomRef.current) return;
|
|
529
|
+
scrollMessageToEnd({ animated: true, closeKeyboard: false });
|
|
530
|
+
buttonOpacity.value = withTiming(0, BUTTON_FADE_CONFIG);
|
|
531
|
+
isAtBottomRef.current = true;
|
|
532
|
+
setShowBadge(false);
|
|
546
533
|
}, [hasMoreNew, onLoadMoreOld, scrollMessageToEnd, buttonOpacity]);
|
|
547
534
|
|
|
548
535
|
const animatedButtonStyle = useAnimatedStyle(() => ({
|
|
@@ -559,14 +546,113 @@ export const ChatListLegend = memo(
|
|
|
559
546
|
};
|
|
560
547
|
}, []);
|
|
561
548
|
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
549
|
+
// Tracks the keyboard's height as the trigger for the re-anchor below.
|
|
550
|
+
// The `willHide` side is debounced because Android's event stream flaps
|
|
551
|
+
// willShow→willHide→willShow roughly every 1.3s for as long as the
|
|
552
|
+
// keyboard stays open (measured on device) — without this, every flap
|
|
553
|
+
// would fire another scroll. A real close still lands, just one tick
|
|
554
|
+
// later. Setting the same height twice is a no-op for the effect below,
|
|
555
|
+
// so a stable open only ever re-anchors once.
|
|
556
|
+
//
|
|
557
|
+
// JS-thread only (no worklet), so none of the reentrant-lock crash risk
|
|
558
|
+
// that reaching into `useKeyboardHandler`'s worklets carries.
|
|
566
559
|
useEffect(() => {
|
|
567
|
-
|
|
560
|
+
let pendingHide: ReturnType<typeof setTimeout> | undefined;
|
|
561
|
+
const clearPendingHide = () => {
|
|
562
|
+
if (pendingHide) {
|
|
563
|
+
clearTimeout(pendingHide);
|
|
564
|
+
pendingHide = undefined;
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
const applyInset = (nextInset: number) => {
|
|
568
|
+
// Android only: the bottom inset the library applies there is a
|
|
569
|
+
// synthetic prop that `onScroll` does not report (its own
|
|
570
|
+
// `ScrollViewWithBottomPadding` says as much), so LegendList never
|
|
571
|
+
// learns about it, `alignItemsAtEndPadding` never shrinks, and the
|
|
572
|
+
// re-anchor below has no room to scroll into. Reporting it closes
|
|
573
|
+
// that gap. iOS needs none of this — there the inset is real and
|
|
574
|
+
// arrives through `onScroll` on its own, and overriding it here
|
|
575
|
+
// would double-count.
|
|
576
|
+
if (Platform.OS === 'android') {
|
|
577
|
+
listRef.current?.reportContentInset(
|
|
578
|
+
nextInset > 0 ? { bottom: nextInset } : null
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
setKeyboardInset(nextInset);
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const willShow = KeyboardEvents.addListener('keyboardWillShow', (e) => {
|
|
585
|
+
clearPendingHide();
|
|
586
|
+
applyInset(Math.max(e.height - keyboardOffset, 0));
|
|
587
|
+
});
|
|
588
|
+
const willHide = KeyboardEvents.addListener('keyboardWillHide', () => {
|
|
589
|
+
clearPendingHide();
|
|
590
|
+
pendingHide = setTimeout(
|
|
591
|
+
() => applyInset(0),
|
|
592
|
+
KEYBOARD_HIDE_DEBOUNCE_MS
|
|
593
|
+
);
|
|
594
|
+
});
|
|
595
|
+
return () => {
|
|
596
|
+
clearPendingHide();
|
|
597
|
+
willShow.remove();
|
|
598
|
+
willHide.remove();
|
|
599
|
+
};
|
|
600
|
+
}, [keyboardOffset, listRef]);
|
|
601
|
+
|
|
602
|
+
// Re-anchors to the end once the keyboard's inset has landed, which puts
|
|
603
|
+
// the last message at exactly the spacing the closed-keyboard layout has
|
|
604
|
+
// (verified on device against both a 7-message and a long conversation).
|
|
605
|
+
// Skipped when the user has scrolled away from the bottom — opening the
|
|
606
|
+
// keyboard shouldn't yank them back down.
|
|
607
|
+
useEffect(() => {
|
|
608
|
+
if (!hasSeenFirstKeyboardInsetRef.current) {
|
|
609
|
+
hasSeenFirstKeyboardInsetRef.current = true;
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (!isAtBottomRef.current) return;
|
|
613
|
+
scrollMessageToEnd({ animated: true, closeKeyboard: false });
|
|
614
|
+
}, [keyboardInset, scrollMessageToEnd]);
|
|
615
|
+
|
|
616
|
+
// This component can stay mounted across a conversationId change (e.g.
|
|
617
|
+
// navigation.replace reusing the same screen instance), so this ref must
|
|
618
|
+
// reset per-conversation — otherwise a leftover "user scrolled" flag from
|
|
619
|
+
// the previous conversation would wrongly let load-more fire on the
|
|
620
|
+
// newly opened one before any real scroll there.
|
|
621
|
+
useEffect(() => {
|
|
622
|
+
hasUserScrolledRef.current = false;
|
|
623
|
+
hasCorrectedInitialPaddingRef.current = false;
|
|
624
|
+
previousLastMessageIdRef.current = null;
|
|
568
625
|
}, [conversationId]);
|
|
569
626
|
|
|
627
|
+
// Single source of truth for "scroll to bottom on a new message" (self-
|
|
628
|
+
// sent or received). Skipped while prepending old history or scrolled
|
|
629
|
+
// away from the bottom. `moreOldLoading` is read via ref, not a
|
|
630
|
+
// dependency, so its own start/finish toggle doesn't re-run this.
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
const previousLastMessageId = previousLastMessageIdRef.current;
|
|
633
|
+
previousLastMessageIdRef.current = lastMessageId;
|
|
634
|
+
|
|
635
|
+
// null → a real id is the initial fetch landing (initialScrollAtEnd +
|
|
636
|
+
// handleListLoad's job), not a new message; a real id → a different
|
|
637
|
+
// one is.
|
|
638
|
+
const shouldFire =
|
|
639
|
+
previousLastMessageId !== null &&
|
|
640
|
+
!!lastMessageId &&
|
|
641
|
+
!moreOldLoadingRef.current &&
|
|
642
|
+
isAtBottomRef.current;
|
|
643
|
+
|
|
644
|
+
if (!shouldFire) return;
|
|
645
|
+
|
|
646
|
+
scrollMessageToEnd({ animated: true, closeKeyboard: false });
|
|
647
|
+
}, [lastMessageId, scrollMessageToEnd]);
|
|
648
|
+
|
|
649
|
+
const handleListLoad = useCallback(() => {
|
|
650
|
+
if (hasCorrectedInitialPaddingRef.current) return;
|
|
651
|
+
hasCorrectedInitialPaddingRef.current = true;
|
|
652
|
+
|
|
653
|
+
scrollMessageToEnd({ animated: false, closeKeyboard: false });
|
|
654
|
+
}, [scrollMessageToEnd]);
|
|
655
|
+
|
|
570
656
|
useEffect(() => {
|
|
571
657
|
const bottomSubscription = DeviceEventEmitter.addListener(
|
|
572
658
|
ChatEvent.ScrollToBottom,
|
|
@@ -576,7 +662,7 @@ export const ChatListLegend = memo(
|
|
|
576
662
|
const subscription = DeviceEventEmitter.addListener(
|
|
577
663
|
ChatEvent.ScrollToMessage,
|
|
578
664
|
({ clientMessageId }: ScrollToMessagePayload) => {
|
|
579
|
-
const index =
|
|
665
|
+
const index = messagesRef.current.findIndex(
|
|
580
666
|
(m) => m.clientMsgID === clientMessageId
|
|
581
667
|
);
|
|
582
668
|
|
|
@@ -621,11 +707,9 @@ export const ChatListLegend = memo(
|
|
|
621
707
|
subscription.remove();
|
|
622
708
|
};
|
|
623
709
|
}, [
|
|
624
|
-
messages,
|
|
625
710
|
applicationType,
|
|
626
711
|
searchMessages,
|
|
627
712
|
conversationId,
|
|
628
|
-
buttonOpacity,
|
|
629
713
|
handleScrollToBottom,
|
|
630
714
|
listRef,
|
|
631
715
|
]);
|
|
@@ -638,46 +722,62 @@ export const ChatListLegend = memo(
|
|
|
638
722
|
);
|
|
639
723
|
}
|
|
640
724
|
|
|
641
|
-
console.log('messages length: ', +messages.length);
|
|
642
725
|
return (
|
|
643
726
|
<KContainer.View flex background={KColors.white} style={styles.container}>
|
|
644
|
-
<
|
|
727
|
+
<KeyboardAwareLegendList
|
|
645
728
|
ref={listRef}
|
|
646
729
|
data={messages}
|
|
647
730
|
renderItem={renderItem}
|
|
648
|
-
|
|
731
|
+
// Deliberate lower bound (not a best-guess average) — see the
|
|
732
|
+
// comment above `hasCorrectedInitialPaddingRef`. A single-line
|
|
733
|
+
// bubble floors out around 38-44px, so this stays safely under it.
|
|
734
|
+
estimatedItemSize={36}
|
|
649
735
|
getItemType={getItemType}
|
|
650
736
|
getFixedItemSize={getFixedItemSize}
|
|
651
737
|
keyExtractor={(item) => String(item.clientMsgID || item.serverMsgID)}
|
|
652
|
-
recycleItems
|
|
738
|
+
recycleItems
|
|
653
739
|
extraData={listExtraData}
|
|
654
740
|
alignItemsAtEnd
|
|
741
|
+
alwaysRender={{
|
|
742
|
+
top: 10,
|
|
743
|
+
}}
|
|
744
|
+
maintainScrollAtEnd
|
|
655
745
|
maintainVisibleContentPosition={maintainVisiblePositionConfig}
|
|
746
|
+
// See the comment above `maintainVisiblePositionConfig` — this is
|
|
747
|
+
// what keeps a history prepend's anchor item mounted through a slow
|
|
748
|
+
// fetch instead of the default 250px window losing it.
|
|
749
|
+
drawDistance={800}
|
|
656
750
|
freeze={freeze}
|
|
657
751
|
keyboardOffset={keyboardOffset}
|
|
752
|
+
// The list's own keyboard compensation is off on both platforms —
|
|
753
|
+
// measured, it overshoots badly either way. On iOS it scrolls on
|
|
754
|
+
// top of React Native's `automaticallyAdjustContentInsets` (which
|
|
755
|
+
// already insets the scroll view by the full keyboard height), and
|
|
756
|
+
// that double lift is the blank gap. On Android it overshot by
|
|
757
|
+
// ~690px against a 128px target. What both platforms do keep from
|
|
758
|
+
// the library is the bottom inset it sets, which is the room the
|
|
759
|
+
// re-anchor above scrolls into.
|
|
760
|
+
keyboardLiftBehavior="never"
|
|
658
761
|
scrollEventThrottle={16}
|
|
659
762
|
onScroll={handleScroll}
|
|
763
|
+
onScrollBeginDrag={handleScrollBeginDrag}
|
|
764
|
+
onLoad={handleListLoad}
|
|
660
765
|
contentContainerStyle={styles.content}
|
|
661
766
|
scrollIndicatorInsets={{ right: 1 }}
|
|
662
767
|
ListHeaderComponent={
|
|
663
768
|
<LegendChatLoadEarlier isLoading={moreOldLoading} />
|
|
664
769
|
}
|
|
665
|
-
onLoad={(info: { elapsedTimeInMs: number }) => {
|
|
666
|
-
hasSettledRef.current = true;
|
|
667
|
-
if (__DEV__) {
|
|
668
|
-
console.log('[LegendList] onLoad (initial settle done)', {
|
|
669
|
-
conversationId,
|
|
670
|
-
messagesLength: messages.length,
|
|
671
|
-
elapsedTimeInMs: info.elapsedTimeInMs,
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
}}
|
|
675
770
|
onStartReached={handleStartReached}
|
|
676
|
-
|
|
771
|
+
// LegendList only re-fires onStartReached after scrolling away from
|
|
772
|
+
// the threshold by 1.3x and back. At 0.5 that distance exceeded a
|
|
773
|
+
// short conversation's whole scrollable range, so the mount-time
|
|
774
|
+
// firing we suppress in handleStartReached "used up" the only
|
|
775
|
+
// trigger. 0.3 keeps that reset distance safely under a
|
|
776
|
+
// ~1.5-screen conversation; shorter ones can still hit it.
|
|
777
|
+
onStartReachedThreshold={0.2}
|
|
677
778
|
onEndReached={handleEndReached}
|
|
678
779
|
onEndReachedThreshold={0.2}
|
|
679
|
-
|
|
680
|
-
initialScrollIndex={messages.length - 1}
|
|
780
|
+
initialScrollAtEnd
|
|
681
781
|
/>
|
|
682
782
|
<Animated.View
|
|
683
783
|
style={[styles.scrollToBottomButton, animatedButtonStyle]}
|
|
@@ -52,7 +52,11 @@ const PinnedMessagesSheetContent = memo(
|
|
|
52
52
|
);
|
|
53
53
|
|
|
54
54
|
const handlePressMessage = useCallback(
|
|
55
|
-
(
|
|
55
|
+
(messageId: string) => {
|
|
56
|
+
const message = usePinnedMessageStore
|
|
57
|
+
.getState()
|
|
58
|
+
.messages.find((m) => (m.clientMsgID || m.serverMsgID) === messageId);
|
|
59
|
+
if (!message) return;
|
|
56
60
|
UIUtils.bottomSheet.dismiss();
|
|
57
61
|
onPressMessage?.(message);
|
|
58
62
|
},
|
|
@@ -117,6 +121,7 @@ const PinnedMessagesSheetContent = memo(
|
|
|
117
121
|
|
|
118
122
|
<LegendChatMessage
|
|
119
123
|
message={message}
|
|
124
|
+
messageId={messageId}
|
|
120
125
|
isOutgoing={isOutgoing}
|
|
121
126
|
createdAtTime={createdAtTime}
|
|
122
127
|
onPress={handlePressMessage}
|
|
@@ -90,6 +90,7 @@ const PinnedMessageToolboxOverlay = memo(
|
|
|
90
90
|
message={
|
|
91
91
|
<LegendChatMessage
|
|
92
92
|
message={message}
|
|
93
|
+
messageId={message.clientMsgID || message.serverMsgID || ''}
|
|
93
94
|
isOutgoing={isOutgoing}
|
|
94
95
|
createdAtTime={message.sendTime || message.createTime}
|
|
95
96
|
customMessageItemFactory={customMessageItemFactory}
|
|
@@ -191,9 +192,10 @@ const PinnedMessagesSheetContent = memo(
|
|
|
191
192
|
>
|
|
192
193
|
<LegendChatMessage
|
|
193
194
|
message={message}
|
|
195
|
+
messageId={messageId}
|
|
194
196
|
isOutgoing={isOutgoing}
|
|
195
197
|
createdAtTime={createdAtTime}
|
|
196
|
-
onLongPress={
|
|
198
|
+
onLongPress={handleLongPress}
|
|
197
199
|
customMessageItemFactory={customMessageItemFactory}
|
|
198
200
|
/>
|
|
199
201
|
{!!timeLabel && (
|
|
@@ -38,13 +38,15 @@ type MessageProps = BaseLegendMessageProps & {
|
|
|
38
38
|
|
|
39
39
|
interface LegendChatMessageProps {
|
|
40
40
|
message: MessageItem;
|
|
41
|
+
/** Stable per-row id (see getMessageRenderId) — bound into onPress/onLongPress locally so those callbacks stay referentially stable across unrelated list re-renders. */
|
|
42
|
+
messageId: string;
|
|
41
43
|
isOutgoing: boolean;
|
|
42
44
|
createdAtTime: number;
|
|
43
45
|
onMediaPress?: (items: MediaItem[], index?: number) => void;
|
|
44
46
|
onPressUrl?: (url: string) => void;
|
|
45
47
|
onRetry?: (message: MessageItem) => void;
|
|
46
|
-
onPress?: (
|
|
47
|
-
onLongPress?: () => void;
|
|
48
|
+
onPress?: (messageId: string) => void;
|
|
49
|
+
onLongPress?: (messageId: string, message: MessageItem) => void;
|
|
48
50
|
customMessageItemFactory?: (m: MessageItem) => ReactNode;
|
|
49
51
|
isHighlighted?: boolean;
|
|
50
52
|
isTimeRevealed?: boolean;
|
|
@@ -99,6 +101,7 @@ const TIME_REVEAL_EXCLUDED_TYPES: number[] = [
|
|
|
99
101
|
export const LegendChatMessage = memo(
|
|
100
102
|
({
|
|
101
103
|
message,
|
|
104
|
+
messageId,
|
|
102
105
|
isOutgoing,
|
|
103
106
|
createdAtTime,
|
|
104
107
|
onMediaPress,
|
|
@@ -115,12 +118,12 @@ export const LegendChatMessage = memo(
|
|
|
115
118
|
groupPosition,
|
|
116
119
|
}: LegendChatMessageProps) => {
|
|
117
120
|
const handlePress = useCallback(() => {
|
|
118
|
-
onPress?.(
|
|
119
|
-
}, [onPress,
|
|
121
|
+
onPress?.(messageId);
|
|
122
|
+
}, [onPress, messageId]);
|
|
120
123
|
|
|
121
124
|
const handleLongPress = useCallback(() => {
|
|
122
|
-
onLongPress?.();
|
|
123
|
-
}, [onLongPress]);
|
|
125
|
+
onLongPress?.(messageId, message);
|
|
126
|
+
}, [onLongPress, messageId, message]);
|
|
124
127
|
|
|
125
128
|
const customNode = customMessageItemFactory?.(message);
|
|
126
129
|
|
|
@@ -147,14 +150,16 @@ export const LegendChatMessage = memo(
|
|
|
147
150
|
|
|
148
151
|
const hasMediaPress =
|
|
149
152
|
MEDIA_TYPES.includes(message?.contentType) && onMediaPress;
|
|
150
|
-
// AtTextMessage
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// a tap handler.
|
|
153
|
+
// AtTextMessage: mention+URL combo attaches its link-preview card via
|
|
154
|
+
// ex.urlMetadata (see TextMessage's doc comment) and/or the native
|
|
155
|
+
// AtTextElem.urls (SDK rc39+) for in-text link highlighting — either
|
|
156
|
+
// way the card/text needs a tap handler. QuoteMessage: same for a
|
|
157
|
+
// reply's own text via the native QuoteElem.urls (see QuotedMessage).
|
|
154
158
|
const hasUrlPress =
|
|
155
159
|
onPressUrl &&
|
|
156
160
|
(message?.contentType === MessageType.UrlTextMessage ||
|
|
157
|
-
message?.contentType === MessageType.AtTextMessage
|
|
161
|
+
message?.contentType === MessageType.AtTextMessage ||
|
|
162
|
+
message?.contentType === MessageType.QuoteMessage);
|
|
158
163
|
const canRevealTime = !TIME_REVEAL_EXCLUDED_TYPES.includes(
|
|
159
164
|
message?.contentType
|
|
160
165
|
);
|