Unify notifications panel event design (#9754)

This commit is contained in:
Germain 2022-12-21 10:25:50 +00:00 committed by GitHub
parent 6585fb1f55
commit bef8e077f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 191 additions and 219 deletions

View file

@ -62,7 +62,6 @@
@import "./structures/_MainSplit.pcss";
@import "./structures/_MatrixChat.pcss";
@import "./structures/_NonUrgentToastContainer.pcss";
@import "./structures/_NotificationPanel.pcss";
@import "./structures/_QuickSettingsButton.pcss";
@import "./structures/_RightPanel.pcss";
@import "./structures/_RoomSearch.pcss";

View file

@ -1,113 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket 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_NotificationPanel {
order: 2;
flex: 1 1 0;
overflow-y: auto;
display: flex;
.mx_RoomView_messageListWrapper {
flex-direction: row;
align-items: center;
justify-content: center;
}
.mx_RoomView_MessageList {
width: 100%;
h2 {
margin-left: 0;
}
}
/* FIXME: rather than having EventTile's default CSS be for MessagePanel,
we should make EventTile a base CSS class and customise it specifically
for usage in {Message,File,Notification}Panel. */
.mx_EventTile_avatar {
display: none;
}
.mx_EventTile {
word-break: break-word;
position: relative;
padding-block: 18px;
.mx_EventTile_senderDetails,
.mx_EventTile_line {
padding-left: 36px; /* align with the room name */
}
.mx_EventTile_senderDetails {
position: relative;
a {
display: flex;
column-gap: 5px; /* TODO: Use a spacing variable */
}
}
.mx_DisambiguatedProfile,
.mx_MessageTimestamp {
color: $primary-content;
font-size: $font-12px;
display: inline;
}
&:hover .mx_EventTile_line {
background-color: $background;
}
&:not(.mx_EventTile_last):not(.mx_EventTile_lastInSection)::after {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: $tertiary-content;
height: 1px;
opacity: 0.4;
content: "";
}
}
.mx_EventTile_roomName {
display: flex;
align-items: center;
column-gap: $spacing-8;
font-weight: bold;
font-size: $font-14px;
a {
color: $primary-content;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.mx_EventTile_selected .mx_EventTile_line {
padding-left: 0;
}
.mx_EventTile_content {
margin-right: 0;
}
}
.mx_NotificationPanel_empty::before {
mask-image: url("$(res)/img/element-icons/notifications.svg");
}

View file

@ -210,7 +210,6 @@ limitations under the License.
.mx_FilePanel,
.mx_UserInfo,
.mx_NotificationPanel,
.mx_MemberList {
&.mx_BaseCard {
padding: $spacing-32 0 0;

View file

@ -836,7 +836,8 @@ $left-gutter: 64px;
}
}
.mx_EventTile[data-shape="ThreadsList"] {
.mx_EventTile[data-shape="ThreadsList"],
.mx_EventTile[data-shape="Notification"] {
--topOffset: $spacing-12;
--leftOffset: 48px;
$borderRadius: 8px;
@ -916,9 +917,7 @@ $left-gutter: 64px;
}
.mx_DisambiguatedProfile {
margin-inline: 0 $spacing-12;
display: inline-flex;
flex: 1;
.mx_DisambiguatedProfile_displayName,
.mx_DisambiguatedProfile_mxid {
@ -941,6 +940,7 @@ $left-gutter: 64px;
width: 100%;
box-sizing: border-box;
padding-bottom: 0;
padding-inline-start: var(--leftOffset);
.mx_ThreadPanel_replies {
margin-top: $spacing-8;
@ -966,11 +966,6 @@ $left-gutter: 64px;
}
}
.mx_DisambiguatedProfile,
.mx_EventTile_line {
padding-inline-start: var(--leftOffset);
}
.mx_MessageTimestamp {
font-size: $font-12px;
max-width: var(--MessageTimestamp-max-width);
@ -1300,6 +1295,21 @@ $left-gutter: 64px;
}
}
.mx_EventTile_details {
display: flex;
width: -webkit-fill-available;
align-items: center;
justify-content: space-between;
gap: $spacing-8;
margin-left: var(--leftOffset);
.mx_EventTile_truncated {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
/* Media query for mobile UI */
@media only screen and (max-width: 480px) {
.mx_EventTile_content {

View file

@ -25,6 +25,7 @@ import Spinner from "../views/elements/Spinner";
import { Layout } from "../../settings/enums/Layout";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import Measured from "../views/elements/Measured";
import Heading from "../views/typography/Heading";
interface IProps {
onClose(): void;
@ -90,8 +91,21 @@ export default class NotificationPanel extends React.PureComponent<IProps, IStat
narrow: this.state.narrow,
}}
>
<BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
<Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />
<BaseCard
header={
<Heading size="h4" className="mx_BaseCard_header_title_heading">
{_t("Notifications")}
</Heading>
}
/**
* Need to rename this CSS class to something more generic
* Will be done once all the panels are using a similar layout
*/
className="mx_ThreadPanel"
onClose={this.props.onClose}
withoutScrollContainer={true}
>
{this.card.current && <Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />}
{content}
</BaseCard>
</RoomContext.Provider>

View file

@ -29,8 +29,6 @@ import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { Icon as LinkIcon } from "../../../../res/img/element-icons/link.svg";
import { Icon as ViewInRoomIcon } from "../../../../res/img/element-icons/view-in-room.svg";
import ReplyChain from "../elements/ReplyChain";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
@ -63,8 +61,6 @@ import SettingsStore from "../../../settings/SettingsStore";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import Toolbar from "../../../accessibility/Toolbar";
import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton";
import { ThreadNotificationState } from "../../../stores/notifications/ThreadNotificationState";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
@ -85,6 +81,7 @@ import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayloa
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import { ElementCall } from "../../../models/Call";
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar";
export type GetRelationsForEvent = (
eventId: string,
@ -972,6 +969,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
isContinuation = false;
}
const isRenderingNotification = this.context.timelineRenderingType === TimelineRenderingType.Notification;
const isEditing = !!this.props.editState;
const classes = classNames({
mx_EventTile_bubbleContainer: isBubbleMessage,
@ -996,7 +995,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
mx_EventTile_bad: isEncryptionFailure,
mx_EventTile_emote: msgtype === MsgType.Emote,
mx_EventTile_noSender: this.props.hideSender,
mx_EventTile_clamp: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList,
mx_EventTile_clamp:
this.context.timelineRenderingType === TimelineRenderingType.ThreadsList || isRenderingNotification,
mx_EventTile_noBubble: noBubbleEvent,
});
@ -1012,12 +1012,12 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
// Local echos have a send "status".
const scrollToken = this.props.mxEvent.status ? undefined : this.props.mxEvent.getId();
let avatar: JSX.Element;
let sender: JSX.Element;
let avatar: JSX.Element | null = null;
let sender: JSX.Element | null = null;
let avatarSize: number;
let needsSenderProfile: boolean;
if (this.context.timelineRenderingType === TimelineRenderingType.Notification) {
if (isRenderingNotification) {
avatarSize = 24;
needsSenderProfile = true;
} else if (isInfoMessage) {
@ -1061,7 +1061,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
member = this.props.mxEvent.sender;
}
// In the ThreadsList view we use the entire EventTile as a click target to open the thread instead
const viewUserOnClick = this.context.timelineRenderingType !== TimelineRenderingType.ThreadsList;
const viewUserOnClick = ![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes(
this.context.timelineRenderingType,
);
avatar = (
<div className="mx_EventTile_avatar">
<MemberAvatar
@ -1202,57 +1204,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.get().getUserId();
switch (this.context.timelineRenderingType) {
case TimelineRenderingType.Notification: {
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
return React.createElement(
this.props.as || "li",
{
"className": classes,
"aria-live": ariaLive,
"aria-atomic": true,
"data-scroll-tokens": scrollToken,
},
[
<div className="mx_EventTile_roomName" key="mx_EventTile_roomName">
<RoomAvatar room={room} width={28} height={28} />
<a href={permalink} onClick={this.onPermalinkClicked}>
{room ? room.name : ""}
</a>
</div>,
<div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails">
{avatar}
<a
href={permalink}
onClick={this.onPermalinkClicked}
onContextMenu={this.onTimestampContextMenu}
>
{sender}
{timestamp}
</a>
</div>,
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
{this.renderContextMenu()}
{renderTile(
TimelineRenderingType.Notification,
{
...this.props,
// overrides
ref: this.tile,
isSeeingThroughMessageHiddenForModeration,
// appease TS
highlights: this.props.highlights,
highlightLink: this.props.highlightLink,
onHeightChanged: this.props.onHeightChanged,
permalinkCreator: this.props.permalinkCreator,
},
this.context.showHiddenEvents,
)}
</div>,
],
);
}
case TimelineRenderingType.Thread: {
return React.createElement(
this.props.as || "li",
@ -1289,8 +1240,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
// appease TS
highlights: this.props.highlights,
highlightLink: this.props.highlightLink,
onHeightChanged: this.props.onHeightChanged,
permalinkCreator: this.props.permalinkCreator,
onHeightChanged: () => this.props.onHeightChanged,
permalinkCreator: this.props.permalinkCreator!,
},
this.context.showHiddenEvents,
)}
@ -1304,6 +1255,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
],
);
}
case TimelineRenderingType.Notification:
case TimelineRenderingType.ThreadsList: {
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
@ -1326,20 +1278,48 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onClick": (ev: MouseEvent) => {
dis.dispatch<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: this.props.mxEvent,
push: true,
});
const target = ev.currentTarget as HTMLElement;
const index = Array.from(target.parentElement.children).indexOf(target);
PosthogTrackers.trackInteraction("WebThreadsPanelThreadItem", ev, index);
let index = -1;
if (target.parentElement) index = Array.from(target.parentElement.children).indexOf(target);
switch (this.context.timelineRenderingType) {
case TimelineRenderingType.Notification:
this.viewInRoom(ev);
break;
case TimelineRenderingType.ThreadsList:
dis.dispatch<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: this.props.mxEvent,
push: true,
});
PosthogTrackers.trackInteraction("WebThreadsPanelThreadItem", ev, index ?? -1);
break;
}
},
},
<>
{sender}
{avatar}
{timestamp}
<div className="mx_EventTile_details">
{sender}
{isRenderingNotification && room ? (
<span className="mx_EventTile_truncated">
{" "}
{_t(
" in <strong>%(room)s</strong>",
{ room: room.name },
{ strong: (sub) => <strong>{sub}</strong> },
)}
</span>
) : (
""
)}
{timestamp}
</div>
{isRenderingNotification && room ? (
<div className="mx_EventTile_avatar">
<RoomAvatar room={room} width={28} height={28} />
</div>
) : (
avatar
)}
<div className={lineClasses} key="mx_EventTile_line">
<div className="mx_EventTile_body">
{this.props.mxEvent.isRedacted() ? (
@ -1350,24 +1330,13 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
</div>
{this.renderThreadPanelSummary()}
</div>
<Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_iconButton"
onClick={this.viewInRoom}
title={_t("View in room")}
key="view_in_room"
>
<ViewInRoomIcon />
</RovingAccessibleTooltipButton>
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_iconButton"
onClick={this.copyLinkToThread}
title={_t("Copy link to thread")}
key="copy_link_to_thread"
>
<LinkIcon />
</RovingAccessibleTooltipButton>
</Toolbar>
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
<EventTileThreadToolbar
viewInRoom={this.viewInRoom}
copyLinkToThread={this.copyLinkToThread}
/>
)}
{msgOption}
<UnreadNotificationBadge room={room} threadId={this.props.mxEvent.getId()} />
</>,

View file

@ -0,0 +1,53 @@
/*
Copyright 2022 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";
import { RovingAccessibleTooltipButton } from "../../../../accessibility/RovingTabIndex";
import Toolbar from "../../../../accessibility/Toolbar";
import { _t } from "../../../../languageHandler";
import { Icon as LinkIcon } from "../../../../../res/img/element-icons/link.svg";
import { Icon as ViewInRoomIcon } from "../../../../../res/img/element-icons/view-in-room.svg";
import { ButtonEvent } from "../../elements/AccessibleButton";
export function EventTileThreadToolbar({
viewInRoom,
copyLinkToThread,
}: {
viewInRoom: (evt: ButtonEvent) => void;
copyLinkToThread: (evt: ButtonEvent) => void;
}) {
return (
<Toolbar className="mx_MessageActionBar" aria-label={_t("Message Actions")} aria-live="off">
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_iconButton"
onClick={viewInRoom}
title={_t("View in room")}
key="view_in_room"
>
<ViewInRoomIcon />
</RovingAccessibleTooltipButton>
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_iconButton"
onClick={copyLinkToThread}
title={_t("Copy link to thread")}
key="copy_link_to_thread"
>
<LinkIcon />
</RovingAccessibleTooltipButton>
</Toolbar>
);
}

View file

@ -1879,9 +1879,7 @@
"Mod": "Mod",
"From a thread": "From a thread",
"This event could not be displayed": "This event could not be displayed",
"Message Actions": "Message Actions",
"View in room": "View in room",
"Copy link to thread": "Copy link to thread",
" in <strong>%(room)s</strong>": " in <strong>%(room)s</strong>",
"Encrypted by an unverified session": "Encrypted by an unverified session",
"Unencrypted": "Unencrypted",
"Encrypted by a deleted session": "Encrypted by a deleted session",
@ -2130,6 +2128,9 @@
"Italic": "Italic",
"Underline": "Underline",
"Code": "Code",
"Message Actions": "Message Actions",
"View in room": "View in room",
"Copy link to thread": "Copy link to thread",
"Error updating main address": "Error updating main address",
"There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.",
"There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.",

View file

@ -15,7 +15,6 @@ limitations under the License.
*/
import * as React from "react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
@ -23,6 +22,7 @@ import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { DeviceTrustLevel, UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning";
import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
import { IEncryptedEventInfo } from "matrix-js-sdk/src/crypto/api";
import { render, waitFor, screen, act, fireEvent } from "@testing-library/react";
import EventTile, { EventTileProps } from "../../../../src/components/views/rooms/EventTile";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
@ -31,6 +31,9 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { getRoomContext, mkEncryptedEvent, mkEvent, mkMessage, stubClient } from "../../../test-utils";
import { mkThread } from "../../../test-utils/threads";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import dis from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions";
describe("EventTile", () => {
const ROOM_ID = "!roomId:example.org";
@ -159,6 +162,43 @@ describe("EventTile", () => {
});
});
describe("EventTile in the right panel", () => {
beforeAll(() => {
const dmRoomMap: DMRoomMap = {
getUserIdForRoomId: jest.fn(),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
});
it("renders the room name for notifications", () => {
const { container } = getComponent({}, TimelineRenderingType.Notification);
expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent(
"@alice:example.org in !roomId:example.org",
);
});
it("renders the sender for the thread list", () => {
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent("@alice:example.org");
});
it.each([
[TimelineRenderingType.Notification, Action.ViewRoom],
[TimelineRenderingType.ThreadsList, Action.ShowThread],
])("type %s dispatches %s", (renderingType, action) => {
jest.spyOn(dis, "dispatch");
const { container } = getComponent({}, renderingType);
fireEvent.click(container.querySelector("li")!);
expect(dis.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
action,
}),
);
});
});
describe("Event verification", () => {
// data for our stubbed getEventEncryptionInfo: a map from event id to result
const eventToEncryptionInfoMap = new Map<string, IEncryptedEventInfo>();