Make shared-components tiles render identically outside Element Web - #4 (#33622)

* Use shared MessageTimestampView in edit history

* Move timestamp typography into shared component

* Updated snapshots

* Update comments after clean up

* Fixed sonar issue and added tests for improved coverage

* Fix Prettier issue

* Fix font-size on timestamps and toolbar button sizing

* Updated snapshots

* Updated Storybook screenshots

* Updated screenshots

* Clean up app/web unused/redundant styling for disambiguated profile

* Fix merge error

* Fixes after merge

* Fixes after merge
This commit is contained in:
rbondesson
2026-06-01 07:47:59 +02:00
committed by GitHub
parent 32b66747f4
commit fc23fcad47
16 changed files with 322 additions and 70 deletions
+7 -13
View File
@@ -163,10 +163,10 @@ b {
font-weight: bold;
}
/* Keep the legacy link colour without overriding Compound anchors. */
a:where(:not([data-kind])):hover,
a:where(:not([data-kind])):link,
a:where(:not([data-kind])):visited {
/* Keep the legacy link colour without overriding Compound anchors or shared timestamp styling. */
a:where(:not([data-kind]):not(.mx_MessageTimestamp)):hover,
a:where(:not([data-kind]):not(.mx_MessageTimestamp)):link,
a:where(:not([data-kind]):not(.mx_MessageTimestamp)):visited {
color: $accent-alt;
}
@@ -600,6 +600,8 @@ legend {
.mx_InviteDialog_section button,
.mx_InviteDialog_editor button,
.mx_UnknownIdentityUsersWarningDialog button,
.mx_ThreadActionBar button,
.mx_HistoryActionBar button,
[class|="maplibregl"]
),
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton),
@@ -907,16 +909,8 @@ legend {
overflow: hidden;
}
/* This class is used extensively in element-web and are included here for compatibility with the existing timeline and layout.
/* TODO: Review mx_MessageTimestamp usage after finishing migration of timeline tiles to shared components. */
/* https://github.com/element-hq/element-web/issues/31651 */
/* Layout compatibility hook for timestamp placement in existing app/web timeline surfaces. */
.mx_MessageTimestamp {
color: var(--cpd-color-text-secondary) !important; /* override anchor color */
font-size: $font-10px;
font-variant-numeric: tabular-nums;
display: block; /* enable the width setting below */
width: var(--MessageTimestamp-width);
white-space: nowrap;
user-select: none;
text-decoration: none;
}
@@ -67,8 +67,6 @@ Please see LICENSE files in the repository root for full details.
.mx_MessageTimestamp {
margin-left: auto;
font: var(--cpd-font-body-xs-regular);
color: var(--cpd-color-text-secondary);
}
}
}
@@ -7,26 +7,6 @@ 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.
*/
.mx_DisambiguatedProfile {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
.mx_DisambiguatedProfile_displayName {
font: var(--cpd-font-body-md-semibold);
margin-inline-end: 0;
/* keeps the height in check, important for the bubble apperance */
line-height: 1;
}
.mx_DisambiguatedProfile_mxid {
color: $secondary-content;
font-size: var(--cpd-font-size-body-sm);
margin-inline-start: 5px;
}
}
/** Disambiguated profile needs to have a different layout in the member tile */
.mx_MemberTileView .mx_DisambiguatedProfile {
display: flex;
@@ -138,10 +138,6 @@ Please see LICENSE files in the repository root for full details.
box-sizing: border-box;
}
.mx_MessageTimestamp {
color: $secondary-content;
}
.mx_BaseCard_footer {
text-align: left;
font-size: $font-12px;
@@ -288,7 +288,7 @@ Please see LICENSE files in the repository root for full details.
/* the selector here is quite weird because timestamps can appear linked & unlinked and in different places */
/* in the DOM depending on the specific rendering context */
> a, /* timestamp wrapper anchor */
> a, /* linked timestamp element */
.mx_MessageActionBar + .mx_MessageTimestamp {
position: absolute;
padding: 4px 8px;
@@ -322,7 +322,7 @@ Please see LICENSE files in the repository root for full details.
}
&.mx_EventTile_sticker {
> a, /* timestamp wrapper anchor */
> a, /* linked timestamp element */
.mx_MessageActionBar + .mx_MessageTimestamp {
/* position timestamps for stickers to the right of the un-bubbled sticker */
right: unset;
@@ -434,7 +434,7 @@ Please see LICENSE files in the repository root for full details.
.mx_EventTile_reply {
max-width: 90%;
padding: 0;
> a, /* timestamp wrapper anchor */
> a, /* linked timestamp element */
.mx_MessageActionBar + .mx_MessageTimestamp {
display: none !important;
}
@@ -549,7 +549,7 @@ Please see LICENSE files in the repository root for full details.
font-style: italic;
> a {
/* timestamp anchor wrapper */
/* linked timestamp element */
align-self: center;
bottom: unset;
top: unset;
@@ -606,7 +606,7 @@ Please see LICENSE files in the repository root for full details.
}
.mx_EventTile_line {
> a, /* timestamp wrapper anchor */
> a, /* linked timestamp element */
.mx_MessageActionBar + .mx_MessageTimestamp {
right: auto;
left: -77px;
@@ -9,12 +9,12 @@ Please see LICENSE files in the repository root for full details.
import React, { createRef } from "react";
import { type EventStatus, type IContent, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { ActionBarView, EventContentBodyView } from "@element-hq/web-shared-components";
import { ActionBarView, EventContentBodyView, MessageTimestampView } from "@element-hq/web-shared-components";
import { EditHistoryActionBarViewModel } from "../../../viewmodels/message-body/EditHistoryActionBarViewModel";
import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel";
import { MessageTimestampViewModel } from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel";
import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils";
import { formatTime } from "../../../DateUtils";
import Modal from "../../../Modal";
import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog";
import ViewSource from "../../structures/ViewSource";
@@ -45,8 +45,9 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
declare public context: React.ContextType<typeof MatrixClientContext>;
private content = createRef<HTMLDivElement>();
private EventContentBodyViewModel: EventContentBodyViewModel;
private editHistoryActionBarViewModel: EditHistoryActionBarViewModel;
private readonly eventContentBodyViewModel: EventContentBodyViewModel;
private readonly editHistoryActionBarViewModel: EditHistoryActionBarViewModel;
private readonly messageTimestampViewModel: MessageTimestampViewModel;
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
@@ -60,7 +61,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
this.state = { canRedact, sendStatus: event.getAssociatedStatus() };
const mxEventContent = getReplacedContent(event);
this.EventContentBodyViewModel = new EventContentBodyViewModel({
this.eventContentBodyViewModel = new EventContentBodyViewModel({
mxEvent: event,
content: mxEventContent,
highlights: [],
@@ -72,6 +73,11 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
linkify: true,
client: cli,
});
this.messageTimestampViewModel = new MessageTimestampViewModel({
ts: event.getTs(),
showTwelveHour: props.isTwelveHour,
inhibitTooltip: true,
});
this.editHistoryActionBarViewModel = new EditHistoryActionBarViewModel({
canRemove: !props.mxEvent.isRedacted() && !props.isBaseEvent && canRedact,
@@ -84,7 +90,15 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
public componentDidUpdate(prevProps: IProps): void {
if (prevProps.mxEvent !== this.props.mxEvent) {
const mxEventContent = getReplacedContent(this.props.mxEvent);
this.EventContentBodyViewModel.setEventContent(this.props.mxEvent, mxEventContent);
this.eventContentBodyViewModel.setEventContent(this.props.mxEvent, mxEventContent);
}
if (prevProps.mxEvent !== this.props.mxEvent || prevProps.isTwelveHour !== this.props.isTwelveHour) {
this.messageTimestampViewModel.setProps({
ts: this.props.mxEvent.getTs(),
showTwelveHour: this.props.isTwelveHour,
inhibitTooltip: true,
});
}
this.editHistoryActionBarViewModel.setProps({
@@ -129,18 +143,12 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
public componentWillUnmount(): void {
const event = this.props.mxEvent;
event.localRedactionEvent()?.off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
this.EventContentBodyViewModel.dispose();
this.eventContentBodyViewModel.dispose();
this.editHistoryActionBarViewModel.dispose();
this.messageTimestampViewModel.dispose();
}
private renderActionBar(): React.ReactNode {
this.editHistoryActionBarViewModel.setProps({
canRemove: !this.props.mxEvent.isRedacted() && !this.props.isBaseEvent && this.state.canRedact,
showViewSource: SettingsStore.getValue("developerMode"),
onRemoveClick: this.onRedactClick,
onViewSourceClick: this.onViewSourceClick,
});
return (
<ActionBarView vm={this.editHistoryActionBarViewModel} className="mx_ThreadActionBar mx_HistoryActionBar" />
);
@@ -157,7 +165,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
if (this.props.previousEdit) {
contentElements = editBodyDiffToHtml(getReplacedContent(this.props.previousEdit), content);
} else {
contentElements = <EventContentBodyView vm={this.EventContentBodyViewModel} as="span" />;
contentElements = <EventContentBodyView vm={this.eventContentBodyViewModel} as="span" />;
}
if (mxEvent.getContent().msgtype === MsgType.Emote) {
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
@@ -177,7 +185,6 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
}
}
const timestamp = formatTime(new Date(mxEvent.getTs()), this.props.isTwelveHour);
const isSending = ["sending", "queued", "encrypting"].includes(this.state.sendStatus!);
const classes = classNames("mx_EventTile", {
// Note: we keep the `sending` state class for tests, not for our styles
@@ -187,7 +194,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
<li>
<div className={classes}>
<div className="mx_EventTile_line">
<span className="mx_MessageTimestamp">{timestamp}</span>
<MessageTimestampView vm={this.messageTimestampViewModel} className="mx_MessageTimestamp" />
{contentContainer}
{this.renderActionBar()}
</div>
@@ -0,0 +1,273 @@
/*
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 from "react";
import { act, fireEvent, render, waitFor } from "jest-matrix-react";
import { MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix";
import { mkEvent, stubClient } from "../../../../test-utils";
import Modal from "../../../../../src/Modal";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import type { SettingKey } from "../../../../../src/settings/Settings";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import EditHistoryMessage from "../../../../../src/components/views/messages/EditHistoryMessage";
import ConfirmAndWaitRedactDialog from "../../../../../src/components/views/dialogs/ConfirmAndWaitRedactDialog";
import ViewSource from "../../../../../src/components/structures/ViewSource";
import { EventContentBodyViewModel } from "../../../../../src/viewmodels/message-body/EventContentBodyViewModel";
import { EditHistoryActionBarViewModel } from "../../../../../src/viewmodels/message-body/EditHistoryActionBarViewModel";
import { MessageTimestampViewModel } from "../../../../../src/viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel";
describe("EditHistoryMessage", () => {
const roomId = "!room:example.com";
let client: ReturnType<typeof stubClient>;
let developerMode = false;
const renderComponent = (props: React.ComponentProps<typeof EditHistoryMessage>) => {
return render(
<MatrixClientContext.Provider value={client}>
<EditHistoryMessage {...props} />
</MatrixClientContext.Provider>,
);
};
beforeEach(() => {
client = stubClient();
developerMode = false;
jest.spyOn(SettingsStore, "getValue").mockImplementation(((settingName: SettingKey) => {
switch (settingName) {
case "developerMode":
return developerMode;
case "showTwelveHourTimestamps":
case "TextualBody.enableBigEmoji":
return false;
case "Pill.shouldShowPillAvatar":
return true;
default:
return null;
}
}) as typeof SettingsStore.getValue);
jest.spyOn(Modal, "createDialog").mockReturnValue({} as any);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("renders replaced content, tracks send status, and disposes view models on unmount", async () => {
const event = mkEvent({
event: true,
id: "$event",
room: roomId,
user: "@alice:example.com",
type: "m.room.message",
content: {
"msgtype": MsgType.Text,
"body": "Original body",
"m.new_content": {
msgtype: MsgType.Text,
body: "Edited body",
},
},
});
const localRedactionEvent = {
on: jest.fn(),
off: jest.fn(),
};
const getAssociatedStatus = jest.fn().mockReturnValue("sending");
const eventContentDisposeSpy = jest.spyOn(EventContentBodyViewModel.prototype, "dispose");
const actionBarDisposeSpy = jest.spyOn(EditHistoryActionBarViewModel.prototype, "dispose");
const timestampDisposeSpy = jest.spyOn(MessageTimestampViewModel.prototype, "dispose");
(event as any).localRedactionEvent = jest.fn().mockReturnValue(localRedactionEvent);
(event as any).getAssociatedStatus = getAssociatedStatus;
const { container, getByRole, getByText, unmount } = renderComponent({ mxEvent: event });
expect(getByText("Edited body")).toBeInTheDocument();
expect(container.querySelector(".mx_EventTile_sending")).not.toBeNull();
expect(getByRole("button", { name: /remove/i })).toBeInTheDocument();
expect(localRedactionEvent.on).toHaveBeenCalledWith(MatrixEventEvent.Status, expect.any(Function));
getAssociatedStatus.mockReturnValue(null);
const onStatusChanged = localRedactionEvent.on.mock.calls[0][1];
act(() => {
onStatusChanged();
});
await waitFor(() => expect(container.querySelector(".mx_EventTile_sending")).toBeNull());
unmount();
expect(localRedactionEvent.off).toHaveBeenCalledWith(MatrixEventEvent.Status, expect.any(Function));
expect(eventContentDisposeSpy).toHaveBeenCalled();
expect(actionBarDisposeSpy).toHaveBeenCalled();
expect(timestampDisposeSpy).toHaveBeenCalled();
});
it("renders diffs for previous edits and formats emotes with the sender name", () => {
const previousEdit = mkEvent({
event: true,
id: "$prev",
room: roomId,
user: "@alice:example.com",
type: "m.room.message",
content: {
msgtype: MsgType.Emote,
body: "smiles",
},
});
const event = mkEvent({
event: true,
id: "$event",
room: roomId,
user: "@alice:example.com",
type: "m.room.message",
content: {
msgtype: MsgType.Emote,
body: "waves",
},
});
if (event.sender) {
event.sender.name = "Alice";
}
const { container, getByText } = renderComponent({ mxEvent: event, previousEdit });
expect(getByText("Alice")).toHaveClass("mx_EditHistoryMessage_emoteSender");
expect(container.querySelector(".mx_EditHistoryMessage_deletion")).not.toBeNull();
expect(container.querySelector(".mx_EditHistoryMessage_insertion")).not.toBeNull();
expect(container.querySelector(".mx_EditHistoryMessage_deletion")).not.toBeEmptyDOMElement();
expect(container.querySelector(".mx_EditHistoryMessage_insertion")).not.toBeEmptyDOMElement();
});
it("renders redacted events and opens view source from the action bar", () => {
developerMode = true;
const event = mkEvent({
event: true,
id: "$event",
room: roomId,
user: "@alice:example.com",
type: "m.room.message",
content: {
msgtype: MsgType.Text,
body: "Removed",
},
unsigned: {
redacted_because: {
event_id: "$redaction",
sender: "@moderator:example.com",
type: "m.room.redaction",
origin_server_ts: Date.UTC(2024, 0, 1, 12, 0, 0),
content: {},
unsigned: {},
},
},
});
jest.spyOn(event, "isRedacted").mockReturnValue(true);
const { container, getByRole, queryByRole } = renderComponent({ mxEvent: event, isBaseEvent: true });
expect(container.querySelector(".mx_RedactedBody")).not.toBeNull();
expect(queryByRole("button", { name: /remove/i })).toBeNull();
fireEvent.click(getByRole("button", { name: /view source/i }));
expect(Modal.createDialog).toHaveBeenCalledWith(
ViewSource,
{
mxEvent: event,
ignoreEdits: true,
},
"mx_Dialog_viewsource",
);
});
it("opens the redact dialog and uses the client redact callback", async () => {
const event = mkEvent({
event: true,
id: "$event",
room: roomId,
user: "@alice:example.com",
type: "m.room.message",
content: {
msgtype: MsgType.Text,
body: "Editable",
},
});
const { getByRole } = renderComponent({ mxEvent: event });
fireEvent.click(getByRole("button", { name: /remove/i }));
expect(Modal.createDialog).toHaveBeenCalledWith(
ConfirmAndWaitRedactDialog,
expect.objectContaining({
event,
redact: expect.any(Function),
}),
"mx_Dialog_confirmredact",
);
const [, dialogProps] = (Modal.createDialog as jest.Mock).mock.calls[0];
await dialogProps.redact();
expect(client.redactEvent).toHaveBeenCalledWith(roomId, "$event");
});
it("updates content and timestamp view models when props change", () => {
developerMode = true;
const firstEventTs = Date.parse("2021-12-17T08:09:00.000Z");
const secondEventTs = Date.parse("2021-12-17T09:09:00.000Z");
const firstEvent = mkEvent({
event: true,
id: "$first",
room: roomId,
user: "@alice:example.com",
type: "m.room.message",
ts: firstEventTs,
content: {
msgtype: MsgType.Text,
body: "First",
},
});
const secondEvent = mkEvent({
event: true,
id: "$second",
room: roomId,
user: "@alice:example.com",
type: "m.room.message",
ts: secondEventTs,
content: {
"msgtype": MsgType.Text,
"body": "Second",
"m.new_content": {
msgtype: MsgType.Text,
body: "Second edited",
},
},
});
const { getByRole, getByText, queryByRole, rerender } = renderComponent({
mxEvent: firstEvent,
isTwelveHour: false,
});
expect(getByRole("button", { name: /remove/i })).toBeInTheDocument();
expect(getByText("08:09")).toBeInTheDocument();
rerender(
<MatrixClientContext.Provider value={client}>
<EditHistoryMessage mxEvent={secondEvent} isBaseEvent={true} isTwelveHour={true} />
</MatrixClientContext.Provider>,
);
expect(getByText("Second edited")).toBeInTheDocument();
expect(getByText("9:09 AM")).toBeInTheDocument();
expect(queryByRole("button", { name: /remove/i })).toBeNull();
expect(getByRole("button", { name: /view source/i })).toBeInTheDocument();
});
});
@@ -72,7 +72,8 @@ exports[`<MessageEditHistory /> should match the snapshot 1`] = `
class="mx_EventTile_line"
>
<span
class="mx_MessageTimestamp"
aria-live="off"
class="mx_MessageTimestamp _content_1jaux_8"
>
00:00
</span>
@@ -212,7 +213,8 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
class="mx_EventTile_line"
>
<span
class="mx_MessageTimestamp"
aria-live="off"
class="mx_MessageTimestamp _content_1jaux_8"
>
00:00
</span>
@@ -263,7 +265,8 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
class="mx_EventTile_line"
>
<span
class="mx_MessageTimestamp"
aria-live="off"
class="mx_MessageTimestamp _content_1jaux_8"
>
00:00
</span>
@@ -325,7 +328,8 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
class="mx_EventTile_line"
>
<span
class="mx_MessageTimestamp"
aria-live="off"
class="mx_MessageTimestamp _content_1jaux_8"
>
00:00
</span>
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -7,7 +7,7 @@
.content {
color: var(--cpd-color-text-secondary); /* override anchor color */
font-size: var(--cpd-font-size-body-xs);
font-size: 0.625rem; /* There is no Compound equivalent to $font-10px */
font-variant-numeric: tabular-nums;
display: inline-block;
white-space: nowrap;
@@ -53,7 +53,7 @@ export interface MessageTimestampViewActions {
* The view model for the message timestamp.
*
* Snapshot data describes timestamp content and rendering behavior, while
* container styling is supplied via component props.
* host layout and placement styling is supplied via component props.
*/
export type MessageTimestampViewModel = ViewModel<MessageTimestampViewSnapshot> & MessageTimestampViewActions;
@@ -74,11 +74,11 @@ interface MessageTimestampViewProps {
* The view model provides the timestamp values and display options. The component
* can render as a link when `href` is set, and can show both sent-at and received-at
* times in the tooltip when `tsReceivedAt` is provided. Use `className` for
* host-level styling.
* host-level layout and placement styling.
*
* @example
* ```tsx
* <MessageTimestampView vm={messageTimestampViewModel} className="mx_MessageTimestamp" />
* <MessageTimestampView vm={messageTimestampViewModel} />
* ```
*/
export function MessageTimestampView({ vm, className }: Readonly<MessageTimestampViewProps>): JSX.Element {