From fea066beb246d358ef8ae1dd2fee25fc72f56181 Mon Sep 17 00:00:00 2001 From: Nazareno Bucciarelli Date: Wed, 19 Aug 2026 20:06:14 -0300 Subject: [PATCH] fix: use RoomManager store instead of a context with attachments collapsed states --- .changeset/giphy-collapse-state-scroll.md | 5 ++++ .../message/content/Attachments.tsx | 11 +++++-- .../content/attachments/AttachmentsItem.tsx | 7 +++-- .../content/attachments/DefaultAttachment.tsx | 8 +++-- .../content/attachments/QuoteAttachment.tsx | 5 +++- .../components/message/hooks/useCollapse.ts | 30 +++++++++++++++++-- .../message/hooks/useIsCollapsibleToggled.ts | 25 ++++++++++++++++ apps/meteor/client/lib/RoomManager.ts | 20 +++++++++++++ 8 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 .changeset/giphy-collapse-state-scroll.md create mode 100644 apps/meteor/client/components/message/hooks/useIsCollapsibleToggled.ts diff --git a/.changeset/giphy-collapse-state-scroll.md b/.changeset/giphy-collapse-state-scroll.md new file mode 100644 index 0000000000000..13f4ed28c72b0 --- /dev/null +++ b/.changeset/giphy-collapse-state-scroll.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where message attachments lose their collapsed state when scrolled out of view. diff --git a/apps/meteor/client/components/message/content/Attachments.tsx b/apps/meteor/client/components/message/content/Attachments.tsx index 70574ad6a1e53..bac6ba1fbeac4 100644 --- a/apps/meteor/client/components/message/content/Attachments.tsx +++ b/apps/meteor/client/components/message/content/Attachments.tsx @@ -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) => )} + <> + {attachments?.map((attachment, index) => { + const path = keyPrefix ? `${keyPrefix}-${index}` : String(index); + return ; + })} + ); }; diff --git a/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx b/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx index 82d0ed9ccf60a..2bf82fa59d255 100644 --- a/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx +++ b/apps/meteor/client/components/message/content/attachments/AttachmentsItem.tsx @@ -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 ; } if (isQuoteAttachment(attachment)) { - return ; + return ; } - return ; + return ; }; export default memo(AttachmentsItem); diff --git a/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx b/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx index 42e774b2e55dc..3e3240e90a2d5 100644 --- a/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx @@ -25,10 +25,12 @@ const applyMarkdownIfRequires = ( variant: ComponentProps['variant'] = 'inline', ): ReactNode => (list?.includes(key) ? : 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 ( { +export const QuoteAttachment = ({ attachment, source, path }: QuoteAttachmentProps) => { const formatTime = useTimeAgo(); const displayAvatarPreference = useUserPreference('displayAvatars'); @@ -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} /> )} diff --git a/apps/meteor/client/components/message/hooks/useCollapse.ts b/apps/meteor/client/components/message/hooks/useCollapse.ts index 2dd422f37b93c..a3ae2d53e054b 100644 --- a/apps/meteor/client/components/message/hooks/useCollapse.ts +++ b/apps/meteor/client/components/message/hooks/useCollapse.ts @@ -2,8 +2,32 @@ 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]); + + const toggleLocalCollapse = useCallback(() => toggleLocalCollapsed(), [toggleLocalCollapsed]); + + if (key) { + return [toggled !== defaultCollapsed, togglePersistedCollapsed] as const; + } + + return [localCollapsed, toggleLocalCollapse] as const; }; diff --git a/apps/meteor/client/components/message/hooks/useIsCollapsibleToggled.ts b/apps/meteor/client/components/message/hooks/useIsCollapsibleToggled.ts new file mode 100644 index 0000000000000..567f74353ab81 --- /dev/null +++ b/apps/meteor/client/components/message/hooks/useIsCollapsibleToggled.ts @@ -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); +}; diff --git a/apps/meteor/client/lib/RoomManager.ts b/apps/meteor/client/lib/RoomManager.ts index cbdeb6f0f0843..eea4b69a1383b 100644 --- a/apps/meteor/client/lib/RoomManager.ts +++ b/apps/meteor/client/lib/RoomManager.ts @@ -8,8 +8,13 @@ import { RoomHistoryManager } from '../../app/ui-utils/client/lib/RoomHistoryMan 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; @@ -19,6 +24,8 @@ class RoomStore extends Emitter<{ atBottom = true; + private readonly toggledCollapsibles = new Set(); + constructor(readonly rid: string) { super(); @@ -40,6 +47,19 @@ class RoomStore extends Emitter<{ this.emit('changed'); } } + + toggleCollapsible(key: string): void { + if (this.toggledCollapsibles.has(key)) { + this.toggledCollapsibles.delete(key); + } else { + this.toggledCollapsibles.add(key); + } + this.emit(getCollapsibleEventKey(key)); + } + + isCollapsibleToggled(key: string): boolean { + return this.toggledCollapsibles.has(key); + } } const debugRoomManager = !!(getConfig('debug') || getConfig('debug-RoomManager'));