Multi-select forwarding and deleting

This commit is contained in:
Jamie Kyle 2023-03-20 15:23:53 -07:00 committed by GitHub
parent d986356eea
commit 1d549a9991
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
82 changed files with 2607 additions and 991 deletions

View file

@ -3,7 +3,12 @@
/* eslint-disable react/jsx-pascal-case */
import type { ReactNode, RefObject } from 'react';
import type {
DetailedHTMLProps,
HTMLAttributes,
ReactNode,
RefObject,
} from 'react';
import React from 'react';
import { createPortal } from 'react-dom';
import classNames from 'classnames';
@ -113,7 +118,7 @@ const GROUP_AVATAR_SIZE = AvatarSize.TWENTY_EIGHT;
const STICKER_SIZE = 200;
const GIF_SIZE = 300;
// Note: this needs to match the animation time
const SELECTED_TIMEOUT = 1200;
const TARGETED_TIMEOUT = 1200;
const THREE_HOURS = 3 * 60 * 60 * 1000;
const SENT_STATUSES = new Set<MessageStatusType>([
'delivered',
@ -202,8 +207,10 @@ export type PropsData = {
textDirection: TextDirection;
textAttachment?: AttachmentType;
isSticker?: boolean;
isSelected?: boolean;
isSelectedCounter?: number;
isTargeted?: boolean;
isTargetedCounter?: number;
isSelected: boolean;
isSelectMode: boolean;
direction: DirectionType;
timestamp: number;
status?: MessageStatusType;
@ -297,7 +304,7 @@ export type PropsHousekeeping = {
};
export type PropsActions = {
clearSelectedMessage: () => unknown;
clearTargetedMessage: () => unknown;
doubleCheckMissingQuoteReference: (messageId: string) => unknown;
messageExpanded: (id: string, displayLimit: number) => unknown;
checkForAccount: (phoneNumber: string) => unknown;
@ -328,11 +335,14 @@ export type PropsActions = {
conversationId: string;
sentAt: number;
}) => void;
selectMessage?: (messageId: string, conversationId: string) => unknown;
targetMessage?: (messageId: string, conversationId: string) => unknown;
showExpiredIncomingTapToViewToast: () => unknown;
showExpiredOutgoingTapToViewToast: () => unknown;
viewStory: ViewStoryActionCreatorType;
onToggleSelect: (selected: boolean, shift: boolean) => void;
onReplyToMessage: () => void;
};
export type Props = PropsData & PropsHousekeeping & PropsActions;
@ -344,8 +354,8 @@ type State = {
expired: boolean;
imageBroken: boolean;
isSelected?: boolean;
prevSelectedCounter?: number;
isTargeted?: boolean;
prevTargetedCounter?: number;
reactionViewerRoot: HTMLDivElement | null;
reactionViewerOutsideClickDestructor?: () => void;
@ -372,7 +382,7 @@ export class Message extends React.PureComponent<Props, State> {
public expiredTimeout: NodeJS.Timeout | undefined;
public selectedTimeout: NodeJS.Timeout | undefined;
public targetedTimeout: NodeJS.Timeout | undefined;
public deleteForEveryoneTimeout: NodeJS.Timeout | undefined;
@ -386,8 +396,8 @@ export class Message extends React.PureComponent<Props, State> {
expired: false,
imageBroken: false,
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
isTargeted: props.isTargeted,
prevTargetedCounter: props.isTargetedCounter,
reactionViewerRoot: null,
@ -400,22 +410,22 @@ export class Message extends React.PureComponent<Props, State> {
}
public static getDerivedStateFromProps(props: Props, state: State): State {
if (!props.isSelected) {
if (!props.isTargeted) {
return {
...state,
isSelected: false,
prevSelectedCounter: 0,
isTargeted: false,
prevTargetedCounter: 0,
};
}
if (
props.isSelected &&
props.isSelectedCounter !== state.prevSelectedCounter
props.isTargeted &&
props.isTargetedCounter !== state.prevTargetedCounter
) {
return {
...state,
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
isTargeted: props.isTargeted,
prevTargetedCounter: props.isTargetedCounter,
};
}
@ -428,10 +438,10 @@ export class Message extends React.PureComponent<Props, State> {
}
public handleFocus = (): void => {
const { interactionMode, isSelected } = this.props;
const { interactionMode, isTargeted } = this.props;
if (interactionMode === 'keyboard' && !isSelected) {
this.setSelected();
if (interactionMode === 'keyboard' && !isTargeted) {
this.setTargeted();
}
};
@ -445,11 +455,11 @@ export class Message extends React.PureComponent<Props, State> {
});
};
public setSelected = (): void => {
const { id, conversationId, selectMessage } = this.props;
public setTargeted = (): void => {
const { id, conversationId, targetMessage } = this.props;
if (selectMessage) {
selectMessage(id, conversationId);
if (targetMessage) {
targetMessage(id, conversationId);
}
};
@ -465,12 +475,12 @@ export class Message extends React.PureComponent<Props, State> {
const { conversationId } = this.props;
window.ConversationController?.onConvoMessageMount(conversationId);
this.startSelectedTimer();
this.startTargetedTimer();
this.startDeleteForEveryoneTimerIfApplicable();
this.startGiftBadgeInterval();
const { isSelected } = this.props;
if (isSelected) {
const { isTargeted } = this.props;
if (isTargeted) {
this.setFocus();
}
@ -493,7 +503,7 @@ export class Message extends React.PureComponent<Props, State> {
}
public override componentWillUnmount(): void {
clearTimeoutIfNecessary(this.selectedTimeout);
clearTimeoutIfNecessary(this.targetedTimeout);
clearTimeoutIfNecessary(this.expirationCheckInterval);
clearTimeoutIfNecessary(this.expiredTimeout);
clearTimeoutIfNecessary(this.deleteForEveryoneTimeout);
@ -502,12 +512,12 @@ export class Message extends React.PureComponent<Props, State> {
}
public override componentDidUpdate(prevProps: Readonly<Props>): void {
const { isSelected, status, timestamp } = this.props;
const { isTargeted, status, timestamp } = this.props;
this.startSelectedTimer();
this.startTargetedTimer();
this.startDeleteForEveryoneTimerIfApplicable();
if (!prevProps.isSelected && isSelected) {
if (!prevProps.isTargeted && isTargeted) {
this.setFocus();
}
@ -610,20 +620,20 @@ export class Message extends React.PureComponent<Props, State> {
return result;
}
public startSelectedTimer(): void {
const { clearSelectedMessage, interactionMode } = this.props;
const { isSelected } = this.state;
public startTargetedTimer(): void {
const { clearTargetedMessage, interactionMode } = this.props;
const { isTargeted } = this.state;
if (interactionMode === 'keyboard' || !isSelected) {
if (interactionMode === 'keyboard' || !isTargeted) {
return;
}
if (!this.selectedTimeout) {
this.selectedTimeout = setTimeout(() => {
this.selectedTimeout = undefined;
this.setState({ isSelected: false });
clearSelectedMessage();
}, SELECTED_TIMEOUT);
if (!this.targetedTimeout) {
this.targetedTimeout = setTimeout(() => {
this.targetedTimeout = undefined;
this.setState({ isTargeted: false });
clearTargetedMessage();
}, TARGETED_TIMEOUT);
}
}
@ -2450,7 +2460,7 @@ export class Message extends React.PureComponent<Props, State> {
onKeyDown,
text,
} = this.props;
const { isSelected } = this.state;
const { isTargeted } = this.state;
const isAttachmentPending = this.isAttachmentPending();
@ -2462,7 +2472,7 @@ export class Message extends React.PureComponent<Props, State> {
// If it's a mostly-normal gray incoming text box, we don't want to darken it as much
const lighterSelect =
isSelected &&
isTargeted &&
direction === 'incoming' &&
!isStickerLike &&
(text || (!isVideo(attachments) && !isImage(attachments)));
@ -2470,8 +2480,8 @@ export class Message extends React.PureComponent<Props, State> {
const containerClassnames = classNames(
'module-message__container',
isGIF(attachments) ? 'module-message__container--gif' : null,
isSelected ? 'module-message__container--selected' : null,
lighterSelect ? 'module-message__container--selected-lighter' : null,
isTargeted ? 'module-message__container--targeted' : null,
lighterSelect ? 'module-message__container--targeted-lighter' : null,
!isStickerLike ? `module-message__container--${direction}` : null,
isEmojiOnly ? 'module-message__container--emoji' : null,
isTapToView ? 'module-message__container--with-tap-to-view' : null,
@ -2525,18 +2535,42 @@ export class Message extends React.PureComponent<Props, State> {
);
}
renderAltAccessibilityTree(): JSX.Element {
const { id, i18n, author } = this.props;
return (
<span className="module-message__alt-accessibility-tree">
<span id={`message-accessibility-label:${id}`}>
{author.isMe
? i18n('icu:messageAccessibilityLabel--outgoing', {})
: i18n('icu:messageAccessibilityLabel--incoming', {
author: author.title,
})}
</span>
<span id={`message-accessibility-description:${id}`}>
{this.renderText()}
</span>
</span>
);
}
public override render(): JSX.Element | null {
const {
id,
attachments,
direction,
isSticker,
isSelected,
isSelectMode,
onKeyDown,
renderMenu,
shouldCollapseAbove,
shouldCollapseBelow,
timestamp,
onToggleSelect,
onReplyToMessage,
} = this.props;
const { expired, expiring, isSelected, imageBroken } = this.state;
const { expired, expiring, isTargeted, imageBroken } = this.state;
if (expired) {
return null;
@ -2546,29 +2580,85 @@ export class Message extends React.PureComponent<Props, State> {
return null;
}
let wrapperProps: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
if (isSelectMode) {
wrapperProps = {
role: 'checkbox',
'aria-checked': isSelected,
'aria-labelledby': `message-accessibility-label:${id}`,
'aria-describedby': `message-accessibility-description:${id}`,
tabIndex: 0,
onClick: event => {
event.preventDefault();
onToggleSelect(!isSelected, event.shiftKey);
},
onKeyDown: event => {
if (event.code === 'Space') {
event.preventDefault();
onToggleSelect(!isSelected, event.shiftKey);
}
},
};
} else {
wrapperProps = {
onDoubleClick: event => {
event.stopPropagation();
event.preventDefault();
if (!isSelectMode) {
onReplyToMessage();
}
},
};
}
return (
<div
className={classNames(
'module-message',
`module-message--${direction}`,
shouldCollapseAbove && 'module-message--collapsed-above',
shouldCollapseBelow && 'module-message--collapsed-below',
isSelected ? 'module-message--selected' : null,
expiring ? 'module-message--expired' : null
'module-message__wrapper',
isSelectMode && 'module-message__wrapper--select-mode',
isSelected && 'module-message__wrapper--selected'
)}
data-testid={timestamp}
tabIndex={0}
// We need to have a role because screenreaders need to be able to focus here to
// read the message, but we can't be a button; that would break inner buttons.
role="row"
onKeyDown={onKeyDown}
onFocus={this.handleFocus}
ref={this.focusRef}
{...wrapperProps}
>
{this.renderError()}
{this.renderAvatar()}
{this.renderContainer()}
{renderMenu?.()}
{isSelectMode && (
<>
<span
role="presentation"
className="module-message__select-checkbox"
/>
{this.renderAltAccessibilityTree()}
</>
)}
<div
className={classNames(
'module-message',
`module-message--${direction}`,
shouldCollapseAbove && 'module-message--collapsed-above',
shouldCollapseBelow && 'module-message--collapsed-below',
isTargeted ? 'module-message--targeted' : null,
expiring ? 'module-message--expired' : null
)}
data-testid={timestamp}
tabIndex={0}
// We need to have a role because screenreaders need to be able to focus here to
// read the message, but we can't be a button; that would break inner buttons.
role="row"
onKeyDown={onKeyDown}
onFocus={this.handleFocus}
ref={this.focusRef}
// @ts-expect-error -- React/TS doesn't know about inert
// eslint-disable-next-line react/no-unknown-property
inert={isSelectMode ? '' : undefined}
>
{this.renderError()}
{this.renderAvatar()}
{this.renderContainer()}
{renderMenu?.()}
</div>
</div>
);
}