From 4445ef80eb71f34b3be5600298d49e1b67065c4b Mon Sep 17 00:00:00 2001 From: Alvaro <110414366+alvaro-signal@users.noreply.github.com> Date: Fri, 4 Nov 2022 07:22:07 -0600 Subject: [PATCH] Implement group story reply deletion --- _locales/en/messages.json | 8 + stylesheets/_modules.scss | 4 +- ts/components/ConfirmationDialog.tsx | 2 +- ts/components/ContextMenu.tsx | 88 +-- ts/components/SendStoryModal.tsx | 25 +- ts/components/StoryViewer.tsx | 6 + ts/components/StoryViewsNRepliesModal.tsx | 340 +++++++--- ts/components/conversation/Message.tsx | 549 +-------------- ts/components/conversation/MessageAudio.tsx | 132 ++-- .../conversation/MessageDetail.stories.tsx | 13 +- ts/components/conversation/MessageDetail.tsx | 34 +- ts/components/conversation/Quote.stories.tsx | 13 +- ts/components/conversation/Timeline.tsx | 2 +- ts/components/conversation/TimelineItem.tsx | 11 +- ...tories.tsx => TimelineMessage.stories.tsx} | 54 +- .../conversation/TimelineMessage.tsx | 625 ++++++++++++++++++ ts/jobs/helpers/sendNormalMessage.ts | 4 +- ts/state/ducks/globalModals.ts | 5 +- ts/state/ducks/stories.ts | 51 ++ ts/state/selectors/conversations.ts | 10 +- ts/state/selectors/message.ts | 80 +-- ts/state/smart/MessageAudio.tsx | 31 +- ts/state/smart/MessageDetail.tsx | 14 - ts/state/smart/renderAudioAttachment.tsx | 5 +- ts/util/deleteGroupStoryReplyForEveryone.ts | 39 ++ ts/util/lint/exceptions.json | 7 + 26 files changed, 1218 insertions(+), 934 deletions(-) rename ts/components/conversation/{Message.stories.tsx => TimelineMessage.stories.tsx} (97%) create mode 100644 ts/components/conversation/TimelineMessage.tsx create mode 100644 ts/util/deleteGroupStoryReplyForEveryone.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 1d9962eb3ce9..f64499a62f76 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5867,6 +5867,14 @@ "messageformat": "You can’t reply to this story because you’re longer a member of this group.", "description": "Shown in the composer area of the reply-to-story modal when a user can't make a reply because they are no longer a member" }, + "icu:StoryViewsNRepliesModal__delete-reply": { + "messageformat": "Delete for me", + "description": "Shown as a menu item in the context menu of a story reply, to the author of the reply, for deleting the reply just for the author" + }, + "icu:StoryViewsNRepliesModal__delete-reply-for-everyone": { + "messageformat": "Delete for everyone", + "description": "Shown as a menu item in the context menu of a story reply, to the author of the reply, for deleting the reply for everyone" + }, "StoryListItem__label": { "message": "Story", "description": "aria-label for the story list button" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 939104dae4cd..6866c32db29d 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -466,14 +466,14 @@ $message-padding-horizontal: 12px; @include light-theme { color: $color-gray-90; border: 1px solid $color-gray-25; - background-color: $color-white; + background-color: transparent; background-image: none; } @include dark-theme { color: $color-gray-05; border: 1px solid $color-gray-75; - background-color: $color-gray-95; + background-color: transparent; background-image: none; } } diff --git a/ts/components/ConfirmationDialog.tsx b/ts/components/ConfirmationDialog.tsx index ce0a7db85fa6..484a21ffd661 100644 --- a/ts/components/ConfirmationDialog.tsx +++ b/ts/components/ConfirmationDialog.tsx @@ -32,7 +32,7 @@ export type OwnProps = Readonly<{ onClose: () => unknown; onTopOfEverything?: boolean; theme?: Theme; - title?: string | React.ReactNode; + title?: React.ReactNode; }>; export type Props = OwnProps; diff --git a/ts/components/ContextMenu.tsx b/ts/components/ContextMenu.tsx index 34ead0227295..482b095c500f 100644 --- a/ts/components/ContextMenu.tsx +++ b/ts/components/ContextMenu.tsx @@ -24,10 +24,11 @@ export type ContextMenuOptionType = Readonly<{ }>; type RenderButtonProps = Readonly<{ - openMenu: (() => void) | ((ev: React.MouseEvent) => void); + openMenu: (ev: React.MouseEvent) => void; onKeyDown: (ev: KeyboardEvent) => void; isMenuShowing: boolean; ref: React.Ref | null; + menuNode: ReactNode; }>; export type PropsType = Readonly<{ @@ -38,7 +39,7 @@ export type PropsType = Readonly<{ menuOptions: ReadonlyArray>; moduleClassName?: string; button?: () => JSX.Element; - onClick?: () => unknown; + onClick?: (ev: React.MouseEvent) => unknown; onMenuShowingChanged?: (value: boolean) => unknown; popperOptions?: Pick; theme?: Theme; @@ -260,59 +261,60 @@ export function ContextMenu({ ); } - let buttonNode: ReactNode; + const menuNode = isMenuShowing ? ( +
+
+ {title &&
{title}
} + {optionElements} +
+
+ ) : undefined; + + let buttonNode: JSX.Element; + if (typeof children === 'function') { buttonNode = (children as (props: RenderButtonProps) => JSX.Element)({ openMenu: onClick || handleClick, onKeyDown: handleKeyDown, isMenuShowing, ref: setReferenceElement, + menuNode, }); } else { buttonNode = ( - + + {menuNode} + ); } - - return ( -
- {buttonNode} - {isMenuShowing && ( -
-
- {title &&
{title}
} - {optionElements} -
-
- )} -
- ); + return buttonNode; } diff --git a/ts/components/SendStoryModal.tsx b/ts/components/SendStoryModal.tsx index e578bcfd6014..9dc1e63f94ec 100644 --- a/ts/components/SendStoryModal.tsx +++ b/ts/components/SendStoryModal.tsx @@ -646,17 +646,20 @@ export const SendStoryModal = ({ }} theme={Theme.Dark} > - {({ openMenu, onKeyDown, ref }) => ( - + {({ openMenu, onKeyDown, ref, menuNode }) => ( +
+ + {menuNode} +
)} diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 149b0fbb7699..0026f9bd1286 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -96,6 +96,8 @@ export type PropsType = { storyViewMode: StoryViewModeType; toggleHasAllStoriesMuted: () => unknown; viewStory: ViewStoryActionCreatorType; + deleteGroupStoryReply: (id: string) => void; + deleteGroupStoryReplyForEveryone: (id: string) => void; }; const CAPTION_BUFFER = 20; @@ -141,6 +143,8 @@ export const StoryViewer = ({ storyViewMode, toggleHasAllStoriesMuted, viewStory, + deleteGroupStoryReply, + deleteGroupStoryReplyForEveryone, }: PropsType): JSX.Element => { const [isShowingContextMenu, setIsShowingContextMenu] = useState(false); @@ -829,6 +833,8 @@ export const StoryViewer = ({ views={views} viewTarget={currentViewTarget} onChangeViewTarget={setCurrentViewTarget} + deleteGroupStoryReply={deleteGroupStoryReply} + deleteGroupStoryReplyForEveryone={deleteGroupStoryReplyForEveryone} /> )} {hasConfirmHideStory && ( diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx index 9a5af141e868..1de1cbc00ce9 100644 --- a/ts/components/StoryViewsNRepliesModal.tsx +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -36,22 +36,17 @@ import { WidthBreakpoint } from './_util'; import { getAvatarColor } from '../types/Colors'; import { getStoryReplyText } from '../util/getStoryReplyText'; import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled'; +import { ContextMenu } from './ContextMenu'; +import { ConfirmationDialog } from './ConfirmationDialog'; // Menu is disabled so these actions are inaccessible. We also don't support // link previews, tap to view messages, attachments, or gifts. Just regular // text messages and reactions. const MESSAGE_DEFAULT_PROPS = { canDeleteForEveryone: false, - canDownload: false, - canReact: false, - canReply: false, - canRetry: false, - canRetryDeleteForEveryone: false, checkForAccount: shouldNeverBeCalled, clearSelectedMessage: shouldNeverBeCalled, containerWidthBreakpoint: WidthBreakpoint.Medium, - deleteMessage: shouldNeverBeCalled, - deleteMessageForEveryone: shouldNeverBeCalled, displayTapToViewMessage: shouldNeverBeCalled, doubleCheckMissingQuoteReference: shouldNeverBeCalled, downloadAttachment: shouldNeverBeCalled, @@ -65,19 +60,12 @@ const MESSAGE_DEFAULT_PROPS = { openGiftBadge: shouldNeverBeCalled, openLink: shouldNeverBeCalled, previews: [], - reactToMessage: shouldNeverBeCalled, renderAudioAttachment: () =>
, - renderEmojiPicker: () =>
, - renderReactionPicker: () =>
, - replyToMessage: shouldNeverBeCalled, - retryDeleteForEveryone: shouldNeverBeCalled, - retrySend: shouldNeverBeCalled, scrollToQuotedMessage: shouldNeverBeCalled, showContactDetail: shouldNeverBeCalled, showContactModal: shouldNeverBeCalled, showExpiredIncomingTapToViewToast: shouldNeverBeCalled, showExpiredOutgoingTapToViewToast: shouldNeverBeCalled, - showForwardMessageModal: shouldNeverBeCalled, showMessageDetail: shouldNeverBeCalled, showVisualAttachment: shouldNeverBeCalled, startConversation: shouldNeverBeCalled, @@ -118,6 +106,8 @@ export type PropsType = { views: Array; viewTarget: StoryViewTargetType; onChangeViewTarget: (target: StoryViewTargetType) => unknown; + deleteGroupStoryReply: (id: string) => void; + deleteGroupStoryReplyForEveryone: (id: string) => void; }; export const StoryViewsNRepliesModal = ({ @@ -144,7 +134,16 @@ export const StoryViewsNRepliesModal = ({ views, viewTarget, onChangeViewTarget, + deleteGroupStoryReply, + deleteGroupStoryReplyForEveryone, }: PropsType): JSX.Element | null => { + const [deleteReplyId, setDeleteReplyId] = useState( + undefined + ); + const [deleteForEveryoneReplyId, setDeleteForEveryoneReplyId] = useState< + string | undefined + >(undefined); + const containerElementRef = useRef(null); const inputApiRef = useRef(); const shouldScrollToBottomRef = useRef(true); @@ -310,80 +309,36 @@ export const StoryViewsNRepliesModal = ({ className="StoryViewsNRepliesModal__replies" ref={containerElementRef} > - {replies.map((reply, index) => - reply.reactionEmoji ? ( -
-
- -
-
- -
- {i18n('StoryViewsNRepliesModal__reacted')} - -
-
- -
+ {replies.map((reply, index) => { + return reply.reactionEmoji ? ( + ) : ( -
- -
- ) - )} + setDeleteReplyId(reply.id)} + deleteGroupStoryReplyForEveryone={() => + setDeleteForEveryoneReplyId(reply.id) + } + getPreferredBadge={getPreferredBadge} + shouldCollapseAbove={ + reply.conversationId === replies[index - 1]?.conversationId && + !replies[index - 1]?.reactionEmoji + } + shouldCollapseBelow={ + reply.conversationId === replies[index + 1]?.conversationId && + !replies[index + 1]?.reactionEmoji + } + /> + ); + })}
); @@ -483,26 +438,197 @@ export const StoryViewsNRepliesModal = ({ } return ( - -
+ - {tabsElement || ( - <> - {viewsElement || repliesElement} - {composerElement} - - )} -
-
+
+ {tabsElement || ( + <> + {viewsElement || repliesElement} + {composerElement} + + )} +
+ + {deleteReplyId && ( + deleteGroupStoryReply(deleteReplyId), + style: 'negative', + }, + ]} + title={i18n('deleteWarning')} + onClose={() => setDeleteReplyId(undefined)} + onCancel={() => setDeleteReplyId(undefined)} + /> + )} + {deleteForEveryoneReplyId && ( + + deleteGroupStoryReplyForEveryone(deleteForEveryoneReplyId), + style: 'negative', + }, + ]} + title={i18n('deleteWarning')} + onClose={() => setDeleteForEveryoneReplyId(undefined)} + onCancel={() => setDeleteForEveryoneReplyId(undefined)} + > + {i18n('deleteForEveryoneWarning')} + + )} + + ); +}; + +type ReactionProps = { + i18n: LocalizerType; + reply: ReplyType; + getPreferredBadge: PreferredBadgeSelectorType; +}; + +const Reaction = ({ + i18n, + reply, + getPreferredBadge, +}: ReactionProps): JSX.Element => { + // TODO: DESKTOP-4503 - reactions delete/doe + return ( +
+
+ +
+
+ +
+ {i18n('StoryViewsNRepliesModal__reacted')} + +
+
+ +
+ ); +}; + +type ReplyProps = { + i18n: LocalizerType; + reply: ReplyType; + deleteGroupStoryReply: (replyId: string) => void; + deleteGroupStoryReplyForEveryone: (replyId: string) => void; + getPreferredBadge: PreferredBadgeSelectorType; + shouldCollapseAbove: boolean; + shouldCollapseBelow: boolean; + containerElementRef: React.RefObject; +}; + +const Reply = ({ + i18n, + reply, + deleteGroupStoryReply, + deleteGroupStoryReplyForEveryone, + getPreferredBadge, + shouldCollapseAbove, + shouldCollapseBelow, + containerElementRef, +}: ReplyProps): JSX.Element => { + const renderMessage = (onContextMenu?: (ev: React.MouseEvent) => void) => ( +
+ +
+ ); + + return reply.author.isMe ? ( + deleteGroupStoryReply(reply.id), + }, + { + icon: 'module-message__context--icon module-message__context__delete-message-for-everyone', + label: i18n('icu:StoryViewsNRepliesModal__delete-reply-for-everyone'), + onClick: () => deleteGroupStoryReplyForEveryone(reply.id), + }, + ]} + > + {({ openMenu, menuNode }) => ( + <> + {renderMessage(openMenu)} + {menuNode} + + )} + + ) : ( + renderMessage() ); }; diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 99ecc6bbaaeb..c2595ffb4063 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -3,11 +3,10 @@ import type { ReactNode, RefObject } from 'react'; import React from 'react'; -import ReactDOM, { createPortal } from 'react-dom'; +import { createPortal } from 'react-dom'; import classNames from 'classnames'; import getDirection from 'direction'; import { drop, groupBy, orderBy, take, unescape } from 'lodash'; -import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; import { Manager, Popper, Reference } from 'react-popper'; import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow'; @@ -17,15 +16,11 @@ import type { InteractionModeType, } from '../../state/ducks/conversations'; import type { ViewStoryActionCreatorType } from '../../state/ducks/stories'; -import type { TimelineItemType } from './TimelineItem'; import type { ReadStatus } from '../../messages/MessageReadStatus'; import { Avatar, AvatarSize } from '../Avatar'; import { AvatarSpacer } from '../AvatarSpacer'; import { Spinner } from '../Spinner'; -import { - doesMessageBodyOverflow, - MessageBodyReadMore, -} from './MessageBodyReadMore'; +import { MessageBodyReadMore } from './MessageBodyReadMore'; import { MessageMetadata } from './MessageMetadata'; import { MessageTextMetadataSpacer } from './MessageTextMetadataSpacer'; import { ImageGrid } from './ImageGrid'; @@ -37,16 +32,14 @@ import { Quote } from './Quote'; import { EmbeddedContact } from './EmbeddedContact'; import type { OwnProps as ReactionViewerProps } from './ReactionViewer'; import { ReactionViewer } from './ReactionViewer'; -import type { Props as ReactionPickerProps } from './ReactionPicker'; import { Emoji } from '../emoji/Emoji'; import { LinkPreviewDate } from './LinkPreviewDate'; import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage'; -import { WidthBreakpoint } from '../_util'; +import type { WidthBreakpoint } from '../_util'; import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal'; import * as log from '../../logging/log'; import { StoryViewModeType } from '../../types/Stories'; - import type { AttachmentType } from '../../types/Attachment'; import { canDisplayImage, @@ -84,21 +77,13 @@ import type { import { createRefMerger } from '../../util/refMerger'; import { emojiToData, getEmojiCount } from '../emoji/lib'; import { isEmojiOnlyText } from '../../util/isEmojiOnlyText'; -import type { SmartReactionPicker } from '../../state/smart/ReactionPicker'; import { getCustomColorStyle } from '../../util/getCustomColorStyle'; -import { offsetDistanceModifier } from '../../util/popperUtil'; -import * as KeyboardLayout from '../../services/keyboardLayout'; -import { StopPropagation } from '../StopPropagation'; import type { UUIDStringType } from '../../types/UUID'; import { DAY, HOUR, MINUTE, SECOND } from '../../util/durations'; import { BadgeImageTheme } from '../../badges/BadgeImageTheme'; import { getBadgeImageFileLocalPath } from '../../badges/getBadgeImageFileLocalPath'; import { handleOutsideClick } from '../../util/handleOutsideClick'; -type Trigger = { - handleContextClick: (event: React.MouseEvent) => void; -}; - const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10; const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18; const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record = { @@ -270,37 +255,30 @@ export type PropsData = { expirationTimestamp?: number; reactions?: ReactionViewerProps['reactions']; - selectedReaction?: string; deletedForEveryone?: boolean; - canRetry: boolean; - canRetryDeleteForEveryone: boolean; - canReact: boolean; - canReply: boolean; - canDownload: boolean; canDeleteForEveryone: boolean; isBlocked: boolean; isMessageRequestAccepted: boolean; bodyRanges?: BodyRangesType; + + menu: JSX.Element | undefined; + onKeyDown?: (event: React.KeyboardEvent) => void; }; export type PropsHousekeeping = { containerElementRef: RefObject; containerWidthBreakpoint: WidthBreakpoint; - disableMenu?: boolean; disableScroll?: boolean; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; interactionMode: InteractionModeType; - item?: TimelineItemType; renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element; - renderReactionPicker: ( - props: React.ComponentProps - ) => JSX.Element; shouldCollapseAbove: boolean; shouldCollapseBelow: boolean; shouldHideMetadata: boolean; + onContextMenu?: (event: React.MouseEvent) => void; theme: ThemeType; }; @@ -310,16 +288,6 @@ export type PropsActions = { messageExpanded: (id: string, displayLimit: number) => unknown; checkForAccount: (phoneNumber: string) => unknown; - reactToMessage: ( - id: string, - { emoji, remove }: { emoji: string; remove: boolean } - ) => void; - replyToMessage: (id: string) => void; - retryDeleteForEveryone: (id: string) => void; - retrySend: (id: string) => void; - showForwardMessageModal: (id: string) => void; - deleteMessage: (id: string) => void; - deleteMessageForEveryone: (id: string) => void; showMessageDetail: (id: string) => void; startConversation: (e164: string, uuid: UUIDStringType) => void; @@ -366,10 +334,7 @@ export type PropsActions = { viewStory: ViewStoryActionCreatorType; }; -export type Props = PropsData & - PropsHousekeeping & - PropsActions & - Pick; +export type Props = PropsData & PropsHousekeeping & PropsActions; type State = { metadataWidth: number; @@ -383,8 +348,6 @@ type State = { reactionViewerRoot: HTMLDivElement | null; reactionViewerOutsideClickDestructor?: () => void; - reactionPickerRoot: HTMLDivElement | null; - reactionPickerOutsideClickDestructor?: () => void; giftBadgeCounter: number | null; showOutgoingGiftBadgeModal: boolean; @@ -393,8 +356,6 @@ type State = { }; export class Message extends React.PureComponent { - public menuTriggerRef: Trigger | undefined; - public focusRef: React.RefObject = React.createRef(); public audioButtonRef: React.RefObject = React.createRef(); @@ -428,7 +389,6 @@ export class Message extends React.PureComponent { prevSelectedCounter: props.isSelectedCounter, reactionViewerRoot: null, - reactionPickerRoot: null, giftBadgeCounter: null, showOutgoingGiftBadgeModal: false, @@ -466,27 +426,14 @@ export class Message extends React.PureComponent { return Boolean(reactions && reactions.length); } - public captureMenuTrigger = (triggerRef: Trigger): void => { - this.menuTriggerRef = triggerRef; - }; + public handleFocus = (): void => { + const { interactionMode, isSelected } = this.props; - public showMenu = (event: React.MouseEvent): void => { - if (this.menuTriggerRef) { - this.menuTriggerRef.handleContextClick(event); + if (interactionMode === 'keyboard' && !isSelected) { + this.setSelected(); } }; - public showContextMenu = (event: React.MouseEvent): void => { - const selection = window.getSelection(); - if (selection && !selection.isCollapsed) { - return; - } - if (event.target instanceof HTMLAnchorElement) { - return; - } - this.showMenu(event); - }; - public handleImageError = (): void => { const { id } = this.props; log.info( @@ -497,14 +444,6 @@ export class Message extends React.PureComponent { }); }; - public handleFocus = (): void => { - const { interactionMode, isSelected } = this.props; - - if (interactionMode === 'keyboard' && !isSelected) { - this.setSelected(); - } - }; - public setSelected = (): void => { const { id, conversationId, selectMessage } = this.props; @@ -559,7 +498,6 @@ export class Message extends React.PureComponent { clearTimeoutIfNecessary(this.deleteForEveryoneTimeout); clearTimeoutIfNecessary(this.giftBadgeInterval); this.toggleReactionViewer(true); - this.toggleReactionPicker(true); } public override componentDidUpdate(prevProps: Readonly): void { @@ -711,12 +649,6 @@ export class Message extends React.PureComponent { return Math.max(timestamp - Date.now() + THREE_HOURS, 0); } - private canDeleteForEveryone(): boolean { - const { canDeleteForEveryone } = this.props; - const { hasDeleteForEveryoneTimerExpired } = this.state; - return canDeleteForEveryone && !hasDeleteForEveryoneTimerExpired; - } - private startDeleteForEveryoneTimerIfApplicable(): void { const { canDeleteForEveryone } = this.props; const { hasDeleteForEveryoneTimerExpired } = this.state; @@ -917,7 +849,6 @@ export class Message extends React.PureComponent { theme, timestamp, } = this.props; - const { imageBroken } = this.state; const collapseMetadata = @@ -1814,382 +1745,6 @@ export class Message extends React.PureComponent { ); } - private renderMenu(triggerId: string): ReactNode { - const { - attachments, - canDownload, - canReact, - canReply, - direction, - disableMenu, - i18n, - id, - isSticker, - isTapToView, - reactToMessage, - renderEmojiPicker, - renderReactionPicker, - replyToMessage, - selectedReaction, - } = this.props; - - if (disableMenu) { - return null; - } - - const { reactionPickerRoot } = this.state; - - const multipleAttachments = attachments && attachments.length > 1; - const firstAttachment = attachments && attachments[0]; - - const downloadButton = - !isSticker && - !multipleAttachments && - !isTapToView && - firstAttachment && - !firstAttachment.pending ? ( - // This a menu meant for mouse use only - // eslint-disable-next-line max-len - // eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events -
- ) : null; - - const reactButton = ( - - {({ ref: popperRef }) => { - // Only attach the popper reference to the reaction button if it is - // visible (it is hidden when the timeline is narrow) - const maybePopperRef = this.isWindowWidthNotNarrow() - ? popperRef - : undefined; - - return ( - // This a menu meant for mouse use only - // eslint-disable-next-line max-len - // eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events -
{ - event.stopPropagation(); - event.preventDefault(); - - this.toggleReactionPicker(); - }} - role="button" - className="module-message__buttons__react" - aria-label={i18n('reactToMessage')} - /> - ); - }} - - ); - - const replyButton = ( - // This a menu meant for mouse use only - // eslint-disable-next-line max-len - // eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events -
{ - event.stopPropagation(); - event.preventDefault(); - - replyToMessage(id); - }} - // This a menu meant for mouse use only - role="button" - aria-label={i18n('replyToMessage')} - className={classNames( - 'module-message__buttons__reply', - `module-message__buttons__download--${direction}` - )} - /> - ); - - // This a menu meant for mouse use only - /* eslint-disable jsx-a11y/interactive-supports-focus */ - /* eslint-disable jsx-a11y/click-events-have-key-events */ - const menuButton = ( - - {({ ref: popperRef }) => { - // Only attach the popper reference to the collapsed menu button if the reaction - // button is not visible (it is hidden when the timeline is narrow) - const maybePopperRef = !this.isWindowWidthNotNarrow() - ? popperRef - : undefined; - - return ( - - -
- - - ); - }} - - ); - /* eslint-enable jsx-a11y/interactive-supports-focus */ - /* eslint-enable jsx-a11y/click-events-have-key-events */ - - return ( - -
- {this.isWindowWidthNotNarrow() && ( - <> - {canReact ? reactButton : null} - {canDownload ? downloadButton : null} - {canReply ? replyButton : null} - - )} - {menuButton} -
- {reactionPickerRoot && - createPortal( - - {({ ref, style }) => - renderReactionPicker({ - ref, - style, - selected: selectedReaction, - onClose: this.toggleReactionPicker, - onPick: emoji => { - this.toggleReactionPicker(true); - reactToMessage(id, { - emoji, - remove: emoji === selectedReaction, - }); - }, - renderEmojiPicker, - }) - } - , - reactionPickerRoot - )} -
- ); - } - - public renderContextMenu(triggerId: string): JSX.Element { - const { - attachments, - canDownload, - contact, - canReact, - canReply, - canRetry, - canRetryDeleteForEveryone, - deleteMessage, - deleteMessageForEveryone, - deletedForEveryone, - giftBadge, - i18n, - id, - isSticker, - isTapToView, - replyToMessage, - retrySend, - retryDeleteForEveryone, - showForwardMessageModal, - showMessageDetail, - text, - } = this.props; - - const canForward = - !isTapToView && !deletedForEveryone && !giftBadge && !contact; - const multipleAttachments = attachments && attachments.length > 1; - - const shouldShowAdditional = - doesMessageBodyOverflow(text || '') || !this.isWindowWidthNotNarrow(); - - const menu = ( - - {canDownload && - shouldShowAdditional && - !isSticker && - !multipleAttachments && - !isTapToView && - attachments && - attachments[0] ? ( - - {i18n('downloadAttachment')} - - ) : null} - {shouldShowAdditional ? ( - <> - {canReply && ( - { - event.stopPropagation(); - event.preventDefault(); - - replyToMessage(id); - }} - > - {i18n('replyToMessage')} - - )} - {canReact && ( - { - event.stopPropagation(); - event.preventDefault(); - - this.toggleReactionPicker(); - }} - > - {i18n('reactToMessage')} - - )} - - ) : null} - { - event.stopPropagation(); - event.preventDefault(); - - showMessageDetail(id); - }} - > - {i18n('moreInfo')} - - {canRetry ? ( - { - event.stopPropagation(); - event.preventDefault(); - - retrySend(id); - }} - > - {i18n('retrySend')} - - ) : null} - {canRetryDeleteForEveryone ? ( - { - event.stopPropagation(); - event.preventDefault(); - - retryDeleteForEveryone(id); - }} - > - {i18n('retryDeleteForEveryone')} - - ) : null} - {canForward ? ( - { - event.stopPropagation(); - event.preventDefault(); - - showForwardMessageModal(id); - }} - > - {i18n('forwardMessage')} - - ) : null} - { - event.stopPropagation(); - event.preventDefault(); - - deleteMessage(id); - }} - > - {i18n('deleteMessage')} - - {this.canDeleteForEveryone() ? ( - { - event.stopPropagation(); - event.preventDefault(); - - deleteMessageForEveryone(id); - }} - > - {i18n('deleteMessageForEveryone')} - - ) : null} - - ); - - return ReactDOM.createPortal(menu, document.body); - } - - private isWindowWidthNotNarrow(): boolean { - const { containerWidthBreakpoint } = this.props; - return containerWidthBreakpoint !== WidthBreakpoint.Narrow; - } - public getWidth(): number | undefined { const { attachments, giftBadge, isSticker, previews } = this.props; @@ -2420,42 +1975,6 @@ export class Message extends React.PureComponent { }); }; - public toggleReactionPicker = (onlyRemove = false): void => { - this.setState(oldState => { - const { reactionPickerRoot } = oldState; - if (reactionPickerRoot) { - document.body.removeChild(reactionPickerRoot); - - oldState.reactionPickerOutsideClickDestructor?.(); - - return { - reactionPickerRoot: null, - reactionPickerOutsideClickDestructor: undefined, - }; - } - - if (!onlyRemove) { - const root = document.createElement('div'); - document.body.appendChild(root); - - const reactionPickerOutsideClickDestructor = handleOutsideClick( - () => { - this.toggleReactionPicker(true); - return true; - }, - { containerElements: [root], name: 'Message.reactionPicker' } - ); - - return { - reactionPickerRoot: root, - reactionPickerOutsideClickDestructor, - }; - } - - return null; - }); - }; - public renderReactions(outgoing: boolean): JSX.Element | null { const { getPreferredBadge, reactions = [], i18n, theme } = this.props; @@ -2844,28 +2363,6 @@ export class Message extends React.PureComponent { }); }; - public handleKeyDown = (event: React.KeyboardEvent): void => { - // Do not allow reactions to error messages - const { canReact } = this.props; - - const key = KeyboardLayout.lookup(event.nativeEvent); - - if ( - (key === 'E' || key === 'e') && - (event.metaKey || event.ctrlKey) && - event.shiftKey && - canReact - ) { - this.toggleReactionPicker(); - } - - if (event.key !== 'Enter' && event.key !== 'Space') { - return; - } - - this.handleOpen(event); - }; - public handleClick = (event: React.MouseEvent): void => { // We don't want clicks on body text to result in the 'default action' for the message const { text } = this.props; @@ -2888,6 +2385,8 @@ export class Message extends React.PureComponent { isTapToView, isTapToViewExpired, isTapToViewError, + onContextMenu, + onKeyDown, text, } = this.props; const { isSelected } = this.state; @@ -2947,9 +2446,9 @@ export class Message extends React.PureComponent {
@@ -2963,20 +2462,15 @@ export class Message extends React.PureComponent { public override render(): JSX.Element | null { const { - author, attachments, direction, - id, isSticker, shouldCollapseAbove, shouldCollapseBelow, - timestamp, + menu, + onKeyDown, } = this.props; - const { expired, expiring, imageBroken, isSelected } = this.state; - - // This id is what connects our triple-dot click with our associated pop-up menu. - // It needs to be unique. - const triggerId = String(id || `${author.id}-${timestamp}`); + const { expired, expiring, isSelected, imageBroken } = this.state; if (expired) { return null; @@ -3000,15 +2494,14 @@ export class Message extends React.PureComponent { // 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={this.handleKeyDown} + onKeyDown={onKeyDown} onFocus={this.handleFocus} ref={this.focusRef} > {this.renderError()} {this.renderAvatar()} {this.renderContainer()} - {this.renderMenu(triggerId)} - {this.renderContextMenu(triggerId)} + {menu}
); } diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx index 81120d6e3522..e2a35aead9ec 100644 --- a/ts/components/conversation/MessageAudio.tsx +++ b/ts/components/conversation/MessageAudio.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import React, { useCallback, useRef, useEffect, useState } from 'react'; +import type { RefObject, ReactNode } from 'react'; import classNames from 'classnames'; import { noop } from 'lodash'; import { animated, useSpring } from '@react-spring/web'; @@ -18,6 +19,7 @@ import type { ActiveAudioPlayerStateType } from '../../state/ducks/audioPlayer'; export type OwnProps = Readonly<{ active: ActiveAudioPlayerStateType | undefined; + buttonRef: RefObject; renderingContext: string; i18n: LocalizerType; attachment: AttachmentType; @@ -66,6 +68,7 @@ type ButtonProps = { onClick: () => void; onMouseDown?: () => void; onMouseUp?: () => void; + children?: ReactNode; }; enum State { @@ -122,73 +125,77 @@ const timeToText = (time: number): string => { * Handles animations, key events, and stoping event propagation * for play button and playback rate button */ -const Button: React.FC = props => { - const { - i18n, - variant, - mod, - label, - children, - onClick, - visible = true, - animateClick = true, - } = props; - const [isDown, setIsDown] = useState(false); +const Button = React.forwardRef( + (props, ref) => { + const { + i18n, + variant, + mod, + label, + children, + onClick, + visible = true, + animateClick = true, + } = props; + const [isDown, setIsDown] = useState(false); - const [animProps] = useSpring( - { - config: SPRING_CONFIG, - to: isDown && animateClick ? { scale: 1.3 } : { scale: visible ? 1 : 0 }, - }, - [visible, isDown, animateClick] - ); + const [animProps] = useSpring( + { + config: SPRING_CONFIG, + to: + isDown && animateClick ? { scale: 1.3 } : { scale: visible ? 1 : 0 }, + }, + [visible, isDown, animateClick] + ); - // Clicking button toggle playback - const onButtonClick = useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); + // Clicking button toggle playback + const onButtonClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); - onClick(); - }, - [onClick] - ); + onClick(); + }, + [onClick] + ); - // Keyboard playback toggle - const onButtonKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== 'Enter' && event.key !== 'Space') { - return; - } - event.stopPropagation(); - event.preventDefault(); + // Keyboard playback toggle + const onButtonKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== 'Space') { + return; + } + event.stopPropagation(); + event.preventDefault(); - onClick(); - }, - [onClick] - ); + onClick(); + }, + [onClick] + ); - return ( - - - - ); -}; + return ( + + + + ); + } +); const PlayedDot = ({ played, @@ -242,6 +249,7 @@ const PlayedDot = ({ export const MessageAudio: React.FC = (props: Props) => { const { active, + buttonRef, i18n, renderingContext, attachment, @@ -506,6 +514,7 @@ export const MessageAudio: React.FC = (props: Props) => { } else if (state === State.NotDownloaded) { button = (