feat: shift modifier shortcut for message row

This commit is contained in:
2025-04-17 23:36:41 +02:00
parent fc58dc5115
commit 9d0c84a239
3 changed files with 115 additions and 27 deletions

View File

@@ -134,4 +134,12 @@ Please see LICENSE files in the repository root for full details.
.mx_MessageActionBar_optionsButton {
--MessageActionBar-icon-size: 22px;
}
}
.mx_MessageActionBar_deleteButton {
color: var(--cpd-color-red-800);
&:hover {
color: var(--cpd-color-red-600);
}
}
}

View File

@@ -53,8 +53,6 @@ import type ReplyChain from "../elements/ReplyChain";
import ReactionPicker from "../emojipicker/ReactionPicker";
import { CardContext } from "../right_panel/context";
import { shouldDisplayReply } from "../../../utils/Reply";
import { Key } from "../../../Keyboard";
import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts";
import { Action } from "../../../dispatcher/actions";
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { type GetRelationsForEvent, type IEventTileType } from "../rooms/EventTile";
@@ -62,6 +60,8 @@ import { type ButtonEvent } from "../elements/AccessibleButton";
import PinningUtils from "../../../utils/PinningUtils";
import PosthogTrackers from "../../../PosthogTrackers.ts";
import { HideActionButton } from "./HideActionButton.tsx";
import { ALTERNATE_KEY_NAME } from "../../../accessibility/KeyboardShortcuts.ts";
import { Key } from "../../../Keyboard.ts";
interface IOptionsButtonProps {
mxEvent: MatrixEvent;
@@ -258,12 +258,27 @@ interface IMessageActionBarProps {
toggleThreadExpanded: () => void;
isQuoteExpanded?: boolean;
getRelationsForEvent?: GetRelationsForEvent;
isShiftHeld?: boolean;
setupShiftKeyListener?: () => void;
removeShiftKeyListener?: () => void;
}
export default class MessageActionBar extends React.PureComponent<IMessageActionBarProps> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
private onDelete = async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
ev.stopPropagation();
const cli = MatrixClientPeg.safeGet();
try {
await cli.redactEvent(this.props.mxEvent.getRoomId()!, this.props.mxEvent.getId()!);
} catch (e) {
console.error("Error redacting event: ", e);
}
};
public componentDidMount(): void {
if (this.props.mxEvent.status && this.props.mxEvent.status !== EventStatus.SENT) {
this.props.mxEvent.on(MatrixEventEvent.Status, this.onSent);
@@ -410,7 +425,17 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
PosthogTrackers.trackPinUnpinMessage(isPinned ? "Pin" : "Unpin", "Timeline");
};
private onMouseEnter = (): void => {
this.props.setupShiftKeyListener?.();
};
private onMouseLeave = (): void => {
this.props.removeShiftKeyListener?.();
};
public render(): React.ReactNode {
const isRedacted = this.props.mxEvent.isRedacted();
const toolbarOpts: JSX.Element[] = [];
if (canEditContent(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
toolbarOpts.push(
@@ -577,23 +602,44 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
);
}
// The menu button should be last, so dump it there.
toolbarOpts.push(
<OptionsButton
mxEvent={this.props.mxEvent}
getReplyChain={this.props.getReplyChain}
getTile={this.props.getTile}
permalinkCreator={this.props.permalinkCreator}
onFocusChange={this.onFocusChange}
key="menu"
getRelationsForEvent={this.props.getRelationsForEvent}
/>,
);
// Replace the menu button with delete button when shift is held
if (this.props.isShiftHeld && !isRedacted) {
toolbarOpts.push(
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton mx_MessageActionBar_deleteButton"
title={_t("action|delete")}
onClick={this.onDelete}
onContextMenu={this.onDelete}
key="delete"
placement="left"
>
<DeleteIcon />
</RovingAccessibleButton>,
);
} else {
toolbarOpts.push(
<OptionsButton
mxEvent={this.props.mxEvent}
getReplyChain={this.props.getReplyChain}
getTile={this.props.getTile}
permalinkCreator={this.props.permalinkCreator}
onFocusChange={this.onFocusChange}
key="menu"
getRelationsForEvent={this.props.getRelationsForEvent}
/>,
);
}
}
// aria-live=off to not have this read out automatically as navigating around timeline, gets repetitive.
return (
<Toolbar className="mx_MessageActionBar" aria-label={_t("timeline|mab|label")} aria-live="off">
<Toolbar
className="mx_MessageActionBar"
aria-label={_t("timeline|mab|label")}
aria-live="off"
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
{toolbarOpts}
</Toolbar>
);

View File

@@ -261,6 +261,8 @@ interface IState {
thread: Thread | null;
threadNotification?: NotificationCountType;
isShiftHeld: boolean;
}
/**
@@ -315,6 +317,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
hover: false,
thread,
isShiftHeld: false,
};
// don't do RR animations until we are mounted
@@ -441,6 +445,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
this.props.mxEvent.off(ThreadEvent.Update, this.updateThread);
this.unmounted = false;
if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.unobserve(this.ref.current);
this.removeShiftKeyListener();
}
public componentDidUpdate(prevProps: Readonly<EventTileProps>, prevState: Readonly<IState>): void {
@@ -798,6 +803,34 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
this.setState({ actionBarFocused });
};
private checkShiftKey = (): void => {
if (window.event instanceof MouseEvent) {
this.setState({ isShiftHeld: (window.event as unknown as MouseEvent).shiftKey });
}
};
private setupShiftKeyListener = (): void => {
document.addEventListener("keydown", this.checkShiftKey);
document.addEventListener("keyup", this.checkShiftKey);
this.checkShiftKey();
};
private removeShiftKeyListener = (): void => {
document.removeEventListener("keydown", this.checkShiftKey);
document.removeEventListener("keyup", this.checkShiftKey);
this.setState({ isShiftHeld: false });
};
private onMouseEnter = (): void => {
this.setState({ hover: true });
this.setupShiftKeyListener();
};
private onMouseLeave = (): void => {
this.setState({ hover: false });
this.removeShiftKeyListener();
};
private getTile: () => IEventTileType | null = () => this.tile.current;
private getReplyChain = (): ReplyChain | null => this.replyChain.current;
@@ -1102,6 +1135,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
isQuoteExpanded={isQuoteExpanded}
toggleThreadExpanded={() => this.setQuoteExpanded(!isQuoteExpanded)}
getRelationsForEvent={this.props.getRelationsForEvent}
isShiftHeld={this.state.isShiftHeld}
setupShiftKeyListener={this.setupShiftKeyListener}
removeShiftKeyListener={this.removeShiftKeyListener}
/>
) : undefined;
@@ -1224,8 +1260,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"data-layout": this.props.layout,
"data-self": isOwnEvent,
"data-event-id": this.props.mxEvent.getId(),
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onMouseEnter": this.onMouseEnter,
"onMouseLeave": this.onMouseLeave,
},
[
<div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails">
@@ -1284,8 +1320,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"data-shape": this.context.timelineRenderingType,
"data-self": isOwnEvent,
"data-has-reply": !!replyChain,
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onMouseEnter": this.onMouseEnter,
"onMouseLeave": this.onMouseLeave,
"onClick": (ev: MouseEvent) => {
const target = ev.currentTarget as HTMLElement;
let index = -1;
@@ -1418,8 +1454,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"data-self": isOwnEvent,
"data-event-id": this.props.mxEvent.getId(),
"data-has-reply": !!replyChain,
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onMouseEnter": this.onMouseEnter,
"onMouseLeave": this.onMouseLeave,
},
<>
{ircTimestamp}
@@ -1484,11 +1520,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
// Wrap all event tiles with the tile error boundary so that any throws even during construction are captured
const SafeEventTile = forwardRef<UnwrappedEventTile, EventTileProps>((props, ref) => {
return (
<>
<TileErrorBoundary mxEvent={props.mxEvent} layout={props.layout ?? Layout.Group}>
<UnwrappedEventTile ref={ref} {...props} />
</TileErrorBoundary>
</>
<><></><TileErrorBoundary mxEvent={props.mxEvent} layout={props.layout ?? Layout.Group}>
<UnwrappedEventTile ref={ref} {...props} />
</TileErrorBoundary></>
);
});
export default SafeEventTile;