Render timeline separator for late event groups (#11739)

* Use Compound tooltips on MessageTimestamp to improve UX of date time discovery

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Show io.element.late_event in MessageTimestamp when known

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update snapshot

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Avoid needing new Compound changes

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Move groupers into their own directory

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Refactor date separator code to be more generic

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Render timeline separator for late event groups

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix date used in copy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update snapshot

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Move groupers into their own directory

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update copy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update copy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update snapshot

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* i18n

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add comments

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add comments

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
pull/28217/head
Michael Telatynski 2023-10-16 15:14:04 +01:00 committed by GitHub
parent dfdb613673
commit 5ff965106a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 198 additions and 66 deletions

View File

@ -117,7 +117,7 @@ describe("Editing", () => {
cy.get(".mx_EventTile").should("have.css", "padding-block-start", "0px");
// Assert that the date separator is rendered at the top
cy.get("li:nth-child(1) .mx_DateSeparator").within(() => {
cy.get("li:nth-child(1) .mx_TimelineSeparator").within(() => {
cy.get("h2").within(() => {
cy.findByText("today").should("have.css", "text-transform", "capitalize");
});
@ -182,7 +182,7 @@ describe("Editing", () => {
// Assert that the message edit history dialog is rendered again
cy.get(".mx_MessageEditHistoryDialog").within(() => {
// Assert that the date is rendered
cy.get("li:nth-child(1) .mx_DateSeparator").within(() => {
cy.get("li:nth-child(1) .mx_TimelineSeparator").within(() => {
cy.get("h2").within(() => {
cy.findByText("today").should("have.css", "text-transform", "capitalize");
});

View File

@ -251,6 +251,7 @@
@import "./views/messages/_RedactedBody.pcss";
@import "./views/messages/_RoomAvatarEvent.pcss";
@import "./views/messages/_TextualEvent.pcss";
@import "./views/messages/_TimelineSeparator.pcss";
@import "./views/messages/_UnknownBody.pcss";
@import "./views/messages/_ViewSourceEvent.pcss";
@import "./views/messages/_common_CryptoEvent.pcss";

View File

@ -14,22 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_DateSeparator {
clear: both;
margin: 4px 0;
display: flex;
align-items: center;
font: var(--cpd-font-body-md-regular);
color: $roomtopic-color;
}
.mx_DateSeparator > hr {
flex: 1 1 0;
height: 0;
border: none;
border-bottom: 1px solid $menu-selected-color;
}
.mx_DateSeparator_dateContent {
padding: 0 25px;
}

View File

@ -0,0 +1,31 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_TimelineSeparator {
clear: both;
margin: 4px 0;
display: flex;
align-items: center;
font: var(--cpd-font-body-md-regular);
color: $roomtopic-color;
}
.mx_TimelineSeparator > hr {
flex: 1 1 0;
height: 0;
border: none;
border-bottom: 1px solid $menu-selected-color;
}

View File

@ -112,7 +112,7 @@ limitations under the License.
/* Account for scrollbar when hovering */
padding-top: 0;
.mx_DateSeparator {
.mx_TimelineSeparator {
display: none;
}

View File

@ -20,10 +20,9 @@ import classNames from "classnames";
import { Room, MatrixClient, RoomStateEvent, EventStatus, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { isSupportedReceiptType } from "matrix-js-sdk/src/utils";
import { Optional } from "matrix-events-sdk";
import shouldHideEvent from "../../shouldHideEvent";
import { wantsDateSeparator } from "../../DateUtils";
import { formatDate, wantsDateSeparator } from "../../DateUtils";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import SettingsStore from "../../settings/SettingsStore";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
@ -40,6 +39,7 @@ import LegacyCallEventGrouper from "./LegacyCallEventGrouper";
import WhoIsTypingTile from "../views/rooms/WhoIsTypingTile";
import ScrollPanel, { IScrollState } from "./ScrollPanel";
import DateSeparator from "../views/messages/DateSeparator";
import TimelineSeparator, { SeparatorKind } from "../views/messages/TimelineSeparator";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import ResizeNotifier from "../../utils/ResizeNotifier";
import Spinner from "../views/elements/Spinner";
@ -54,6 +54,8 @@ import { hasThreadSummary } from "../../utils/EventUtils";
import { BaseGrouper } from "./grouper/BaseGrouper";
import { MainGrouper } from "./grouper/MainGrouper";
import { CreationGrouper } from "./grouper/CreationGrouper";
import { _t } from "../../languageHandler";
import { getLateEventInfo } from "./grouper/LateEventGrouper";
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
@ -739,31 +741,38 @@ export default class MessagePanel extends React.Component<IProps, IState> {
const isEditing = this.props.editState?.getEvent().getId() === mxEv.getId();
// local echoes have a fake date, which could even be yesterday. Treat them as 'today' for the date separators.
let ts1 = mxEv.getTs();
let eventDate = mxEv.getDate();
if (mxEv.status) {
eventDate = new Date();
ts1 = eventDate.getTime();
}
const ts1 = mxEv.getTs() ?? Date.now();
// do we need a date separator since the last event?
const wantsDateSeparator = this.wantsDateSeparator(prevEvent, eventDate);
if (wantsDateSeparator && !isGrouped && this.props.room) {
const dateSeparator = (
<li key={ts1}>
<DateSeparator key={ts1} roomId={this.props.room.roomId} ts={ts1} />
</li>
);
ret.push(dateSeparator);
// do we need a separator since the last event?
const wantsSeparator = this.wantsSeparator(prevEvent, mxEv);
if (!isGrouped && this.props.room) {
if (wantsSeparator === SeparatorKind.Date) {
ret.push(
<li key={ts1}>
<DateSeparator key={ts1} roomId={this.props.room.roomId} ts={ts1} />
</li>,
);
} else if (wantsSeparator === SeparatorKind.LateEvent) {
const text = _t("timeline|late_event_separator", {
dateTime: formatDate(mxEv.getDate() ?? new Date()),
});
ret.push(
<li key={ts1}>
<TimelineSeparator key={ts1} label={text}>
{text}
</TimelineSeparator>
</li>,
);
}
}
const cli = MatrixClientPeg.safeGet();
let lastInSection = true;
if (nextEventWithTile) {
const nextEv = nextEventWithTile;
const willWantDateSeparator = this.wantsDateSeparator(mxEv, nextEv.getDate() || new Date());
const willWantSeparator = this.wantsSeparator(mxEv, nextEv);
lastInSection =
willWantDateSeparator ||
willWantSeparator === SeparatorKind.Date ||
mxEv.getSender() !== nextEv.getSender() ||
getEventDisplayInfo(cli, nextEv, this.showHiddenEvents).isInfoMessage ||
!shouldFormContinuation(mxEv, nextEv, cli, this.showHiddenEvents, this.context.timelineRenderingType);
@ -771,7 +780,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// is this a continuation of the previous message?
const continuation =
!wantsDateSeparator &&
wantsSeparator === SeparatorKind.None &&
shouldFormContinuation(prevEvent, mxEv, cli, this.showHiddenEvents, this.context.timelineRenderingType);
const eventId = mxEv.getId()!;
@ -816,16 +825,31 @@ export default class MessagePanel extends React.Component<IProps, IState> {
return ret;
}
public wantsDateSeparator(prevEvent: MatrixEvent | null, nextEventDate: Optional<Date>): boolean {
public wantsSeparator(prevEvent: MatrixEvent | null, mxEvent: MatrixEvent): SeparatorKind {
if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList) {
return false;
return SeparatorKind.None;
}
if (prevEvent == null) {
// first event in the panel: depends if we could back-paginate from
// here.
return !this.props.canBackPaginate;
if (prevEvent !== null) {
// If the previous event was late but current is not then show a date separator for orientation
// Otherwise if the current event is of a different late group than the previous show a late event separator
const lateEventInfo = getLateEventInfo(mxEvent);
if (lateEventInfo?.group_id !== getLateEventInfo(prevEvent)?.group_id) {
return lateEventInfo !== undefined ? SeparatorKind.LateEvent : SeparatorKind.Date;
}
}
return wantsDateSeparator(prevEvent.getDate() || undefined, nextEventDate);
// first event in the panel: depends on if we could back-paginate from here.
if (prevEvent === null && !this.props.canBackPaginate) {
return SeparatorKind.Date;
}
const nextEventDate = mxEvent.getDate() ?? new Date();
if (prevEvent !== null && wantsDateSeparator(prevEvent.getDate() || undefined, nextEventDate)) {
return SeparatorKind.Date;
}
return SeparatorKind.None;
}
// Get a list of read receipts that should be shown next to this event

View File

@ -25,6 +25,7 @@ import { _t } from "../../../languageHandler";
import DateSeparator from "../../views/messages/DateSeparator";
import NewRoomIntro from "../../views/rooms/NewRoomIntro";
import GenericEventListSummary from "../../views/elements/GenericEventListSummary";
import { SeparatorKind } from "../../views/messages/TimelineSeparator";
// Wrap initial room creation events into a GenericEventListSummary
// Grouping only events sent by the same user that sent the `m.room.create` and only until
@ -41,7 +42,7 @@ export class CreationGrouper extends BaseGrouper {
if (!shouldShow) {
return true;
}
if (panel.wantsDateSeparator(this.firstEventAndShouldShow.event, event.getDate())) {
if (panel.wantsSeparator(this.firstEventAndShouldShow.event, event) === SeparatorKind.Date) {
return false;
}
const eventType = event.getType();
@ -96,7 +97,7 @@ export class CreationGrouper extends BaseGrouper {
const createEvent = this.firstEventAndShouldShow;
const lastShownEvent = this.lastShownEvent;
if (panel.wantsDateSeparator(this.prevEvent, createEvent.event.getDate())) {
if (panel.wantsSeparator(this.prevEvent, createEvent.event) === SeparatorKind.Date) {
const ts = createEvent.event.getTs();
ret.push(
<li key={ts + "~"}>

View File

@ -0,0 +1,43 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
const UNSIGNED_KEY = "io.element.late_event";
/**
* This metadata describes when events arrive late after a net-split to offer improved UX.
*/
interface UnsignedLateEventInfo {
/**
* Milliseconds since epoch representing the time the event was received by the server
*/
received_at: number;
/**
* An opaque identifier representing the group the server has put the late arriving event into
*/
group_id: string;
}
/**
* Get io.element.late_event metadata from unsigned as sent by the server.
*
* @experimental this is not in the Matrix spec and needs special server support
* @param mxEvent the Matrix Event to get UnsignedLateEventInfo on
*/
export function getLateEventInfo(mxEvent: MatrixEvent): UnsignedLateEventInfo | undefined {
return mxEvent.getUnsigned()[UNSIGNED_KEY];
}

View File

@ -25,6 +25,7 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import DateSeparator from "../../views/messages/DateSeparator";
import HistoryTile from "../../views/rooms/HistoryTile";
import EventListSummary from "../../views/elements/EventListSummary";
import { SeparatorKind } from "../../views/messages/TimelineSeparator";
const groupedStateEvents = [
EventType.RoomMember,
@ -70,7 +71,7 @@ export class MainGrouper extends BaseGrouper {
// absorb hidden events so that they do not break up streams of messages & redaction events being grouped
return true;
}
if (this.panel.wantsDateSeparator(this.events[0].event, ev.getDate())) {
if (this.panel.wantsSeparator(this.events[0].event, ev) === SeparatorKind.Date) {
return false;
}
if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) {
@ -114,7 +115,7 @@ export class MainGrouper extends BaseGrouper {
const lastShownEvent = this.lastShownEvent;
const ret: ReactNode[] = [];
if (panel.wantsDateSeparator(this.prevEvent, this.events[0].event.getDate())) {
if (panel.wantsSeparator(this.prevEvent, this.events[0].event) === SeparatorKind.Date) {
const ts = this.events[0].event.getTs();
ret.push(
<li key={ts + "~"}>

View File

@ -40,6 +40,7 @@ import IconizedContextMenu, {
import JumpToDatePicker from "./JumpToDatePicker";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { SdkContextClass } from "../../../contexts/SDKContext";
import TimelineSeparator from "./TimelineSeparator";
interface IProps {
roomId: string;
@ -52,6 +53,11 @@ interface IState {
jumpToDateEnabled: boolean;
}
/**
* Timeline separator component to render within a MessagePanel bearing the date of the ts given
*
* Has additional jump to date functionality when labs flag is enabled
*/
export default class DateSeparator extends React.Component<IProps, IState> {
private settingWatcherRef?: string;
@ -328,13 +334,6 @@ export default class DateSeparator extends React.Component<IProps, IState> {
);
}
// ARIA treats <hr/>s as separators, here we abuse them slightly so manually treat this entire thing as one
return (
<div className="mx_DateSeparator" role="separator" aria-label={label}>
<hr role="none" />
{dateHeaderContent}
<hr role="none" />
</div>
);
return <TimelineSeparator label={label}>{dateHeaderContent}</TimelineSeparator>;
}
}

View File

@ -0,0 +1,46 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
interface Props {
label: string;
}
export const enum SeparatorKind {
None,
Date,
LateEvent,
}
/**
* Generic timeline separator component to render within a MessagePanel
*
* @param label the accessible label string describing the separator
* @param children the children to draw within the timeline separator
*/
const TimelineSeparator: React.FC<Props> = ({ label, children }) => {
// ARIA treats <hr/>s as separators, here we abuse them slightly so manually treat this entire thing as one
return (
<div className="mx_TimelineSeparator" role="separator" aria-label={label}>
<hr role="none" />
{children}
<hr role="none" />
</div>
);
};
export default TimelineSeparator;

View File

@ -85,6 +85,7 @@ import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import { ElementCall } from "../../../models/Call";
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar";
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
export type GetRelationsForEvent = (
eventId: string,
@ -1126,7 +1127,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
showRelative={this.context.timelineRenderingType === TimelineRenderingType.ThreadsList}
showTwelveHour={this.props.isTwelveHour}
ts={ts}
receivedTs={this.props.mxEvent.getUnsigned()["io.element.late_event"]?.received_at}
receivedTs={getLateEventInfo(this.props.mxEvent)?.received_at}
/>
);

View File

@ -3203,6 +3203,7 @@
"you": "You ended a <a>voice broadcast</a>"
},
"io.element.widgets.layout": "%(senderName)s has updated the room layout",
"late_event_separator": "Originally sent %(dateTime)s",
"load_error": {
"no_permission": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
"title": "Failed to load timeline position",

View File

@ -40,7 +40,7 @@ exports[`MessagePanel should handle lots of membership events quickly 1`] = `
>
<div
aria-label="Thu, Jan 1, 1970"
class="mx_DateSeparator"
class="mx_TimelineSeparator"
role="separator"
>
<hr

View File

@ -46,7 +46,7 @@ exports[`<MessageEditHistory /> should match the snapshot 1`] = `
<li>
<div
aria-label="Thu, Jan 1, 1970"
class="mx_DateSeparator"
class="mx_TimelineSeparator"
role="separator"
>
<hr
@ -162,7 +162,7 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
<li>
<div
aria-label="Thu, Jan 1, 1970"
class="mx_DateSeparator"
class="mx_TimelineSeparator"
role="separator"
>
<hr

View File

@ -4,7 +4,7 @@ exports[`DateSeparator renders the date separator correctly 1`] = `
<DocumentFragment>
<div
aria-label="today"
class="mx_DateSeparator"
class="mx_TimelineSeparator"
role="separator"
>
<hr
@ -31,7 +31,7 @@ exports[`DateSeparator when feature_jump_to_date is enabled renders the date sep
<DocumentFragment>
<div
aria-label="Fri, Dec 17, 2021"
class="mx_DateSeparator"
class="mx_TimelineSeparator"
role="separator"
>
<hr

View File

@ -98,7 +98,7 @@ describe("SearchResultTile", () => {
),
});
const separators = container.querySelectorAll(".mx_DateSeparator");
const separators = container.querySelectorAll(".mx_TimelineSeparator");
// One separator is always rendered at the top, we don't want any
// between messages.
expect(separators.length).toBe(1);

File diff suppressed because one or more lines are too long