Skip to content

Commit 91eba1b

Browse files
authored
fix: keep MessageList scrolled to the bottom (#3068)
1 parent 630e5c7 commit 91eba1b

5 files changed

Lines changed: 185 additions & 19 deletions

File tree

src/components/Attachment/Giphy.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,60 @@ import {
88
useTranslationContext,
99
} from '../../context';
1010
import { IconGiphy } from '../Icons';
11-
import { useMemo } from 'react';
11+
import { type CSSProperties, useLayoutEffect, useMemo, useRef, useState } from 'react';
12+
import type { ImageAttachmentConfiguration } from '../../types/types';
1213

1314
export type GiphyAttachmentProps = {
1415
attachment: Attachment;
1516
};
1617

1718
export const Giphy = ({ attachment }: GiphyAttachmentProps) => {
18-
const { giphyVersion: giphyVersionName } = useChannelStateContext();
19+
const { giphyVersion: giphyVersionName, imageAttachmentSizeHandler } =
20+
useChannelStateContext();
1921
const { BaseImage = DefaultBaseImage } = useComponentContext();
2022
const { t } = useTranslationContext();
2123
const usesDefaultBaseImage = BaseImage === DefaultBaseImage;
24+
const imageElement = useRef<HTMLImageElement>(null);
25+
const [attachmentConfiguration, setAttachmentConfiguration] = useState<
26+
ImageAttachmentConfiguration | undefined
27+
>(undefined);
2228

2329
const imageDescriptors = useMemo(
2430
() => toGalleryItemDescriptors(attachment, { giphyVersionName }),
2531
[attachment, giphyVersionName],
2632
);
33+
const alt = imageDescriptors && imageDescriptors.alt;
34+
const dimensions = imageDescriptors && imageDescriptors.dimensions;
35+
const imageUrl = imageDescriptors && imageDescriptors.imageUrl;
36+
const title = imageDescriptors && imageDescriptors.title;
37+
const resolvedImageUrl = attachmentConfiguration?.url || imageUrl;
38+
const imageStyleVariables = useMemo(() => {
39+
const originalHeight = Number(dimensions?.height);
40+
const originalWidth = Number(dimensions?.width);
2741

28-
if (!imageDescriptors?.imageUrl) return null;
42+
return {
43+
'--original-height': String(originalHeight > 1 ? originalHeight : 1000000),
44+
'--original-width': String(originalWidth > 1 ? originalWidth : 1000000),
45+
} as CSSProperties;
46+
}, [dimensions?.height, dimensions?.width]);
2947

30-
const { alt, dimensions, imageUrl, title } = imageDescriptors;
48+
useLayoutEffect(() => {
49+
if (!imageElement.current || !imageAttachmentSizeHandler) return;
50+
51+
const config = imageAttachmentSizeHandler(attachment, imageElement.current);
52+
setAttachmentConfiguration(config);
53+
}, [attachment, imageAttachmentSizeHandler]);
54+
55+
if (!imageUrl) return null;
3156

3257
return (
3358
<div className={clsx(`str-chat__message-attachment-giphy`)}>
3459
<BaseImage
3560
alt={alt ?? title ?? t('User uploaded content')}
3661
height={dimensions?.height}
37-
src={imageUrl}
62+
ref={imageElement}
63+
src={resolvedImageUrl}
64+
style={imageStyleVariables}
3865
width={dimensions?.width}
3966
{...(usesDefaultBaseImage ? { showDownloadButtonOnError: false } : {})}
4067
/>

src/components/Attachment/__tests__/Attachment.test.tsx

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import { render, screen, waitFor } from '@testing-library/react';
33
import { nanoid } from 'nanoid';
4+
import { vi } from 'vitest';
45

56
import {
67
generateAttachmentAction,
@@ -18,10 +19,8 @@ import {
1819

1920
import { Attachment } from '../Attachment';
2021
import { SUPPORTED_VIDEO_FORMATS } from '../utils';
21-
import {
22-
generateScrapedVideoAttachment,
23-
mockChannelStateContext,
24-
} from '../../../mock-builders';
22+
import { generateScrapedVideoAttachment } from '../../../mock-builders';
23+
import type { ChannelStateContextValue } from '../../../context';
2524
import { ChannelStateProvider } from '../../../context';
2625

2726
const UNSUPPORTED_ATTACHMENT_TEST_ID = 'attachment-unsupported';
@@ -58,16 +57,20 @@ const ATTACHMENTS = {
5857
},
5958
};
6059

61-
const renderComponent = (props) =>
60+
const renderComponent = (
61+
props,
62+
channelStateValue = {},
63+
{ useDefaultGiphy = false } = {},
64+
) =>
6265
render(
63-
<ChannelStateProvider value={mockChannelStateContext()}>
66+
<ChannelStateProvider value={channelStateValue as ChannelStateContextValue}>
6467
<Attachment
6568
AttachmentActions={AttachmentActions}
6669
Audio={Audio}
6770
Card={Card}
6871
File={File}
6972
Geolocation={Geolocation}
70-
Giphy={Giphy}
73+
{...(!useDefaultGiphy ? { Giphy } : {})}
7174
Image={Image}
7275
Media={Media}
7376
ModalGallery={ModalGallery}
@@ -184,6 +187,25 @@ describe('attachment', () => {
184187
expect(screen.getByTestId('giphy-attachment')).toBeInTheDocument();
185188
});
186189
});
190+
191+
it('should apply imageAttachmentSizeHandler to giphy attachments', async () => {
192+
const resizedGiphyUrl = 'https://example.com/resized.gif';
193+
const imageAttachmentSizeHandler = vi.fn(() => ({ url: resizedGiphyUrl }));
194+
195+
renderComponent(
196+
{ attachments: [ATTACHMENTS.scraped.giphy] },
197+
{ giphyVersion: 'fixed_height', imageAttachmentSizeHandler },
198+
{ useDefaultGiphy: true },
199+
);
200+
201+
await waitFor(() => {
202+
expect(imageAttachmentSizeHandler).toHaveBeenCalled();
203+
const imageElement = screen.getByTestId('str-chat__base-image');
204+
expect(imageElement.getAttribute('src')).toBe(resizedGiphyUrl);
205+
expect(imageElement.style.getPropertyValue('--original-height')).toBe('200');
206+
expect(imageElement.style.getPropertyValue('--original-width')).toBe('200');
207+
});
208+
});
187209
});
188210

189211
describe('combines scraped & uploaded content', () => {

src/components/BaseImage/toBaseImageDescriptors.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export type BaseImageDescriptor = {
3434
export const toBaseImageDescriptors = (
3535
attachment: AttachmentPreviewableInGallery,
3636
options: { giphyVersionName?: string } = {},
37-
): BaseImageDescriptor | void => {
37+
): BaseImageDescriptor | undefined => {
3838
if (isGiphyAttachment(attachment)) {
3939
const giphyVersion =
4040
options?.giphyVersionName && attachment.giphy
@@ -98,4 +98,6 @@ export const toBaseImageDescriptors = (
9898
title: attachment.title,
9999
};
100100
}
101+
102+
return undefined;
101103
};

src/components/Gallery/GalleryContext.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ export type GalleryItem = Omit<BaseImageProps, 'src'> & {
1717
*/
1818
export const toGalleryItemDescriptors = (
1919
...args: Parameters<typeof toBaseImageDescriptors>
20-
): Pick<
21-
GalleryItem,
22-
'alt' | 'dimensions' | 'imageUrl' | 'title' | 'videoThumbnailUrl' | 'videoUrl'
23-
> | void => toBaseImageDescriptors(...args);
20+
):
21+
| Pick<
22+
GalleryItem,
23+
'alt' | 'dimensions' | 'imageUrl' | 'title' | 'videoThumbnailUrl' | 'videoUrl'
24+
>
25+
| undefined => toBaseImageDescriptors(...args);
2426

2527
export type GalleryContextValue = {
2628
/** Whether clicking the empty gallery background should request close */

src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,35 @@ import { useMessageListScrollManager } from './useMessageListScrollManager';
55
import type { LocalMessage } from 'stream-chat';
66

77
export type UseScrollLocationLogicParams = {
8+
/** Disables automatic scroll-to-bottom updates after message changes. */
89
disableAutoScrollToBottom?: boolean;
10+
/** Disables scroll-management adjustments (anchor restore, append/prepend handling). */
911
disableScrollManagement?: boolean;
12+
/** True when there are newer messages to load beyond the currently rendered page. */
1013
hasMoreNewer: boolean;
14+
/** Scrollable message-list container element. */
1115
listElement: HTMLDivElement | null;
16+
/** Threshold used to detect older-page pagination proximity near the top. */
1217
loadMoreScrollThreshold: number;
18+
/** Indicates whether older-page pagination is currently in progress. */
1319
loadingMore?: boolean;
20+
/** Hard-disable all autoscroll behavior. */
1421
suppressAutoscroll: boolean;
22+
/** Current rendered message set used for scroll reconciliation. */
1523
messages?: LocalMessage[];
24+
/** Distance from bottom (px) considered as "near bottom". */
1625
scrolledUpThreshold?: number;
1726
};
1827

28+
/**
29+
* Centralized scroll-position logic for MessageList.
30+
*
31+
* Responsibilities:
32+
* - Keep viewport stable during prepend/append pagination updates.
33+
* - Track whether the list is near bottom and expose that state to UI.
34+
* - Auto-scroll to bottom when appropriate while respecting suppression flags.
35+
* - Perform a short hydration settle pass so freshly loaded lists land at bottom.
36+
*/
1937
export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) => {
2038
const {
2139
disableAutoScrollToBottom = false,
@@ -41,6 +59,7 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
4159
const closeToBottom = useRef(false);
4260
const closeToTop = useRef(false);
4361
const previousScrollTopRef = useRef(0);
62+
const previousMessagesLengthRef = useRef(messages.length);
4463
const anchorRestoreCleanupRef = useRef<(() => void) | null>(null);
4564

4665
const captureAnchor = useCallback(() => {
@@ -225,6 +244,10 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
225244
[hasMoreNewer, justReachedLatestMessageSet, listElement, suppressAutoscroll],
226245
);
227246

247+
/**
248+
* Keeps wrapper geometry up to date and handles the "reached latest merged set"
249+
* path where existing viewport position must be preserved.
250+
*/
228251
useLayoutEffect(() => {
229252
if (listElement) {
230253
setWrapperRect(listElement.getBoundingClientRect());
@@ -247,6 +270,85 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
247270
// eslint-disable-next-line react-hooks/exhaustive-deps
248271
}, [disableAutoScrollToBottom, justReachedLatestMessageSet, listElement, hasMoreNewer]);
249272

273+
/**
274+
* Short post-render bottom settle. This is intentionally small (immediate + 2 retries)
275+
* to catch late layout updates without keeping the list in a prolonged lock loop.
276+
*/
277+
useLayoutEffect(() => {
278+
if (
279+
!listElement ||
280+
disableAutoScrollToBottom ||
281+
hasMoreNewer ||
282+
suppressAutoscroll ||
283+
justReachedLatestMessageSet ||
284+
isRestoringOlderAnchorRef.current
285+
) {
286+
return;
287+
}
288+
289+
const initialDistanceToBottom =
290+
listElement.scrollHeight - (listElement.scrollTop + listElement.clientHeight);
291+
const messagesHydrated =
292+
previousMessagesLengthRef.current === 0 && messages.length > 0;
293+
294+
if (initialDistanceToBottom > scrolledUpThreshold && !messagesHydrated) {
295+
return;
296+
}
297+
298+
let keepPinnedToBottom = true;
299+
300+
const maybeScrollToBottom = () => {
301+
if (keepPinnedToBottom) {
302+
scrollToBottom();
303+
}
304+
};
305+
306+
maybeScrollToBottom();
307+
const settleDelays = [80, messagesHydrated ? 260 : 420, 900, 1700];
308+
const settleTimeoutIds = settleDelays.map((delay) =>
309+
setTimeout(maybeScrollToBottom, delay),
310+
);
311+
312+
const stopKeepingPinnedToBottom = () => {
313+
keepPinnedToBottom = false;
314+
};
315+
316+
// Any direct user interaction with the scroller disables the temporary
317+
// initial-load pin, so manual scrolling is never force-overridden.
318+
listElement.addEventListener('pointerdown', stopKeepingPinnedToBottom, {
319+
passive: true,
320+
});
321+
listElement.addEventListener('touchstart', stopKeepingPinnedToBottom, {
322+
passive: true,
323+
});
324+
listElement.addEventListener('wheel', stopKeepingPinnedToBottom, {
325+
passive: true,
326+
});
327+
listElement.addEventListener('keydown', stopKeepingPinnedToBottom);
328+
329+
const pinWindowTimeoutId = setTimeout(() => {
330+
stopKeepingPinnedToBottom();
331+
}, 2200);
332+
333+
return () => {
334+
settleTimeoutIds.forEach(clearTimeout);
335+
clearTimeout(pinWindowTimeoutId);
336+
listElement.removeEventListener('pointerdown', stopKeepingPinnedToBottom);
337+
listElement.removeEventListener('touchstart', stopKeepingPinnedToBottom);
338+
listElement.removeEventListener('wheel', stopKeepingPinnedToBottom);
339+
listElement.removeEventListener('keydown', stopKeepingPinnedToBottom);
340+
};
341+
}, [
342+
disableAutoScrollToBottom,
343+
hasMoreNewer,
344+
justReachedLatestMessageSet,
345+
listElement,
346+
messages.length,
347+
scrollToBottom,
348+
scrolledUpThreshold,
349+
suppressAutoscroll,
350+
]);
351+
250352
const updateScrollTop = useMessageListScrollManager({
251353
captureAnchor,
252354
disableScrollManagement: disableScrollManagement || isRestoringOlderAnchorRef.current,
@@ -276,6 +378,14 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
276378
previousHasMoreNewerRef.current = hasMoreNewer;
277379
}, [hasMoreNewer]);
278380

381+
useLayoutEffect(() => {
382+
previousMessagesLengthRef.current = messages.length;
383+
}, [messages.length]);
384+
385+
/**
386+
* Updates cached scroll metrics and bottom/top proximity state used by
387+
* notifications, autoscroll decisions, and paginator behavior.
388+
*/
279389
const onScroll = useCallback(
280390
(event: React.UIEvent<HTMLDivElement>) => {
281391
const element = event.target as HTMLDivElement;
@@ -286,10 +396,13 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
286396

287397
const offsetHeight = element.offsetHeight;
288398
const scrollHeight = element.scrollHeight;
399+
const distanceToBottom = scrollHeight - (scrollTop + offsetHeight);
400+
const bottomEnterThreshold = Math.max(Math.floor(scrolledUpThreshold * 0.6), 24);
289401

290402
const prevCloseToBottom = closeToBottom.current;
291-
closeToBottom.current =
292-
scrollHeight - (scrollTop + offsetHeight) < scrolledUpThreshold;
403+
closeToBottom.current = prevCloseToBottom
404+
? distanceToBottom < scrolledUpThreshold
405+
: distanceToBottom < bottomEnterThreshold;
293406
closeToTop.current = scrollTop < scrolledUpThreshold;
294407

295408
if (closeToBottom.current) {

0 commit comments

Comments
 (0)