From 70e2ea38d9151adca187053c4515a92de8898242 Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Wed, 26 Aug 2026 15:55:30 -0400 Subject: [PATCH 01/21] feat(message): show hover timestamps on grouped messages and edit-indicator tooltips --- .changeset/message-edit-timestamps.md | 5 + .../components/RenderMessageContent.test.tsx | 125 ++++++++++- src/app/components/RenderMessageContent.tsx | 41 +++- .../components/message/MsgTypeRenderers.tsx | 46 +++- .../message/content/FallbackContent.tsx | 85 +++++++- src/app/features/room/message/Message.tsx | 200 ++++++++++-------- src/app/features/room/message/styles.css.ts | 16 ++ src/app/generated/tauri/commands.ts | 2 +- src/app/generated/tauri/events.ts | 2 +- src/app/generated/tauri/index.ts | 2 +- src/app/generated/tauri/types.ts | 2 +- .../timeline/useTimelineEventRenderer.tsx | 1 + 12 files changed, 416 insertions(+), 111 deletions(-) create mode 100644 .changeset/message-edit-timestamps.md diff --git a/.changeset/message-edit-timestamps.md b/.changeset/message-edit-timestamps.md new file mode 100644 index 0000000000..b4cfbe0f03 --- /dev/null +++ b/.changeset/message-edit-timestamps.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Show a timestamp footnote when hovering grouped messages and a tooltip with edit times on the "(edited)" indicator. Clicking the indicator opens the message version history, matching the context-menu "Version History" action. diff --git a/src/app/components/RenderMessageContent.test.tsx b/src/app/components/RenderMessageContent.test.tsx index 2eb3fbf319..06614e8795 100644 --- a/src/app/components/RenderMessageContent.test.tsx +++ b/src/app/components/RenderMessageContent.test.tsx @@ -1,9 +1,10 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MsgType } from '$types/matrix-sdk'; import { M_POLL_START } from 'matrix-js-sdk'; import { ClientConfigProvider } from '$hooks/useClientConfig'; import { MatrixClientProvider } from '$hooks/useMatrixClient'; +import { ModalType } from '$state/modal'; import { RenderMessageContent } from './RenderMessageContent'; vi.mock('./message/content/UploadedSableCssContent', () => ({ @@ -16,6 +17,20 @@ vi.mock('$hooks/useMediaAuthentication', () => ({ useMediaAuthentication: () => false, })); +const { setModalSpy } = vi.hoisted(() => ({ + setModalSpy: vi.fn<(next: Record) => void>(), +})); + +vi.mock('$state/modal', async () => { + const { atom } = await import('jotai'); + return { + ModalType: { EditHistory: 'edit_history' }, + modalAtom: atom(null, (_get: unknown, _set: unknown, next: Record) => + setModalSpy(next) + ), + }; +}); + vi.mock('./url-preview', () => ({ UrlPreviewHolder: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -223,3 +238,111 @@ describe('RenderMessageContent', () => { expect(screen.getByTestId('poll-event')).toBeInTheDocument(); }); }); + +const roomWithEdits = { + getTimelineForEvent: () => ({ + getTimelineSet: () => ({ + relations: { + getChildEventsForEvent: () => ({ + getRelations: () => [{ getTs: () => 1710000000000 }], + }), + }, + }), + }), + hasEncryptionStateEvent: () => false, +}; + +const messageEvent = { + getId: () => '$evt:example.com', + getType: () => 'm.room.message', + getTs: () => 1700000000000, +}; + +function renderEditedMessage(room?: unknown) { + return render( + + + ({ msgtype: MsgType.Text, body: 'hello world' }) as never} + htmlReactParserOptions={{}} + linkifyOpts={{}} + room={room as never} + mEvent={messageEvent as never} + /> + + + ); +} + +describe('edited indicator', () => { + beforeEach(() => { + setModalSpy.mockClear(); + }); + + it('opens version history on click with the same modal payload as the context menu', () => { + renderEditedMessage(roomWithEdits); + + const indicator = screen.getByText('(edited)'); + expect(indicator).toHaveAttribute('role', 'button'); + + fireEvent.click(indicator); + + expect(setModalSpy).toHaveBeenCalledTimes(1); + expect(setModalSpy).toHaveBeenCalledWith({ + type: ModalType.EditHistory, + room: roomWithEdits, + mEvent: messageEvent, + }); + }); + + it('keeps the tooltip-capable wrapper around the clickable indicator', () => { + renderEditedMessage(roomWithEdits); + + const wrapper = screen.getByText('(edited)').closest('span[style*="inline"]'); + expect(wrapper?.firstElementChild).toHaveAttribute('role', 'button'); + }); + + it('stops propagation so surrounding message click handlers do not fire', () => { + const outsideClick = vi.fn<(event: Event) => void>(); + document.addEventListener('click', outsideClick); + + try { + renderEditedMessage(roomWithEdits); + + fireEvent.click(screen.getByText('(edited)')); + + expect(setModalSpy).toHaveBeenCalledTimes(1); + expect(outsideClick).not.toHaveBeenCalled(); + } finally { + document.removeEventListener('click', outsideClick); + } + }); + + it('opens version history on Enter and Space keys', () => { + renderEditedMessage(roomWithEdits); + + fireEvent.keyDown(screen.getByText('(edited)'), { key: 'Enter' }); + expect(setModalSpy).toHaveBeenCalledTimes(1); + expect(setModalSpy).toHaveBeenCalledWith({ + type: ModalType.EditHistory, + room: roomWithEdits, + mEvent: messageEvent, + }); + + fireEvent.keyDown(screen.getByText('(edited)'), { key: ' ' }); + expect(setModalSpy).toHaveBeenCalledTimes(2); + }); + + it('is not interactive when the event context is missing', () => { + renderEditedMessage(); + + expect(screen.queryByRole('button', { name: '(edited)' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('(edited)')); + expect(setModalSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index d8da91dd7e..593ab75037 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -60,6 +60,7 @@ import { MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME, MATRIX_UNSTABLE_SPOILER_PROPERTY_NAME, } from '$unstable/prefixes'; +import { getEventEdits } from '$utils/room/relations'; import { convertBeeperFormatToOurPerMessageProfile, type PerMessageProfileBeeperFormat, @@ -130,6 +131,22 @@ function RenderMessageContentInternal({ }: RenderMessageContentProps) { const content = useMemo(() => getContent() as Record, [getContent]); + // Edit timestamps for the "(edited)" hover tooltip, newest last. + const editTimestamps = useMemo(() => { + if (!mEvent || !room || typeof mEvent.getId !== 'function') return undefined; + const evtId = mEvent.getId(); + const evtTimeline = evtId ? room.getTimelineForEvent(evtId) : undefined; + const edits = + evtTimeline && evtId + ? getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations() + : undefined; + return edits + ? Array.from(edits) + .map((evt) => evt.getTs()) + .toSorted((a, b) => a - b) + : undefined; + }, [mEvent, room]); + const [autoplayGifs] = useSetting(settingsAtom, 'autoplayGifs'); const [captionPosition] = useSetting(settingsAtom, 'captionPosition'); const [themeChatSableWidgets] = useSetting(settingsAtom, 'themeChatSableWidgetsEnabled'); @@ -263,6 +280,9 @@ function RenderMessageContentInternal({ - + )} ; renderBody: (props: RenderBodyProps) => ReactNode; renderUrlsPreview?: (urls: string[]) => ReactNode; @@ -191,6 +200,9 @@ const getUrlsFromContent = ( export function MText({ edited, + editTimestamps, + room, + mEvent, content, renderBody, renderUrlsPreview, @@ -288,7 +300,9 @@ export function MText({ body: trimmedBody, customBody: unwrappedPmpCustomBody, })} - {edited && } + {edited && ( + + )} {(renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)) || (renderBundledPreviews && @@ -306,7 +320,9 @@ export function MText({ body: trimmedBody, customBody: unwrappedForwardedContent, })} - {edited && } + {edited && ( + + )} {(renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)) || (renderBundledPreviews && bundleContent && @@ -327,7 +343,9 @@ export function MText({ body: trimmedBody, customBody: typeof cleanedMessage === 'string' ? cleanedMessage : undefined, })} - {edited && } + {edited && ( + + )} {(renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)) || (renderBundledPreviews && @@ -341,6 +359,9 @@ export function MText({ type MEmoteProps = { displayName: string; edited?: boolean; + editTimestamps?: number[]; + room?: Room; + mEvent?: MatrixEvent; content: Record; renderBody: (props: RenderBodyProps) => ReactNode; renderUrlsPreview?: (urls: string[]) => ReactNode; @@ -349,6 +370,9 @@ type MEmoteProps = { export function MEmote({ displayName, edited, + editTimestamps, + room, + mEvent, content, renderBody, renderUrlsPreview, @@ -384,7 +408,9 @@ export function MEmote({ body: trimmedBody, customBody: typeof cleanedMessage === 'string' ? cleanedMessage : undefined, })} - {edited && } + {edited && ( + + )} {(renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)) || (renderBundledPreviews && @@ -397,6 +423,9 @@ export function MEmote({ type MNoticeProps = { edited?: boolean; + editTimestamps?: number[]; + room?: Room; + mEvent?: MatrixEvent; content: Record; renderBody: (props: RenderBodyProps) => ReactNode; renderUrlsPreview?: (urls: string[]) => ReactNode; @@ -404,6 +433,9 @@ type MNoticeProps = { }; export function MNotice({ edited, + editTimestamps, + room, + mEvent, content, renderBody, renderUrlsPreview, @@ -438,7 +470,9 @@ export function MNotice({ body: trimmedBody, customBody: typeof cleanedMessage === 'string' ? cleanedMessage : undefined, })} - {edited && } + {edited && ( + + )} {(renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)) || (renderBundledPreviews && diff --git a/src/app/components/message/content/FallbackContent.tsx b/src/app/components/message/content/FallbackContent.tsx index 89c8365ab8..7c295de55c 100644 --- a/src/app/components/message/content/FallbackContent.tsx +++ b/src/app/components/message/content/FallbackContent.tsx @@ -1,5 +1,10 @@ -import { Box, Text, as, color, config } from 'folds'; -import type { MatrixClient } from '$types/matrix-sdk'; +import { useCallback } from 'react'; +import type { KeyboardEvent, MouseEvent, SyntheticEvent } from 'react'; +import { useSetAtom } from 'jotai'; +import { Box, Text, Tooltip, as, color, config, toRem } from 'folds'; +import { TooltipProvider } from '$components/overlay-stack'; +import { modalAtom, ModalType } from '$state/modal'; +import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; import { Lock, timelineIcon, Trash, Warning, X } from '$components/icons/phosphor'; import { ReactionKeyInline } from '../ReactionKeyInline'; @@ -136,8 +141,74 @@ export const MessageBlockedContent = as<'div', { children?: never }>(({ ...props )); -export const MessageEditedContent = as<'span', { children?: never }>(({ ...props }, ref) => ( - - {' (edited)'} - -)); +export const MessageEditedContent = as< + 'span', + { editTimestamps?: number[]; room?: Room; mEvent?: MatrixEvent } +>(({ editTimestamps, room, mEvent, ...props }, ref) => { + // Same open action as the context-menu "Version History" item. + const setModal = useSetAtom(modalAtom); + + const openEditHistory = useCallback( + (e: SyntheticEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!room || !mEvent) return; + setModal({ type: ModalType.EditHistory, room, mEvent }); + }, + [setModal, room, mEvent] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') openEditHistory(e); + }, + [openEditHistory] + ); + + const timestamps = + editTimestamps && editTimestamps.length > 0 + ? editTimestamps.map((ts) => new Date(ts).toLocaleString()) + : undefined; + const edited = ( + ) => openEditHistory(e), + onKeyDown: handleKeyDown, + style: { cursor: 'pointer' }, + } + : {})} + {...props} + ref={ref} + > + {' (edited)'} + + ); + + if (!timestamps) return edited; + return ( + + + {timestamps.length === 1 + ? `Edited at ${timestamps[0]}` + : `Last edited at ${timestamps[timestamps.length - 1]}`} + + + } + > + {(triggerRef) => ( + + {edited} + + )} + + ); +}); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 2297045b11..b2b236115c 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -59,6 +59,7 @@ import type { PerMessageProfileBeeperFormat } from '$hooks/usePerMessageProfile' import { convertBeeperFormatToOurPerMessageProfile } from '$hooks/usePerMessageProfile'; import { MessageEditor } from './MessageEditor'; import * as css from './styles.css'; +import { timeHourMinute } from '$utils/time'; import { modalAtom, ModalType } from '$state/modal'; import { OptionQuickMenu } from '$components/message/modals/Options'; @@ -255,6 +256,12 @@ export const Pronouns = as< ); }); +const CollapsedMessageTime = ({ ts, hour24Clock }: { ts: number; hour24Clock: boolean }) => ( + + {timeHourMinute(ts, hour24Clock)} + +); + type WrappedMessageProps = { headerJSX: JSX.Element; avatarJSX: JSX.Element; @@ -557,112 +564,121 @@ function MessageInternal( return existing; }, [pronouns, inlinePronoun]); + const collapsedTimeJSX = + collapse && isDesktopHover ? ( + + + + ) : undefined; + const headerJSX = (collapsed?: boolean) => { - if (!collapsed) - return ( + if (collapsed) { + if (messageLayout === MessageLayout.Compact) return collapsedTimeJSX ?? <>; + return <>; + } + return ( + - - - - {cleanedDisplayName} - - - {showPronouns && ( - - )} - {showPmPInfo && ( - - - - via - - - {resolvedSenderDisplayName} - - - - )} - {tagIconSrc && } - - - {messageLayout === MessageLayout.Modern && isDesktopHover && ( - <> - - {senderId} + {cleanedDisplayName} + + + {showPronouns && ( + + )} + {showPmPInfo && ( + + + + via - - | + + {resolvedSenderDisplayName} - - )} - + + + )} + {tagIconSrc && } - ); - return <>; + + {messageLayout === MessageLayout.Modern && isDesktopHover && ( + <> + + {senderId} + + + | + + + )} + + + ); }; const avatarJSX = (collapsed?: boolean) => { - if (!collapsed && messageLayout !== MessageLayout.Compact) + if (collapsed) { + if (messageLayout === MessageLayout.Compact) return <>; + return collapsedTimeJSX ?? <>; + } + if (messageLayout !== MessageLayout.Compact) return ( Date: Fri, 14 Aug 2026 13:56:05 -0400 Subject: [PATCH 02/21] feat(message): hide the count on single-reaction chips --- src/app/components/message/Reaction.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/components/message/Reaction.tsx b/src/app/components/message/Reaction.tsx index 12047913d6..e3d1c242e4 100644 --- a/src/app/components/message/Reaction.tsx +++ b/src/app/components/message/Reaction.tsx @@ -64,9 +64,11 @@ export const Reaction = as< )} - - {count} - + {count > 1 && ( + + {count} + + )} ); }); From 2b27e4737e83177ab32d2ccd931596590bdb72fc Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Fri, 14 Aug 2026 13:56:05 -0400 Subject: [PATCH 03/21] feat(message): collapse very long messages behind an expand toggle --- .../message/LongMessageCollapse.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/app/components/message/LongMessageCollapse.tsx diff --git a/src/app/components/message/LongMessageCollapse.tsx b/src/app/components/message/LongMessageCollapse.tsx new file mode 100644 index 0000000000..26d665b881 --- /dev/null +++ b/src/app/components/message/LongMessageCollapse.tsx @@ -0,0 +1,53 @@ +import { useLayoutEffect, useRef, useState } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; +import { Box, Button, Text, config } from 'folds'; + +const LONG_MESSAGE_LINE_LIMIT = 30; + +type LongMessageCollapseProps = { + children: ReactNode; +}; + +export function LongMessageCollapse({ children }: LongMessageCollapseProps) { + const [expanded, setExpanded] = useState(false); + const [overflowing, setOverflowing] = useState(false); + const bodyRef = useRef(null); + + useLayoutEffect(() => { + const body = bodyRef.current; + if (!body) return; + setOverflowing(body.scrollHeight > body.clientHeight); + }, [children]); + + const collapsedStyle: CSSProperties | undefined = expanded + ? undefined + : { + display: '-webkit-box', + WebkitLineClamp: LONG_MESSAGE_LINE_LIMIT, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + }; + + return ( + <> +
+ {children} +
+ {overflowing && ( + + + + )} + + ); +} From 6a935e05a8848c1a2868520ae5be8eeb41ac34d4 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 26 Aug 2026 18:39:39 -0500 Subject: [PATCH 04/21] pin crypto wasm version to prevent key spam --- pnpm-lock.yaml | 9 +++++---- pnpm-workspace.yaml | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 149e3ce5af..7e398c4d14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + '@matrix-org/matrix-sdk-crypto-wasm': 18.4.0 brace-expansion: '>=5.0.9' esbuild: '>=0.28.1' fast-uri: '>=3.1.5' @@ -1289,8 +1290,8 @@ packages: resolution: {integrity: sha512-UKk+DmY2yurQs2T02AekNGVLD+fSGlygHKnf9485LCWF+CUvMSUXizAgR2qDGFUH2APj2i58nk21QhfVi9EG6g==} engines: {node: '>=18.17.0', npm: '>=9.5.0'} - '@matrix-org/matrix-sdk-crypto-wasm@18.5.0': - resolution: {integrity: sha512-E826Hy1rG26LanPjtSsOiVRcVoHfSgPgj2r2Xsb5RPScpaKi9XJADQ0u3dNjRCitZPX4oyLNl1FDd5AfDlmIwQ==} + '@matrix-org/matrix-sdk-crypto-wasm@18.4.0': + resolution: {integrity: sha512-osxkU1DQ+05+anGHapjWyvZqdHUb94Id37gy54mCKn1Cq/D7iGT5oEUEhjp4oTnCLo4TOtI6ULJ/LHsapaIptQ==} engines: {node: '>= 18'} '@napi-rs/canvas-android-arm64@1.0.3': @@ -6419,7 +6420,7 @@ snapshots: '@lottiefiles/dotlottie-web@0.79.1': {} - '@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {} + '@matrix-org/matrix-sdk-crypto-wasm@18.4.0': {} '@napi-rs/canvas-android-arm64@1.0.3': optional: true @@ -8961,7 +8962,7 @@ snapshots: matrix-js-sdk@42.1.0: dependencies: '@babel/runtime': 8.0.0 - '@matrix-org/matrix-sdk-crypto-wasm': 18.5.0 + '@matrix-org/matrix-sdk-crypto-wasm': 18.4.0 another-json: 0.2.0 bs58: 6.0.0 content-type: 2.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 219a579b03..abaac8c581 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,8 @@ minimumReleaseAgeExclude: - '@sableclient/twemoji-font' overrides: + # Temporary: matrix-js-sdk#5501 + '@matrix-org/matrix-sdk-crypto-wasm': '18.4.0' brace-expansion: '>=5.0.9' esbuild: '>=0.28.1' fast-uri: '>=3.1.5' From 784d2aea17445bc4b4e5b8f18d4b4b660d26b160 Mon Sep 17 00:00:00 2001 From: "sable-actions[bot]" <268226691+sable-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:47:04 +0000 Subject: [PATCH 05/21] chore(nix): auto-fix nix hashes --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 75daff6dc9..0afc34dfe8 100644 --- a/flake.nix +++ b/flake.nix @@ -124,7 +124,7 @@ ; pname = "sable"; fetcherVersion = 3; - hash = "sha256-zGUJ7Kl+nHumhrM51hS8nWzzxwUh9xn6TGPLcydakdo="; + hash = "sha256-VLkFY25Lwr4YvhCN8UAkKib3Z1kaNXnPGM2eTc3izYI="; }; mkPnpmCheck = From ec08ce71daa33915f6d77d51fd093b2717163154 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Wed, 26 Aug 2026 23:26:37 -0500 Subject: [PATCH 06/21] Update styles.css.ts --- src/app/components/nav/styles.css.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 4436bfedf0..f72d3fe38c 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -107,8 +107,8 @@ export const NavItem = recipe({ export type RoomSelectorVariants = RecipeVariants; export const NavItemContent = style({ - paddingLeft: `${config.space.S200} !important`, - paddingRight: `${config.space.S300} !important`, + paddingLeft: config.space.S200, + paddingRight: config.space.S300, height: 'inherit', minWidth: 0, flexGrow: 1, From ebe3fece51febf3249e36bcfcb43d9874bd28f2b Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Thu, 27 Aug 2026 19:33:51 +0200 Subject: [PATCH 07/21] fix: batch composer layout measurement --- src/app/components/editor/Editor.test.tsx | 2 +- src/app/components/editor/Editor.tsx | 32 +++++++++++++++++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/app/components/editor/Editor.test.tsx b/src/app/components/editor/Editor.test.tsx index 7d777c862c..62ee713e8f 100644 --- a/src/app/components/editor/Editor.test.tsx +++ b/src/app/components/editor/Editor.test.tsx @@ -116,7 +116,7 @@ describe('CustomEditor layout', () => { act(() => editor.insertText('two')); await waitFor(() => expect(editor.getText()).toBe('one\ntwo')); - expect(row(container)).toHaveClass(css.EditorRowMultiline); + await waitFor(() => expect(row(container)).toHaveClass(css.EditorRowMultiline)); }); it('installs a hidden measurer for text layout', () => { diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 8de73ba693..ee9744aa65 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -93,8 +93,8 @@ export const CustomEditor = forwardRef( const measurerRef = useRef(null); const latestTextRef = useRef(editor.getText()); const focusScrollTimerRef = useRef(undefined); + const layoutFrameRef = useRef(undefined); const [isMultiline, setIsMultiline] = useState(false); - const [measurementVersion, setMeasurementVersion] = useState(0); const hasBefore = Boolean(before); const hasAfter = Boolean(after); @@ -134,9 +134,19 @@ export const CustomEditor = forwardRef( nextMultiline = measurer.scrollHeight > singleLineHeight + MULTILINE_HEIGHT_EPSILON; } } - setIsMultiline(nextMultiline); + setIsMultiline((isMultiline) => + isMultiline === nextMultiline ? isMultiline : nextMultiline + ); }, []); + const scheduleMultilineLayout = useCallback(() => { + if (layoutFrameRef.current !== undefined) return; + layoutFrameRef.current = requestAnimationFrame(() => { + layoutFrameRef.current = undefined; + updateMultilineLayout(); + }); + }, [updateMultilineLayout]); + useEffect(() => { const root = rootRef.current; if (!root) return undefined; @@ -170,17 +180,23 @@ export const CustomEditor = forwardRef( }, [editableName]); useLayoutEffect(() => { - updateMultilineLayout(); - }, [measurementVersion, updateMultilineLayout]); + scheduleMultilineLayout(); + return () => { + if (layoutFrameRef.current !== undefined) { + cancelAnimationFrame(layoutFrameRef.current); + layoutFrameRef.current = undefined; + } + }; + }, [scheduleMultilineLayout]); useEffect(() => { if (typeof ResizeObserver === 'undefined') return undefined; - const observer = new ResizeObserver(updateMultilineLayout); + const observer = new ResizeObserver(scheduleMultilineLayout); [rowRef.current, beforeRef.current, afterRef.current].forEach((element) => { if (element) observer.observe(element); }); return () => observer.disconnect(); - }, [updateMultilineLayout, hasBefore, hasAfter]); + }, [scheduleMultilineLayout, hasBefore, hasAfter]); useEffect(() => () => window.clearTimeout(focusScrollTimerRef.current), []); const handleKeyDown: KeyboardEventHandler = useCallback( @@ -248,10 +264,10 @@ export const CustomEditor = forwardRef( const handleDocumentChange = useCallback( (document: EditorDocument) => { latestTextRef.current = editor.getText(); - setMeasurementVersion((version) => version + 1); + scheduleMultilineLayout(); onChange?.(document); }, - [editor, onChange] + [editor, onChange, scheduleMultilineLayout] ); return ( From 9807273336e4bc83e5b3e21ccbfb032211579f3d Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Thu, 27 Aug 2026 19:36:25 +0200 Subject: [PATCH 08/21] fix: avoid shadowed editor state --- src/app/components/editor/Editor.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index ee9744aa65..99bc19b225 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -134,8 +134,8 @@ export const CustomEditor = forwardRef( nextMultiline = measurer.scrollHeight > singleLineHeight + MULTILINE_HEIGHT_EPSILON; } } - setIsMultiline((isMultiline) => - isMultiline === nextMultiline ? isMultiline : nextMultiline + setIsMultiline((currentMultiline) => + currentMultiline === nextMultiline ? currentMultiline : nextMultiline ); }, []); From 58d8d8a023e802986e3aad88161ee35a9992aa63 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 27 Aug 2026 14:38:00 -0500 Subject: [PATCH 09/21] fix: devices showing as unverified when unverified --- src/app/utils/matrix-crypto.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/utils/matrix-crypto.ts b/src/app/utils/matrix-crypto.ts index 99d38b5a99..bf6accd20b 100644 --- a/src/app/utils/matrix-crypto.ts +++ b/src/app/utils/matrix-crypto.ts @@ -9,6 +9,7 @@ export const verifiedDevice = async ( if (!status) return null; - const verified = status.crossSigningVerified || status.localVerified; + const verified = + status.crossSigningVerified || status.localVerified || status.signedByOwner; return verified; }; From b1bda65004fcc78ddb699a335aac0fb65f7e3b78 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 27 Aug 2026 14:39:52 -0500 Subject: [PATCH 10/21] formatting --- src/app/utils/matrix-crypto.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/utils/matrix-crypto.ts b/src/app/utils/matrix-crypto.ts index bf6accd20b..c68d37a2f9 100644 --- a/src/app/utils/matrix-crypto.ts +++ b/src/app/utils/matrix-crypto.ts @@ -9,7 +9,6 @@ export const verifiedDevice = async ( if (!status) return null; - const verified = - status.crossSigningVerified || status.localVerified || status.signedByOwner; + const verified = status.crossSigningVerified || status.localVerified || status.signedByOwner; return verified; }; From 40afc807a6aae537823fcafdf3e744cd4a58c497 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 27 Aug 2026 15:03:05 -0500 Subject: [PATCH 11/21] fix tests --- .../useDeviceVerificationStatus.test.tsx | 89 +++++++++++++------ src/app/utils/matrix-crypto.ts | 3 +- 2 files changed, 61 insertions(+), 31 deletions(-) diff --git a/src/app/hooks/useDeviceVerificationStatus.test.tsx b/src/app/hooks/useDeviceVerificationStatus.test.tsx index e627e0dffa..cf02c388c4 100644 --- a/src/app/hooks/useDeviceVerificationStatus.test.tsx +++ b/src/app/hooks/useDeviceVerificationStatus.test.tsx @@ -50,13 +50,22 @@ vi.mock('@sentry/react', () => sentry); const USER_ID = '@me:example.org'; const DEVICE_ID = 'DEVICEONE'; -const getDeviceVerificationStatus = - vi.fn< - ( - userId: string, - deviceId: string - ) => Promise<{ crossSigningVerified: boolean; localVerified: boolean } | null> - >(); +const deviceVerificationStatus = (status: { + crossSigningVerified: boolean; + localVerified: boolean; + signedByOwner?: boolean; +}) => ({ signedByOwner: false, ...status }); + +const getDeviceVerificationStatus = vi.fn< + ( + userId: string, + deviceId: string + ) => Promise<{ + crossSigningVerified: boolean; + localVerified: boolean; + signedByOwner: boolean; + } | null> +>(); const crypto = { getDeviceVerificationStatus } as unknown as CryptoApi; const createWrapper = () => { @@ -71,10 +80,9 @@ const createWrapper = () => { describe('useDeviceVerificationStatus', () => { beforeEach(() => { getDeviceVerificationStatus.mockReset(); - getDeviceVerificationStatus.mockResolvedValue({ - crossSigningVerified: true, - localVerified: false, - }); + getDeviceVerificationStatus.mockResolvedValue( + deviceVerificationStatus({ crossSigningVerified: true, localVerified: false }) + ); sentry.addBreadcrumb.mockClear(); sentry.metrics.count.mockClear(); }); @@ -89,11 +97,26 @@ describe('useDeviceVerificationStatus', () => { }); it('reports a locally verified device as verified', async () => { - getDeviceVerificationStatus.mockResolvedValue({ - crossSigningVerified: false, - localVerified: true, + getDeviceVerificationStatus.mockResolvedValue( + deviceVerificationStatus({ crossSigningVerified: false, localVerified: true }) + ); + + const { result } = renderHook(() => useDeviceVerificationStatus(crypto, USER_ID, DEVICE_ID), { + wrapper: createWrapper(), }); + await waitFor(() => expect(result.current).toBe(VerificationStatus.Verified)); + }); + + it('reports an owner-signed device as verified', async () => { + getDeviceVerificationStatus.mockResolvedValue( + deviceVerificationStatus({ + crossSigningVerified: false, + localVerified: false, + signedByOwner: true, + }) + ); + const { result } = renderHook(() => useDeviceVerificationStatus(crypto, USER_ID, DEVICE_ID), { wrapper: createWrapper(), }); @@ -194,10 +217,9 @@ describe('useDeviceVerificationStatus', () => { await waitFor(() => expect(result.current).toBe(VerificationStatus.Verified)); expect(getDeviceVerificationStatus).toHaveBeenCalledTimes(1); - getDeviceVerificationStatus.mockResolvedValue({ - crossSigningVerified: false, - localVerified: false, - }); + getDeviceVerificationStatus.mockResolvedValue( + deviceVerificationStatus({ crossSigningVerified: false, localVerified: false }) + ); await act(async () => { mockMx.emit(event, ...(args as never[])); }); @@ -212,10 +234,9 @@ describe('useDeviceVerificationStatus', () => { }); await waitFor(() => expect(result.current).toBe(VerificationStatus.Verified)); - getDeviceVerificationStatus.mockResolvedValue({ - crossSigningVerified: false, - localVerified: false, - }); + getDeviceVerificationStatus.mockResolvedValue( + deviceVerificationStatus({ crossSigningVerified: false, localVerified: false }) + ); await act(async () => { mockMx.emit(CryptoEvent.KeysChanged, ...([{}] as never[])); @@ -249,9 +270,15 @@ describe('useDeviceVerificationStatus', () => { ( userId: string, deviceId: string - ) => Promise<{ crossSigningVerified: boolean; localVerified: boolean } | null> + ) => Promise<{ + crossSigningVerified: boolean; + localVerified: boolean; + signedByOwner: boolean; + } | null> >() - .mockResolvedValue({ crossSigningVerified: true, localVerified: false }); + .mockResolvedValue( + deviceVerificationStatus({ crossSigningVerified: true, localVerified: false }) + ); const crypto2 = { getDeviceVerificationStatus: getDeviceVerificationStatus2, } as unknown as CryptoApi; @@ -290,10 +317,9 @@ describe('useDeviceVerificationStatus', () => { await waitFor(() => expect(resultB.current).toBe(VerificationStatus.Verified)); expect(getDeviceVerificationStatus).toHaveBeenCalledTimes(2); - getDeviceVerificationStatus.mockResolvedValue({ - crossSigningVerified: false, - localVerified: false, - }); + getDeviceVerificationStatus.mockResolvedValue( + deviceVerificationStatus({ crossSigningVerified: false, localVerified: false }) + ); await act(async () => { mockMx.emit(CryptoEvent.UserTrustStatusChanged, ...([USER_B, {}] as never[])); @@ -335,7 +361,12 @@ describe('useUnverifiedDeviceCount', () => { it('counts only devices that are not cross-signing verified', async () => { getDeviceVerificationStatus.mockImplementation((_userId: string, deviceId: string) => - Promise.resolve({ crossSigningVerified: deviceId === 'VERIFIED', localVerified: false }) + Promise.resolve( + deviceVerificationStatus({ + crossSigningVerified: deviceId === 'VERIFIED', + localVerified: false, + }) + ) ); const { result } = renderHook( diff --git a/src/app/utils/matrix-crypto.ts b/src/app/utils/matrix-crypto.ts index c68d37a2f9..233ad0bd58 100644 --- a/src/app/utils/matrix-crypto.ts +++ b/src/app/utils/matrix-crypto.ts @@ -9,6 +9,5 @@ export const verifiedDevice = async ( if (!status) return null; - const verified = status.crossSigningVerified || status.localVerified || status.signedByOwner; - return verified; + return !!(status.crossSigningVerified || status.localVerified || status.signedByOwner); }; From a95f920cd2b1e23e1ead4237a5fcaf0c63bfa784 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 27 Aug 2026 15:20:56 -0500 Subject: [PATCH 12/21] redact events optimistically and reduce timeline flicker maybe --- .../message/modals/MessageDelete.tsx | 9 +-- src/app/features/room/RoomTimeline.test.tsx | 9 +-- src/app/features/room/RoomTimeline.tsx | 25 +++----- .../hooks/timeline/useProcessedTimeline.ts | 5 ++ .../hooks/timeline/useTimelineSync.test.tsx | 47 ++++++++------- src/app/hooks/timeline/useTimelineSync.ts | 57 +++++++++---------- src/app/utils/matrix.test.ts | 51 +++++++++++++++-- src/app/utils/matrix.ts | 31 +++++++++- 8 files changed, 150 insertions(+), 84 deletions(-) diff --git a/src/app/components/message/modals/MessageDelete.tsx b/src/app/components/message/modals/MessageDelete.tsx index cda942786b..fc665b204f 100644 --- a/src/app/components/message/modals/MessageDelete.tsx +++ b/src/app/components/message/modals/MessageDelete.tsx @@ -21,6 +21,7 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { modalAtom, ModalType } from '$state/modal'; import * as css from '$features/room/message/styles.css'; import { createDebugLogger } from '$utils/debugLogger'; +import { optimisticallyRedactEvent } from '$utils/matrix'; import * as Sentry from '@sentry/react'; const debugLog = createDebugLogger('MessageDelete'); @@ -72,9 +73,9 @@ export function MessageDeleteInternal({ room, mEvent, onClose }: MessageDeleteIn const [deleteState, deleteMessage] = useAsyncCallback( useCallback( - (eventId: string, reason?: string) => - mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined), - [mx, room] + (reason?: string) => + optimisticallyRedactEvent(mx, room, mEvent, reason ? { reason } : undefined), + [mx, room, mEvent] ) ); @@ -106,7 +107,7 @@ export function MessageDeleteInternal({ room, mEvent, onClose }: MessageDeleteIn debugLog.info('ui', 'Deleting message', { eventId, hasReason: !!reason }); Sentry.metrics.count('sable.message.delete.attempt', 1); - deleteMessage(eventId, reason); + deleteMessage(reason); }; return ( diff --git a/src/app/features/room/RoomTimeline.test.tsx b/src/app/features/room/RoomTimeline.test.tsx index 087a64de2e..bb442d05ab 100644 --- a/src/app/features/room/RoomTimeline.test.tsx +++ b/src/app/features/room/RoomTimeline.test.tsx @@ -46,6 +46,7 @@ const { }, timelineSync: { eventsLength: 1, + timelineVersion: 0, timeline: { linkedTimelines: [] }, liveTimelineLinked: true, backwardStatus: 'idle', @@ -940,13 +941,13 @@ describe('MemoizedTimelineItem', () => { }); describe('jump reveal and focus-regain read receipts', () => { - it('keeps the timeline hidden while a jump is still pending', () => { + it('keeps rendering the timeline while a jump is still pending', () => { timelineSync.jumpFailed = false; const { getByText } = render( ); - expect(getByText('canRedact:false hideReads:false')).not.toBeVisible(); + expect(getByText('canRedact:false hideReads:false')).toBeVisible(); }); it('restarts a route jump when the Room instance is replaced with the same id', () => { @@ -963,12 +964,12 @@ describe('jump reveal and focus-regain read receipts', () => { expect(timelineSync.loadEventTimeline).toHaveBeenCalledTimes(2); }); - it('reveals the timeline when the jump fails instead of leaving a blank room', () => { + it('keeps the timeline visible when the jump fails', () => { timelineSync.jumpFailed = false; const { getByText, rerender } = render( ); - expect(getByText('canRedact:false hideReads:false')).not.toBeVisible(); + expect(getByText('canRedact:false hideReads:false')).toBeVisible(); timelineSync.jumpFailed = true; act(() => { diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 28faa6c915..3310d54cf6 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -773,11 +773,9 @@ export function RoomTimeline({ ); useLayoutEffect(() => { - if (!isReady) return; if (timelineSync.eventsLength > 0) return; - setIsReady(false); hasInitialScrolledRef.current = false; - }, [isReady, timelineSync.eventsLength]); + }, [timelineSync.eventsLength]); const recalcTopSpacer = useCallback(() => { const v = vListRef.current; @@ -886,7 +884,6 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; - if (!timelineSyncRef.current.jumpFailed) setIsReady(false); jumpToEvent(eventId); }, [eventId, room, jumpToEvent]); @@ -1324,15 +1321,15 @@ export function RoomTimeline({ if (showLoadingPlaceholders) vListItemCount = 3; // One row so the error and its Retry have somewhere to render. else if (showEmptyPaginationError) vListItemCount = 1; - const vListIndices = useMemo(() => { - // Keep the cache-busting timeline identity explicit for exhaustive-deps. - void timelineSync.timeline; - return Array.from({ length: vListItemCount }, (_, i) => i); - }, [vListItemCount, timelineSync.timeline]); + const vListIndices = useMemo( + () => Array.from({ length: vListItemCount }, (_, i) => i), + [vListItemCount] + ); const processedEvents = useProcessedTimeline({ items: vListIndices, linkedTimelines: timelineSync.timeline.linkedTimelines, + timelineVersion: timelineSync.timelineVersion, ignoredUsersSet, hiddenEvents, mxUserId: mx.getUserId(), @@ -1471,16 +1468,12 @@ export function RoomTimeline({ width: '100%', overflow: 'hidden', position: 'relative', - opacity: - !hideTimelineForRoomState && - (isReady || showLoadingPlaceholders || showEmptyPaginationError) - ? 1 - : 0, + opacity: hideTimelineForRoomState ? 0 : 1, }} > - key={`${room.roomId}:${timelineSync.liveTimelineLinked ? 'live' : (timelineSync.focusItem?.eventId ?? scrollAnchorRef.current)}`} + key={room.roomId} ref={vListRef} data={processedEvents} shift={shouldShift} @@ -1552,7 +1545,7 @@ export function RoomTimeline({ )} - {(!atBottomState || !timelineSync.liveTimelineLinked) && isReady && ( + {(!atBottomState || !timelineSync.liveTimelineLinked) && ( (undefined); return useMemo(() => { + void timelineVersion; const timelineEvents = flattenTimelineEvents(linkedTimelines); const processingOptions: TimelineProcessingOptions = { ignoredUsersSet, @@ -814,5 +818,6 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, + timelineVersion, ]); } diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index 900ab9bc9a..00def7e869 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -532,7 +532,7 @@ describe('useTimelineSync', () => { await act(async () => { emitLiveTimelineEvent(room, timeline, events, '@bob:test'); - await Promise.resolve(); + await flushRaf(); }); expect(scrollToBottom).toHaveBeenCalledWith('smooth'); @@ -560,7 +560,7 @@ describe('useTimelineSync', () => { await act(async () => { emitLiveTimelineEvent(room, timeline, events, '@alice:test'); - await Promise.resolve(); + await flushRaf(); }); expect(scrollToBottom).toHaveBeenCalledWith('instant'); @@ -591,7 +591,7 @@ describe('useTimelineSync', () => { await act(async () => { emitLiveTimelineEvent(room, timeline, events, '@bob:test'); - await Promise.resolve(); + await flushRaf(); }); expect(setUnreadInfo).toHaveBeenCalledWith(unread); @@ -1273,7 +1273,7 @@ describe('live-arrive edge cases', () => { it('renders a stale non-live event appended to the live timeline', async () => { const { room, timeline, events } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; vi.mocked(isWindowFocused).mockReturnValue(true); await act(async () => { await flushRaf(); @@ -1293,7 +1293,7 @@ describe('live-arrive edge cases', () => { await flushRaf(); }); - expect(result.current.timeline).not.toBe(before); + expect(result.current.timelineVersion).toBeGreaterThan(before); expect(markAsRead).not.toHaveBeenCalled(); vi.mocked(isWindowFocused).mockReturnValue(false); }); @@ -1301,7 +1301,7 @@ describe('live-arrive edge cases', () => { it('renders a removal', async () => { const { room, timeline, events } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; await act(async () => { events.pop(); @@ -1309,10 +1309,10 @@ describe('live-arrive edge cases', () => { liveEvent: false, timeline, }); - await Promise.resolve(); + await flushRaf(); }); - expect(result.current.timeline).not.toBe(before); + expect(result.current.timelineVersion).toBeGreaterThan(before); }); it('ignores events emitted for a thread timeline set', async () => { @@ -1320,7 +1320,7 @@ describe('live-arrive edge cases', () => { const otherSet = new EventEmitter() as FakeTimelineSet; const threadTimeline = { ...createTimeline(events), getTimelineSet: () => otherSet }; const { result, scrollToBottom } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; await act(async () => { room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, false, { @@ -1330,14 +1330,14 @@ describe('live-arrive edge cases', () => { await Promise.resolve(); }); - expect(result.current.timeline).toBe(before); + expect(result.current.timelineVersion).toBe(before); expect(scrollToBottom).not.toHaveBeenCalled(); }); it('does not treat a threaded reply as an arrival when it lands on the main set', async () => { const { room, timeline, events } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; vi.mocked(isWindowFocused).mockReturnValue(true); await act(async () => { await flushRaf(); @@ -1363,7 +1363,7 @@ describe('live-arrive edge cases', () => { await flushRaf(); }); - expect(result.current.timeline).not.toBe(before); + expect(result.current.timelineVersion).toBeGreaterThan(before); expect(markAsRead).not.toHaveBeenCalled(); vi.mocked(isWindowFocused).mockReturnValue(false); }); @@ -1378,7 +1378,7 @@ describe('live-arrive edge cases', () => { liveEvent: true, timeline, }); - await Promise.resolve(); + await flushRaf(); }); expect(scrollToBottom).toHaveBeenCalledWith('instant'); @@ -1461,7 +1461,7 @@ describe('live-arrive edge cases', () => { liveEvent: false, timeline: freshTimeline, }); - await Promise.resolve(); + await flushRaf(); }); expect(scrollToBottom).toHaveBeenCalledWith('instant'); }); @@ -1504,7 +1504,7 @@ describe('live-arrive edge cases', () => { it('re-renders when an event finishes decrypting', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; await act(async () => { mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => room.roomId }); @@ -1513,13 +1513,13 @@ describe('live-arrive edge cases', () => { }); }); - expect(result.current.timeline).not.toBe(before); + expect(result.current.timelineVersion).toBeGreaterThan(before); }); it('ignores decryption of an event in another room', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; await act(async () => { mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => '!other:test' }); @@ -1528,20 +1528,20 @@ describe('live-arrive edge cases', () => { }); }); - expect(result.current.timeline).toBe(before); + expect(result.current.timelineVersion).toBe(before); }); it('re-renders when a late local echo updates (slow send acknowledgement)', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; await act(async () => { room.emit(RoomEvent.LocalEchoUpdated, {}, room); - await Promise.resolve(); + await flushRaf(); }); - expect(result.current.timeline).not.toBe(before); + expect(result.current.timelineVersion).toBeGreaterThan(before); }); }); @@ -2409,9 +2409,8 @@ const flushFrame = async () => { }; describe('decryption refresh coalescing', () => { - // Counts distinct timeline objects, not renders: unrelated re-renders reuse the object. const renderTrackingHook = (room: FakeRoom) => { - const seen: unknown[] = []; + const seen: number[] = []; renderHook(() => { const sync = useTimelineSync({ room: room as Room, @@ -2425,7 +2424,7 @@ describe('decryption refresh coalescing', () => { readUptoEventIdRef: { current: undefined }, isInactivePanelRef: { current: false }, }); - if (!seen.includes(sync.timeline)) seen.push(sync.timeline); + if (!seen.includes(sync.timelineVersion)) seen.push(sync.timelineVersion); return sync; }); return seen; diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 35cf886e50..2a4a98a8cd 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -443,6 +443,8 @@ export function useTimelineSync({ const [focusItem, setFocusItem] = useState(); const [jumpFailedFor, setJumpFailedFor] = useState(); const jumpFailed = jumpFailedFor !== undefined && jumpFailedFor === eventId; + const [timelineVersion, setTimelineVersion] = useState(0); + const bumpTimeline = useCallback(() => setTimelineVersion((version) => version + 1), []); const resetAutoScrollPendingRef = useRef(false); const pendingAutoScrollBehaviorRef = useRef<'instant' | 'smooth' | undefined>(undefined); @@ -613,6 +615,22 @@ export function useTimelineSync({ [room] ); + const refreshFrameRef = useRef(undefined); + const scheduleTimelineRefresh = useCallback(() => { + if (refreshFrameRef.current !== undefined) return; + refreshFrameRef.current = requestAnimationFrame(() => { + refreshFrameRef.current = undefined; + if (!alive()) return; + bumpTimeline(); + }); + }, [alive, bumpTimeline]); + useEffect( + () => () => { + if (refreshFrameRef.current !== undefined) cancelAnimationFrame(refreshFrameRef.current); + }, + [] + ); + useLiveEventArrive( room, useCallback( @@ -621,9 +639,7 @@ export function useTimelineSync({ const isDisplayedTimeline = evtTimeline === undefined || linkedTimelinesRef.current.includes(evtTimeline); - if (isDisplayedTimeline) { - setActiveTimeline((ct) => ({ ...ct })); - } + if (isDisplayedTimeline) scheduleTimelineRefresh(); if (!isLive) return; @@ -675,10 +691,10 @@ export function useTimelineSync({ setUnreadInfo, hideReadsRef, isInactivePanelRef, - setActiveTimeline, focusLiveTimeline, redactInFocusedWindow, onReturnToLive, + scheduleTimelineRefresh, ] ) ); @@ -686,34 +702,19 @@ export function useTimelineSync({ const handleLocalEchoUpdated = useCallback( (_mEvent: MatrixEvent, eventRoom: Room | undefined) => { if (eventRoom?.roomId !== room.roomId) return; - setActiveTimeline((ct) => ({ ...ct })); + scheduleTimelineRefresh(); }, - [room, setActiveTimeline] + [room, scheduleTimelineRefresh] ); useMatrixEvent(room, RoomEvent.LocalEchoUpdated, handleLocalEchoUpdated); - const decryptedFrameRef = useRef(undefined); const handleDecrypted = useCallback( (mEvent: MatrixEvent) => { if (mEvent.getRoomId() !== room.roomId) return; - if (decryptedFrameRef.current !== undefined) return; - decryptedFrameRef.current = requestAnimationFrame(() => { - decryptedFrameRef.current = undefined; - if (!alive()) return; - setActiveTimeline((ct) => ({ ...ct })); - }); + scheduleTimelineRefresh(); }, - [alive, room, setActiveTimeline] - ); - - useEffect( - () => () => { - if (decryptedFrameRef.current !== undefined) { - cancelAnimationFrame(decryptedFrameRef.current); - } - }, - [] + [room, scheduleTimelineRefresh] ); useMatrixEvent(mx, MatrixEventEvent.Decrypted, handleDecrypted); @@ -746,12 +747,7 @@ export function useTimelineSync({ ) ); - useThreadUpdate( - room, - useCallback(() => { - setActiveTimeline((ct) => ({ ...ct })); - }, [setActiveTimeline]) - ); + useThreadUpdate(room, scheduleTimelineRefresh); useEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; @@ -773,7 +769,7 @@ export function useTimelineSync({ lastScrolledAtEventsLengthRef.current = eventsLength; scrollToBottom(behavior); - }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); + }, [isAtBottom, liveTimelineLinked, eventsLength, timelineVersion, scrollToBottom]); useEffect(() => { if (eventId) return; @@ -793,6 +789,7 @@ export function useTimelineSync({ return { timeline, + timelineVersion, eventsLength, liveTimelineLinked, canPaginateBack, diff --git a/src/app/utils/matrix.test.ts b/src/app/utils/matrix.test.ts index aa8d832db4..770f6dcefd 100644 --- a/src/app/utils/matrix.test.ts +++ b/src/app/utils/matrix.test.ts @@ -21,8 +21,13 @@ vi.mock('@tauri-apps/api/core', () => tauriApi); vi.mock('./mediaTransport', () => mediaTransport); vi.mock('./room/relations', () => reactions); -const { getDMRoomFor, mxcUrlToHttp, rewriteAuthenticatedMediaUrl, toggleReaction } = - await import('./matrix'); +const { + getDMRoomFor, + mxcUrlToHttp, + rewriteAuthenticatedMediaUrl, + toggleReaction, + optimisticallyRedactEvent, +} = await import('./matrix'); describe('rewriteAuthenticatedMediaUrl', () => { beforeEach(() => { @@ -109,28 +114,66 @@ describe('rewriteAuthenticatedMediaUrl', () => { describe('toggleReaction', () => { it('redacts the existing reaction from the current user', () => { + const redaction = {}; const reaction = { getId: () => '$reaction', getSender: () => '@me:example.org', + getRelation: () => ({ event_id: '$message' }), + isRedacted: () => false, + markLocallyRedacted: vi.fn<(event: unknown) => void>(), + unmarkLocallyRedacted: vi.fn<() => void>(), }; reactions.getEventReactions.mockReturnValue({ getSortedAnnotationsByKey: () => [['👍', new Set([reaction])]], }); const mx = { getUserId: () => '@me:example.org', - redactEvent: vi.fn<(roomId: string, eventId: string) => void>(), + makeTxnId: () => 'txn', + redactEvent: vi.fn<(...args: unknown[]) => Promise>(() => Promise.resolve({})), sendEvent: vi.fn<(...args: unknown[]) => void>(), } as unknown as MatrixClient; const room = { roomId: '!room:example.org', getUnfilteredTimelineSet: vi.fn<() => unknown>(), + findEventById: () => redaction, }; toggleReaction(mx, room as never, '$message', '👍'); - expect(mx.redactEvent).toHaveBeenCalledWith('!room:example.org', '$reaction'); + expect(mx.redactEvent).toHaveBeenCalledWith('!room:example.org', '$reaction', 'txn', undefined); + expect(reaction.markLocallyRedacted).toHaveBeenCalledWith(redaction); expect(mx.sendEvent).not.toHaveBeenCalled(); }); + + it('rolls back an optimistic redaction when sending fails', async () => { + const relation = { addEvent: vi.fn<(event: unknown) => Promise>(() => Promise.resolve()) }; + reactions.getEventReactions.mockReturnValue(relation); + const target = { + getId: () => '$reaction', + getRelation: () => ({ event_id: '$message' }), + isRedacted: () => false, + markLocallyRedacted: vi.fn<(event: unknown) => void>(), + unmarkLocallyRedacted: vi.fn<() => void>(), + }; + const error = new Error('failed'); + const mx = { + makeTxnId: () => 'txn', + redactEvent: () => Promise.reject(error), + } as unknown as MatrixClient; + const timelineSet = {}; + const room = { + roomId: '!room:example.org', + getUnfilteredTimelineSet: () => timelineSet, + findEventById: () => ({}), + }; + + await expect( + optimisticallyRedactEvent(mx, room as never, target as never) + ).rejects.toBe(error); + + expect(target.unmarkLocallyRedacted).toHaveBeenCalledOnce(); + expect(relation.addEvent).toHaveBeenCalledWith(target); + }); }); describe('mxcUrlToHttp', () => { diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 7fbd9bfed0..4b445c148a 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -571,8 +571,9 @@ export const toggleReaction = ( const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!)); if (myReaction) { - const eventId = myReaction.getId(); - if (eventId) mx.redactEvent(room.roomId, eventId); + void optimisticallyRedactEvent(mx, room, myReaction, undefined, timelineSet).catch( + () => undefined + ); return; } const rShortcode = @@ -590,3 +591,29 @@ export const toggleReaction = ( ) as TimelineEvents[keyof TimelineEvents] ); }; + +export const optimisticallyRedactEvent = ( + mx: MatrixClient, + room: Room, + target: MatrixEvent, + opts?: { reason?: string }, + timelineSet = room.getUnfilteredTimelineSet() +) => { + const eventId = target.getId(); + if (!eventId) return Promise.reject(new Error('Cannot redact an event without an ID')); + + const txnId = mx.makeTxnId(); + const request = mx.redactEvent(room.roomId, eventId, txnId, opts); + const redaction = room.findEventById(`~${room.roomId}:${txnId}`); + if (!redaction || target.isRedacted()) return request; + + target.markLocallyRedacted(redaction); + return request.catch(async (error) => { + target.unmarkLocallyRedacted(); + const relation = target.getRelation(); + if (relation?.event_id) { + await getEventReactions(timelineSet, relation.event_id)?.addEvent(target); + } + throw error; + }); +}; From e864e6aeb443fe02ba7166a08496935f927b6de4 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 27 Aug 2026 15:22:26 -0500 Subject: [PATCH 13/21] formatting --- src/app/utils/matrix.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/utils/matrix.test.ts b/src/app/utils/matrix.test.ts index 770f6dcefd..b1a00cceea 100644 --- a/src/app/utils/matrix.test.ts +++ b/src/app/utils/matrix.test.ts @@ -146,7 +146,9 @@ describe('toggleReaction', () => { }); it('rolls back an optimistic redaction when sending fails', async () => { - const relation = { addEvent: vi.fn<(event: unknown) => Promise>(() => Promise.resolve()) }; + const relation = { + addEvent: vi.fn<(event: unknown) => Promise>(() => Promise.resolve()), + }; reactions.getEventReactions.mockReturnValue(relation); const target = { getId: () => '$reaction', @@ -167,9 +169,7 @@ describe('toggleReaction', () => { findEventById: () => ({}), }; - await expect( - optimisticallyRedactEvent(mx, room as never, target as never) - ).rejects.toBe(error); + await expect(optimisticallyRedactEvent(mx, room as never, target as never)).rejects.toBe(error); expect(target.unmarkLocallyRedacted).toHaveBeenCalledOnce(); expect(relation.addEvent).toHaveBeenCalledWith(target); From 088c337a531a0576523b11f217349c0e244dbafd Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 27 Aug 2026 13:58:59 -0500 Subject: [PATCH 14/21] fix: improve legacy image pack compatibility --- .changeset/merge-legacy-image-pack-rooms.md | 5 + .../image-pack-view/RoomImagePack.tsx | 22 ++--- .../emojis-stickers/RoomPacks.tsx | 22 +++-- src/app/plugins/custom-emoji/ImagePack.ts | 14 ++- src/app/plugins/custom-emoji/utils.test.ts | 52 ++++++++++ src/app/plugins/custom-emoji/utils.ts | 94 +++++++++---------- 6 files changed, 139 insertions(+), 70 deletions(-) create mode 100644 .changeset/merge-legacy-image-pack-rooms.md create mode 100644 src/app/plugins/custom-emoji/utils.test.ts diff --git a/.changeset/merge-legacy-image-pack-rooms.md b/.changeset/merge-legacy-image-pack-rooms.md new file mode 100644 index 0000000000..d42ba3c317 --- /dev/null +++ b/.changeset/merge-legacy-image-pack-rooms.md @@ -0,0 +1,5 @@ +--- +type: patch +--- + +Merge image-pack updates from legacy clients and mirror edits or deletions to active legacy pack state keys. diff --git a/src/app/components/image-pack-view/RoomImagePack.tsx b/src/app/components/image-pack-view/RoomImagePack.tsx index 483436e3f6..92feb2f81c 100644 --- a/src/app/components/image-pack-view/RoomImagePack.tsx +++ b/src/app/components/image-pack-view/RoomImagePack.tsx @@ -3,14 +3,13 @@ import type { Room } from '$types/matrix-sdk'; import { usePowerLevels } from '$hooks/usePowerLevels'; import { useMatrixClient } from '$hooks/useMatrixClient'; import type { PackContent } from '$plugins/custom-emoji'; -import { ImagePack } from '$plugins/custom-emoji'; +import { getImagePackStateEventTypes, ImagePack } from '$plugins/custom-emoji'; import { useRoomImagePack } from '$hooks/useImagePacks'; import { randomStr } from '$utils/common'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { ImagePackContent } from './ImagePackContent'; -import { CustomStateEvent } from '$types/matrix/room'; type RoomImagePackProps = { room: Room; @@ -23,9 +22,6 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) { const powerLevels = usePowerLevels(room); const creators = useRoomCreators(room); - const permissions = useRoomPermissions(creators, powerLevels); - const canEditImagePack = permissions.stateEvent(CustomStateEvent.ImagePack, userId); - const fallbackPack = useMemo(() => { const fakePackId = randomStr(4); return new ImagePack( @@ -39,19 +35,23 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) { }, [room.roomId, stateKey]); const imagePack = useRoomImagePack(room, stateKey) ?? fallbackPack; + const permissions = useRoomPermissions(creators, powerLevels); + const canEditImagePack = getImagePackStateEventTypes(room, stateKey).every((eventType) => + permissions.stateEvent(eventType, userId) + ); + const handleUpdate = useCallback( async (packContent: PackContent) => { const { address } = imagePack; if (!address) return; - await mx.sendStateEvent( - address.roomId, - CustomStateEvent.ImagePack, - packContent, - address.stateKey + await Promise.all( + getImagePackStateEventTypes(room, address.stateKey).map((eventType) => + mx.sendStateEvent(address.roomId, eventType, packContent, address.stateKey) + ) ); }, - [mx, imagePack] + [mx, imagePack, room] ); return ( diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx index 291bcc4d64..856bc7fe18 100644 --- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx +++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx @@ -17,7 +17,7 @@ import { composerIcon, menuIcon, Plus, Sticker, X } from '$components/icons/phos import type { MatrixError } from '$types/matrix-sdk'; import { SequenceCard, SequenceCardStyle } from '$components/sequence-card'; import type { ImagePack, PackAddress, PackContent } from '$plugins/custom-emoji'; -import { ImageUsage, packAddressEqual } from '$plugins/custom-emoji'; +import { getImagePackStateEventTypes, ImageUsage, packAddressEqual } from '$plugins/custom-emoji'; import { useRoom } from '$hooks/useRoom'; import { useRoomImagePacks } from '$hooks/useImagePacks'; import { LineClamp2 } from '$styles/Text.css'; @@ -152,7 +152,7 @@ export function RoomPacks({ onViewPack }: Readonly) { const creators = useRoomCreators(room); const permissions = useRoomPermissions(creators, powerLevels); - const canEdit = permissions.stateEvent(CustomStateEvent.ImagePack, mx.getSafeUserId()); + const canCreate = permissions.stateEvent(CustomStateEvent.ImagePack, mx.getSafeUserId()); const unfilteredPacks = useRoomImagePacks(room); const packs = useMemo(() => unfilteredPacks.filter((pack) => !pack.deleted), [unfilteredPacks]); @@ -162,12 +162,13 @@ export function RoomPacks({ onViewPack }: Readonly) { const [applyState, applyChanges] = useAsyncCallback( useCallback(async () => { - for (let i = 0; i < removedPacks.length; i += 1) { - const addr = removedPacks[i]; - if (!addr) continue; - // oxlint-disable-next-line no-await-in-loop - await mx.sendStateEvent(room.roomId, CustomStateEvent.ImagePack, {}, addr.stateKey); - } + await Promise.all( + removedPacks.flatMap((addr) => + getImagePackStateEventTypes(room, addr.stateKey).map((eventType) => + mx.sendStateEvent(room.roomId, eventType, {}, addr.stateKey) + ) + ) + ); }, [mx, room, removedPacks]) ); const applyingChanges = applyState.status === AsyncStatus.Loading; @@ -195,6 +196,9 @@ export function RoomPacks({ onViewPack }: Readonly) { const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined; const { address } = pack; if (!address) return null; + const canEdit = getImagePackStateEventTypes(room, address.stateKey).every((eventType) => + permissions.stateEvent(eventType, mx.getSafeUserId()) + ); const removed = removedPacks.some((addr) => packAddressEqual(addr, address)); return ( @@ -268,7 +272,7 @@ export function RoomPacks({ onViewPack }: Readonly) { <> Packs - {canEdit && } + {canCreate && } {packs.map(renderPack)} {packs.length === 0 && ( (); - - const imagePack: ImagePack = new ImagePack(id, content, address); + const legacyContent = legacyEvent?.getContent(); + const mergedContent = + legacyContent && (content.pack !== undefined || content.images !== undefined) + ? { + pack: { ...legacyContent.pack, ...content.pack }, + images: { ...legacyContent.images, ...content.images }, + } + : content; + + const imagePack: ImagePack = new ImagePack(id, mergedContent, address); return imagePack; } diff --git a/src/app/plugins/custom-emoji/utils.test.ts b/src/app/plugins/custom-emoji/utils.test.ts new file mode 100644 index 0000000000..6794584cc2 --- /dev/null +++ b/src/app/plugins/custom-emoji/utils.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { MatrixEvent, Room } from '$types/matrix-sdk'; +import { CustomStateEvent } from '$types/matrix/room'; +import { getImagePackStateEventTypes, getRoomImagePacks } from './utils'; + +type TestRoom = Room & { packEvents: Record }; + +vi.mock('$utils/room/hierarchy', () => ({ + getAccountData: vi.fn<() => undefined>(), + getStateEvent: (room: TestRoom, type: string, stateKey: string) => + room.packEvents[type]?.find((event) => event.getStateKey() === stateKey), + getStateEvents: (room: TestRoom, type: string) => room.packEvents[type] ?? [], +})); + +const packEvent = (id: string, content: object): MatrixEvent => + ({ + getId: () => id, + getRoomId: () => '!packs:example.org', + getStateKey: () => 'pack', + getContent: () => content, + }) as MatrixEvent; + +describe('legacy image pack compatibility', () => { + it('merges legacy additions and keeps the legacy key updated', () => { + const room = { + roomId: '!packs:example.org', + packEvents: { + [CustomStateEvent.ImagePack]: [ + packEvent('$stable', { + pack: { display_name: 'Stable name' }, + images: { stable: { url: 'mxc://example.org/stable' } }, + }), + ], + [CustomStateEvent.PoniesRoomEmotes]: [ + packEvent('$legacy', { + images: { addedByLegacyClient: { url: 'mxc://example.org/legacy' } }, + }), + ], + }, + } as unknown as TestRoom; + + const [pack] = getRoomImagePacks(room); + expect(Array.from(pack?.images.collection.keys() ?? [])).toEqual([ + 'addedByLegacyClient', + 'stable', + ]); + expect(getImagePackStateEventTypes(room, 'pack')).toEqual([ + CustomStateEvent.ImagePack, + CustomStateEvent.PoniesRoomEmotes, + ]); + }); +}); diff --git a/src/app/plugins/custom-emoji/utils.ts b/src/app/plugins/custom-emoji/utils.ts index 2b52ab7602..1eddb91146 100644 --- a/src/app/plugins/custom-emoji/utils.ts +++ b/src/app/plugins/custom-emoji/utils.ts @@ -3,7 +3,7 @@ import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; import { getAccountData, getStateEvent, getStateEvents } from '$utils/room/hierarchy'; import type { IImageInfo } from '$types/matrix/common'; -import type { ImageUsage } from './types'; +import type { ImageUsage, PackContent } from './types'; import { ImagePack } from './ImagePack'; import type { PackMetaReader } from './PackMetaReader'; import type { PackAddress } from './PackAddress'; @@ -16,6 +16,20 @@ export function packAddressEqual(a1?: PackAddress, a2?: PackAddress): boolean { return a1.roomId === a2.roomId && a1.stateKey === a2.stateKey; } +export function getImagePackStateEventTypes( + room: Room, + stateKey: string +): Array { + const legacyContent = getStateEvent( + room, + CustomStateEvent.PoniesRoomEmotes, + stateKey + )?.getContent(); + return legacyContent && (legacyContent.pack !== undefined || legacyContent.images !== undefined) + ? [CustomStateEvent.ImagePack, CustomStateEvent.PoniesRoomEmotes] + : [CustomStateEvent.ImagePack]; +} + export function imageUsageEqual(u1: ImageUsage[], u2: ImageUsage[]) { return u1.length === u2.length && u1.every((u) => u2.includes(u)); } @@ -29,40 +43,39 @@ export function packMetaEqual(a: PackMetaReader, b: PackMetaReader): boolean { ); } -function makeImagePacks(packEvents: MatrixEvent[]): ImagePack[] { - return packEvents.reduce((imagePacks, packEvent) => { - const packId = packEvent.getId(); - if (!packId) return imagePacks; - imagePacks.push(ImagePack.fromMatrixEvent(packId, packEvent)); - return imagePacks; - }, []); +function makeImagePacks( + stableEvents: MatrixEvent[], + legacyEvents: MatrixEvent[], + includeStateKey: (stateKey: string) => boolean = () => true +): ImagePack[] { + const legacyByKey = new Map(); + const eventByKey = new Map(); + legacyEvents.concat(stableEvents).forEach((event) => { + const key = event.getStateKey(); + if (typeof key !== 'string' || !includeStateKey(key)) return; + eventByKey.set(key, event); + if (legacyEvents.includes(event)) legacyByKey.set(key, event); + }); + + return Array.from(eventByKey, ([key, event]) => { + const id = event.getId(); + if (!id) return undefined; + const legacyEvent = legacyByKey.get(key); + return ImagePack.fromMatrixEvent(id, event, legacyEvent === event ? undefined : legacyEvent); + }).filter((pack): pack is ImagePack => pack !== undefined); } export function getRoomImagePack(room: Room, stateKey: string): ImagePack | undefined { - const packEvent = - getStateEvent(room, CustomStateEvent.ImagePack, stateKey) || - getStateEvent(room, CustomStateEvent.PoniesRoomEmotes, stateKey); - if (!packEvent) return undefined; - const packId = packEvent.getId(); - if (!packId) return undefined; - return ImagePack.fromMatrixEvent(packId, packEvent); + const stable = getStateEvent(room, CustomStateEvent.ImagePack, stateKey); + const legacy = getStateEvent(room, CustomStateEvent.PoniesRoomEmotes, stateKey); + return makeImagePacks(stable ? [stable] : [], legacy ? [legacy] : [])[0]; } export function getRoomImagePacks(room: Room): ImagePack[] { - const packEventsStable = getStateEvents(room, CustomStateEvent.ImagePack); - const packEventsUnstable = getStateEvents(room, CustomStateEvent.PoniesRoomEmotes); - - const uniquePackEvents = new Map(); - packEventsUnstable.forEach((ev) => { - const key = ev.getStateKey(); - if (typeof key === 'string') uniquePackEvents.set(key, ev); - }); - packEventsStable.forEach((ev) => { - const key = ev.getStateKey(); - if (typeof key === 'string') uniquePackEvents.set(key, ev); - }); - - return makeImagePacks(Array.from(uniquePackEvents.values())); + return makeImagePacks( + getStateEvents(room, CustomStateEvent.ImagePack), + getStateEvents(room, CustomStateEvent.PoniesRoomEmotes) + ); } export function getGlobalImagePacks(mx: MatrixClient): ImagePack[] { @@ -83,24 +96,11 @@ export function getGlobalImagePacks(mx: MatrixClient): ImagePack[] { if (!room) return []; const packStateKeyToUnknown = roomIdToPackInfo[roomId]; - const packEventsStable = getStateEvents(room, CustomStateEvent.ImagePack); - const packEventsUnstable = getStateEvents(room, CustomStateEvent.PoniesRoomEmotes); - - const uniquePackEvents = new Map(); - packEventsUnstable.forEach((ev) => { - const key = ev.getStateKey(); - if (typeof key === 'string' && !!packStateKeyToUnknown[key]) { - uniquePackEvents.set(key, ev); - } - }); - packEventsStable.forEach((ev) => { - const key = ev.getStateKey(); - if (typeof key === 'string' && !!packStateKeyToUnknown[key]) { - uniquePackEvents.set(key, ev); - } - }); - - return makeImagePacks(Array.from(uniquePackEvents.values())); + return makeImagePacks( + getStateEvents(room, CustomStateEvent.ImagePack), + getStateEvents(room, CustomStateEvent.PoniesRoomEmotes), + (stateKey) => !!packStateKeyToUnknown[stateKey] + ); }); return packs; From a6ed734aaf4bd681cfd33779aba0841213a77e5d Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 27 Aug 2026 15:49:04 -0500 Subject: [PATCH 15/21] undo some stuff that broke tests --- src/app/features/room/RoomTimeline.test.tsx | 20 ++++++- src/app/features/room/RoomTimeline.tsx | 12 ++-- .../hooks/timeline/useProcessedTimeline.ts | 5 -- .../hooks/timeline/useTimelineSync.test.tsx | 47 +++++++-------- src/app/hooks/timeline/useTimelineSync.ts | 57 ++++++++++--------- 5 files changed, 78 insertions(+), 63 deletions(-) diff --git a/src/app/features/room/RoomTimeline.test.tsx b/src/app/features/room/RoomTimeline.test.tsx index bb442d05ab..e0de641f1b 100644 --- a/src/app/features/room/RoomTimeline.test.tsx +++ b/src/app/features/room/RoomTimeline.test.tsx @@ -1,5 +1,5 @@ import { EventEmitter } from 'events'; -import { forwardRef, useImperativeHandle, type ReactNode } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, type ReactNode } from 'react'; import { act, render, waitFor } from '@testing-library/react'; import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import type { ProseMirrorEditorController as Editor } from '$components/editor/prosemirrorController'; @@ -22,6 +22,7 @@ const { windowFocused, rowItemIndex, rowRenders, + vListMounts, eventRedacted, unrenderedJumpTarget, liveTimeline, @@ -46,7 +47,6 @@ const { }, timelineSync: { eventsLength: 1, - timelineVersion: 0, timeline: { linkedTimelines: [] }, liveTimelineLinked: true, backwardStatus: 'idle', @@ -77,6 +77,7 @@ const { windowFocused: { current: false }, rowItemIndex: { current: 0 }, rowRenders: { count: 0 }, + vListMounts: { count: 0 }, eventRedacted: { current: false }, unrenderedJumpTarget: { current: undefined as { eventId: string; rawIndex: number } | undefined, @@ -115,6 +116,9 @@ vi.mock('virtua', () => ({ }, ref ) { + useEffect(() => { + vListMounts.count += 1; + }, []); lastOnScroll = onScroll; lastOnScrollEnd = onScrollEnd; vListProps.shift = shift ?? false; @@ -394,6 +398,7 @@ beforeEach(() => { windowFocused.current = false; rowItemIndex.current = 0; rowRenders.count = 0; + vListMounts.count = 0; eventRedacted.current = false; unrenderedJumpTarget.current = undefined; eventTimeline.current = liveTimeline; @@ -605,6 +610,17 @@ describe('RoomTimeline content ResizeObserver', () => { expect(getByText('Jump to Latest')).toBeTruthy(); }); + it('remounts the virtualizer when switching to a focused timeline window', () => { + const { rerender } = renderTimeline(); + const mounts = vListMounts.count; + + timelineSync.liveTimelineLinked = false; + timelineSync.focusItem = { eventId: '$evt1', scrollTo: true, highlight: true }; + rerender(); + + expect(vListMounts.count).toBe(mounts + 1); + }); + it('shifts the virtual list when rendered history prepends', async () => { timelineSync.liveTimelineLinked = false; const { rerender } = render(); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 3310d54cf6..ba40f2833c 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1321,15 +1321,15 @@ export function RoomTimeline({ if (showLoadingPlaceholders) vListItemCount = 3; // One row so the error and its Retry have somewhere to render. else if (showEmptyPaginationError) vListItemCount = 1; - const vListIndices = useMemo( - () => Array.from({ length: vListItemCount }, (_, i) => i), - [vListItemCount] - ); + const vListIndices = useMemo(() => { + // Keep the cache-busting timeline identity explicit for exhaustive-deps. + void timelineSync.timeline; + return Array.from({ length: vListItemCount }, (_, i) => i); + }, [vListItemCount, timelineSync.timeline]); const processedEvents = useProcessedTimeline({ items: vListIndices, linkedTimelines: timelineSync.timeline.linkedTimelines, - timelineVersion: timelineSync.timelineVersion, ignoredUsersSet, hiddenEvents, mxUserId: mx.getUserId(), @@ -1473,7 +1473,7 @@ export function RoomTimeline({ > - key={room.roomId} + key={`${room.roomId}:${timelineSync.liveTimelineLinked ? 'live' : (timelineSync.focusItem?.eventId ?? scrollAnchorRef.current)}`} ref={vListRef} data={processedEvents} shift={shouldShift} diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 7c9d9de9a3..f22479d8d4 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -35,8 +35,6 @@ export interface UseProcessedTimelineOptions { * where every reply legitimately has `threadRootId` set to the root. */ skipThreadFilter?: boolean; - /** Bumped when displayed timeline events mutate in place (redactions, decrypt, reactions). */ - timelineVersion?: number; } export interface ProcessedEvent { @@ -667,7 +665,6 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, - timelineVersion = 0, }: UseProcessedTimelineOptions): ProcessedEvent[] { const { showHiddenEvents, @@ -682,7 +679,6 @@ export function useProcessedTimeline({ const cacheRef = useRef(undefined); return useMemo(() => { - void timelineVersion; const timelineEvents = flattenTimelineEvents(linkedTimelines); const processingOptions: TimelineProcessingOptions = { ignoredUsersSet, @@ -818,6 +814,5 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, - timelineVersion, ]); } diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index 00def7e869..900ab9bc9a 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -532,7 +532,7 @@ describe('useTimelineSync', () => { await act(async () => { emitLiveTimelineEvent(room, timeline, events, '@bob:test'); - await flushRaf(); + await Promise.resolve(); }); expect(scrollToBottom).toHaveBeenCalledWith('smooth'); @@ -560,7 +560,7 @@ describe('useTimelineSync', () => { await act(async () => { emitLiveTimelineEvent(room, timeline, events, '@alice:test'); - await flushRaf(); + await Promise.resolve(); }); expect(scrollToBottom).toHaveBeenCalledWith('instant'); @@ -591,7 +591,7 @@ describe('useTimelineSync', () => { await act(async () => { emitLiveTimelineEvent(room, timeline, events, '@bob:test'); - await flushRaf(); + await Promise.resolve(); }); expect(setUnreadInfo).toHaveBeenCalledWith(unread); @@ -1273,7 +1273,7 @@ describe('live-arrive edge cases', () => { it('renders a stale non-live event appended to the live timeline', async () => { const { room, timeline, events } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; vi.mocked(isWindowFocused).mockReturnValue(true); await act(async () => { await flushRaf(); @@ -1293,7 +1293,7 @@ describe('live-arrive edge cases', () => { await flushRaf(); }); - expect(result.current.timelineVersion).toBeGreaterThan(before); + expect(result.current.timeline).not.toBe(before); expect(markAsRead).not.toHaveBeenCalled(); vi.mocked(isWindowFocused).mockReturnValue(false); }); @@ -1301,7 +1301,7 @@ describe('live-arrive edge cases', () => { it('renders a removal', async () => { const { room, timeline, events } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; await act(async () => { events.pop(); @@ -1309,10 +1309,10 @@ describe('live-arrive edge cases', () => { liveEvent: false, timeline, }); - await flushRaf(); + await Promise.resolve(); }); - expect(result.current.timelineVersion).toBeGreaterThan(before); + expect(result.current.timeline).not.toBe(before); }); it('ignores events emitted for a thread timeline set', async () => { @@ -1320,7 +1320,7 @@ describe('live-arrive edge cases', () => { const otherSet = new EventEmitter() as FakeTimelineSet; const threadTimeline = { ...createTimeline(events), getTimelineSet: () => otherSet }; const { result, scrollToBottom } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; await act(async () => { room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, false, { @@ -1330,14 +1330,14 @@ describe('live-arrive edge cases', () => { await Promise.resolve(); }); - expect(result.current.timelineVersion).toBe(before); + expect(result.current.timeline).toBe(before); expect(scrollToBottom).not.toHaveBeenCalled(); }); it('does not treat a threaded reply as an arrival when it lands on the main set', async () => { const { room, timeline, events } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; vi.mocked(isWindowFocused).mockReturnValue(true); await act(async () => { await flushRaf(); @@ -1363,7 +1363,7 @@ describe('live-arrive edge cases', () => { await flushRaf(); }); - expect(result.current.timelineVersion).toBeGreaterThan(before); + expect(result.current.timeline).not.toBe(before); expect(markAsRead).not.toHaveBeenCalled(); vi.mocked(isWindowFocused).mockReturnValue(false); }); @@ -1378,7 +1378,7 @@ describe('live-arrive edge cases', () => { liveEvent: true, timeline, }); - await flushRaf(); + await Promise.resolve(); }); expect(scrollToBottom).toHaveBeenCalledWith('instant'); @@ -1461,7 +1461,7 @@ describe('live-arrive edge cases', () => { liveEvent: false, timeline: freshTimeline, }); - await flushRaf(); + await Promise.resolve(); }); expect(scrollToBottom).toHaveBeenCalledWith('instant'); }); @@ -1504,7 +1504,7 @@ describe('live-arrive edge cases', () => { it('re-renders when an event finishes decrypting', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; await act(async () => { mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => room.roomId }); @@ -1513,13 +1513,13 @@ describe('live-arrive edge cases', () => { }); }); - expect(result.current.timelineVersion).toBeGreaterThan(before); + expect(result.current.timeline).not.toBe(before); }); it('ignores decryption of an event in another room', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; await act(async () => { mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => '!other:test' }); @@ -1528,20 +1528,20 @@ describe('live-arrive edge cases', () => { }); }); - expect(result.current.timelineVersion).toBe(before); + expect(result.current.timeline).toBe(before); }); it('re-renders when a late local echo updates (slow send acknowledgement)', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; await act(async () => { room.emit(RoomEvent.LocalEchoUpdated, {}, room); - await flushRaf(); + await Promise.resolve(); }); - expect(result.current.timelineVersion).toBeGreaterThan(before); + expect(result.current.timeline).not.toBe(before); }); }); @@ -2409,8 +2409,9 @@ const flushFrame = async () => { }; describe('decryption refresh coalescing', () => { + // Counts distinct timeline objects, not renders: unrelated re-renders reuse the object. const renderTrackingHook = (room: FakeRoom) => { - const seen: number[] = []; + const seen: unknown[] = []; renderHook(() => { const sync = useTimelineSync({ room: room as Room, @@ -2424,7 +2425,7 @@ describe('decryption refresh coalescing', () => { readUptoEventIdRef: { current: undefined }, isInactivePanelRef: { current: false }, }); - if (!seen.includes(sync.timelineVersion)) seen.push(sync.timelineVersion); + if (!seen.includes(sync.timeline)) seen.push(sync.timeline); return sync; }); return seen; diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 2a4a98a8cd..35cf886e50 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -443,8 +443,6 @@ export function useTimelineSync({ const [focusItem, setFocusItem] = useState(); const [jumpFailedFor, setJumpFailedFor] = useState(); const jumpFailed = jumpFailedFor !== undefined && jumpFailedFor === eventId; - const [timelineVersion, setTimelineVersion] = useState(0); - const bumpTimeline = useCallback(() => setTimelineVersion((version) => version + 1), []); const resetAutoScrollPendingRef = useRef(false); const pendingAutoScrollBehaviorRef = useRef<'instant' | 'smooth' | undefined>(undefined); @@ -615,22 +613,6 @@ export function useTimelineSync({ [room] ); - const refreshFrameRef = useRef(undefined); - const scheduleTimelineRefresh = useCallback(() => { - if (refreshFrameRef.current !== undefined) return; - refreshFrameRef.current = requestAnimationFrame(() => { - refreshFrameRef.current = undefined; - if (!alive()) return; - bumpTimeline(); - }); - }, [alive, bumpTimeline]); - useEffect( - () => () => { - if (refreshFrameRef.current !== undefined) cancelAnimationFrame(refreshFrameRef.current); - }, - [] - ); - useLiveEventArrive( room, useCallback( @@ -639,7 +621,9 @@ export function useTimelineSync({ const isDisplayedTimeline = evtTimeline === undefined || linkedTimelinesRef.current.includes(evtTimeline); - if (isDisplayedTimeline) scheduleTimelineRefresh(); + if (isDisplayedTimeline) { + setActiveTimeline((ct) => ({ ...ct })); + } if (!isLive) return; @@ -691,10 +675,10 @@ export function useTimelineSync({ setUnreadInfo, hideReadsRef, isInactivePanelRef, + setActiveTimeline, focusLiveTimeline, redactInFocusedWindow, onReturnToLive, - scheduleTimelineRefresh, ] ) ); @@ -702,19 +686,34 @@ export function useTimelineSync({ const handleLocalEchoUpdated = useCallback( (_mEvent: MatrixEvent, eventRoom: Room | undefined) => { if (eventRoom?.roomId !== room.roomId) return; - scheduleTimelineRefresh(); + setActiveTimeline((ct) => ({ ...ct })); }, - [room, scheduleTimelineRefresh] + [room, setActiveTimeline] ); useMatrixEvent(room, RoomEvent.LocalEchoUpdated, handleLocalEchoUpdated); + const decryptedFrameRef = useRef(undefined); const handleDecrypted = useCallback( (mEvent: MatrixEvent) => { if (mEvent.getRoomId() !== room.roomId) return; - scheduleTimelineRefresh(); + if (decryptedFrameRef.current !== undefined) return; + decryptedFrameRef.current = requestAnimationFrame(() => { + decryptedFrameRef.current = undefined; + if (!alive()) return; + setActiveTimeline((ct) => ({ ...ct })); + }); }, - [room, scheduleTimelineRefresh] + [alive, room, setActiveTimeline] + ); + + useEffect( + () => () => { + if (decryptedFrameRef.current !== undefined) { + cancelAnimationFrame(decryptedFrameRef.current); + } + }, + [] ); useMatrixEvent(mx, MatrixEventEvent.Decrypted, handleDecrypted); @@ -747,7 +746,12 @@ export function useTimelineSync({ ) ); - useThreadUpdate(room, scheduleTimelineRefresh); + useThreadUpdate( + room, + useCallback(() => { + setActiveTimeline((ct) => ({ ...ct })); + }, [setActiveTimeline]) + ); useEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; @@ -769,7 +773,7 @@ export function useTimelineSync({ lastScrolledAtEventsLengthRef.current = eventsLength; scrollToBottom(behavior); - }, [isAtBottom, liveTimelineLinked, eventsLength, timelineVersion, scrollToBottom]); + }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => { if (eventId) return; @@ -789,7 +793,6 @@ export function useTimelineSync({ return { timeline, - timelineVersion, eventsLength, liveTimelineLinked, canPaginateBack, From 2762992aa7df2641e997bb117f6b40214e729d2e Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Thu, 27 Aug 2026 20:26:07 -0400 Subject: [PATCH 16/21] Add setting to show timestamps on every message --- .changeset/show-all-timestamps.md | 5 + src/app/features/room/message/Message.tsx | 182 ++++++++++-------- src/app/features/settings/general/General.tsx | 8 + src/app/state/settings.ts | 2 + 4 files changed, 120 insertions(+), 77 deletions(-) create mode 100644 .changeset/show-all-timestamps.md diff --git a/.changeset/show-all-timestamps.md b/.changeset/show-all-timestamps.md new file mode 100644 index 0000000000..7fb415be95 --- /dev/null +++ b/.changeset/show-all-timestamps.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add a setting to show all timestamps, including on grouped messages from the same sender diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index b2b236115c..a4fb6520ae 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -542,6 +542,7 @@ function MessageInternal( const [parsePronouns] = useSetting(settingsAtom, 'parsePronouns'); const [useRightBubbles] = useSetting(settingsAtom, 'useRightBubbles'); + const [showAllTimestamps] = useSetting(settingsAtom, 'showAllTimestamps'); const { cleanedDisplayName, inlinePronoun } = useMemo(() => { const rawName = pmp?.displayname || resolvedSenderDisplayName || ''; return getParsedPronouns(rawName, parsePronouns); @@ -572,11 +573,107 @@ function MessageInternal( ) : undefined; const headerJSX = (collapsed?: boolean) => { - if (collapsed) { - if (messageLayout === MessageLayout.Compact) return collapsedTimeJSX ?? <>; - return <>; - } - return ( + if (!collapsed) + return ( + + + + + {cleanedDisplayName} + + + {showPronouns && ( + + )} + {showPmPInfo && ( + + + + via + + + {resolvedSenderDisplayName} + + + + )} + {tagIconSrc && } + + + {messageLayout === MessageLayout.Modern && isDesktopHover && ( + <> + + {senderId} + + + | + + + )} + + + ); + return showAllTimestamps ? ( - - - - {cleanedDisplayName} - - - {showPronouns && ( - - )} - {showPmPInfo && ( - - - - via - - - {resolvedSenderDisplayName} - - - - )} - {tagIconSrc && } - + - {messageLayout === MessageLayout.Modern && isDesktopHover && ( - <> - - {senderId} - - - | - - - )} + ) : ( + <> ); }; diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 1e0ed551d5..7b559a0280 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -819,6 +819,7 @@ function Messages() { settingsAtom, 'hideMembershipInReadOnly' ); + const [showAllTimestamps, setShowAllTimestamps] = useSetting(settingsAtom, 'showAllTimestamps'); const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); const [rightBubbles, setRightBubbles] = useSetting(settingsAtom, 'useRightBubbles'); @@ -839,6 +840,13 @@ function Messages() { after={} /> + Date: Thu, 27 Aug 2026 12:44:15 -0500 Subject: [PATCH 17/21] Register show-all-timestamps as a shareable settings focus id --- src/app/features/settings/settingsLink.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index 8959dc82fe..1f5dfd9ec5 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -47,6 +47,7 @@ export const settingsLinkFocusIdsBySection: Record Date: Thu, 27 Aug 2026 20:33:07 -0400 Subject: [PATCH 18/21] fix: batch composer layout measurement --- src/app/components/editor/Editor.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 99bc19b225..32e3a2463a 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -147,6 +147,14 @@ export const CustomEditor = forwardRef( }); }, [updateMultilineLayout]); + const scheduleMultilineLayout = useCallback(() => { + if (layoutFrameRef.current !== undefined) return; + layoutFrameRef.current = requestAnimationFrame(() => { + layoutFrameRef.current = undefined; + updateMultilineLayout(); + }); + }, [updateMultilineLayout]); + useEffect(() => { const root = rootRef.current; if (!root) return undefined; From 45eb320511b00259e28c81d4a1d55c49465b1983 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 27 Aug 2026 15:20:56 -0500 Subject: [PATCH 19/21] redact events optimistically and reduce timeline flicker maybe --- src/app/features/room/RoomTimeline.test.tsx | 1 + src/app/features/room/RoomTimeline.tsx | 12 ++-- .../hooks/timeline/useProcessedTimeline.ts | 5 ++ .../hooks/timeline/useTimelineSync.test.tsx | 47 ++++++++------- src/app/hooks/timeline/useTimelineSync.ts | 57 +++++++++---------- src/app/utils/matrix.test.ts | 17 ++++++ 6 files changed, 79 insertions(+), 60 deletions(-) diff --git a/src/app/features/room/RoomTimeline.test.tsx b/src/app/features/room/RoomTimeline.test.tsx index e0de641f1b..973795b09e 100644 --- a/src/app/features/room/RoomTimeline.test.tsx +++ b/src/app/features/room/RoomTimeline.test.tsx @@ -47,6 +47,7 @@ const { }, timelineSync: { eventsLength: 1, + timelineVersion: 0, timeline: { linkedTimelines: [] }, liveTimelineLinked: true, backwardStatus: 'idle', diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index ba40f2833c..3310d54cf6 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1321,15 +1321,15 @@ export function RoomTimeline({ if (showLoadingPlaceholders) vListItemCount = 3; // One row so the error and its Retry have somewhere to render. else if (showEmptyPaginationError) vListItemCount = 1; - const vListIndices = useMemo(() => { - // Keep the cache-busting timeline identity explicit for exhaustive-deps. - void timelineSync.timeline; - return Array.from({ length: vListItemCount }, (_, i) => i); - }, [vListItemCount, timelineSync.timeline]); + const vListIndices = useMemo( + () => Array.from({ length: vListItemCount }, (_, i) => i), + [vListItemCount] + ); const processedEvents = useProcessedTimeline({ items: vListIndices, linkedTimelines: timelineSync.timeline.linkedTimelines, + timelineVersion: timelineSync.timelineVersion, ignoredUsersSet, hiddenEvents, mxUserId: mx.getUserId(), @@ -1473,7 +1473,7 @@ export function RoomTimeline({ > - key={`${room.roomId}:${timelineSync.liveTimelineLinked ? 'live' : (timelineSync.focusItem?.eventId ?? scrollAnchorRef.current)}`} + key={room.roomId} ref={vListRef} data={processedEvents} shift={shouldShift} diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index f22479d8d4..7c9d9de9a3 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -35,6 +35,8 @@ export interface UseProcessedTimelineOptions { * where every reply legitimately has `threadRootId` set to the root. */ skipThreadFilter?: boolean; + /** Bumped when displayed timeline events mutate in place (redactions, decrypt, reactions). */ + timelineVersion?: number; } export interface ProcessedEvent { @@ -665,6 +667,7 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, + timelineVersion = 0, }: UseProcessedTimelineOptions): ProcessedEvent[] { const { showHiddenEvents, @@ -679,6 +682,7 @@ export function useProcessedTimeline({ const cacheRef = useRef(undefined); return useMemo(() => { + void timelineVersion; const timelineEvents = flattenTimelineEvents(linkedTimelines); const processingOptions: TimelineProcessingOptions = { ignoredUsersSet, @@ -814,5 +818,6 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, + timelineVersion, ]); } diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index 900ab9bc9a..00def7e869 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -532,7 +532,7 @@ describe('useTimelineSync', () => { await act(async () => { emitLiveTimelineEvent(room, timeline, events, '@bob:test'); - await Promise.resolve(); + await flushRaf(); }); expect(scrollToBottom).toHaveBeenCalledWith('smooth'); @@ -560,7 +560,7 @@ describe('useTimelineSync', () => { await act(async () => { emitLiveTimelineEvent(room, timeline, events, '@alice:test'); - await Promise.resolve(); + await flushRaf(); }); expect(scrollToBottom).toHaveBeenCalledWith('instant'); @@ -591,7 +591,7 @@ describe('useTimelineSync', () => { await act(async () => { emitLiveTimelineEvent(room, timeline, events, '@bob:test'); - await Promise.resolve(); + await flushRaf(); }); expect(setUnreadInfo).toHaveBeenCalledWith(unread); @@ -1273,7 +1273,7 @@ describe('live-arrive edge cases', () => { it('renders a stale non-live event appended to the live timeline', async () => { const { room, timeline, events } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; vi.mocked(isWindowFocused).mockReturnValue(true); await act(async () => { await flushRaf(); @@ -1293,7 +1293,7 @@ describe('live-arrive edge cases', () => { await flushRaf(); }); - expect(result.current.timeline).not.toBe(before); + expect(result.current.timelineVersion).toBeGreaterThan(before); expect(markAsRead).not.toHaveBeenCalled(); vi.mocked(isWindowFocused).mockReturnValue(false); }); @@ -1301,7 +1301,7 @@ describe('live-arrive edge cases', () => { it('renders a removal', async () => { const { room, timeline, events } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; await act(async () => { events.pop(); @@ -1309,10 +1309,10 @@ describe('live-arrive edge cases', () => { liveEvent: false, timeline, }); - await Promise.resolve(); + await flushRaf(); }); - expect(result.current.timeline).not.toBe(before); + expect(result.current.timelineVersion).toBeGreaterThan(before); }); it('ignores events emitted for a thread timeline set', async () => { @@ -1320,7 +1320,7 @@ describe('live-arrive edge cases', () => { const otherSet = new EventEmitter() as FakeTimelineSet; const threadTimeline = { ...createTimeline(events), getTimelineSet: () => otherSet }; const { result, scrollToBottom } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; await act(async () => { room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, false, { @@ -1330,14 +1330,14 @@ describe('live-arrive edge cases', () => { await Promise.resolve(); }); - expect(result.current.timeline).toBe(before); + expect(result.current.timelineVersion).toBe(before); expect(scrollToBottom).not.toHaveBeenCalled(); }); it('does not treat a threaded reply as an arrival when it lands on the main set', async () => { const { room, timeline, events } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; vi.mocked(isWindowFocused).mockReturnValue(true); await act(async () => { await flushRaf(); @@ -1363,7 +1363,7 @@ describe('live-arrive edge cases', () => { await flushRaf(); }); - expect(result.current.timeline).not.toBe(before); + expect(result.current.timelineVersion).toBeGreaterThan(before); expect(markAsRead).not.toHaveBeenCalled(); vi.mocked(isWindowFocused).mockReturnValue(false); }); @@ -1378,7 +1378,7 @@ describe('live-arrive edge cases', () => { liveEvent: true, timeline, }); - await Promise.resolve(); + await flushRaf(); }); expect(scrollToBottom).toHaveBeenCalledWith('instant'); @@ -1461,7 +1461,7 @@ describe('live-arrive edge cases', () => { liveEvent: false, timeline: freshTimeline, }); - await Promise.resolve(); + await flushRaf(); }); expect(scrollToBottom).toHaveBeenCalledWith('instant'); }); @@ -1504,7 +1504,7 @@ describe('live-arrive edge cases', () => { it('re-renders when an event finishes decrypting', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; await act(async () => { mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => room.roomId }); @@ -1513,13 +1513,13 @@ describe('live-arrive edge cases', () => { }); }); - expect(result.current.timeline).not.toBe(before); + expect(result.current.timelineVersion).toBeGreaterThan(before); }); it('ignores decryption of an event in another room', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; await act(async () => { mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => '!other:test' }); @@ -1528,20 +1528,20 @@ describe('live-arrive edge cases', () => { }); }); - expect(result.current.timeline).toBe(before); + expect(result.current.timelineVersion).toBe(before); }); it('re-renders when a late local echo updates (slow send acknowledgement)', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timeline; + const before = result.current.timelineVersion; await act(async () => { room.emit(RoomEvent.LocalEchoUpdated, {}, room); - await Promise.resolve(); + await flushRaf(); }); - expect(result.current.timeline).not.toBe(before); + expect(result.current.timelineVersion).toBeGreaterThan(before); }); }); @@ -2409,9 +2409,8 @@ const flushFrame = async () => { }; describe('decryption refresh coalescing', () => { - // Counts distinct timeline objects, not renders: unrelated re-renders reuse the object. const renderTrackingHook = (room: FakeRoom) => { - const seen: unknown[] = []; + const seen: number[] = []; renderHook(() => { const sync = useTimelineSync({ room: room as Room, @@ -2425,7 +2424,7 @@ describe('decryption refresh coalescing', () => { readUptoEventIdRef: { current: undefined }, isInactivePanelRef: { current: false }, }); - if (!seen.includes(sync.timeline)) seen.push(sync.timeline); + if (!seen.includes(sync.timelineVersion)) seen.push(sync.timelineVersion); return sync; }); return seen; diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 35cf886e50..2a4a98a8cd 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -443,6 +443,8 @@ export function useTimelineSync({ const [focusItem, setFocusItem] = useState(); const [jumpFailedFor, setJumpFailedFor] = useState(); const jumpFailed = jumpFailedFor !== undefined && jumpFailedFor === eventId; + const [timelineVersion, setTimelineVersion] = useState(0); + const bumpTimeline = useCallback(() => setTimelineVersion((version) => version + 1), []); const resetAutoScrollPendingRef = useRef(false); const pendingAutoScrollBehaviorRef = useRef<'instant' | 'smooth' | undefined>(undefined); @@ -613,6 +615,22 @@ export function useTimelineSync({ [room] ); + const refreshFrameRef = useRef(undefined); + const scheduleTimelineRefresh = useCallback(() => { + if (refreshFrameRef.current !== undefined) return; + refreshFrameRef.current = requestAnimationFrame(() => { + refreshFrameRef.current = undefined; + if (!alive()) return; + bumpTimeline(); + }); + }, [alive, bumpTimeline]); + useEffect( + () => () => { + if (refreshFrameRef.current !== undefined) cancelAnimationFrame(refreshFrameRef.current); + }, + [] + ); + useLiveEventArrive( room, useCallback( @@ -621,9 +639,7 @@ export function useTimelineSync({ const isDisplayedTimeline = evtTimeline === undefined || linkedTimelinesRef.current.includes(evtTimeline); - if (isDisplayedTimeline) { - setActiveTimeline((ct) => ({ ...ct })); - } + if (isDisplayedTimeline) scheduleTimelineRefresh(); if (!isLive) return; @@ -675,10 +691,10 @@ export function useTimelineSync({ setUnreadInfo, hideReadsRef, isInactivePanelRef, - setActiveTimeline, focusLiveTimeline, redactInFocusedWindow, onReturnToLive, + scheduleTimelineRefresh, ] ) ); @@ -686,34 +702,19 @@ export function useTimelineSync({ const handleLocalEchoUpdated = useCallback( (_mEvent: MatrixEvent, eventRoom: Room | undefined) => { if (eventRoom?.roomId !== room.roomId) return; - setActiveTimeline((ct) => ({ ...ct })); + scheduleTimelineRefresh(); }, - [room, setActiveTimeline] + [room, scheduleTimelineRefresh] ); useMatrixEvent(room, RoomEvent.LocalEchoUpdated, handleLocalEchoUpdated); - const decryptedFrameRef = useRef(undefined); const handleDecrypted = useCallback( (mEvent: MatrixEvent) => { if (mEvent.getRoomId() !== room.roomId) return; - if (decryptedFrameRef.current !== undefined) return; - decryptedFrameRef.current = requestAnimationFrame(() => { - decryptedFrameRef.current = undefined; - if (!alive()) return; - setActiveTimeline((ct) => ({ ...ct })); - }); + scheduleTimelineRefresh(); }, - [alive, room, setActiveTimeline] - ); - - useEffect( - () => () => { - if (decryptedFrameRef.current !== undefined) { - cancelAnimationFrame(decryptedFrameRef.current); - } - }, - [] + [room, scheduleTimelineRefresh] ); useMatrixEvent(mx, MatrixEventEvent.Decrypted, handleDecrypted); @@ -746,12 +747,7 @@ export function useTimelineSync({ ) ); - useThreadUpdate( - room, - useCallback(() => { - setActiveTimeline((ct) => ({ ...ct })); - }, [setActiveTimeline]) - ); + useThreadUpdate(room, scheduleTimelineRefresh); useEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; @@ -773,7 +769,7 @@ export function useTimelineSync({ lastScrolledAtEventsLengthRef.current = eventsLength; scrollToBottom(behavior); - }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); + }, [isAtBottom, liveTimelineLinked, eventsLength, timelineVersion, scrollToBottom]); useEffect(() => { if (eventId) return; @@ -793,6 +789,7 @@ export function useTimelineSync({ return { timeline, + timelineVersion, eventsLength, liveTimelineLinked, canPaginateBack, diff --git a/src/app/utils/matrix.test.ts b/src/app/utils/matrix.test.ts index b1a00cceea..96d4352849 100644 --- a/src/app/utils/matrix.test.ts +++ b/src/app/utils/matrix.test.ts @@ -21,6 +21,13 @@ vi.mock('@tauri-apps/api/core', () => tauriApi); vi.mock('./mediaTransport', () => mediaTransport); vi.mock('./room/relations', () => reactions); +const { + getDMRoomFor, + mxcUrlToHttp, + rewriteAuthenticatedMediaUrl, + toggleReaction, + optimisticallyRedactEvent, +} = await import('./matrix'); const { getDMRoomFor, mxcUrlToHttp, @@ -114,6 +121,7 @@ describe('rewriteAuthenticatedMediaUrl', () => { describe('toggleReaction', () => { it('redacts the existing reaction from the current user', () => { + const redaction = {}; const redaction = {}; const reaction = { getId: () => '$reaction', @@ -122,6 +130,10 @@ describe('toggleReaction', () => { isRedacted: () => false, markLocallyRedacted: vi.fn<(event: unknown) => void>(), unmarkLocallyRedacted: vi.fn<() => void>(), + getRelation: () => ({ event_id: '$message' }), + isRedacted: () => false, + markLocallyRedacted: vi.fn<(event: unknown) => void>(), + unmarkLocallyRedacted: vi.fn<() => void>(), }; reactions.getEventReactions.mockReturnValue({ getSortedAnnotationsByKey: () => [['👍', new Set([reaction])]], @@ -130,16 +142,21 @@ describe('toggleReaction', () => { getUserId: () => '@me:example.org', makeTxnId: () => 'txn', redactEvent: vi.fn<(...args: unknown[]) => Promise>(() => Promise.resolve({})), + makeTxnId: () => 'txn', + redactEvent: vi.fn<(...args: unknown[]) => Promise>(() => Promise.resolve({})), sendEvent: vi.fn<(...args: unknown[]) => void>(), } as unknown as MatrixClient; const room = { roomId: '!room:example.org', getUnfilteredTimelineSet: vi.fn<() => unknown>(), findEventById: () => redaction, + findEventById: () => redaction, }; toggleReaction(mx, room as never, '$message', '👍'); + expect(mx.redactEvent).toHaveBeenCalledWith('!room:example.org', '$reaction', 'txn', undefined); + expect(reaction.markLocallyRedacted).toHaveBeenCalledWith(redaction); expect(mx.redactEvent).toHaveBeenCalledWith('!room:example.org', '$reaction', 'txn', undefined); expect(reaction.markLocallyRedacted).toHaveBeenCalledWith(redaction); expect(mx.sendEvent).not.toHaveBeenCalled(); From f72c6936dbb57c2ac28ad181a315c639c4528ede Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 27 Aug 2026 15:49:04 -0500 Subject: [PATCH 20/21] undo some stuff that broke tests --- src/app/features/room/RoomTimeline.test.tsx | 1 - src/app/features/room/RoomTimeline.tsx | 12 ++-- .../hooks/timeline/useProcessedTimeline.ts | 5 -- .../hooks/timeline/useTimelineSync.test.tsx | 47 +++++++-------- src/app/hooks/timeline/useTimelineSync.ts | 57 ++++++++++--------- 5 files changed, 60 insertions(+), 62 deletions(-) diff --git a/src/app/features/room/RoomTimeline.test.tsx b/src/app/features/room/RoomTimeline.test.tsx index 973795b09e..e0de641f1b 100644 --- a/src/app/features/room/RoomTimeline.test.tsx +++ b/src/app/features/room/RoomTimeline.test.tsx @@ -47,7 +47,6 @@ const { }, timelineSync: { eventsLength: 1, - timelineVersion: 0, timeline: { linkedTimelines: [] }, liveTimelineLinked: true, backwardStatus: 'idle', diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 3310d54cf6..ba40f2833c 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1321,15 +1321,15 @@ export function RoomTimeline({ if (showLoadingPlaceholders) vListItemCount = 3; // One row so the error and its Retry have somewhere to render. else if (showEmptyPaginationError) vListItemCount = 1; - const vListIndices = useMemo( - () => Array.from({ length: vListItemCount }, (_, i) => i), - [vListItemCount] - ); + const vListIndices = useMemo(() => { + // Keep the cache-busting timeline identity explicit for exhaustive-deps. + void timelineSync.timeline; + return Array.from({ length: vListItemCount }, (_, i) => i); + }, [vListItemCount, timelineSync.timeline]); const processedEvents = useProcessedTimeline({ items: vListIndices, linkedTimelines: timelineSync.timeline.linkedTimelines, - timelineVersion: timelineSync.timelineVersion, ignoredUsersSet, hiddenEvents, mxUserId: mx.getUserId(), @@ -1473,7 +1473,7 @@ export function RoomTimeline({ > - key={room.roomId} + key={`${room.roomId}:${timelineSync.liveTimelineLinked ? 'live' : (timelineSync.focusItem?.eventId ?? scrollAnchorRef.current)}`} ref={vListRef} data={processedEvents} shift={shouldShift} diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 7c9d9de9a3..f22479d8d4 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -35,8 +35,6 @@ export interface UseProcessedTimelineOptions { * where every reply legitimately has `threadRootId` set to the root. */ skipThreadFilter?: boolean; - /** Bumped when displayed timeline events mutate in place (redactions, decrypt, reactions). */ - timelineVersion?: number; } export interface ProcessedEvent { @@ -667,7 +665,6 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, - timelineVersion = 0, }: UseProcessedTimelineOptions): ProcessedEvent[] { const { showHiddenEvents, @@ -682,7 +679,6 @@ export function useProcessedTimeline({ const cacheRef = useRef(undefined); return useMemo(() => { - void timelineVersion; const timelineEvents = flattenTimelineEvents(linkedTimelines); const processingOptions: TimelineProcessingOptions = { ignoredUsersSet, @@ -818,6 +814,5 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, - timelineVersion, ]); } diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index 00def7e869..900ab9bc9a 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -532,7 +532,7 @@ describe('useTimelineSync', () => { await act(async () => { emitLiveTimelineEvent(room, timeline, events, '@bob:test'); - await flushRaf(); + await Promise.resolve(); }); expect(scrollToBottom).toHaveBeenCalledWith('smooth'); @@ -560,7 +560,7 @@ describe('useTimelineSync', () => { await act(async () => { emitLiveTimelineEvent(room, timeline, events, '@alice:test'); - await flushRaf(); + await Promise.resolve(); }); expect(scrollToBottom).toHaveBeenCalledWith('instant'); @@ -591,7 +591,7 @@ describe('useTimelineSync', () => { await act(async () => { emitLiveTimelineEvent(room, timeline, events, '@bob:test'); - await flushRaf(); + await Promise.resolve(); }); expect(setUnreadInfo).toHaveBeenCalledWith(unread); @@ -1273,7 +1273,7 @@ describe('live-arrive edge cases', () => { it('renders a stale non-live event appended to the live timeline', async () => { const { room, timeline, events } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; vi.mocked(isWindowFocused).mockReturnValue(true); await act(async () => { await flushRaf(); @@ -1293,7 +1293,7 @@ describe('live-arrive edge cases', () => { await flushRaf(); }); - expect(result.current.timelineVersion).toBeGreaterThan(before); + expect(result.current.timeline).not.toBe(before); expect(markAsRead).not.toHaveBeenCalled(); vi.mocked(isWindowFocused).mockReturnValue(false); }); @@ -1301,7 +1301,7 @@ describe('live-arrive edge cases', () => { it('renders a removal', async () => { const { room, timeline, events } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; await act(async () => { events.pop(); @@ -1309,10 +1309,10 @@ describe('live-arrive edge cases', () => { liveEvent: false, timeline, }); - await flushRaf(); + await Promise.resolve(); }); - expect(result.current.timelineVersion).toBeGreaterThan(before); + expect(result.current.timeline).not.toBe(before); }); it('ignores events emitted for a thread timeline set', async () => { @@ -1320,7 +1320,7 @@ describe('live-arrive edge cases', () => { const otherSet = new EventEmitter() as FakeTimelineSet; const threadTimeline = { ...createTimeline(events), getTimelineSet: () => otherSet }; const { result, scrollToBottom } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; await act(async () => { room.emit(RoomEvent.Timeline, makeLiveEvent(room.roomId, Date.now()), room, false, false, { @@ -1330,14 +1330,14 @@ describe('live-arrive edge cases', () => { await Promise.resolve(); }); - expect(result.current.timelineVersion).toBe(before); + expect(result.current.timeline).toBe(before); expect(scrollToBottom).not.toHaveBeenCalled(); }); it('does not treat a threaded reply as an arrival when it lands on the main set', async () => { const { room, timeline, events } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; vi.mocked(isWindowFocused).mockReturnValue(true); await act(async () => { await flushRaf(); @@ -1363,7 +1363,7 @@ describe('live-arrive edge cases', () => { await flushRaf(); }); - expect(result.current.timelineVersion).toBeGreaterThan(before); + expect(result.current.timeline).not.toBe(before); expect(markAsRead).not.toHaveBeenCalled(); vi.mocked(isWindowFocused).mockReturnValue(false); }); @@ -1378,7 +1378,7 @@ describe('live-arrive edge cases', () => { liveEvent: true, timeline, }); - await flushRaf(); + await Promise.resolve(); }); expect(scrollToBottom).toHaveBeenCalledWith('instant'); @@ -1461,7 +1461,7 @@ describe('live-arrive edge cases', () => { liveEvent: false, timeline: freshTimeline, }); - await flushRaf(); + await Promise.resolve(); }); expect(scrollToBottom).toHaveBeenCalledWith('instant'); }); @@ -1504,7 +1504,7 @@ describe('live-arrive edge cases', () => { it('re-renders when an event finishes decrypting', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; await act(async () => { mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => room.roomId }); @@ -1513,13 +1513,13 @@ describe('live-arrive edge cases', () => { }); }); - expect(result.current.timelineVersion).toBeGreaterThan(before); + expect(result.current.timeline).not.toBe(before); }); it('ignores decryption of an event in another room', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; await act(async () => { mxEmitter.emit(MatrixEventEvent.Decrypted, { getRoomId: () => '!other:test' }); @@ -1528,20 +1528,20 @@ describe('live-arrive edge cases', () => { }); }); - expect(result.current.timelineVersion).toBe(before); + expect(result.current.timeline).toBe(before); }); it('re-renders when a late local echo updates (slow send acknowledgement)', async () => { const { room } = createRoom(); const { result } = renderSyncHook(room); - const before = result.current.timelineVersion; + const before = result.current.timeline; await act(async () => { room.emit(RoomEvent.LocalEchoUpdated, {}, room); - await flushRaf(); + await Promise.resolve(); }); - expect(result.current.timelineVersion).toBeGreaterThan(before); + expect(result.current.timeline).not.toBe(before); }); }); @@ -2409,8 +2409,9 @@ const flushFrame = async () => { }; describe('decryption refresh coalescing', () => { + // Counts distinct timeline objects, not renders: unrelated re-renders reuse the object. const renderTrackingHook = (room: FakeRoom) => { - const seen: number[] = []; + const seen: unknown[] = []; renderHook(() => { const sync = useTimelineSync({ room: room as Room, @@ -2424,7 +2425,7 @@ describe('decryption refresh coalescing', () => { readUptoEventIdRef: { current: undefined }, isInactivePanelRef: { current: false }, }); - if (!seen.includes(sync.timelineVersion)) seen.push(sync.timelineVersion); + if (!seen.includes(sync.timeline)) seen.push(sync.timeline); return sync; }); return seen; diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 2a4a98a8cd..35cf886e50 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -443,8 +443,6 @@ export function useTimelineSync({ const [focusItem, setFocusItem] = useState(); const [jumpFailedFor, setJumpFailedFor] = useState(); const jumpFailed = jumpFailedFor !== undefined && jumpFailedFor === eventId; - const [timelineVersion, setTimelineVersion] = useState(0); - const bumpTimeline = useCallback(() => setTimelineVersion((version) => version + 1), []); const resetAutoScrollPendingRef = useRef(false); const pendingAutoScrollBehaviorRef = useRef<'instant' | 'smooth' | undefined>(undefined); @@ -615,22 +613,6 @@ export function useTimelineSync({ [room] ); - const refreshFrameRef = useRef(undefined); - const scheduleTimelineRefresh = useCallback(() => { - if (refreshFrameRef.current !== undefined) return; - refreshFrameRef.current = requestAnimationFrame(() => { - refreshFrameRef.current = undefined; - if (!alive()) return; - bumpTimeline(); - }); - }, [alive, bumpTimeline]); - useEffect( - () => () => { - if (refreshFrameRef.current !== undefined) cancelAnimationFrame(refreshFrameRef.current); - }, - [] - ); - useLiveEventArrive( room, useCallback( @@ -639,7 +621,9 @@ export function useTimelineSync({ const isDisplayedTimeline = evtTimeline === undefined || linkedTimelinesRef.current.includes(evtTimeline); - if (isDisplayedTimeline) scheduleTimelineRefresh(); + if (isDisplayedTimeline) { + setActiveTimeline((ct) => ({ ...ct })); + } if (!isLive) return; @@ -691,10 +675,10 @@ export function useTimelineSync({ setUnreadInfo, hideReadsRef, isInactivePanelRef, + setActiveTimeline, focusLiveTimeline, redactInFocusedWindow, onReturnToLive, - scheduleTimelineRefresh, ] ) ); @@ -702,19 +686,34 @@ export function useTimelineSync({ const handleLocalEchoUpdated = useCallback( (_mEvent: MatrixEvent, eventRoom: Room | undefined) => { if (eventRoom?.roomId !== room.roomId) return; - scheduleTimelineRefresh(); + setActiveTimeline((ct) => ({ ...ct })); }, - [room, scheduleTimelineRefresh] + [room, setActiveTimeline] ); useMatrixEvent(room, RoomEvent.LocalEchoUpdated, handleLocalEchoUpdated); + const decryptedFrameRef = useRef(undefined); const handleDecrypted = useCallback( (mEvent: MatrixEvent) => { if (mEvent.getRoomId() !== room.roomId) return; - scheduleTimelineRefresh(); + if (decryptedFrameRef.current !== undefined) return; + decryptedFrameRef.current = requestAnimationFrame(() => { + decryptedFrameRef.current = undefined; + if (!alive()) return; + setActiveTimeline((ct) => ({ ...ct })); + }); }, - [room, scheduleTimelineRefresh] + [alive, room, setActiveTimeline] + ); + + useEffect( + () => () => { + if (decryptedFrameRef.current !== undefined) { + cancelAnimationFrame(decryptedFrameRef.current); + } + }, + [] ); useMatrixEvent(mx, MatrixEventEvent.Decrypted, handleDecrypted); @@ -747,7 +746,12 @@ export function useTimelineSync({ ) ); - useThreadUpdate(room, scheduleTimelineRefresh); + useThreadUpdate( + room, + useCallback(() => { + setActiveTimeline((ct) => ({ ...ct })); + }, [setActiveTimeline]) + ); useEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; @@ -769,7 +773,7 @@ export function useTimelineSync({ lastScrolledAtEventsLengthRef.current = eventsLength; scrollToBottom(behavior); - }, [isAtBottom, liveTimelineLinked, eventsLength, timelineVersion, scrollToBottom]); + }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => { if (eventId) return; @@ -789,7 +793,6 @@ export function useTimelineSync({ return { timeline, - timelineVersion, eventsLength, liveTimelineLinked, canPaginateBack, From a077c4c4aac6b67db4a62748777589d7d891557b Mon Sep 17 00:00:00 2001 From: Th-Underscore Date: Thu, 27 Aug 2026 20:26:07 -0400 Subject: [PATCH 21/21] Add setting to show timestamps on every message