Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/giphy-collapse-state-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes an issue where message attachments lose their collapsed state when scrolled out of view.
11 changes: 9 additions & 2 deletions apps/meteor/client/components/message/content/Attachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@ export type AttachmentsProps = {
attachments: MessageAttachmentBase[];
id?: string | undefined;
source?: AudioAttachmentSource;
/** Prefixes nested attachments' collapse-state keys so they don't collide with the top-level ones. */
keyPrefix?: string;
};

const Attachments = ({ attachments, id, source }: AttachmentsProps) => {
const Attachments = ({ attachments, id, source, keyPrefix }: AttachmentsProps) => {
return (
<>{attachments?.map((attachment, index) => <AttachmentsItem key={index} id={id} attachment={{ ...attachment }} source={source} />)}</>
<>
{attachments?.map((attachment, index) => {
const path = keyPrefix ? `${keyPrefix}-${index}` : String(index);
Comment thread
yash-rajpal marked this conversation as resolved.
return <AttachmentsItem key={index} id={id} attachment={{ ...attachment }} source={source} path={path} />;
})}
</>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,20 @@ import type { AudioAttachmentSource } from './file/AudioAttachment';
export type AttachmentsItemProps = {
attachment: MessageAttachmentBase;
id: string | undefined;
path: string;
source?: AudioAttachmentSource;
};

const AttachmentsItem = ({ attachment, id, source }: AttachmentsItemProps) => {
const AttachmentsItem = ({ attachment, id, path, source }: AttachmentsItemProps) => {
if (isFileAttachment(attachment)) {
return <FileAttachment id={id} source={source} {...attachment} />;
}

if (isQuoteAttachment(attachment)) {
return <QuoteAttachment attachment={attachment} source={source} />;
return <QuoteAttachment attachment={attachment} source={source} path={path} />;
}

return <DefaultAttachment {...(attachment as any)} />;
return <DefaultAttachment {...attachment} collapseKey={source?.mid ? `${source.mid}-${path}` : undefined} />;
Comment thread
nazabucciarelli marked this conversation as resolved.
};

export default memo(AttachmentsItem);
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ const applyMarkdownIfRequires = (
variant: ComponentProps<typeof MarkdownText>['variant'] = 'inline',
): ReactNode => (list?.includes(key) ? <MarkdownText parseEmoji variant={variant} content={text} /> : text);

export type DefaultAttachmentProps = MessageAttachmentDefault;
export type DefaultAttachmentProps = MessageAttachmentDefault & {
collapseKey?: string;
};

const DefaultAttachment = (attachment: DefaultAttachmentProps) => {
const [collapsed, toggleCollapse] = useCollapse(!!attachment.collapsed);
const DefaultAttachment = ({ collapseKey, ...attachment }: DefaultAttachmentProps) => {
const [collapsed, toggleCollapse] = useCollapse(!!attachment.collapsed, collapseKey);

return (
<AttachmentBlock
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ const quoteStyles = css`
export type QuoteAttachmentProps = {
attachment: MessageQuoteAttachment;
source?: AudioAttachmentSource;
/** This quote's own path, used to prefix its nested attachments' collapse-state keys. */
path?: string;
};

export const QuoteAttachment = ({ attachment, source }: QuoteAttachmentProps) => {
export const QuoteAttachment = ({ attachment, source, path }: QuoteAttachmentProps) => {
const formatTime = useTimeAgo();
const displayAvatarPreference = useUserPreference<boolean>('displayAvatars');

Expand Down Expand Up @@ -71,6 +73,7 @@ export const QuoteAttachment = ({ attachment, source }: QuoteAttachmentProps) =>
attachments={attachment.attachments}
id={attachment.attachments[0]?.title_link}
source={source && { rid: source.rid, mid: source.mid, name: attachment.author_name }}
keyPrefix={path}
/>
</AttachmentInner>
)}
Expand Down
28 changes: 25 additions & 3 deletions apps/meteor/client/components/message/hooks/useCollapse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,30 @@ import { useToggle } from '@rocket.chat/fuselage-hooks';
import { useAttachmentIsCollapsedByDefault } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';

export const useCollapse = (attachmentCollapsed?: boolean) => {
import { useIsCollapsibleToggled } from './useIsCollapsibleToggled';
import { RoomManager, useOpenedRoom } from '../../../lib/RoomManager';

// `key` identifies this collapsible within the room's store, so its toggled state survives
// the row unmounting and remounting (e.g. virtua recycling it on scroll), it falls back to
// plain local state.
export const useCollapse = (attachmentCollapsed?: boolean, key?: string) => {
const collapseByDefault = useAttachmentIsCollapsedByDefault();
const [collapsed, toggleCollapsed] = useToggle(collapseByDefault || attachmentCollapsed);
return [collapsed, useCallback(() => toggleCollapsed(), [toggleCollapsed])] as const;
const defaultCollapsed = !!(collapseByDefault || attachmentCollapsed);

const rid = useOpenedRoom();
const toggled = useIsCollapsibleToggled(key);
const [localCollapsed, toggleLocalCollapsed] = useToggle(defaultCollapsed);

const togglePersistedCollapsed = useCallback(() => {
if (!key || !rid) {
return;
}
RoomManager.getStore(rid)?.toggleCollapsible(key);
}, [key, rid]);

if (key) {
return [toggled !== defaultCollapsed, togglePersistedCollapsed] as const;
}

return [localCollapsed, () => toggleLocalCollapsed()] as const;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { act, renderHook } from '@testing-library/react';

import { useIsCollapsibleToggled } from './useIsCollapsibleToggled';
import { RoomManager } from '../../../lib/RoomManager';
import { MAX_TOGGLED_COLLAPSIBLES_PER_ROOM } from '../../../lib/constants';

jest.mock('../../../../app/ui-utils/client/lib/RoomHistoryManager', () => ({
RoomHistoryManager: {},
}));

it('should not be toggled by default', () => {
RoomManager.open('room-a');
const { result } = renderHook(() => useIsCollapsibleToggled('key-a'));
expect(result.current).toBe(false);
});

it('should reflect an already-toggled key in the opened room store', () => {
RoomManager.open('room-b');
RoomManager.getStore('room-b')?.toggleCollapsible('key-b');

const { result } = renderHook(() => useIsCollapsibleToggled('key-b'));
expect(result.current).toBe(true);
});

it('should re-render when the store toggles after mount', () => {
RoomManager.open('room-c');
const { result } = renderHook(() => useIsCollapsibleToggled('key-c'));
expect(result.current).toBe(false);

act(() => {
RoomManager.getStore('room-c')?.toggleCollapsible('key-c');
});
expect(result.current).toBe(true);
});

it('should not leak a toggle to a different key in the same room', () => {
RoomManager.open('room-d');
RoomManager.getStore('room-d')?.toggleCollapsible('key-d1');

const { result } = renderHook(() => useIsCollapsibleToggled('key-d2'));
expect(result.current).toBe(false);
});

it('should be a no-op when there is no key', () => {
RoomManager.open('room-e');
const { result } = renderHook(() => useIsCollapsibleToggled(undefined));
expect(result.current).toBe(false);
});

it('should be a no-op when there is no opened room', () => {
const currentlyOpened = RoomManager.opened;
if (currentlyOpened) {
RoomManager.back(currentlyOpened);
}

const { result } = renderHook(() => useIsCollapsibleToggled('key-f'));
expect(result.current).toBe(false);
});

it('should evict the oldest toggle once a room store is full, keeping it bounded', () => {
RoomManager.open('room-bound');
const store = RoomManager.getStore('room-bound');
if (!store) {
throw new Error('store was not created');
}

const total = MAX_TOGGLED_COLLAPSIBLES_PER_ROOM + 1;
for (let i = 0; i < total; i++) {
store.toggleCollapsible(`bulk-${i}`);
}

expect(store.isCollapsibleToggled('bulk-0')).toBe(false);
expect(store.isCollapsibleToggled('bulk-1')).toBe(true);
expect(store.isCollapsibleToggled(`bulk-${total - 1}`)).toBe(true);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useMemo, useSyncExternalStore } from 'react';

import { RoomManager, getCollapsibleEventKey, useOpenedRoom } from '../../../lib/RoomManager';

export const useIsCollapsibleToggled = (key: string | undefined): boolean => {
const rid = useOpenedRoom();

const { subscribe, getSnapshot } = useMemo(() => {
const store = rid && key ? RoomManager.getStore(rid) : undefined;

if (!store || !key) {
return {
subscribe: () => () => undefined,
getSnapshot: () => false,
};
}

return {
subscribe: (cb: () => void) => store.on(getCollapsibleEventKey(key), cb),
getSnapshot: () => store.isCollapsibleToggled(key),
};
}, [rid, key]);

return useSyncExternalStore(subscribe, getSnapshot);
};
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ jest.mock('../../../views/room/MessageList/hooks/useAutoTranslate', () => ({
}),
}));
jest.mock('../../../lib/actionLinks', () => undefined);
jest.mock('../../../lib/RoomManager', () => ({
RoomManager: { getStore: jest.fn() },
useOpenedRoom: () => undefined,
getCollapsibleEventKey: (key: string) => `collapsibleToggled-${key}`,
}));

it('should show normal message', () => {
render(
Expand Down
31 changes: 31 additions & 0 deletions apps/meteor/client/lib/RoomManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ import type { IRoom } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import { useMemo, useSyncExternalStore } from 'react';

import { MAX_TOGGLED_COLLAPSIBLES_PER_ROOM } from './constants';
import { getConfig } from './utils/getConfig';
import { LegacyRoomManager } from '../../app/ui-utils/client';
import { RoomHistoryManager } from '../../app/ui-utils/client/lib/RoomHistoryManager';

const debug = !!(getConfig('debug') || getConfig('debug-RoomStore'));

type CollapsibleKey = `collapsibleToggled-${string}`;

export const getCollapsibleEventKey = (key: string): CollapsibleKey => `collapsibleToggled-${key}`;

class RoomStore extends Emitter<{
changed: undefined;
[key: CollapsibleKey]: undefined;
}> {
lastTime?: Date;

Expand All @@ -19,6 +25,8 @@ class RoomStore extends Emitter<{

atBottom = true;

private readonly toggledCollapsibles = new Set<string>();
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

constructor(readonly rid: string) {
super();

Expand All @@ -40,6 +48,29 @@ class RoomStore extends Emitter<{
this.emit('changed');
}
}

toggleCollapsible(key: string): void {
if (this.toggledCollapsibles.has(key)) {
this.toggledCollapsibles.delete(key);
this.emit(getCollapsibleEventKey(key));
return;
}

if (this.toggledCollapsibles.size >= MAX_TOGGLED_COLLAPSIBLES_PER_ROOM) {
const oldest = this.toggledCollapsibles.values().next().value;
if (oldest !== undefined) {
this.toggledCollapsibles.delete(oldest);
this.emit(getCollapsibleEventKey(oldest));
}
}

this.toggledCollapsibles.add(key);
this.emit(getCollapsibleEventKey(key));
}

isCollapsibleToggled(key: string): boolean {
return this.toggledCollapsibles.has(key);
}
}

const debugRoomManager = !!(getConfig('debug') || getConfig('debug-RoomManager'));
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/client/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export const BIO_TEXT_MAX_LENGTH = 260;
export const VIDEOCONF_STACK_MAX_USERS = 6;
export const NAVIGATION_REGION_ID = 'navigation-region';
export const MAX_FILE_SIZE_PREVIEW = 10485760; // 10MB
export const MAX_TOGGLED_COLLAPSIBLES_PER_ROOM = 1000;
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ export const useKeepMountedMessages = (messages: IMessage[], canPreview: boolean
// attachments (audio/video players) and URL previews (e.g. YouTube iframes).
// Otherwise virtua recycles them on scroll-to-bottom (new message / reaction
// growing a message) and the iframe flickers.
//
// Don't extend this to other message/attachment types just to preserve some local
// UI state. Every match here stays mounted for the life of the list regardless of
// scroll position, so it only scales for the "needs a live browser resource" case
// above and adding more to it defeats virtualization in media-heavy channels.
const hasUrlPreview = message.urls?.some((url) => Object.keys(url.meta ?? {}).length > 0 || !!url.headers) ?? false;
if ((message.files?.length ?? 0) > 0 || hasUrlPreview) {
acc.push(index + offset);
Expand Down
Loading
Loading