Multi-select forwarding and deleting
This commit is contained in:
parent
d986356eea
commit
1d549a9991
82 changed files with 2607 additions and 991 deletions
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue