From f615968835d706e73a48806fe7f36190e1075c42 Mon Sep 17 00:00:00 2001 From: Zack Date: Tue, 14 Apr 2026 13:56:52 +0200 Subject: [PATCH] Refactoring readMarkerForEvent into ReadMarkerView in shared-components (#32777) * Refactoring readMarkerForEvent into ReadMarketView in shared-components * Use shared ReadMarkerView in MessagePanel * Rename ReadMarkerView to ReadMarker * Fix Prettier * Update snapshots screenshots * Use plain props for ReadMarker * Fix Prettier * Move ReadMarker into room timeline * Replace ReadMarker nested ternary * Update snapshot --- apps/web/res/css/_components.pcss | 1 - .../web/res/css/structures/_MessagePanel.pcss | 29 ------ .../components/structures/MessagePanel.tsx | 60 ++++++------- .../ReadMarker.stories.tsx/current-auto.png | Bin 0 -> 3850 bytes .../ReadMarker.stories.tsx/ghost-auto.png | Bin 0 -> 3850 bytes .../hidden-current-auto.png | Bin 0 -> 3719 bytes packages/shared-components/src/index.ts | 1 + .../timeline/ReadMarker/ReadMarker.module.css | 28 ++++++ .../ReadMarker/ReadMarker.stories.tsx | 60 +++++++++++++ .../timeline/ReadMarker/ReadMarker.test.tsx | 85 ++++++++++++++++++ .../room/timeline/ReadMarker/ReadMarker.tsx | 80 +++++++++++++++++ .../__snapshots__/ReadMarker.test.tsx.snap | 42 +++++++++ .../src/room/timeline/ReadMarker/index.tsx | 8 ++ .../src/room/timeline/ReadMarkerView/.gitkeep | 1 - 14 files changed, 330 insertions(+), 65 deletions(-) delete mode 100644 apps/web/res/css/structures/_MessagePanel.pcss create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/ReadMarker/ReadMarker.stories.tsx/current-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/ReadMarker/ReadMarker.stories.tsx/ghost-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/ReadMarker/ReadMarker.stories.tsx/hidden-current-auto.png create mode 100644 packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.module.css create mode 100644 packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.stories.tsx create mode 100644 packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.test.tsx create mode 100644 packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.tsx create mode 100644 packages/shared-components/src/room/timeline/ReadMarker/__snapshots__/ReadMarker.test.tsx.snap create mode 100644 packages/shared-components/src/room/timeline/ReadMarker/index.tsx delete mode 100644 packages/shared-components/src/room/timeline/ReadMarkerView/.gitkeep diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index bdca70276d..fdf774a754 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -68,7 +68,6 @@ @import "./structures/_LeftPanel.pcss"; @import "./structures/_MainSplit.pcss"; @import "./structures/_MatrixChat.pcss"; -@import "./structures/_MessagePanel.pcss"; @import "./structures/_NonUrgentToastContainer.pcss"; @import "./structures/_PictureInPictureDragger.pcss"; @import "./structures/_QuickSettingsButton.pcss"; diff --git a/apps/web/res/css/structures/_MessagePanel.pcss b/apps/web/res/css/structures/_MessagePanel.pcss deleted file mode 100644 index fb2830bce7..0000000000 --- a/apps/web/res/css/structures/_MessagePanel.pcss +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 The Matrix.org Foundation C.I.C. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_MessagePanel_myReadMarker { - height: 0; - margin: 0; - padding: 0; - border: 0; - - hr { - border-top: solid 1px $accent; - border-bottom: solid 1px $accent; - margin-top: 0; - position: relative; - top: -1px; - z-index: 1; - will-change: width; - transition: - width 400ms easeinsine 1s, - opacity 400ms easeinsine 1s; - width: 99%; - opacity: 1; - } -} diff --git a/apps/web/src/components/structures/MessagePanel.tsx b/apps/web/src/components/structures/MessagePanel.tsx index 629b9d3c7a..a38f264aab 100644 --- a/apps/web/src/components/structures/MessagePanel.tsx +++ b/apps/web/src/components/structures/MessagePanel.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, createRef, type ReactNode, type TransitionEvent } from "react"; +import React, { type JSX, createRef, type ReactNode, type TransitionEventHandler } from "react"; import classNames from "classnames"; import { type Room, @@ -20,6 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { isSupportedReceiptType } from "matrix-js-sdk/src/utils"; import { DateSeparatorView, + ReadMarker, TimelineSeparator, useCreateAutoDisposedViewModel, } from "@element-hq/web-shared-components"; @@ -271,7 +272,7 @@ export default class MessagePanel extends React.Component { private readonly _showHiddenEvents: boolean; private unmounted = false; - private readMarkerNode = createRef(); + private readMarkerNode: HTMLLIElement | null = null; private whoIsTyping = createRef(); public scrollPanel = createRef(); @@ -403,7 +404,7 @@ export default class MessagePanel extends React.Component { // 0: read marker is within the window // +1: read marker is below the window public getReadMarkerPosition(): number | null { - const readMarker = this.readMarkerNode.current; + const readMarker = this.readMarkerNode; const messageWrapper = this.scrollPanel.current?.divScroll; if (!readMarker || !messageWrapper) { @@ -507,29 +508,18 @@ export default class MessagePanel extends React.Component { public readMarkerForEvent(eventId: string, isLastEvent: boolean): ReactNode { if (this.context.timelineRenderingType === TimelineRenderingType.File) return null; - const visible = !isLastEvent && this.props.readMarkerVisible; + const showLine = !isLastEvent && !!this.props.readMarkerVisible; if (this.props.readMarkerEventId === eventId) { - let hr; - // if the read marker comes at the end of the timeline (except - // for local echoes, which are excluded from RMs, because they - // don't have useful event ids), we don't want to show it, but - // we still want to create the
  • for it so that the - // algorithms which depend on its position on the screen aren't - // confused. - if (visible) { - hr =
    ; - } - return ( -
  • - {hr} -
  • + /> ); } else if (this.state.ghostReadMarkers.includes(eventId)) { // We render 'ghost' read markers in the DOM while they @@ -542,28 +532,30 @@ export default class MessagePanel extends React.Component { // case is a little more complex because only some of the items // transition (ie. the read markers do but the event tiles do not) // and TransitionGroup requires that all its children are Transitions. - const hr = ( -
    - ); - // give it a key which depends on the event id. That will ensure that // we get a new DOM node (restarting the animation) when the ghost // moves to a different event. return ( -
  • - {hr} -
  • + ); } return null; } - private collectGhostReadMarker = (node: HTMLElement | null): void => { + private collectReadMarker = (node: HTMLLIElement | null): void => { + this.readMarkerNode = node; + }; + + private collectGhostReadMarker = (node: HTMLHRElement | null): void => { if (node) { // now the element has appeared, change the style which will trigger the CSS transition requestAnimationFrame(() => { @@ -573,7 +565,7 @@ export default class MessagePanel extends React.Component { } }; - private onGhostTransitionEnd = (ev: TransitionEvent): void => { + private onGhostTransitionEnd: TransitionEventHandler = (ev): void => { // we can now clean up the ghost element const finishedEventId = (ev.target as HTMLElement).dataset.eventid; this.setState({ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/ReadMarker/ReadMarker.stories.tsx/current-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/ReadMarker/ReadMarker.stories.tsx/current-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..bdb6e51256be6023d74ee489b85ddae3752ec7f4 GIT binary patch literal 3850 zcmeAS@N?(olHy`uVBq!ia0y~yU_QXWz;uCw2`F+wwC^zk1K)m67srr_IdASB^b!dW zX?Q4^KEd#o$CYI|8p`5Vm7>1c3EKKQzQ5od;xBMT@3O!hu`Z*Tk5vNsKb&_u@pIqP z*4_Ut@0^xA|NQg8Rtr13y5CP9-&?=|* z4yYzb@GvkmHn1@?H~{$$3JVw+6a+3XFbL>KT=@L6#%%W7dGq>?Kfah@BE!edz_5Wu zjPu3j&6^J&KI}i=?){s(>OVg&K6|!z>q!@9W`+$sK-tBM7ni@c-&6JX+3)Z2(b4>I z`zro@c{%yB$BnG@t9Y|J|E+Z{ECn|2{uI|NYyyXVW$_GBD^gx~g56eDc=Z z+tROJzg|3bs`T~s`8jut>ev_(Jasmm`u*qB!Gj0)n_u96pP%Qqrx7S@sUviu+5SJr z^2;yJPoAuuoxOVX>Q|Bs4XPUxK)x&51c3EKKQzQ5od;xBMT@3O!hu`Z*Tk5vNsKb&_u@pIqP z*4_Ut@0^xA|NQg8Rtr13y5CP9-&?=|* z4yYzb@GvkmHn1@?H~{$$3JVw+6a+3XFbL>KT=@L6#%%W7dGq>?Kfah@BE!edz_5Wu zjPu3j&6^J&KI}i=?){s(>OVg&K6|!z>q!@9W`+$sK-tBM7ni@c-&6JX+3)Z2(b4>I z`zro@c{%yB$BnG@t9Y|J|E+Z{ECn|2{uI|NYyyXVW$_GBD^gx~g56eDc=Z z+tROJzg|3bs`T~s`8jut>ev_(Jasmm`u*qB!Gj0)n_u96pP%Qqrx7S@sUviu+5SJr z^2;yJPoAuuoxOVX>Q|Bs4XPUxK)x&5CX=z9%Nu>V4d-kY46QD|M_;`&D*?F8mN@Vn7L}N-Cx;d znO64k{0s~cZ8NoH9;dTc?XI=Awzagg1KPHM=QA^q5<6jD#=s!JfzKrh3m6#`1TKs! zgGJ3~fS{$-(Eu3@kkK*(Qf`mth0(k)niod%0@g;|XyGtgIE)q!BfN0f2kZg<|G)ad hBsCy|4b+chW=M)RRu??;)&MBN;OXk;vd$@?2>@p^9Dx7; literal 0 HcmV?d00001 diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 6574069be0..ed5360d84e 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -13,6 +13,7 @@ export * from "./core/AvatarWithDetails"; export * from "./composer/Banner"; export * from "./crypto/SasEmoji"; export * from "./event-tiles/UrlPreviewGroupView"; +export * from "./room/timeline/ReadMarker"; export * from "./room/timeline/event-tile/body/EventContentBodyView"; export * from "./room/timeline/event-tile/body/RedactedBodyView"; export * from "./room/timeline/event-tile/body/MFileBodyView"; diff --git a/packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.module.css b/packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.module.css new file mode 100644 index 0000000000..b2717d31b3 --- /dev/null +++ b/packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.module.css @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.readMarker { + height: 0; + margin: 0; + padding: 0; + border: 0; +} + +.line { + margin-top: 0; + position: relative; + top: -1px; + z-index: 1; + width: 99%; + opacity: 1; + border-top: solid 1px var(--cpd-color-icon-accent-tertiary); + border-bottom: solid 1px var(--cpd-color-icon-accent-tertiary); + will-change: width, opacity; + transition: + width 400ms easeinsine 1s, + opacity 400ms easeinsine 1s; +} diff --git a/packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.stories.tsx b/packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.stories.tsx new file mode 100644 index 0000000000..8b393e0151 --- /dev/null +++ b/packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.stories.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ReadMarker, type ReadMarkerProps } from "./ReadMarker"; + +const ReadMarkerWrapper = ({ + className, + onCurrentMarkerRef, + onGhostLineRef, + onGhostTransitionEnd, + ...props +}: Readonly): JSX.Element => { + return ( +
      + +
    + ); +}; + +const meta = { + title: "Timeline/ReadMarker", + component: ReadMarkerWrapper, + tags: ["autodocs"], + args: { + eventId: "$event", + kind: "current", + showLine: true, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Current: Story = {}; + +export const HiddenCurrent: Story = { + args: { + showLine: false, + }, +}; + +export const Ghost: Story = { + args: { + kind: "ghost", + }, +}; diff --git a/packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.test.tsx b/packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.test.tsx new file mode 100644 index 0000000000..37d8ad186f --- /dev/null +++ b/packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { composeStories } from "@storybook/react-vite"; +import { fireEvent, render, screen } from "@test-utils"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { ReadMarker } from "./ReadMarker"; +import * as stories from "./ReadMarker.stories"; + +const { Current, HiddenCurrent, Ghost } = composeStories(stories); + +describe("ReadMarker", () => { + it("renders the current read marker", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the hidden current read marker without a line", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + expect(container.querySelector("hr")).toBeNull(); + }); + + it("renders the ghost read marker", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("applies custom className to the list item", () => { + render( +
      + +
    , + ); + + const item = screen.getByRole("listitem"); + expect(item).toHaveClass("custom-read-marker", "compatibility-class"); + expect(item).toHaveAttribute("data-scroll-tokens", "$event"); + }); + + it("wires ghost marker actions", () => { + const onGhostLineRef = vi.fn(); + const onGhostTransitionEnd = vi.fn(); + + render( +
      + +
    , + ); + + const line = screen.getByRole("separator"); + fireEvent.transitionEnd(line); + + expect(onGhostLineRef).toHaveBeenCalled(); + expect(onGhostTransitionEnd).toHaveBeenCalledTimes(1); + }); + + it("wires the current marker ref", () => { + const onCurrentMarkerRef = vi.fn(); + + render( +
      + +
    , + ); + + expect(onCurrentMarkerRef).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.tsx b/packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.tsx new file mode 100644 index 0000000000..48396b14cd --- /dev/null +++ b/packages/shared-components/src/room/timeline/ReadMarker/ReadMarker.tsx @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX, type RefCallback, type TransitionEventHandler } from "react"; +import classNames from "classnames"; + +import styles from "./ReadMarker.module.css"; + +export type ReadMarkerKind = "current" | "ghost"; + +export interface ReadMarkerProps { + /** + * The event ID this marker is associated with. + */ + eventId: string; + /** + * Whether this is the active read marker or a ghost marker transitioning out. + */ + kind: ReadMarkerKind; + /** + * Whether the visible line should be rendered for the active marker. + * Hidden active markers still render the host `
  • ` to preserve layout calculations. + */ + showLine?: boolean; + /** + * Ref callback for the active read marker `
  • `. + */ + onCurrentMarkerRef?: RefCallback; + /** + * Ref callback for the ghost marker `
    `. + */ + onGhostLineRef?: RefCallback; + /** + * Transition-end handler for the ghost marker `
    `. + */ + onGhostTransitionEnd?: TransitionEventHandler; + /** + * Optional CSS className for the outer list item. + */ + className?: string; +} + +export function ReadMarker({ + eventId, + kind, + showLine = true, + onCurrentMarkerRef, + onGhostLineRef, + onGhostTransitionEnd, + className, +}: Readonly): JSX.Element { + let line: JSX.Element | null = null; + + if (kind === "ghost") { + line = ( +
    + ); + } else if (showLine) { + line =
    ; + } + + return ( +
  • + {line} +
  • + ); +} diff --git a/packages/shared-components/src/room/timeline/ReadMarker/__snapshots__/ReadMarker.test.tsx.snap b/packages/shared-components/src/room/timeline/ReadMarker/__snapshots__/ReadMarker.test.tsx.snap new file mode 100644 index 0000000000..1edd576858 --- /dev/null +++ b/packages/shared-components/src/room/timeline/ReadMarker/__snapshots__/ReadMarker.test.tsx.snap @@ -0,0 +1,42 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ReadMarker > renders the current read marker 1`] = ` +
    +
      +
    • +
      +
    • +
    +
    +`; + +exports[`ReadMarker > renders the ghost read marker 1`] = ` +
    +
      +
    • +
      +
    • +
    +
    +`; + +exports[`ReadMarker > renders the hidden current read marker without a line 1`] = ` +
    +
      +
    • +
    +
    +`; diff --git a/packages/shared-components/src/room/timeline/ReadMarker/index.tsx b/packages/shared-components/src/room/timeline/ReadMarker/index.tsx new file mode 100644 index 0000000000..b172c3a943 --- /dev/null +++ b/packages/shared-components/src/room/timeline/ReadMarker/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { ReadMarker, type ReadMarkerKind, type ReadMarkerProps } from "./ReadMarker"; diff --git a/packages/shared-components/src/room/timeline/ReadMarkerView/.gitkeep b/packages/shared-components/src/room/timeline/ReadMarkerView/.gitkeep deleted file mode 100644 index 8b13789179..0000000000 --- a/packages/shared-components/src/room/timeline/ReadMarkerView/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -