From ee2ee3c08c86746346de944f97303c1542e4e7d6 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 22 Apr 2022 17:09:44 +0200 Subject: [PATCH] Implement new Read Receipt design (#8389) * feat: introduce new alignment types for tooltip * feat: introduce new hook for tooltips * feat: allow using onFocus callback for RovingAccessibleButton * feat: allow using custom class for ContextMenu * feat: allow setting tab index for avatar * refactor: move read receipts out of event tile * feat: implement new read receipt design * feat: update SentReceipt to match new read receipts as well --- res/css/_components.scss | 1 + res/css/views/right_panel/_TimelineCard.scss | 4 +- res/css/views/rooms/_EventBubbleTile.scss | 6 +- res/css/views/rooms/_EventTile.scss | 44 +-- res/css/views/rooms/_GroupLayout.scss | 2 +- res/css/views/rooms/_IRCLayout.scss | 4 +- res/css/views/rooms/_ReadReceiptGroup.scss | 146 ++++++++++ .../roving/RovingAccessibleButton.tsx | 16 +- .../roving/RovingAccessibleTooltipButton.tsx | 16 +- src/components/structures/ContextMenu.tsx | 3 +- src/components/views/avatars/BaseAvatar.tsx | 1 + src/components/views/avatars/MemberAvatar.tsx | 3 +- src/components/views/elements/Tooltip.tsx | 12 + src/components/views/rooms/EventTile.tsx | 231 ++++------------ .../views/rooms/ReadReceiptGroup.tsx | 254 ++++++++++++++++++ .../views/rooms/ReadReceiptMarker.tsx | 52 +--- src/i18n/strings/en_EN.json | 4 +- src/utils/useTooltip.tsx | 24 ++ 18 files changed, 553 insertions(+), 270 deletions(-) create mode 100644 res/css/views/rooms/_ReadReceiptGroup.scss create mode 100644 src/components/views/rooms/ReadReceiptGroup.tsx create mode 100644 src/utils/useTooltip.tsx diff --git a/res/css/_components.scss b/res/css/_components.scss index bcd3111663..b222e8ce23 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -248,6 +248,7 @@ @import "./views/rooms/_NotificationBadge.scss"; @import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PresenceLabel.scss"; +@import "./views/rooms/_ReadReceiptGroup.scss"; @import "./views/rooms/_RecentlyViewedButton.scss"; @import "./views/rooms/_ReplyPreview.scss"; @import "./views/rooms/_ReplyTile.scss"; diff --git a/res/css/views/right_panel/_TimelineCard.scss b/res/css/views/right_panel/_TimelineCard.scss index 447933a39f..1b35d16cdd 100644 --- a/res/css/views/right_panel/_TimelineCard.scss +++ b/res/css/views/right_panel/_TimelineCard.scss @@ -125,8 +125,8 @@ limitations under the License. padding-left: 36px; } - .mx_EventTile_readAvatars { - top: -10px; + .mx_ReadReceiptGroup { + top: -6px; } .mx_WhoIsTypingTile { diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index ca58c666cc..4329d37c0e 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -447,7 +447,7 @@ limitations under the License. } } - .mx_EventTile_readAvatars { + .mx_ReadReceiptGroup { position: absolute; right: -78px; // as close to right gutter without clipping as possible bottom: 0; @@ -585,8 +585,8 @@ limitations under the License. right: 127px; // align with that of right-column bubbles } - .mx_EventTile_readAvatars { - right: -18px; // match alignment to RRs of chat bubbles + .mx_ReadReceiptGroup { + right: -14px; // match alignment to RRs of chat bubbles } &::before { diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 497de258dc..480ac08654 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -22,20 +22,18 @@ $left-gutter: 64px; .mx_EventTile_receiptSent, .mx_EventTile_receiptSending { - // Give it some dimensions so the tooltip can position properly + position: relative; display: inline-block; - width: 14px; - height: 14px; - // We don't use `position: relative` on the element because then it won't line - // up with the other read receipts + width: 16px; + height: 16px; &::before { background-color: $tertiary-content; mask-repeat: no-repeat; mask-position: center; - mask-size: 14px; - width: 14px; - height: 14px; + mask-size: 16px; + width: 16px; + height: 16px; content: ''; position: absolute; top: 0; @@ -349,36 +347,6 @@ $left-gutter: 64px; } } -.mx_EventTile_readAvatars { - position: relative; - display: inline-block; - width: 14px; - height: 14px; - // This aligns the avatar with the last line of the - // message. We want to move it one line up - 2.2rem - top: -2.2rem; - user-select: none; - z-index: 1; -} - -.mx_EventTile_readAvatars .mx_BaseAvatar { - position: absolute; - display: inline-block; - height: $font-14px; - width: $font-14px; - - will-change: left, top; - transition: - left var(--transition-short) ease-out, - top var(--transition-standard) ease-out; -} - -.mx_EventTile_readAvatarRemainder { - color: $event-timestamp-color; - font-size: $font-11px; - position: absolute; -} - .mx_EventTile_bigEmoji { font-size: 48px; line-height: 57px; diff --git a/res/css/views/rooms/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss index c054256b6a..b1d6e8b535 100644 --- a/res/css/views/rooms/_GroupLayout.scss +++ b/res/css/views/rooms/_GroupLayout.scss @@ -97,7 +97,7 @@ $left-gutter: 64px; top: 3px; } - .mx_EventTile_readAvatars { + .mx_ReadReceiptGroup { // This aligns the avatar with the last line of the // message. We want to move it one line up - 2rem top: -2rem; diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index b0401a447c..36d045610a 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -49,8 +49,8 @@ $irc-line-height: $font-18px; order: 5; flex-shrink: 0; - .mx_EventTile_readAvatars { - top: 0.2rem; // ($irc-line-height - avatar height) / 2 + .mx_ReadReceiptGroup { + top: -0.3rem; // ($irc-line-height - avatar height) / 2 } } diff --git a/res/css/views/rooms/_ReadReceiptGroup.scss b/res/css/views/rooms/_ReadReceiptGroup.scss new file mode 100644 index 0000000000..fe40b1263f --- /dev/null +++ b/res/css/views/rooms/_ReadReceiptGroup.scss @@ -0,0 +1,146 @@ +/* +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. +*/ + +.mx_ReadReceiptGroup { + position: relative; + display: inline-block; + // This aligns the avatar with the last line of the + // message. We want to move it one line up + // See .mx_GroupLayout .mx_EventTile .mx_EventTile_line in _GroupLayout.scss + top: calc(-$font-22px - 3px); + user-select: none; + z-index: 1; + + .mx_ReadReceiptGroup_button { + display: inline-flex; + flex-direction: row; + height: 16px; + padding: 4px; + border-radius: 6px; + + &.mx_AccessibleButton { + &:hover { + background: $event-selected-color; + } + } + } + + .mx_ReadReceiptGroup_remainder { + color: $secondary-content; + font-size: $font-11px; + line-height: $font-16px; + margin-right: 4px; + } + + .mx_ReadReceiptGroup_container { + position: relative; + display: block; + height: 100%; + + .mx_BaseAvatar { + position: absolute; + display: inline-block; + height: 14px; + width: 14px; + border: 1px solid $background; + border-radius: 100%; + + will-change: left, top; + transition: + left var(--transition-short) ease-out, + top var(--transition-standard) ease-out; + } + } +} + +.mx_ReadReceiptGroup_popup { + max-height: 300px; + width: 220px; + border-radius: 8px; + display: flex; + flex-direction: column; + text-align: left; + font-size: 12px; + line-height: 15px; + + right: 0; + + &.mx_ContextualMenu_top { + top: 8px; + } + + &.mx_ContextualMenu_bottom { + bottom: 8px; + } + + .mx_ReadReceiptGroup_title { + font-size: 12px; + line-height: 15px; + margin: 16px 16px 8px; + font-weight: 600; + // shouldn’t be actually focusable + outline: none; + } + + .mx_AutoHideScrollbar { + .mx_ReadReceiptGroup_person { + display: flex; + flex-direction: row; + padding: 4px; + margin: 0 12px; + border-radius: 8px; + + &:hover { + background: $menu-selected-color; + } + + &:last-child { + margin-bottom: 8px; + } + + .mx_BaseAvatar { + margin: 6px 8px; + align-self: center; + justify-self: center; + } + + .mx_ReadReceiptGroup_name { + display: flex; + flex-direction: column; + flex-grow: 1; + flex-shrink: 1; + overflow: hidden; + + p { + margin: 2px 0; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .mx_ReadReceiptGroup_secondary { + color: $secondary-content; + } + } + } + } +} + +.mx_ReadReceiptGroup_person--tooltip { + overflow-y: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/accessibility/roving/RovingAccessibleButton.tsx b/src/accessibility/roving/RovingAccessibleButton.tsx index f9ce87db6a..0d9025dd59 100644 --- a/src/accessibility/roving/RovingAccessibleButton.tsx +++ b/src/accessibility/roving/RovingAccessibleButton.tsx @@ -20,13 +20,21 @@ import AccessibleButton from "../../components/views/elements/AccessibleButton"; import { useRovingTabIndex } from "../RovingTabIndex"; import { Ref } from "./types"; -interface IProps extends Omit, "onFocus" | "inputRef" | "tabIndex"> { +interface IProps extends Omit, "inputRef" | "tabIndex"> { inputRef?: Ref; } // Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. -export const RovingAccessibleButton: React.FC = ({ inputRef, ...props }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); - return ; +export const RovingAccessibleButton: React.FC = ({ inputRef, onFocus, ...props }) => { + const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); + return { + onFocusInternal(); + onFocus?.(event); + }} + inputRef={ref} + tabIndex={isActive ? 0 : -1} + />; }; diff --git a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx index d9e393d728..432e888017 100644 --- a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx +++ b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx @@ -21,13 +21,21 @@ import { useRovingTabIndex } from "../RovingTabIndex"; import { Ref } from "./types"; type ATBProps = React.ComponentProps; -interface IProps extends Omit { +interface IProps extends Omit { inputRef?: Ref; } // Wrapper to allow use of useRovingTabIndex for simple AccessibleTooltipButtons outside of React Functional Components. -export const RovingAccessibleTooltipButton: React.FC = ({ inputRef, ...props }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); - return ; +export const RovingAccessibleTooltipButton: React.FC = ({ inputRef, onFocus, ...props }) => { + const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); + return { + onFocusInternal(); + onFocus?.(event); + }} + inputRef={ref} + tabIndex={isActive ? 0 : -1} + />; }; diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index a3c214eb45..187e55cc39 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -82,6 +82,7 @@ export interface IProps extends IPosition { // whether this context menu should be focus managed. If false it must handle itself managed?: boolean; wrapperClassName?: string; + menuClassName?: string; // If true, this context menu will be mounted as a child to the parent container. Otherwise // it will be mounted to a container at the root of the DOM. @@ -319,7 +320,7 @@ export default class ContextMenu extends React.PureComponent { 'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom, 'mx_ContextualMenu_rightAligned': this.props.rightAligned === true, 'mx_ContextualMenu_bottomAligned': this.props.bottomAligned === true, - }); + }, this.props.menuClassName); const menuStyle: CSSProperties = {}; if (props.menuWidth) { diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 22d2d1ab00..76eea6cec0 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -45,6 +45,7 @@ interface IProps { onClick?: React.MouseEventHandler; inputRef?: React.RefObject; className?: string; + tabIndex?: number; } const calculateUrls = (url, urls, lowBandwidth) => { diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index c5dd1bfd3c..09400b7e21 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -43,6 +43,7 @@ interface IProps extends Omit, "name" | title?: string; style?: any; forceHistorical?: boolean; // true to deny `feature_use_only_current_profiles` usage. Default false. + hideTitle?: boolean; } interface IState { @@ -124,7 +125,7 @@ export default class MemberAvatar extends React.PureComponent { { style.top = baseTop + parentBox.height - 50; style.left = horizontalCenter; style.transform = "translate(-50%)"; + break; + case Alignment.TopRight: + style.top = baseTop - 5; + style.right = width - parentBox.right - window.pageXOffset; + style.transform = "translate(5px, -100%)"; + break; + case Alignment.TopCenter: + style.top = baseTop - 5; + style.left = horizontalCenter; + style.transform = "translate(-50%, -100%)"; } return style; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 4c68ee532e..f068e029a8 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -36,12 +36,11 @@ import { formatTime } from "../../../DateUtils"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { E2EState } from "./E2EIcon"; -import { toRem } from "../../../utils/units"; import RoomAvatar from "../avatars/RoomAvatar"; import MessageContextMenu from "../context_menus/MessageContextMenu"; import { aboveRightOf } from '../../structures/ContextMenu'; import { objectHasDiff } from "../../../utils/objects"; -import Tooltip from "../elements/Tooltip"; +import Tooltip, { Alignment } from "../elements/Tooltip"; import EditorStateTransfer from "../../../utils/EditorStateTransfer"; import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; @@ -54,7 +53,7 @@ import MemberAvatar from '../avatars/MemberAvatar'; import SenderProfile from '../messages/SenderProfile'; import MessageTimestamp from '../messages/MessageTimestamp'; import TooltipButton from '../elements/TooltipButton'; -import ReadReceiptMarker, { IReadReceiptInfo } from "./ReadReceiptMarker"; +import { IReadReceiptInfo } from "./ReadReceiptMarker"; import MessageActionBar from "../messages/MessageActionBar"; import ReactionsRow from '../messages/ReactionsRow'; import { getEventDisplayInfo } from '../../../utils/EventRenderingUtils'; @@ -79,6 +78,8 @@ import PosthogTrackers from "../../../PosthogTrackers"; import TileErrorBoundary from '../messages/TileErrorBoundary'; import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory"; import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary"; +import { ReadReceiptGroup } from './ReadReceiptGroup'; +import { useTooltip } from "../../../utils/useTooltip"; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; @@ -221,9 +222,6 @@ interface IProps { interface IState { // Whether the action bar is focused. actionBarFocused: boolean; - // Whether all read receipts are being displayed. If not, only display - // a truncation of them. - allReadAvatars: boolean; // Whether the event's sender has been verified. verified: string; // Whether onRequestKeysClick has been called since mounting. @@ -273,9 +271,6 @@ export class UnwrappedEventTile extends React.Component { this.state = { // Whether the action bar is focused. actionBarFocused: false, - // Whether all read receipts are being displayed. If not, only display - // a truncation of them. - allReadAvatars: false, // Whether the event's sender has been verified. verified: null, // Whether onRequestKeysClick has been called since mounting. @@ -731,108 +726,6 @@ export class UnwrappedEventTile extends React.Component { return actions.tweaks.highlight; } - private toggleAllReadAvatars = () => { - this.setState({ - allReadAvatars: !this.state.allReadAvatars, - }); - }; - - private getReadAvatars() { - if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { - return ; - } - - const MAX_READ_AVATARS = this.props.layout == Layout.Bubble - ? 2 - : 5; - - // return early if there are no read receipts - if (!this.props.readReceipts || this.props.readReceipts.length === 0) { - // We currently must include `mx_EventTile_readAvatars` in the DOM - // of all events, as it is the positioned parent of the animated - // read receipts. We can't let it unmount when a receipt moves - // events, so for now we mount it for all events. Without it, the - // animation will start from the top of the timeline (because it - // lost its container). - // See also https://github.com/vector-im/element-web/issues/17561 - return ( -
- -
- ); - } - - const avatars = []; - const receiptOffset = 15; - let left = 0; - - const receipts = this.props.readReceipts; - - for (let i = 0; i < receipts.length; ++i) { - const receipt = receipts[i]; - - let hidden = true; - if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) { - hidden = false; - } - // TODO: we keep the extra read avatars in the dom to make animation simpler - // we could optimise this to reduce the dom size. - - // If hidden, set offset equal to the offset of the final visible avatar or - // else set it proportional to index - left = (hidden ? MAX_READ_AVATARS - 1 : i) * -receiptOffset; - - const userId = receipt.userId; - let readReceiptInfo: IReadReceiptInfo; - - if (this.props.readReceiptMap) { - readReceiptInfo = this.props.readReceiptMap[userId]; - if (!readReceiptInfo) { - readReceiptInfo = {}; - this.props.readReceiptMap[userId] = readReceiptInfo; - } - } - - // add to the start so the most recent is on the end (ie. ends up rightmost) - avatars.unshift( -