Show hover elements when keyboard focus is within an event tile (#31078)

* Show timestamps when keyboard focus is within an event tile

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

* Ensure toolbar navigation pattern works in MessageActionBar

This requires all buttons within to be roving by using the ref callback given by useRovingTabIndex

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

* Use PureComponent in EventTile to avoid mass re-rendering due to transitive onFocus/onBlur calls

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

* Remove unused timestamp event tile prop

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

* Use MessageTimestamp to generate the wrapping anchor so that focusing it brings up the tooltip

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

* Tweak MessageTimestamp

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

* Switch back to Component as we specify a shouldComponentUpdate already

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

* Update jest tests

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

* Update playwright timestamp masks

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

* Revert snapshot

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

* Update snapshot

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

* Fix IRC layout

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

* Use PureComponent in EventTile to avoid mass re-rendering due to transitive onFocus/onBlur calls

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

* Remove unused timestamp event tile prop

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

* Use MessageTimestamp to generate the wrapping anchor so that focusing it brings up the tooltip

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

* Tweak MessageTimestamp

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

* Switch back to Component as we specify a shouldComponentUpdate already

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

* Update jest tests

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

* Update playwright timestamp masks

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

* Revert snapshot

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

* Update snapshot

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

* Fix IRC layout

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

* Lint styles

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

* Fix layout picker

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

* Update snapshots

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

* Update screenshots

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

* Fix pcss comment

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

* Iterate Playwright

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

* Iterate

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

* Iterate Playwright

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2025-10-28 09:33:02 +00:00
committed by GitHub
parent 7e04998a58
commit f6731ec318
78 changed files with 280 additions and 124 deletions

View File

@@ -19,7 +19,6 @@ import MessageContextMenu from "../context_menus/MessageContextMenu";
import { aboveLeftOf } from "../../structures/ContextMenu";
import MessageTimestamp from "../messages/MessageTimestamp";
import SettingsStore from "../../../settings/SettingsStore";
import { formatFullDate } from "../../../DateUtils";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
@@ -457,18 +456,15 @@ export default class ImageView extends React.Component<IProps, IState> {
const senderName = mxEvent.sender?.name ?? mxEvent.getSender();
const sender = <div className="mx_ImageView_info_sender">{senderName}</div>;
const messageTimestamp = (
<a
<MessageTimestamp
href={permalink}
onClick={this.onPermalinkClicked}
aria-label={formatFullDate(new Date(mxEvent.getTs()), showTwelveHour, false)}
>
<MessageTimestamp
showFullDate={true}
showTwelveHour={showTwelveHour}
ts={mxEvent.getTs()}
showSeconds={false}
/>
</a>
showFullDate={true}
showTwelveHour={showTwelveHour}
ts={mxEvent.getTs()}
showSeconds={false}
inhibitTooltip
/>
);
const avatar = (
<MemberAvatar

View File

@@ -24,6 +24,24 @@ interface IProps {
showFullDate?: boolean;
showSeconds?: boolean;
showRelative?: boolean;
/**
* If set to true then no tooltip will be shown
*/
inhibitTooltip?: boolean;
/**
* If specified, will be rendered as an anchor bearing the href, a `span` element will be used otherwise
*/
href?: string;
/**
* Optional onClick handler to attach to the DOM element
*/
onClick?: React.MouseEventHandler<HTMLElement>;
/**
* Optional onContextMenu handler to attach to the DOM element
*/
onContextMenu?: React.MouseEventHandler<HTMLElement>;
}
export default class MessageTimestamp extends React.Component<IProps> {
@@ -52,12 +70,41 @@ export default class MessageTimestamp extends React.Component<IProps> {
icon = <LateIcon className="mx_MessageTimestamp_lateIcon" width="16" height="16" />;
}
return (
<Tooltip description={label} caption={caption}>
<span className="mx_MessageTimestamp" aria-hidden={true} aria-live="off">
let content;
if (this.props.href) {
content = (
<a
href={this.props.href}
onClick={this.props.onClick}
onContextMenu={this.props.onContextMenu}
className="mx_MessageTimestamp"
aria-live="off"
>
{icon}
{timestamp}
</a>
);
} else {
content = (
<span
onClick={this.props.onClick}
onContextMenu={this.props.onContextMenu}
className="mx_MessageTimestamp"
aria-hidden={true}
aria-live="off"
tabIndex={this.props.onClick || !this.props.inhibitTooltip ? 0 : undefined}
>
{icon}
{timestamp}
</span>
);
}
if (this.props.inhibitTooltip) return content;
return (
<Tooltip description={label} caption={caption}>
{content}
</Tooltip>
);
}

View File

@@ -40,7 +40,6 @@ import ReplyChain from "../elements/ReplyChain";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { Layout } from "../../../settings/enums/Layout";
import { formatTime } from "../../../DateUtils";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { DecryptionFailureBody } from "../messages/DecryptionFailureBody";
import RoomAvatar from "../avatars/RoomAvatar";
@@ -253,6 +252,7 @@ interface IState {
reactions?: Relations | null | undefined;
hover: boolean;
focusWithin: boolean;
// Position of the context menu
contextMenu?: {
@@ -299,6 +299,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
declare public context: React.ContextType<typeof RoomContext>;
private unmounted = false;
private readonly id = uniqueId();
public constructor(props: EventTileProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
@@ -316,6 +317,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
reactions: this.getReactions(),
hover: false,
focusWithin: false,
thread,
};
@@ -922,7 +924,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
public render(): ReactNode {
const msgtype = this.props.mxEvent.getContent().msgtype;
const eventType = this.props.mxEvent.getType();
const id = uniqueId();
const {
hasRenderer,
@@ -1122,6 +1123,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
(this.props.alwaysShowTimestamps ||
this.props.last ||
this.state.hover ||
this.state.focusWithin ||
this.state.actionBarFocused ||
Boolean(this.state.contextMenu));
@@ -1135,20 +1137,32 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
ts = this.props.mxEvent.getTs();
}
const messageTimestamp = (
const messageTimestampProps = {
showRelative: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList,
showTwelveHour: this.props.isTwelveHour,
ts,
receivedTs: getLateEventInfo(this.props.mxEvent)?.received_ts,
};
const messageTimestamp = <MessageTimestamp {...messageTimestampProps} />;
const linkedMessageTimestamp = (
<MessageTimestamp
showRelative={this.context.timelineRenderingType === TimelineRenderingType.ThreadsList}
showTwelveHour={this.props.isTwelveHour}
ts={ts}
receivedTs={getLateEventInfo(this.props.mxEvent)?.received_ts}
{...messageTimestampProps}
href={permalink}
onClick={this.onPermalinkClicked}
onContextMenu={this.onTimestampContextMenu}
/>
);
const timestamp = showTimestamp && ts ? messageTimestamp : null;
const useIRCLayout = this.props.layout === Layout.IRC;
// Used to simplify the UI layout where necessary by not conditionally rendering an element at the start
const dummyTimestamp = useIRCLayout ? <span className="mx_MessageTimestamp" /> : null;
const timestamp = showTimestamp && ts ? messageTimestamp : dummyTimestamp;
const linkedTimestamp =
timestamp !== dummyTimestamp && !this.props.hideTimestamp ? linkedMessageTimestamp : dummyTimestamp;
let pinnedMessageBadge: JSX.Element | undefined;
if (PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
pinnedMessageBadge = <PinnedMessageBadge aria-describedby={id} tabIndex={0} />;
pinnedMessageBadge = <PinnedMessageBadge aria-describedby={this.id} tabIndex={0} />;
}
let reactionsRow: JSX.Element | undefined;
@@ -1165,21 +1179,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
// If we have reactions or a pinned message badge, we need a footer
const hasFooter = Boolean((reactionsRow && this.state.reactions) || pinnedMessageBadge);
const linkedTimestamp = !this.props.hideTimestamp ? (
<a
href={permalink}
onClick={this.onPermalinkClicked}
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
onContextMenu={this.onTimestampContextMenu}
>
{timestamp}
</a>
) : null;
const useIRCLayout = this.props.layout === Layout.IRC;
const groupTimestamp = !useIRCLayout ? linkedTimestamp : null;
const ircTimestamp = useIRCLayout ? linkedTimestamp : null;
const bubbleTimestamp = this.props.layout === Layout.Bubble ? messageTimestamp : undefined;
const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
@@ -1210,7 +1211,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
forExport={this.props.forExport}
permalinkCreator={this.props.permalinkCreator}
layout={this.props.layout}
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover}
alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover || this.state.focusWithin}
isQuoteExpanded={isQuoteExpanded}
setQuoteExpanded={this.setQuoteExpanded}
getRelationsForEvent={this.props.getRelationsForEvent}
@@ -1237,13 +1238,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"data-event-id": this.props.mxEvent.getId(),
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onFocus": () => this.setState({ focusWithin: true }),
"onBlur": () => this.setState({ focusWithin: false }),
},
[
<div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails">
{avatar}
{sender}
</div>,
<div id={id} className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
<div
id={this.id}
className={lineClasses}
key="mx_EventTile_line"
onContextMenu={this.onContextMenu}
>
{this.renderContextMenu()}
{replyChain}
{renderTile(TimelineRenderingType.Thread, {
@@ -1260,9 +1268,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
showHiddenEvents: this.context.showHiddenEvents,
})}
{actionBar}
<a href={permalink} onClick={this.onPermalinkClicked}>
{timestamp}
</a>
{linkedTimestamp}
{msgOption}
</div>,
hasFooter && (
@@ -1294,6 +1300,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"data-has-reply": !!replyChain,
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onFocus": () => this.setState({ focusWithin: true }),
"onBlur": () => this.setState({ focusWithin: false }),
"onClick": (ev: MouseEvent) => {
const target = ev.currentTarget as HTMLElement;
let index = -1;
@@ -1425,13 +1433,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"data-has-reply": !!replyChain,
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onFocus": () => this.setState({ focusWithin: true }),
"onBlur": () => this.setState({ focusWithin: false }),
},
<>
{ircTimestamp}
{sender}
{ircPadlock}
{avatar}
<div id={id} className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
<div
id={this.id}
className={lineClasses}
key="mx_EventTile_line"
onContextMenu={this.onContextMenu}
>
{this.renderContextMenu()}
{groupTimestamp}
{groupPadlock}
@@ -1442,7 +1457,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
// overrides
ref: this.tile,
isSeeingThroughMessageHiddenForModeration,
timestamp: bubbleTimestamp,
// appease TS
highlights: this.props.highlights,

View File

@@ -64,7 +64,6 @@ export interface EventTileTypeProps
| "inhibitInteraction"
> {
ref?: React.RefObject<any>; // `any` because it's effectively impossible to convince TS of a reasonable type
timestamp?: JSX.Element;
maxImageHeight?: number; // pixels
overrideBodyTypes?: Record<string, React.ComponentType<IBodyProps>>;
overrideEventTypes?: Record<string, React.ComponentType<IBodyProps>>;
@@ -288,7 +287,6 @@ export function renderTile(
callEventGrouper,
getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration,
timestamp,
inhibitInteraction,
showHiddenEvents,
} = props;
@@ -336,7 +334,6 @@ export function renderTile(
callEventGrouper,
getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration,
timestamp,
inhibitInteraction,
showHiddenEvents,
}),