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
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ReadMarkerProps>): JSX.Element => {
|
||||
return (
|
||||
<ul>
|
||||
<ReadMarker
|
||||
{...props}
|
||||
className={className}
|
||||
onCurrentMarkerRef={onCurrentMarkerRef ?? fn()}
|
||||
onGhostLineRef={onGhostLineRef ?? fn()}
|
||||
onGhostTransitionEnd={onGhostTransitionEnd ?? fn()}
|
||||
/>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Timeline/ReadMarker",
|
||||
component: ReadMarkerWrapper,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
eventId: "$event",
|
||||
kind: "current",
|
||||
showLine: true,
|
||||
},
|
||||
} satisfies Meta<typeof ReadMarkerWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Current: Story = {};
|
||||
|
||||
export const HiddenCurrent: Story = {
|
||||
args: {
|
||||
showLine: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
kind: "ghost",
|
||||
},
|
||||
};
|
||||
@@ -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(<Current />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the hidden current read marker without a line", () => {
|
||||
const { container } = render(<HiddenCurrent />);
|
||||
expect(container).toMatchSnapshot();
|
||||
expect(container.querySelector("hr")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the ghost read marker", () => {
|
||||
const { container } = render(<Ghost />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("applies custom className to the list item", () => {
|
||||
render(
|
||||
<ul>
|
||||
<ReadMarker
|
||||
eventId="$event"
|
||||
kind="current"
|
||||
showLine={true}
|
||||
className="custom-read-marker compatibility-class"
|
||||
/>
|
||||
</ul>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ul>
|
||||
<ReadMarker
|
||||
eventId="$ghost"
|
||||
kind="ghost"
|
||||
onGhostLineRef={onGhostLineRef}
|
||||
onGhostTransitionEnd={onGhostTransitionEnd}
|
||||
/>
|
||||
</ul>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ul>
|
||||
<ReadMarker eventId="$current" kind="current" showLine={true} onCurrentMarkerRef={onCurrentMarkerRef} />
|
||||
</ul>,
|
||||
);
|
||||
|
||||
expect(onCurrentMarkerRef).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 `<li>` to preserve layout calculations.
|
||||
*/
|
||||
showLine?: boolean;
|
||||
/**
|
||||
* Ref callback for the active read marker `<li>`.
|
||||
*/
|
||||
onCurrentMarkerRef?: RefCallback<HTMLLIElement>;
|
||||
/**
|
||||
* Ref callback for the ghost marker `<hr>`.
|
||||
*/
|
||||
onGhostLineRef?: RefCallback<HTMLHRElement>;
|
||||
/**
|
||||
* Transition-end handler for the ghost marker `<hr>`.
|
||||
*/
|
||||
onGhostTransitionEnd?: TransitionEventHandler<HTMLHRElement>;
|
||||
/**
|
||||
* Optional CSS className for the outer list item.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ReadMarker({
|
||||
eventId,
|
||||
kind,
|
||||
showLine = true,
|
||||
onCurrentMarkerRef,
|
||||
onGhostLineRef,
|
||||
onGhostTransitionEnd,
|
||||
className,
|
||||
}: Readonly<ReadMarkerProps>): JSX.Element {
|
||||
let line: JSX.Element | null = null;
|
||||
|
||||
if (kind === "ghost") {
|
||||
line = (
|
||||
<hr
|
||||
className={styles.line}
|
||||
ref={onGhostLineRef}
|
||||
onTransitionEnd={onGhostTransitionEnd}
|
||||
data-eventid={eventId}
|
||||
/>
|
||||
);
|
||||
} else if (showLine) {
|
||||
line = <hr className={styles.line} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={classNames(className, styles.readMarker)}
|
||||
ref={kind === "current" ? onCurrentMarkerRef : undefined}
|
||||
data-scroll-tokens={kind === "current" ? eventId : undefined}
|
||||
>
|
||||
{line}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ReadMarker > renders the current read marker 1`] = `
|
||||
<div>
|
||||
<ul>
|
||||
<li
|
||||
class="ReadMarker-module_readMarker"
|
||||
data-scroll-tokens="$event"
|
||||
>
|
||||
<hr
|
||||
class="ReadMarker-module_line"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ReadMarker > renders the ghost read marker 1`] = `
|
||||
<div>
|
||||
<ul>
|
||||
<li
|
||||
class="ReadMarker-module_readMarker"
|
||||
>
|
||||
<hr
|
||||
class="ReadMarker-module_line"
|
||||
data-eventid="$event"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ReadMarker > renders the hidden current read marker without a line 1`] = `
|
||||
<div>
|
||||
<ul>
|
||||
<li
|
||||
class="ReadMarker-module_readMarker"
|
||||
data-scroll-tokens="$event"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user