diff --git a/src/components/middle/message/Message.scss b/src/components/middle/message/Message.scss index f32007dc7..5abc43dd2 100644 --- a/src/components/middle/message/Message.scss +++ b/src/components/middle/message/Message.scss @@ -20,6 +20,10 @@ --select-message-scale: 0.9; --select-background-color: white; + &.is-swiped { + transform: translateX(-2.5rem) !important; + } + > .Avatar, > .message-content-wrapper { opacity: 1; diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 7d7242291..534d5e89e 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -16,11 +16,8 @@ import { ApiUser, ApiChat, ApiSticker, - MAIN_THREAD_ID, } from '../../../api/types'; -import { - FocusDirection, IAlbum, ISettings, MediaViewerOrigin, -} from '../../../types'; +import { FocusDirection, IAlbum, ISettings } from '../../../types'; import { IS_ANDROID, IS_TOUCH_ENV } from '../../../util/environment'; import { pick } from '../../../util/iteratees'; @@ -59,7 +56,6 @@ import { getUserColorKey, } from '../../../modules/helpers'; import buildClassName from '../../../util/buildClassName'; -import windowSize from '../../../util/windowSize'; import useEnsureMessage from '../../../hooks/useEnsureMessage'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import { renderMessageText } from '../../common/helpers/renderMessageText'; @@ -67,14 +63,15 @@ import { ROUND_VIDEO_DIMENSIONS } from '../../common/helpers/mediaDimensions'; import { buildContentClassName, isEmojiOnlyMessage } from './helpers/buildContentClassName'; import { getMinMediaWidth, calculateMediaDimensions } from './helpers/mediaDimensions'; import { calculateAlbumLayout } from './helpers/calculateAlbumLayout'; -import { preventMessageInputBlur } from '../helpers/preventMessageInputBlur'; import renderText from '../../common/helpers/renderText'; import calculateAuthorWidth from './helpers/calculateAuthorWidth'; import { ObserveFn, useOnIntersect } from '../../../hooks/useIntersectionObserver'; -import useFocusMessage from './hooks/useFocusMessage'; import useLang from '../../../hooks/useLang'; import useShowTransition from '../../../hooks/useShowTransition'; import useFlag from '../../../hooks/useFlag'; +import useFocusMessage from './hooks/useFocusMessage'; +import useOuterHandlers from './hooks/useOuterHandlers'; +import useInnerHandlers from './hooks/useInnerHandlers'; import Button from '../../ui/Button'; import Avatar from '../../common/Avatar'; @@ -157,13 +154,7 @@ type StateProps = { shouldLoopStickers?: boolean; }; -type DispatchProps = Pick; +type DispatchProps = Pick; const NBSP = '\u00A0'; const GROUP_MESSAGE_HOVER_ATTRIBUTE = 'data-is-document-group-hover'; @@ -173,7 +164,6 @@ const APPENDIX_OWN = ''; const APPEARANCE_DELAY = 10; const NO_MEDIA_CORNERS_THRESHOLD = 18; -const ANDROID_KEYBOARD_HIDE_DELAY_MS = 350; const Message: FC = ({ message, @@ -222,20 +212,9 @@ const Message: FC = ({ shouldAutoLoadMedia, shouldAutoPlayMedia, shouldLoopStickers, - focusMessage, - openMediaViewer, - openAudioPlayer, - openUserInfo, - openChat, - cancelSendingMessage, - markMessagesRead, - sendPollVote, toggleMessageSelection, - setReplyingToId, - openForwardMenu, clickInlineButton, disableContextMenuHint, - showNotification, }) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); @@ -270,7 +249,7 @@ const Message: FC = ({ }, [appearanceOrder, markShown, noAppearanceAnimation]); const { transitionClassNames } = useShowTransition(isShown, undefined, noAppearanceAnimation, false); - const { chatId, id: messageId, threadInfo } = message; + const { id: messageId, chatId, threadInfo } = message; const isLocal = isMessageLocal(message); const isOwn = isOwnMessage(message); @@ -279,7 +258,7 @@ const Message: FC = ({ const hasThread = Boolean(threadInfo) && messageListType === 'thread'; const { forwardInfo, viaBotId } = message; const asForwarded = forwardInfo && !isChatWithSelf && !forwardInfo.isLinkedChannelPost; - const isInDocumentGroup = !!message.groupedId && !message.isInAlbum; + const isInDocumentGroup = Boolean(message.groupedId) && !message.isInAlbum; const isAlbum = Boolean(album) && album!.messages.length > 1; const { text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, @@ -303,6 +282,68 @@ const Message: FC = ({ const avatarPeer = forwardInfo && (isChatWithSelf || !sender) ? originSender : sender; const senderPeer = forwardInfo ? originSender : sender; + const selectMessage = useCallback((e?: React.MouseEvent, groupedId?: string) => { + if (isLocal) { + return; + } + + toggleMessageSelection({ + messageId, + groupedId, + ...(e && e.shiftKey && { withShift: true }), + ...(isAlbum && { childMessageIds: album!.messages.map(({ id }) => id) }), + }); + }, [isLocal, toggleMessageSelection, messageId, isAlbum, album]); + + const { + handleMouseDown, + handleClick, + handleContextMenu, + handleDoubleClick, + handleContentDoubleClick, + isSwiped, + } = useOuterHandlers( + selectMessage, + ref, + messageId, + isLocal, + isAlbum, + Boolean(isInSelectMode), + onContextMenu, + handleBeforeContextMenu, + ); + + const { + handleAvatarClick, + handleSenderClick, + handleViaBotClick, + handleReplyClick, + handleMediaClick, + handleAudioPlay, + handleAlbumMediaClick, + handleMetaClick, + handleReadMedia, + handleCancelUpload, + handleVoteSend, + handleGroupForward, + handleForward, + handleFocus, + handleFocusForwarded, + handleDocumentGroupSelectAll, + } = useInnerHandlers( + lang, + selectMessage, + message, + chatId, + threadId, + isInDocumentGroup, + Boolean(isScheduled), + album, + avatarPeer, + senderPeer, + botSender, + ); + const containerClassName = buildClassName( 'Message message-list-item', isFirstInGroup && 'first-in-group', @@ -325,6 +366,7 @@ const Message: FC = ({ isInSelectMode && 'is-in-selection-mode', isThreadTop && 'is-thread-top', Boolean(message.inlineButtons) && 'has-inline-buttons', + isSwiped && 'is-swiped', transitionClassNames, ); const contentClassName = buildContentClassName(message, { @@ -351,175 +393,6 @@ const Message: FC = ({ appendixRef.current.innerHTML = isOwn ? APPENDIX_OWN : APPENDIX_NOT_OWN; }, [isOwn, withAppendix]); - const handleGroupDocumentMessagesSelect = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - - toggleMessageSelection({ - messageId, - groupedId: message.groupedId, - }); - }, [messageId, message.groupedId, toggleMessageSelection]); - - const handleMessageSelect = useCallback((e?: React.MouseEvent) => { - if (isLocal) { - return; - } - - const params = isAlbum && album && album.messages - ? { - messageId, - childMessageIds: album.messages.map(({ id }) => id), - withShift: e && e.shiftKey, - } - : { messageId, withShift: e && e.shiftKey }; - toggleMessageSelection(params); - }, [isLocal, isAlbum, album, messageId, toggleMessageSelection]); - - const handleContainerDoubleClick = useCallback(() => { - setReplyingToId({ messageId }); - }, [setReplyingToId, messageId]); - - const handleContentDoubleClick = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - }, []); - - const handleMouseDown = (e: React.MouseEvent) => { - preventMessageInputBlur(e); - - if (!isLocal) { - handleBeforeContextMenu(e); - } - }; - - const handleAvatarClick = useCallback(() => { - if (!avatarPeer) { - return; - } - - if (isChatPrivate(avatarPeer.id)) { - openUserInfo({ id: avatarPeer.id }); - } else { - openChat({ id: avatarPeer.id }); - } - }, [avatarPeer, openUserInfo, openChat]); - - const handleSenderClick = useCallback(() => { - if (!senderPeer) { - showNotification({ message: lang('HidAccount') }); - - return; - } - - if (isChatPrivate(senderPeer.id)) { - openUserInfo({ id: senderPeer.id }); - } else { - openChat({ id: senderPeer.id }); - } - }, [senderPeer, showNotification, lang, openUserInfo, openChat]); - - const handleViaBotClick = useCallback(() => { - if (!botSender) { - return; - } - - openUserInfo({ id: botSender.id }); - }, [botSender, openUserInfo]); - - const handleReplyClick = useCallback((): void => { - focusMessage({ - chatId, threadId, messageId: message.replyToMessageId, replyMessageId: messageId, - }); - }, [focusMessage, chatId, threadId, message.replyToMessageId, messageId]); - - const handleMediaClick = useCallback((): void => { - openMediaViewer({ - chatId, threadId, messageId, origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline, - }); - }, [chatId, threadId, messageId, openMediaViewer, isScheduled]); - - const handleAudioPlay = useCallback((): void => { - openAudioPlayer({ chatId, messageId }); - }, [chatId, messageId, openAudioPlayer]); - - const handleAlbumMediaClick = useCallback((albumMessageId: number): void => { - openMediaViewer({ - chatId, - threadId, - messageId: albumMessageId, - origin: isScheduled ? MediaViewerOrigin.ScheduledAlbum : MediaViewerOrigin.Album, - }); - }, [chatId, threadId, openMediaViewer, isScheduled]); - - const handleClick = useCallback((e: React.MouseEvent) => { - const target = e.target as HTMLDivElement; - if (!target.classList.contains('text-content') && !target.classList.contains('Message')) { - return; - } - - if (IS_ANDROID) { - if (windowSize.getIsKeyboardVisible()) { - setTimeout(() => { - onContextMenu(e); - }, ANDROID_KEYBOARD_HIDE_DELAY_MS); - } else { - onContextMenu(e); - } - } else { - onContextMenu(e); - } - }, [onContextMenu]); - - const handleContextMenu = useCallback((e: React.MouseEvent) => { - if (IS_ANDROID) { - handleMessageSelect(e); - } else { - onContextMenu(e); - } - }, [onContextMenu, handleMessageSelect]); - - const handleReadMedia = useCallback((): void => { - markMessagesRead({ messageIds: [messageId] }); - }, [messageId, markMessagesRead]); - - const handleCancelUpload = useCallback(() => { - cancelSendingMessage({ chatId, messageId }); - }, [cancelSendingMessage, chatId, messageId]); - - const handleVoteSend = useCallback((options: string[]) => { - sendPollVote({ chatId, messageId, options }); - }, [chatId, messageId, sendPollVote]); - - const handleGroupForward = useCallback(() => { - openForwardMenu({ fromChatId: chatId, groupedId: message.groupedId }); - }, [openForwardMenu, chatId, message.groupedId]); - - const handleForward = useCallback(() => { - if (album && album.messages) { - const messageIds = album.messages.map(({ id }) => id); - openForwardMenu({ fromChatId: chatId, messageIds }); - } else { - openForwardMenu({ fromChatId: chatId, messageIds: [messageId] }); - } - }, [album, openForwardMenu, chatId, messageId]); - - const handleFocus = useCallback(() => { - focusMessage({ - chatId, threadId: MAIN_THREAD_ID, messageId, - }); - }, [focusMessage, chatId, messageId]); - - const handleFocusForwarded = useCallback(() => { - if (isInDocumentGroup) { - focusMessage({ - chatId: forwardInfo!.fromChatId, groupedId: message.groupedId, groupedChatId: chatId, - }); - return; - } - focusMessage({ - chatId: forwardInfo!.fromChatId, messageId: forwardInfo!.fromMessageId, - }); - }, [focusMessage, forwardInfo, message, chatId, isInDocumentGroup]); - let style = ''; let calculatedWidth; let noMediaCorners = false; @@ -704,7 +577,7 @@ const Message: FC = ({ message={message} outgoingStatus={outgoingStatus} signature={signature} - onClick={handleMessageSelect} + onClick={handleMetaClick} /> )}

@@ -788,10 +661,10 @@ const Message: FC = ({ // @ts-ignore teact feature style={metaSafeAuthorWidth ? `--meta-safe-author-width: ${metaSafeAuthorWidth}px` : undefined} data-message-id={messageId} - onClick={isInSelectMode ? handleMessageSelect : IS_ANDROID ? handleClick : undefined} - onDoubleClick={!isInSelectMode ? handleContainerDoubleClick : undefined} - onMouseDown={!isInSelectMode ? handleMouseDown : undefined} - onContextMenu={!isInSelectMode && !isLocal ? handleContextMenu : undefined} + onMouseDown={handleMouseDown} + onClick={handleClick} + onContextMenu={handleContextMenu} + onDoubleClick={handleDoubleClick} onMouseEnter={isInDocumentGroup && !isLastInDocumentGroup ? handleDocumentGroupMouseEnter : undefined} onMouseLeave={isInDocumentGroup && !isLastInDocumentGroup ? handleDocumentGroupMouseLeave : undefined} > @@ -810,7 +683,7 @@ const Message: FC = ({ {!isLocal && isLastInDocumentGroup && (
{isGroupSelected && ( @@ -820,7 +693,6 @@ const Message: FC = ({ {withAvatar && renderAvatar()}
= ({ message={message} outgoingStatus={outgoingStatus} signature={signature} - onClick={handleMessageSelect} + onClick={handleMetaClick} /> )} {canShowActionButton && canForward ? ( @@ -999,19 +871,8 @@ export default memo(withGlobal( }; }, (setGlobal, actions): DispatchProps => pick(actions, [ - 'focusMessage', - 'openMediaViewer', - 'openAudioPlayer', - 'cancelSendingMessage', - 'openUserInfo', - 'openChat', - 'markMessagesRead', - 'sendPollVote', 'toggleMessageSelection', - 'setReplyingToId', - 'openForwardMenu', 'clickInlineButton', 'disableContextMenuHint', - 'showNotification', ]), )(Message)); diff --git a/src/components/middle/message/MessageMeta.tsx b/src/components/middle/message/MessageMeta.tsx index 9fad4aca0..f8375cdc7 100644 --- a/src/components/middle/message/MessageMeta.tsx +++ b/src/components/middle/message/MessageMeta.tsx @@ -15,7 +15,7 @@ type OwnProps = { message: ApiMessage; outgoingStatus?: ApiMessageOutgoingStatus; signature?: string; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; }; const MessageMeta: FC = ({ diff --git a/src/components/middle/message/hooks/useInnerHandlers.ts b/src/components/middle/message/hooks/useInnerHandlers.ts new file mode 100644 index 000000000..510e31b0f --- /dev/null +++ b/src/components/middle/message/hooks/useInnerHandlers.ts @@ -0,0 +1,159 @@ +import React, { useCallback } from '../../../../lib/teact/teact'; +import { getDispatch } from '../../../../lib/teact/teactn'; + +import { isChatPrivate } from '../../../../modules/helpers'; +import { IAlbum, MediaViewerOrigin } from '../../../../types'; +import { + ApiChat, ApiMessage, ApiUser, MAIN_THREAD_ID, +} from '../../../../api/types'; +import { LangFn } from '../../../../hooks/useLang'; + +export default function useInnerHandlers( + lang: LangFn, + selectMessage: (e: React.MouseEvent, groupedId?: string) => void, + message: ApiMessage, + chatId: number, + threadId: number, + isInDocumentGroup: boolean, + isScheduled?: boolean, + album?: IAlbum, + avatarPeer?: ApiUser | ApiChat, + senderPeer?: ApiUser | ApiChat, + botSender?: ApiUser, +) { + const { + openUserInfo, openChat, showNotification, focusMessage, openMediaViewer, openAudioPlayer, + markMessagesRead, cancelSendingMessage, sendPollVote, openForwardMenu, + } = getDispatch(); + + const { + id: messageId, forwardInfo, replyToMessageId, groupedId, + } = message; + + const handleAvatarClick = useCallback(() => { + if (!avatarPeer) { + return; + } + + if (isChatPrivate(avatarPeer.id)) { + openUserInfo({ id: avatarPeer.id }); + } else { + openChat({ id: avatarPeer.id }); + } + }, [avatarPeer, openUserInfo, openChat]); + + const handleSenderClick = useCallback(() => { + if (!senderPeer) { + showNotification({ message: lang('HidAccount') }); + + return; + } + + if (isChatPrivate(senderPeer.id)) { + openUserInfo({ id: senderPeer.id }); + } else { + openChat({ id: senderPeer.id }); + } + }, [senderPeer, showNotification, lang, openUserInfo, openChat]); + + const handleViaBotClick = useCallback(() => { + if (!botSender) { + return; + } + + openUserInfo({ id: botSender.id }); + }, [botSender, openUserInfo]); + + const handleReplyClick = useCallback((): void => { + focusMessage({ + chatId, threadId, messageId: replyToMessageId, replyMessageId: messageId, + }); + }, [focusMessage, chatId, threadId, replyToMessageId, messageId]); + + const handleMediaClick = useCallback((): void => { + openMediaViewer({ + chatId, threadId, messageId, origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline, + }); + }, [chatId, threadId, messageId, openMediaViewer, isScheduled]); + + const handleAudioPlay = useCallback((): void => { + openAudioPlayer({ chatId, messageId }); + }, [chatId, messageId, openAudioPlayer]); + + const handleAlbumMediaClick = useCallback((albumMessageId: number): void => { + openMediaViewer({ + chatId, + threadId, + messageId: albumMessageId, + origin: isScheduled ? MediaViewerOrigin.ScheduledAlbum : MediaViewerOrigin.Album, + }); + }, [chatId, threadId, openMediaViewer, isScheduled]); + + const handleReadMedia = useCallback((): void => { + markMessagesRead({ messageIds: [messageId] }); + }, [messageId, markMessagesRead]); + + const handleCancelUpload = useCallback(() => { + cancelSendingMessage({ chatId, messageId }); + }, [cancelSendingMessage, chatId, messageId]); + + const handleVoteSend = useCallback((options: string[]) => { + sendPollVote({ chatId, messageId, options }); + }, [chatId, messageId, sendPollVote]); + + const handleGroupForward = useCallback(() => { + openForwardMenu({ fromChatId: chatId, groupedId }); + }, [openForwardMenu, chatId, groupedId]); + + const handleForward = useCallback(() => { + if (album && album.messages) { + const messageIds = album.messages.map(({ id }) => id); + openForwardMenu({ fromChatId: chatId, messageIds }); + } else { + openForwardMenu({ fromChatId: chatId, messageIds: [messageId] }); + } + }, [album, openForwardMenu, chatId, messageId]); + + const handleFocus = useCallback(() => { + focusMessage({ + chatId, threadId: MAIN_THREAD_ID, messageId, + }); + }, [focusMessage, chatId, messageId]); + + const handleFocusForwarded = useCallback(() => { + if (isInDocumentGroup) { + focusMessage({ + chatId: forwardInfo!.fromChatId, groupedId, groupedChatId: chatId, + }); + return; + } + focusMessage({ + chatId: forwardInfo!.fromChatId, messageId: forwardInfo!.fromMessageId, + }); + }, [isInDocumentGroup, focusMessage, forwardInfo, groupedId, chatId]); + + const selectWithGroupedId = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + + selectMessage(e, groupedId); + }, [selectMessage, groupedId]); + + return { + handleAvatarClick, + handleSenderClick, + handleViaBotClick, + handleReplyClick, + handleMediaClick, + handleAudioPlay, + handleAlbumMediaClick, + handleMetaClick: selectWithGroupedId, + handleReadMedia, + handleCancelUpload, + handleVoteSend, + handleGroupForward, + handleForward, + handleFocus, + handleFocusForwarded, + handleDocumentGroupSelectAll: selectWithGroupedId, + }; +} diff --git a/src/components/middle/message/hooks/useOuterHandlers.ts b/src/components/middle/message/hooks/useOuterHandlers.ts new file mode 100644 index 000000000..5cf61fd8b --- /dev/null +++ b/src/components/middle/message/hooks/useOuterHandlers.ts @@ -0,0 +1,112 @@ +import { RefObject } from 'react'; +import React, { useEffect } from '../../../../lib/teact/teact'; +import { getDispatch } from '../../../../lib/teact/teactn'; + +import { IS_ANDROID, IS_TOUCH_ENV } from '../../../../util/environment'; +import windowSize from '../../../../util/windowSize'; +import { captureEvents, SwipeDirection } from '../../../../util/captureEvents'; +import useFlag from '../../../../hooks/useFlag'; +import { preventMessageInputBlur } from '../../helpers/preventMessageInputBlur'; + +const ANDROID_KEYBOARD_HIDE_DELAY_MS = 350; +const SWIPE_ANIMATION_DURATION = 200; + +export default function useOuterHandlers( + selectMessage: (e?: React.MouseEvent, groupedId?: string) => void, + containerRef: RefObject, + messageId: number, + isLocal: boolean, + isAlbum: boolean, + isInSelectMode: boolean, + onContextMenu: (e: React.MouseEvent) => void, + handleBeforeContextMenu: (e: React.MouseEvent) => void, +) { + const { setReplyingToId } = getDispatch(); + + const [isSwiped, markSwiped, unmarkSwiped] = useFlag(); + + function handleMouseDown(e: React.MouseEvent) { + preventMessageInputBlur(e); + + if (!isLocal) { + handleBeforeContextMenu(e); + } + } + + function handleClick(e: React.MouseEvent) { + if (isInSelectMode && !isLocal) { + selectMessage(e); + } else if (IS_ANDROID) { + const target = e.target as HTMLDivElement; + if (!target.classList.contains('text-content') && !target.classList.contains('Message')) { + return; + } + + if (windowSize.getIsKeyboardVisible()) { + setTimeout(() => { + onContextMenu(e); + }, ANDROID_KEYBOARD_HIDE_DELAY_MS); + } else { + onContextMenu(e); + } + } + } + + function handleContextMenu(e: React.MouseEvent) { + if (IS_ANDROID) { + selectMessage(); + } else { + onContextMenu(e); + } + } + + function handleContainerDoubleClick() { + setReplyingToId({ messageId }); + } + + function stopPropagation(e: React.MouseEvent) { + e.stopPropagation(); + } + + useEffect(() => { + if (!IS_TOUCH_ENV || isInSelectMode) { + return undefined; + } + + let startedAt: number | undefined; + return captureEvents(containerRef.current!, { + onSwipe: ((e, direction) => { + if (direction === SwipeDirection.Left) { + if (!startedAt) { + startedAt = Date.now(); + } + + markSwiped(); + } else if (direction === SwipeDirection.Right) { + startedAt = undefined; + + unmarkSwiped(); + } + }), + onRelease: () => { + if (!startedAt) { + return; + } + + setReplyingToId({ messageId }); + + setTimeout(unmarkSwiped, Math.max(0, SWIPE_ANIMATION_DURATION - (Date.now() - startedAt))); + startedAt = undefined; + }, + }); + }, [containerRef, isInSelectMode, messageId, setReplyingToId, markSwiped, unmarkSwiped]); + + return { + handleMouseDown: !isInSelectMode ? handleMouseDown : undefined, + handleClick, + handleContextMenu: !isInSelectMode && !isLocal ? handleContextMenu : undefined, + handleDoubleClick: !isInSelectMode ? handleContainerDoubleClick : undefined, + handleContentDoubleClick: !IS_TOUCH_ENV ? stopPropagation : undefined, + isSwiped, + }; +} diff --git a/src/global/types.ts b/src/global/types.ts index 95acf0691..e410fe5e3 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -19,7 +19,6 @@ import { ApiSession, ApiNewPoll, ApiInviteInfo, - ApiFieldError, } from '../api/types'; import { FocusDirection,