* 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
@@ -163,10 +163,10 @@ b {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep the legacy link colour without overriding Compound anchors. */
|
/* Keep the legacy link colour without overriding Compound anchors or shared timestamp styling. */
|
||||||
a:where(:not([data-kind])):hover,
|
a:where(:not([data-kind]):not(.mx_MessageTimestamp)):hover,
|
||||||
a:where(:not([data-kind])):link,
|
a:where(:not([data-kind]):not(.mx_MessageTimestamp)):link,
|
||||||
a:where(:not([data-kind])):visited {
|
a:where(:not([data-kind]):not(.mx_MessageTimestamp)):visited {
|
||||||
color: $accent-alt;
|
color: $accent-alt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,6 +600,8 @@ legend {
|
|||||||
.mx_InviteDialog_section button,
|
.mx_InviteDialog_section button,
|
||||||
.mx_InviteDialog_editor button,
|
.mx_InviteDialog_editor button,
|
||||||
.mx_UnknownIdentityUsersWarningDialog button,
|
.mx_UnknownIdentityUsersWarningDialog button,
|
||||||
|
.mx_ThreadActionBar button,
|
||||||
|
.mx_HistoryActionBar button,
|
||||||
[class|="maplibregl"]
|
[class|="maplibregl"]
|
||||||
),
|
),
|
||||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton),
|
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton, .mx_AccessibleButton),
|
||||||
@@ -907,16 +909,8 @@ legend {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* This class is used extensively in element-web and are included here for compatibility with the existing timeline and layout.
|
/* Layout compatibility hook for timestamp placement in existing app/web timeline surfaces. */
|
||||||
/* TODO: Review mx_MessageTimestamp usage after finishing migration of timeline tiles to shared components. */
|
|
||||||
/* https://github.com/element-hq/element-web/issues/31651 */
|
|
||||||
.mx_MessageTimestamp {
|
.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 */
|
display: block; /* enable the width setting below */
|
||||||
width: var(--MessageTimestamp-width);
|
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 {
|
.mx_MessageTimestamp {
|
||||||
margin-left: auto;
|
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.
|
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 */
|
/** Disambiguated profile needs to have a different layout in the member tile */
|
||||||
.mx_MemberTileView .mx_DisambiguatedProfile {
|
.mx_MemberTileView .mx_DisambiguatedProfile {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -138,10 +138,6 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_MessageTimestamp {
|
|
||||||
color: $secondary-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_BaseCard_footer {
|
.mx_BaseCard_footer {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: $font-12px;
|
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 */
|
/* 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 */
|
/* in the DOM depending on the specific rendering context */
|
||||||
> a, /* timestamp wrapper anchor */
|
> a, /* linked timestamp element */
|
||||||
.mx_MessageActionBar + .mx_MessageTimestamp {
|
.mx_MessageActionBar + .mx_MessageTimestamp {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@@ -322,7 +322,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.mx_EventTile_sticker {
|
&.mx_EventTile_sticker {
|
||||||
> a, /* timestamp wrapper anchor */
|
> a, /* linked timestamp element */
|
||||||
.mx_MessageActionBar + .mx_MessageTimestamp {
|
.mx_MessageActionBar + .mx_MessageTimestamp {
|
||||||
/* position timestamps for stickers to the right of the un-bubbled sticker */
|
/* position timestamps for stickers to the right of the un-bubbled sticker */
|
||||||
right: unset;
|
right: unset;
|
||||||
@@ -434,7 +434,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
.mx_EventTile_reply {
|
.mx_EventTile_reply {
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
> a, /* timestamp wrapper anchor */
|
> a, /* linked timestamp element */
|
||||||
.mx_MessageActionBar + .mx_MessageTimestamp {
|
.mx_MessageActionBar + .mx_MessageTimestamp {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -549,7 +549,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|
||||||
> a {
|
> a {
|
||||||
/* timestamp anchor wrapper */
|
/* linked timestamp element */
|
||||||
align-self: center;
|
align-self: center;
|
||||||
bottom: unset;
|
bottom: unset;
|
||||||
top: unset;
|
top: unset;
|
||||||
@@ -606,7 +606,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mx_EventTile_line {
|
.mx_EventTile_line {
|
||||||
> a, /* timestamp wrapper anchor */
|
> a, /* linked timestamp element */
|
||||||
.mx_MessageActionBar + .mx_MessageTimestamp {
|
.mx_MessageActionBar + .mx_MessageTimestamp {
|
||||||
right: auto;
|
right: auto;
|
||||||
left: -77px;
|
left: -77px;
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
import React, { createRef } from "react";
|
import React, { createRef } from "react";
|
||||||
import { type EventStatus, type IContent, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix";
|
import { type EventStatus, type IContent, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix";
|
||||||
import classNames from "classnames";
|
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 { EditHistoryActionBarViewModel } from "../../../viewmodels/message-body/EditHistoryActionBarViewModel";
|
||||||
import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel";
|
import { EventContentBodyViewModel } from "../../../viewmodels/message-body/EventContentBodyViewModel";
|
||||||
|
import { MessageTimestampViewModel } from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel";
|
||||||
import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils";
|
import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils";
|
||||||
import { formatTime } from "../../../DateUtils";
|
|
||||||
import Modal from "../../../Modal";
|
import Modal from "../../../Modal";
|
||||||
import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog";
|
import ConfirmAndWaitRedactDialog from "../dialogs/ConfirmAndWaitRedactDialog";
|
||||||
import ViewSource from "../../structures/ViewSource";
|
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>;
|
declare public context: React.ContextType<typeof MatrixClientContext>;
|
||||||
|
|
||||||
private content = createRef<HTMLDivElement>();
|
private content = createRef<HTMLDivElement>();
|
||||||
private EventContentBodyViewModel: EventContentBodyViewModel;
|
private readonly eventContentBodyViewModel: EventContentBodyViewModel;
|
||||||
private editHistoryActionBarViewModel: EditHistoryActionBarViewModel;
|
private readonly editHistoryActionBarViewModel: EditHistoryActionBarViewModel;
|
||||||
|
private readonly messageTimestampViewModel: MessageTimestampViewModel;
|
||||||
|
|
||||||
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
|
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
@@ -60,7 +61,7 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
|||||||
this.state = { canRedact, sendStatus: event.getAssociatedStatus() };
|
this.state = { canRedact, sendStatus: event.getAssociatedStatus() };
|
||||||
|
|
||||||
const mxEventContent = getReplacedContent(event);
|
const mxEventContent = getReplacedContent(event);
|
||||||
this.EventContentBodyViewModel = new EventContentBodyViewModel({
|
this.eventContentBodyViewModel = new EventContentBodyViewModel({
|
||||||
mxEvent: event,
|
mxEvent: event,
|
||||||
content: mxEventContent,
|
content: mxEventContent,
|
||||||
highlights: [],
|
highlights: [],
|
||||||
@@ -72,6 +73,11 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
|||||||
linkify: true,
|
linkify: true,
|
||||||
client: cli,
|
client: cli,
|
||||||
});
|
});
|
||||||
|
this.messageTimestampViewModel = new MessageTimestampViewModel({
|
||||||
|
ts: event.getTs(),
|
||||||
|
showTwelveHour: props.isTwelveHour,
|
||||||
|
inhibitTooltip: true,
|
||||||
|
});
|
||||||
|
|
||||||
this.editHistoryActionBarViewModel = new EditHistoryActionBarViewModel({
|
this.editHistoryActionBarViewModel = new EditHistoryActionBarViewModel({
|
||||||
canRemove: !props.mxEvent.isRedacted() && !props.isBaseEvent && canRedact,
|
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 {
|
public componentDidUpdate(prevProps: IProps): void {
|
||||||
if (prevProps.mxEvent !== this.props.mxEvent) {
|
if (prevProps.mxEvent !== this.props.mxEvent) {
|
||||||
const mxEventContent = getReplacedContent(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({
|
this.editHistoryActionBarViewModel.setProps({
|
||||||
@@ -129,18 +143,12 @@ export default class EditHistoryMessage extends React.PureComponent<IProps, ISta
|
|||||||
public componentWillUnmount(): void {
|
public componentWillUnmount(): void {
|
||||||
const event = this.props.mxEvent;
|
const event = this.props.mxEvent;
|
||||||
event.localRedactionEvent()?.off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
|
event.localRedactionEvent()?.off(MatrixEventEvent.Status, this.onAssociatedStatusChanged);
|
||||||
this.EventContentBodyViewModel.dispose();
|
this.eventContentBodyViewModel.dispose();
|
||||||
this.editHistoryActionBarViewModel.dispose();
|
this.editHistoryActionBarViewModel.dispose();
|
||||||
|
this.messageTimestampViewModel.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderActionBar(): React.ReactNode {
|
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 (
|
return (
|
||||||
<ActionBarView vm={this.editHistoryActionBarViewModel} className="mx_ThreadActionBar mx_HistoryActionBar" />
|
<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) {
|
if (this.props.previousEdit) {
|
||||||
contentElements = editBodyDiffToHtml(getReplacedContent(this.props.previousEdit), content);
|
contentElements = editBodyDiffToHtml(getReplacedContent(this.props.previousEdit), content);
|
||||||
} else {
|
} else {
|
||||||
contentElements = <EventContentBodyView vm={this.EventContentBodyViewModel} as="span" />;
|
contentElements = <EventContentBodyView vm={this.eventContentBodyViewModel} as="span" />;
|
||||||
}
|
}
|
||||||
if (mxEvent.getContent().msgtype === MsgType.Emote) {
|
if (mxEvent.getContent().msgtype === MsgType.Emote) {
|
||||||
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
|
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 isSending = ["sending", "queued", "encrypting"].includes(this.state.sendStatus!);
|
||||||
const classes = classNames("mx_EventTile", {
|
const classes = classNames("mx_EventTile", {
|
||||||
// Note: we keep the `sending` state class for tests, not for our styles
|
// 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>
|
<li>
|
||||||
<div className={classes}>
|
<div className={classes}>
|
||||||
<div className="mx_EventTile_line">
|
<div className="mx_EventTile_line">
|
||||||
<span className="mx_MessageTimestamp">{timestamp}</span>
|
<MessageTimestampView vm={this.messageTimestampViewModel} className="mx_MessageTimestamp" />
|
||||||
{contentContainer}
|
{contentContainer}
|
||||||
{this.renderActionBar()}
|
{this.renderActionBar()}
|
||||||
</div>
|
</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"
|
class="mx_EventTile_line"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="mx_MessageTimestamp"
|
aria-live="off"
|
||||||
|
class="mx_MessageTimestamp _content_1jaux_8"
|
||||||
>
|
>
|
||||||
00:00
|
00:00
|
||||||
</span>
|
</span>
|
||||||
@@ -212,7 +213,8 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
|
|||||||
class="mx_EventTile_line"
|
class="mx_EventTile_line"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="mx_MessageTimestamp"
|
aria-live="off"
|
||||||
|
class="mx_MessageTimestamp _content_1jaux_8"
|
||||||
>
|
>
|
||||||
00:00
|
00:00
|
||||||
</span>
|
</span>
|
||||||
@@ -263,7 +265,8 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
|
|||||||
class="mx_EventTile_line"
|
class="mx_EventTile_line"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="mx_MessageTimestamp"
|
aria-live="off"
|
||||||
|
class="mx_MessageTimestamp _content_1jaux_8"
|
||||||
>
|
>
|
||||||
00:00
|
00:00
|
||||||
</span>
|
</span>
|
||||||
@@ -325,7 +328,8 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
|
|||||||
class="mx_EventTile_line"
|
class="mx_EventTile_line"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="mx_MessageTimestamp"
|
aria-live="off"
|
||||||
|
class="mx_MessageTimestamp _content_1jaux_8"
|
||||||
>
|
>
|
||||||
00:00
|
00:00
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 9.2 KiB |
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
color: var(--cpd-color-text-secondary); /* override anchor color */
|
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;
|
font-variant-numeric: tabular-nums;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export interface MessageTimestampViewActions {
|
|||||||
* The view model for the message timestamp.
|
* The view model for the message timestamp.
|
||||||
*
|
*
|
||||||
* Snapshot data describes timestamp content and rendering behavior, while
|
* 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;
|
export type MessageTimestampViewModel = ViewModel<MessageTimestampViewSnapshot> & MessageTimestampViewActions;
|
||||||
|
|
||||||
@@ -74,11 +74,11 @@ interface MessageTimestampViewProps {
|
|||||||
* The view model provides the timestamp values and display options. The component
|
* 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
|
* 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
|
* times in the tooltip when `tsReceivedAt` is provided. Use `className` for
|
||||||
* host-level styling.
|
* host-level layout and placement styling.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* <MessageTimestampView vm={messageTimestampViewModel} className="mx_MessageTimestamp" />
|
* <MessageTimestampView vm={messageTimestampViewModel} />
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function MessageTimestampView({ vm, className }: Readonly<MessageTimestampViewProps>): JSX.Element {
|
export function MessageTimestampView({ vm, className }: Readonly<MessageTimestampViewProps>): JSX.Element {
|
||||||
|
|||||||