From a09b488846425b920967a8a7d64e28a29957d6bc Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sat, 12 Jun 2021 17:20:22 +0300 Subject: [PATCH] Support more hotkeys (#1137) --- src/components/left/ArchivedChats.tsx | 5 +- src/components/left/LeftColumn.tsx | 33 +++++-- src/components/left/main/ChatFolders.tsx | 94 ++++++++++++++----- src/components/left/main/ChatList.tsx | 54 ++++++++++- src/components/left/main/LeftMain.tsx | 3 +- src/components/left/main/LeftMainHeader.tsx | 1 + src/components/left/search/ChatMessage.tsx | 4 + src/components/left/search/LeftSearch.tsx | 11 ++- .../left/search/LeftSearchResultChat.tsx | 14 ++- src/components/main/ForwardPicker.tsx | 12 +++ .../middle/composer/MessageInput.tsx | 17 +++- src/components/right/RightHeader.tsx | 1 + src/components/right/RightSearch.tsx | 15 ++- src/components/ui/InfiniteScroll.tsx | 10 +- src/components/ui/ListItem.tsx | 3 + src/components/ui/SearchInput.tsx | 14 ++- src/global/initial.ts | 1 + src/global/types.ts | 5 +- src/hooks/useKeyboardListNavigation.ts | 18 +++- src/hooks/useSelectWithEnter.ts | 25 +++++ src/modules/actions/api/chats.ts | 11 +++ src/modules/actions/ui/messages.ts | 44 ++++++++- 22 files changed, 342 insertions(+), 53 deletions(-) create mode 100644 src/hooks/useSelectWithEnter.ts diff --git a/src/components/left/ArchivedChats.tsx b/src/components/left/ArchivedChats.tsx index 009d9000d..dec5d208c 100644 --- a/src/components/left/ArchivedChats.tsx +++ b/src/components/left/ArchivedChats.tsx @@ -8,10 +8,11 @@ import ChatList from './main/ChatList'; import './ArchivedChats.scss'; export type OwnProps = { + isActive: boolean; onReset: () => void; }; -const ArchivedChats: FC = ({ onReset }) => { +const ArchivedChats: FC = ({ isActive, onReset }) => { const lang = useLang(); return ( @@ -28,7 +29,7 @@ const ArchivedChats: FC = ({ onReset }) => {

{lang('ArchivedChats')}

- + ); }; diff --git a/src/components/left/LeftColumn.tsx b/src/components/left/LeftColumn.tsx index 8348eacee..f585be440 100644 --- a/src/components/left/LeftColumn.tsx +++ b/src/components/left/LeftColumn.tsx @@ -21,6 +21,7 @@ import './LeftColumn.scss'; type StateProps = { searchQuery?: string; searchDate?: number; + activeChatFolder: number; }; type DispatchProps = Pick = ({ searchQuery, searchDate, + activeChatFolder, setGlobalSearchQuery, setGlobalSearchChatId, resetChatCreation, @@ -188,6 +190,11 @@ const LeftColumn: FC = ({ } } + if (content === LeftColumnContent.ChatList && activeChatFolder === 0) { + setContent(LeftColumnContent.GlobalSearch); + return; + } + setContent(LeftColumnContent.ChatList); setContactsFilter(''); setGlobalSearchQuery({ query: '' }); @@ -197,7 +204,10 @@ const LeftColumn: FC = ({ setTimeout(() => { setLastResetTime(Date.now()); }, RESET_TRANSITION_DELAY_MS); - }, [content, setGlobalSearchQuery, setGlobalSearchChatId, setGlobalSearchDate, resetChatCreation, settingsScreen]); + }, [ + content, activeChatFolder, setGlobalSearchQuery, setGlobalSearchDate, setGlobalSearchChatId, resetChatCreation, + settingsScreen, + ]); const handleSearchQuery = useCallback((query: string) => { if (content === LeftColumnContent.Contacts) { @@ -213,8 +223,10 @@ const LeftColumn: FC = ({ }, [content, setGlobalSearchQuery, searchQuery]); useEffect( - () => (content !== LeftColumnContent.ChatList ? captureEscKeyListener(() => handleReset()) : undefined), - [content, handleReset], + () => (content !== LeftColumnContent.ChatList || activeChatFolder === 0 + ? captureEscKeyListener(() => handleReset()) + : undefined), + [activeChatFolder, content, handleReset], ); useEffect(() => { @@ -232,11 +244,12 @@ const LeftColumn: FC = ({ renderCount={RENDER_COUNT} activeKey={contentType} > - {() => { + {(isActive) => { switch (contentType) { case ContentType.Archived: return ( ); @@ -287,8 +300,16 @@ const LeftColumn: FC = ({ export default memo(withGlobal( (global): StateProps => { - const { query, date } = global.globalSearch; - return { searchQuery: query, searchDate: date }; + const { + globalSearch: { + query, + date, + }, + chatFolders: { + activeChatFolder, + }, + } = global; + return { searchQuery: query, searchDate: date, activeChatFolder }; }, (setGlobal, actions): DispatchProps => pick(actions, [ 'setGlobalSearchQuery', 'setGlobalSearchChatId', 'resetChatCreation', 'setGlobalSearchDate', diff --git a/src/components/left/main/ChatFolders.tsx b/src/components/left/main/ChatFolders.tsx index 0a46ca111..58c2eda06 100644 --- a/src/components/left/main/ChatFolders.tsx +++ b/src/components/left/main/ChatFolders.tsx @@ -1,5 +1,5 @@ import React, { - FC, memo, useCallback, useEffect, useMemo, useRef, useState, + FC, memo, useCallback, useEffect, useMemo, useRef, } from '../../../lib/teact/teact'; import { withGlobal } from '../../../lib/teact/teactn'; @@ -29,12 +29,15 @@ type StateProps = { notifySettings: NotifySettings; notifyExceptions?: Record; orderedFolderIds?: number[]; + activeChatFolder: number; + currentUserId?: number; lastSyncTime?: number; }; -type DispatchProps = Pick; +type DispatchProps = Pick; const INFO_THROTTLE = 3000; +const SAVED_MESSAGES_HOTKEY = '0'; const ChatFolders: FC = ({ chatsById, @@ -43,16 +46,18 @@ const ChatFolders: FC = ({ notifySettings, notifyExceptions, orderedFolderIds, + activeChatFolder, + currentUserId, lastSyncTime, loadChatFolders, + setActiveChatFolder, + openChat, }) => { // eslint-disable-next-line no-null/no-null const transitionRef = useRef(null); const lang = useLang(); - const [activeTab, setActiveTab] = useState(0); - useEffect(() => { if (lastSyncTime) { loadChatFolders(); @@ -101,8 +106,8 @@ const ChatFolders: FC = ({ }, [displayedFolders, folderCountersById, lang]); const handleSwitchTab = useCallback((index: number) => { - setActiveTab(index); - }, []); + setActiveChatFolder(index); + }, [setActiveChatFolder]); // Prevent `activeTab` pointing at non-existing folder after update useEffect(() => { @@ -110,10 +115,10 @@ const ChatFolders: FC = ({ return; } - if (activeTab >= folderTabs.length) { - setActiveTab(0); + if (activeChatFolder >= folderTabs.length) { + setActiveChatFolder(0); } - }, [activeTab, folderTabs]); + }, [activeChatFolder, folderTabs, setActiveChatFolder]); useEffect(() => { if (!transitionRef.current || !IS_TOUCH_ENV || !folderTabs || !folderTabs.length) { @@ -123,48 +128,81 @@ const ChatFolders: FC = ({ return captureEvents(transitionRef.current, { onSwipe: ((e, direction) => { if (direction === SwipeDirection.Left) { - setActiveTab(Math.min(activeTab + 1, folderTabs.length - 1)); + setActiveChatFolder(Math.min(activeChatFolder + 1, folderTabs.length - 1)); } else if (direction === SwipeDirection.Right) { - setActiveTab(Math.max(0, activeTab - 1)); + setActiveChatFolder(Math.max(0, activeChatFolder - 1)); } }), }); - }, [activeTab, folderTabs]); + }, [activeChatFolder, folderTabs, setActiveChatFolder]); const isNotInAllTabRef = useRef(); - isNotInAllTabRef.current = activeTab !== 0; - useEffect(() => captureEscKeyListener(() => { + isNotInAllTabRef.current = activeChatFolder !== 0; + useEffect(() => (isNotInAllTabRef.current ? captureEscKeyListener(() => { if (isNotInAllTabRef.current) { - setActiveTab(0); + setActiveChatFolder(0); } - }), []); + }) : undefined), [activeChatFolder, setActiveChatFolder]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.metaKey && e.code.startsWith('Digit') && folderTabs) { + const [, digit] = e.code.match(/Digit(\d)/) || []; + if (!digit) return; + + if (digit === SAVED_MESSAGES_HOTKEY) { + openChat({ id: currentUserId }); + return; + } + + const folder = Number(digit) - 1; + if (folder > folderTabs.length - 1) return; + + setActiveChatFolder(folder); + e.preventDefault(); + } + }; + + document.addEventListener('keydown', handleKeyDown, true); + + return () => { + document.removeEventListener('keydown', handleKeyDown, true); + }; + }); const { shouldRender: shouldRenderPlaceholder, transitionClassNames, } = useShowTransition(!orderedFolderIds, undefined, true); - function renderCurrentTab() { + function renderCurrentTab(isActive: boolean) { const activeFolder = Object.values(chatFoldersById) - .find(({ title }) => title === folderTabs![activeTab].title); + .find(({ title }) => title === folderTabs![activeChatFolder].title); - if (!activeFolder || activeTab === 0) { - return ; + if (!activeFolder || activeChatFolder === 0) { + return ; } - return ; + return ( + + ); } return (
{folderTabs && folderTabs.length ? ( - + ) : shouldRenderPlaceholder ? (
) : undefined} {renderCurrentTab} @@ -181,7 +219,9 @@ export default memo(withGlobal( chatFolders: { byId: chatFoldersById, orderedIds: orderedFolderIds, + activeChatFolder, }, + currentUserId, lastSyncTime, } = global; @@ -193,7 +233,13 @@ export default memo(withGlobal( lastSyncTime, notifySettings: selectNotifySettings(global), notifyExceptions: selectNotifyExceptions(global), + activeChatFolder, + currentUserId, }; }, - (setGlobal, actions): DispatchProps => pick(actions, ['loadChatFolders']), + (setGlobal, actions): DispatchProps => pick(actions, [ + 'loadChatFolders', + 'setActiveChatFolder', + 'openChat', + ]), )(ChatFolders)); diff --git a/src/components/left/main/ChatList.tsx b/src/components/left/main/ChatList.tsx index 1ec952503..33ab6e735 100644 --- a/src/components/left/main/ChatList.tsx +++ b/src/components/left/main/ChatList.tsx @@ -28,6 +28,7 @@ type OwnProps = { folderType: 'all' | 'archived' | 'folder'; folderId?: number; noChatsText?: string; + isActive: boolean; }; type StateProps = { @@ -43,7 +44,7 @@ type StateProps = { notifyExceptions?: Record; }; -type DispatchProps = Pick; +type DispatchProps = Pick; enum FolderTypeToListType { 'all' = 'active', @@ -54,6 +55,7 @@ const ChatList: FC = ({ folderType, folderId, noChatsText = 'Chat list is empty.', + isActive, chatFolder, chatsById, usersById, @@ -66,6 +68,7 @@ const ChatList: FC = ({ notifyExceptions, loadMoreChats, preloadTopChatMessages, + openChat, }) => { const [currentListIds, currentPinnedIds] = useMemo(() => { return folderType === 'folder' && chatFolder @@ -161,6 +164,49 @@ const ChatList: FC = ({ ); } + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (isActive && orderedIds) { + if (e.ctrlKey && e.code.startsWith('Digit')) { + const [, digit] = e.code.match(/Digit(\d)/) || []; + if (!digit) return; + + const position = Number(digit) - 1; + if (position > orderedIds.length - 1) return; + + openChat({ id: orderedIds[position] }); + } + + if (e.altKey) { + const targetIndexDelta = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : undefined; + if (!targetIndexDelta) return; + + if (!currentChatId) { + e.preventDefault(); + openChat({ id: orderedIds[0] }); + return; + } + + const position = orderedIds.indexOf(currentChatId); + + if (position === -1) { + return; + } + const nextId = orderedIds[position + targetIndexDelta]; + + e.preventDefault(); + openChat({ id: nextId }); + } + } + }; + + document.addEventListener('keydown', handleKeyDown, false); + + return () => { + document.removeEventListener('keydown', handleKeyDown, false); + }; + }); + return ( ( notifyExceptions: selectNotifyExceptions(global), }; }, - (setGlobal, actions): DispatchProps => pick(actions, ['loadMoreChats', 'preloadTopChatMessages']), + (setGlobal, actions): DispatchProps => pick(actions, [ + 'loadMoreChats', + 'preloadTopChatMessages', + 'openChat', + ]), )(ChatList)); diff --git a/src/components/left/main/LeftMain.tsx b/src/components/left/main/LeftMain.tsx index 7966bdad7..68af7bdda 100644 --- a/src/components/left/main/LeftMain.tsx +++ b/src/components/left/main/LeftMain.tsx @@ -123,7 +123,7 @@ const LeftMain: FC = ({ /> - {() => { + {(isActive) => { switch (content) { case LeftColumnContent.ChatList: return ; @@ -132,6 +132,7 @@ const LeftMain: FC = ({ ); diff --git a/src/components/left/main/LeftMainHeader.tsx b/src/components/left/main/LeftMainHeader.tsx index f5b1c83f1..6cc3d36ad 100644 --- a/src/components/left/main/LeftMainHeader.tsx +++ b/src/components/left/main/LeftMainHeader.tsx @@ -239,6 +239,7 @@ const LeftMainHeader: FC = ({ = ({ const lang = useLang(); + const buttonRef = useSelectWithEnter(handleClick); + if (!chat) { return undefined; } @@ -75,6 +78,7 @@ const ChatMessage: FC = ({ className="ChatMessage chat-item-clickable" ripple={!IS_MOBILE_SCREEN} onClick={handleClick} + buttonRef={buttonRef} > void; }; @@ -53,6 +55,7 @@ const TRANSITION_RENDER_COUNT = Object.keys(GlobalSearchContent).length / 2; const LeftSearch: FC = ({ searchQuery, searchDate, + isActive, currentContent = GlobalSearchContent.ChatList, chatId, setGlobalSearchContent, @@ -73,8 +76,12 @@ const LeftSearch: FC = ({ setGlobalSearchDate({ date: value.getTime() / 1000 }); }, [setGlobalSearchDate]); + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + const handleKeyDown = useKeyboardListNavigation(containerRef, isActive, undefined, '.ListItem-button', true); + return ( -
+
= ({ handleDelete: openDeleteModal, }); + const handleClick = () => { + onClick(chatId); + }; + + const buttonRef = useSelectWithEnter(handleClick); + if (!chat) { return undefined; } @@ -49,8 +58,9 @@ const LeftSearchResultChat: FC = ({ return ( onClick(chatId)} + onClick={handleClick} contextActions={contextActions} + buttonRef={buttonRef} > {isChatPrivate(chatId) ? ( diff --git a/src/components/main/ForwardPicker.tsx b/src/components/main/ForwardPicker.tsx index 9bd0b8cdc..dbefadabc 100644 --- a/src/components/main/ForwardPicker.tsx +++ b/src/components/main/ForwardPicker.tsx @@ -14,6 +14,7 @@ import searchWords from '../../util/searchWords'; import { pick } from '../../util/iteratees'; import useInfiniteScroll from '../../hooks/useInfiniteScroll'; import useLang from '../../hooks/useLang'; +import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation'; import Loading from '../ui/Loading'; import Modal from '../ui/Modal'; @@ -114,6 +115,14 @@ const ForwardPicker: FC = ({ setFilter(e.currentTarget.value); }, []); + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + const handleKeyDown = useKeyboardListNavigation(containerRef, isOpen, (index) => { + if (viewportIds) { + setForwardChatId({ id: viewportIds[index] }); + } + }, '.ListItem-button', true); + const modalHeader = (
@@ -147,6 +157,8 @@ const ForwardPicker: FC = ({ items={viewportIds} onLoadMore={getMore} noScrollRestore={Boolean(filter)} + ref={containerRef} + onKeyDown={handleKeyDown} > {viewportIds.map((id) => ( ; +type DispatchProps = Pick; const MAX_INPUT_HEIGHT = IS_MOBILE_SCREEN ? 256 : 416; const TAB_INDEX_PRIORITY_TIMEOUT = 2000; @@ -87,6 +87,7 @@ const MessageInput: FC = ({ noTabCapture, messageSendKeyCombo, editLastMessage, + replyToNextMessage, }) => { // eslint-disable-next-line no-null/no-null const inputRef = useRef(null); @@ -226,6 +227,16 @@ const MessageInput: FC = ({ e.target.removeEventListener('keyup', handleKeyUp); } + if (e.metaKey) { + const targetIndexDelta = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : undefined; + if (targetIndexDelta) { + e.preventDefault(); + + replyToNextMessage({ targetIndexDelta }); + return; + } + } + if (e.key === 'Enter' && !e.shiftKey) { if ( !(IS_IOS || IS_ANDROID) @@ -239,7 +250,7 @@ const MessageInput: FC = ({ closeTextFormatter(); onSend(); } - } else if (e.key === 'ArrowUp' && !html.length) { + } else if (e.key === 'ArrowUp' && !html.length && !e.metaKey) { e.preventDefault(); editLastMessage(); } else { @@ -391,5 +402,5 @@ export default memo(withGlobal( noTabCapture: global.isPollModalOpen || global.payment.isPaymentModalOpen, }; }, - (setGlobal, actions): DispatchProps => pick(actions, ['editLastMessage']), + (setGlobal, actions): DispatchProps => pick(actions, ['editLastMessage', 'replyToNextMessage']), )(MessageInput)); diff --git a/src/components/right/RightHeader.tsx b/src/components/right/RightHeader.tsx index d4afc65d0..08728d46f 100644 --- a/src/components/right/RightHeader.tsx +++ b/src/components/right/RightHeader.tsx @@ -197,6 +197,7 @@ const RightHeader: FC = ({ return ( <> diff --git a/src/components/right/RightSearch.tsx b/src/components/right/RightSearch.tsx index c8a49c0cf..c86f4859e 100644 --- a/src/components/right/RightSearch.tsx +++ b/src/components/right/RightSearch.tsx @@ -1,4 +1,6 @@ -import React, { FC, useMemo, memo } from '../../lib/teact/teact'; +import React, { + FC, useMemo, memo, useRef, +} from '../../lib/teact/teact'; import { getGlobal, withGlobal } from '../../lib/teact/teactn'; import { ApiMessage, ApiUser, ApiChat } from '../../api/types'; @@ -20,6 +22,7 @@ import renderText from '../common/helpers/renderText'; import useLang from '../../hooks/useLang'; import { orderBy, pick } from '../../util/iteratees'; import { MEMO_EMPTY_ARRAY } from '../../util/memo'; +import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation'; import InfiniteScroll from '../ui/InfiniteScroll'; import ListItem from '../ui/ListItem'; @@ -122,6 +125,14 @@ const RightSearch: FC = ({ ); }; + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + const handleKeyDown = useKeyboardListNavigation(containerRef, true, (index) => { + if (foundResults && foundResults[index]) { + foundResults[index].onClick(); + } + }, '.ListItem-button', true); + return ( = ({ preloadBackwards={0} onLoadMore={searchTextMessagesLocal} noFastList + onKeyDown={handleKeyDown} + ref={containerRef} >

{!query ? ( diff --git a/src/components/ui/InfiniteScroll.tsx b/src/components/ui/InfiniteScroll.tsx index 43400abf2..dfd91a813 100644 --- a/src/components/ui/InfiniteScroll.tsx +++ b/src/components/ui/InfiniteScroll.tsx @@ -13,6 +13,7 @@ type OwnProps = { className?: string; onLoadMore?: ({ direction }: { direction: LoadMoreDirection }) => void; onScroll?: (e: UIEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; items?: any[]; itemSelector?: string; preloadBackwards?: number; @@ -33,6 +34,7 @@ const InfiniteScroll: FC = ({ className, onLoadMore, onScroll, + onKeyDown, items, itemSelector = DEFAULT_LIST_SELECTOR, preloadBackwards = DEFAULT_PRELOAD_BACKWARDS, @@ -205,7 +207,13 @@ const InfiniteScroll: FC = ({ }, [loadMoreBackwards, loadMoreForwards, onScroll, sensitiveArea]); return ( -

+
{children}
); diff --git a/src/components/ui/ListItem.tsx b/src/components/ui/ListItem.tsx index 3c15752f5..79227f341 100644 --- a/src/components/ui/ListItem.tsx +++ b/src/components/ui/ListItem.tsx @@ -24,6 +24,7 @@ type MenuItemContextAction = { type OwnProps = { ref?: RefObject; + buttonRef?: RefObject; icon?: string; className?: string; style?: string; @@ -43,6 +44,7 @@ type OwnProps = { const ListItem: FC = (props) => { const { ref, + buttonRef, icon, className, style, @@ -141,6 +143,7 @@ const ListItem: FC = (props) => {
; children?: any; + parentContainerClassName?: string; className?: string; inputId?: string; value?: string; @@ -32,6 +33,7 @@ type OwnProps = { const SearchInput: FC = ({ ref, children, + parentContainerClassName, value, inputId, className, @@ -86,6 +88,15 @@ const SearchInput: FC = ({ } } + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + const element = document.querySelector(`.${parentContainerClassName} .ListItem-button`) as HTMLElement; + if (element) { + element.focus(); + } + } + }, [parentContainerClassName]); + return (
= ({ onChange={handleChange} onFocus={handleFocus} onBlur={handleBlur} + onKeyDown={handleKeyDown} /> {isLoading && ( diff --git a/src/global/initial.ts b/src/global/initial.ts index 9e518419c..fe26a7b44 100644 --- a/src/global/initial.ts +++ b/src/global/initial.ts @@ -38,6 +38,7 @@ export const INITIAL_STATE: GlobalState = { chatFolders: { byId: {}, + activeChatFolder: 0, }, fileUploads: { diff --git a/src/global/types.ts b/src/global/types.ts index 86fb7f31f..0cb54493d 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -149,6 +149,7 @@ export type GlobalState = { orderedIds?: number[]; byId: Record; recommended?: ApiChatFolder[]; + activeChatFolder: number; }; focusedMessage?: { @@ -404,14 +405,14 @@ export type ActionTypes = ( 'joinChannel' | 'leaveChannel' | 'deleteChannel' | 'toggleChatPinned' | 'toggleChatArchived' | 'toggleChatUnread' | 'loadChatFolders' | 'loadRecommendedChatFolders' | 'editChatFolder' | 'addChatFolder' | 'deleteChatFolder' | 'updateChat' | 'toggleSignatures' | 'loadGroupsForDiscussion' | 'linkDiscussionGroup' | 'unlinkDiscussionGroup' | - 'loadProfilePhotos' | 'loadMoreMembers' | + 'loadProfilePhotos' | 'loadMoreMembers' | 'setActiveChatFolder' | // messages 'loadViewportMessages' | 'selectMessage' | 'sendMessage' | 'cancelSendingMessage' | 'pinMessage' | 'deleteMessages' | 'markMessageListRead' | 'markMessagesRead' | 'loadMessage' | 'focusMessage' | 'focusLastMessage' | 'sendPollVote' | 'editMessage' | 'deleteHistory' | 'enterMessageSelectMode' | 'toggleMessageSelection' | 'exitMessageSelectMode' | 'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' | 'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' | - 'loadMessageLink' | 'toggleMessageWebPage' | + 'loadMessageLink' | 'toggleMessageWebPage' | 'replyToNextMessage' | // scheduled messages 'loadScheduledHistory' | 'sendScheduledMessages' | 'rescheduleMessage' | 'deleteScheduledMessages' | // poll result diff --git a/src/hooks/useKeyboardListNavigation.ts b/src/hooks/useKeyboardListNavigation.ts index 282f12409..69b5f8ca1 100644 --- a/src/hooks/useKeyboardListNavigation.ts +++ b/src/hooks/useKeyboardListNavigation.ts @@ -4,13 +4,21 @@ import { useState, useCallback, useEffect } from '../lib/teact/teact'; export default ( elementRef: RefObject, isOpen: boolean, - onSelectWithEnter?: () => void, + onSelectWithEnter?: (index: number) => void, + itemSelector?: string, + noCaptureFocus?: boolean, ) => { const [focusedIndex, setFocusedIndex] = useState(-1); useEffect(() => { setFocusedIndex(-1); - }, [isOpen]); + + const element = elementRef.current; + if (isOpen && element && !noCaptureFocus) { + element.tabIndex = -1; + element.focus(); + } + }, [elementRef, isOpen, noCaptureFocus]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const element = elementRef.current; @@ -20,7 +28,7 @@ export default ( } if (e.keyCode === 13 && onSelectWithEnter) { - onSelectWithEnter(); + onSelectWithEnter(focusedIndex); return; } @@ -29,7 +37,7 @@ export default ( } const focusedElement = document.activeElement; - const elementChildren = Array.from(element.children); + const elementChildren = Array.from(itemSelector ? element.querySelectorAll(itemSelector) : element.children); let newIndex = (focusedElement && elementChildren.indexOf(focusedElement)) || focusedIndex; @@ -48,7 +56,7 @@ export default ( setFocusedIndex(newIndex); item.focus(); } - }, [focusedIndex, elementRef, onSelectWithEnter]); + }, [elementRef, onSelectWithEnter, itemSelector, focusedIndex]); return handleKeyDown; }; diff --git a/src/hooks/useSelectWithEnter.ts b/src/hooks/useSelectWithEnter.ts new file mode 100644 index 000000000..88eb371f7 --- /dev/null +++ b/src/hooks/useSelectWithEnter.ts @@ -0,0 +1,25 @@ +import { useCallback, useEffect, useRef } from '../lib/teact/teact'; + +export default ( + onSelect: NoneToVoidFunction, +) => { + // eslint-disable-next-line no-null/no-null + const buttonRef = useRef(null); + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key !== 'Enter') return; + const isFocused = buttonRef.current === document.activeElement; + + if (isFocused) { + onSelect(); + } + }, [onSelect]); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown, false); + + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); + + return buttonRef; +}; diff --git a/src/modules/actions/api/chats.ts b/src/modules/actions/api/chats.ts index e4a76e56b..af0c69b26 100644 --- a/src/modules/actions/api/chats.ts +++ b/src/modules/actions/api/chats.ts @@ -664,6 +664,17 @@ addReducer('unlinkDiscussionGroup', (global, actions, payload) => { })(); }); + +addReducer('setActiveChatFolder', (global, actions, payload) => { + return { + ...global, + chatFolders: { + ...global.chatFolders, + activeChatFolder: payload, + }, + }; +}); + addReducer('loadMoreMembers', (global) => { (async () => { const { chatId } = selectCurrentMessageList(global) || {}; diff --git a/src/modules/actions/ui/messages.ts b/src/modules/actions/ui/messages.ts index 1af59b495..9e882ee32 100644 --- a/src/modules/actions/ui/messages.ts +++ b/src/modules/actions/ui/messages.ts @@ -21,7 +21,7 @@ import { selectChatMessages, selectAllowedMessageActions, selectMessageIdsByGroupId, - selectForwardedMessageIdsByGroupId, + selectForwardedMessageIdsByGroupId, selectIsViewportNewest, selectReplyingToId, } from '../../selectors'; import { findLast } from '../../../util/iteratees'; @@ -83,6 +83,48 @@ addReducer('editLastMessage', (global) => { return replaceThreadParam(global, chatId, threadId, 'editingId', lastOwnEditableMessageId); }); +addReducer('replyToNextMessage', (global, actions, payload) => { + const { targetIndexDelta } = payload; + const { chatId, threadId } = selectCurrentMessageList(global) || {}; + if (!chatId || !threadId) { + return; + } + + const chatMessages = selectChatMessages(global, chatId); + const viewportIds = selectViewportIds(global, chatId, threadId); + if (!chatMessages || !viewportIds) { + return; + } + + const replyingToId = selectReplyingToId(global, chatId, threadId); + const isLatest = selectIsViewportNewest(global, chatId, threadId); + + let messageId: number | undefined; + + if (!isLatest || !replyingToId) { + if (threadId === MAIN_THREAD_ID) { + const chat = selectChat(global, chatId); + + messageId = chat && chat.lastMessage ? chat.lastMessage.id : undefined; + } else { + const threadInfo = selectThreadInfo(global, chatId, threadId); + + messageId = threadInfo ? threadInfo.lastMessageId : undefined; + } + } else { + const chatMessageKeys = Object.keys(chatMessages); + const indexOfCurrent = chatMessageKeys.indexOf(replyingToId.toString()); + const newIndex = indexOfCurrent + targetIndexDelta; + messageId = newIndex <= chatMessageKeys.length + 1 && newIndex >= 0 + ? Number(chatMessageKeys[newIndex]) + : undefined; + } + actions.setReplyingToId({ messageId }); + actions.focusMessage({ + chatId, threadId, messageId, + }); +}); + addReducer('openMediaViewer', (global, actions, payload) => { const { chatId, threadId, messageId, avatarOwnerId, profilePhotoIndex, origin,