Support more hotkeys (#1137)

This commit is contained in:
Alexander Zinchuk 2021-06-12 17:20:22 +03:00
parent 45a3064153
commit a09b488846
22 changed files with 342 additions and 53 deletions

View File

@ -8,10 +8,11 @@ import ChatList from './main/ChatList';
import './ArchivedChats.scss'; import './ArchivedChats.scss';
export type OwnProps = { export type OwnProps = {
isActive: boolean;
onReset: () => void; onReset: () => void;
}; };
const ArchivedChats: FC<OwnProps> = ({ onReset }) => { const ArchivedChats: FC<OwnProps> = ({ isActive, onReset }) => {
const lang = useLang(); const lang = useLang();
return ( return (
@ -28,7 +29,7 @@ const ArchivedChats: FC<OwnProps> = ({ onReset }) => {
</Button> </Button>
<h3>{lang('ArchivedChats')}</h3> <h3>{lang('ArchivedChats')}</h3>
</div> </div>
<ChatList folderType="archived" noChatsText="Archive is empty." /> <ChatList folderType="archived" noChatsText="Archive is empty." isActive={isActive} />
</div> </div>
); );
}; };

View File

@ -21,6 +21,7 @@ import './LeftColumn.scss';
type StateProps = { type StateProps = {
searchQuery?: string; searchQuery?: string;
searchDate?: number; searchDate?: number;
activeChatFolder: number;
}; };
type DispatchProps = Pick<GlobalActions, ( type DispatchProps = Pick<GlobalActions, (
@ -45,6 +46,7 @@ const RESET_TRANSITION_DELAY_MS = 250;
const LeftColumn: FC<StateProps & DispatchProps> = ({ const LeftColumn: FC<StateProps & DispatchProps> = ({
searchQuery, searchQuery,
searchDate, searchDate,
activeChatFolder,
setGlobalSearchQuery, setGlobalSearchQuery,
setGlobalSearchChatId, setGlobalSearchChatId,
resetChatCreation, resetChatCreation,
@ -188,6 +190,11 @@ const LeftColumn: FC<StateProps & DispatchProps> = ({
} }
} }
if (content === LeftColumnContent.ChatList && activeChatFolder === 0) {
setContent(LeftColumnContent.GlobalSearch);
return;
}
setContent(LeftColumnContent.ChatList); setContent(LeftColumnContent.ChatList);
setContactsFilter(''); setContactsFilter('');
setGlobalSearchQuery({ query: '' }); setGlobalSearchQuery({ query: '' });
@ -197,7 +204,10 @@ const LeftColumn: FC<StateProps & DispatchProps> = ({
setTimeout(() => { setTimeout(() => {
setLastResetTime(Date.now()); setLastResetTime(Date.now());
}, RESET_TRANSITION_DELAY_MS); }, RESET_TRANSITION_DELAY_MS);
}, [content, setGlobalSearchQuery, setGlobalSearchChatId, setGlobalSearchDate, resetChatCreation, settingsScreen]); }, [
content, activeChatFolder, setGlobalSearchQuery, setGlobalSearchDate, setGlobalSearchChatId, resetChatCreation,
settingsScreen,
]);
const handleSearchQuery = useCallback((query: string) => { const handleSearchQuery = useCallback((query: string) => {
if (content === LeftColumnContent.Contacts) { if (content === LeftColumnContent.Contacts) {
@ -213,8 +223,10 @@ const LeftColumn: FC<StateProps & DispatchProps> = ({
}, [content, setGlobalSearchQuery, searchQuery]); }, [content, setGlobalSearchQuery, searchQuery]);
useEffect( useEffect(
() => (content !== LeftColumnContent.ChatList ? captureEscKeyListener(() => handleReset()) : undefined), () => (content !== LeftColumnContent.ChatList || activeChatFolder === 0
[content, handleReset], ? captureEscKeyListener(() => handleReset())
: undefined),
[activeChatFolder, content, handleReset],
); );
useEffect(() => { useEffect(() => {
@ -232,11 +244,12 @@ const LeftColumn: FC<StateProps & DispatchProps> = ({
renderCount={RENDER_COUNT} renderCount={RENDER_COUNT}
activeKey={contentType} activeKey={contentType}
> >
{() => { {(isActive) => {
switch (contentType) { switch (contentType) {
case ContentType.Archived: case ContentType.Archived:
return ( return (
<ArchivedChats <ArchivedChats
isActive={isActive}
onReset={handleReset} onReset={handleReset}
/> />
); );
@ -287,8 +300,16 @@ const LeftColumn: FC<StateProps & DispatchProps> = ({
export default memo(withGlobal( export default memo(withGlobal(
(global): StateProps => { (global): StateProps => {
const { query, date } = global.globalSearch; const {
return { searchQuery: query, searchDate: date }; globalSearch: {
query,
date,
},
chatFolders: {
activeChatFolder,
},
} = global;
return { searchQuery: query, searchDate: date, activeChatFolder };
}, },
(setGlobal, actions): DispatchProps => pick(actions, [ (setGlobal, actions): DispatchProps => pick(actions, [
'setGlobalSearchQuery', 'setGlobalSearchChatId', 'resetChatCreation', 'setGlobalSearchDate', 'setGlobalSearchQuery', 'setGlobalSearchChatId', 'resetChatCreation', 'setGlobalSearchDate',

View File

@ -1,5 +1,5 @@
import React, { import React, {
FC, memo, useCallback, useEffect, useMemo, useRef, useState, FC, memo, useCallback, useEffect, useMemo, useRef,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn'; import { withGlobal } from '../../../lib/teact/teactn';
@ -29,12 +29,15 @@ type StateProps = {
notifySettings: NotifySettings; notifySettings: NotifySettings;
notifyExceptions?: Record<number, NotifyException>; notifyExceptions?: Record<number, NotifyException>;
orderedFolderIds?: number[]; orderedFolderIds?: number[];
activeChatFolder: number;
currentUserId?: number;
lastSyncTime?: number; lastSyncTime?: number;
}; };
type DispatchProps = Pick<GlobalActions, 'loadChatFolders'>; type DispatchProps = Pick<GlobalActions, 'loadChatFolders' | 'setActiveChatFolder' | 'openChat'>;
const INFO_THROTTLE = 3000; const INFO_THROTTLE = 3000;
const SAVED_MESSAGES_HOTKEY = '0';
const ChatFolders: FC<StateProps & DispatchProps> = ({ const ChatFolders: FC<StateProps & DispatchProps> = ({
chatsById, chatsById,
@ -43,16 +46,18 @@ const ChatFolders: FC<StateProps & DispatchProps> = ({
notifySettings, notifySettings,
notifyExceptions, notifyExceptions,
orderedFolderIds, orderedFolderIds,
activeChatFolder,
currentUserId,
lastSyncTime, lastSyncTime,
loadChatFolders, loadChatFolders,
setActiveChatFolder,
openChat,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const transitionRef = useRef<HTMLDivElement>(null); const transitionRef = useRef<HTMLDivElement>(null);
const lang = useLang(); const lang = useLang();
const [activeTab, setActiveTab] = useState(0);
useEffect(() => { useEffect(() => {
if (lastSyncTime) { if (lastSyncTime) {
loadChatFolders(); loadChatFolders();
@ -101,8 +106,8 @@ const ChatFolders: FC<StateProps & DispatchProps> = ({
}, [displayedFolders, folderCountersById, lang]); }, [displayedFolders, folderCountersById, lang]);
const handleSwitchTab = useCallback((index: number) => { const handleSwitchTab = useCallback((index: number) => {
setActiveTab(index); setActiveChatFolder(index);
}, []); }, [setActiveChatFolder]);
// Prevent `activeTab` pointing at non-existing folder after update // Prevent `activeTab` pointing at non-existing folder after update
useEffect(() => { useEffect(() => {
@ -110,10 +115,10 @@ const ChatFolders: FC<StateProps & DispatchProps> = ({
return; return;
} }
if (activeTab >= folderTabs.length) { if (activeChatFolder >= folderTabs.length) {
setActiveTab(0); setActiveChatFolder(0);
} }
}, [activeTab, folderTabs]); }, [activeChatFolder, folderTabs, setActiveChatFolder]);
useEffect(() => { useEffect(() => {
if (!transitionRef.current || !IS_TOUCH_ENV || !folderTabs || !folderTabs.length) { if (!transitionRef.current || !IS_TOUCH_ENV || !folderTabs || !folderTabs.length) {
@ -123,48 +128,81 @@ const ChatFolders: FC<StateProps & DispatchProps> = ({
return captureEvents(transitionRef.current, { return captureEvents(transitionRef.current, {
onSwipe: ((e, direction) => { onSwipe: ((e, direction) => {
if (direction === SwipeDirection.Left) { 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) { } 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(); const isNotInAllTabRef = useRef();
isNotInAllTabRef.current = activeTab !== 0; isNotInAllTabRef.current = activeChatFolder !== 0;
useEffect(() => captureEscKeyListener(() => { useEffect(() => (isNotInAllTabRef.current ? captureEscKeyListener(() => {
if (isNotInAllTabRef.current) { 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 { const {
shouldRender: shouldRenderPlaceholder, transitionClassNames, shouldRender: shouldRenderPlaceholder, transitionClassNames,
} = useShowTransition(!orderedFolderIds, undefined, true); } = useShowTransition(!orderedFolderIds, undefined, true);
function renderCurrentTab() { function renderCurrentTab(isActive: boolean) {
const activeFolder = Object.values(chatFoldersById) const activeFolder = Object.values(chatFoldersById)
.find(({ title }) => title === folderTabs![activeTab].title); .find(({ title }) => title === folderTabs![activeChatFolder].title);
if (!activeFolder || activeTab === 0) { if (!activeFolder || activeChatFolder === 0) {
return <ChatList folderType="all" />; return <ChatList folderType="all" isActive={isActive} />;
} }
return <ChatList folderType="folder" folderId={activeFolder.id} noChatsText={lang('FilterNoChatsToDisplay')} />; return (
<ChatList
folderType="folder"
folderId={activeFolder.id}
noChatsText={lang('FilterNoChatsToDisplay')}
isActive={isActive}
/>
);
} }
return ( return (
<div className="ChatFolders"> <div className="ChatFolders">
{folderTabs && folderTabs.length ? ( {folderTabs && folderTabs.length ? (
<TabList tabs={folderTabs} activeTab={activeTab} onSwitchTab={handleSwitchTab} /> <TabList tabs={folderTabs} activeTab={activeChatFolder} onSwitchTab={handleSwitchTab} />
) : shouldRenderPlaceholder ? ( ) : shouldRenderPlaceholder ? (
<div className={buildClassName('tabs-placeholder', transitionClassNames)} /> <div className={buildClassName('tabs-placeholder', transitionClassNames)} />
) : undefined} ) : undefined}
<Transition <Transition
ref={transitionRef} ref={transitionRef}
name={lang.isRtl ? 'slide-reversed' : 'slide'} name={lang.isRtl ? 'slide-reversed' : 'slide'}
activeKey={activeTab} activeKey={activeChatFolder}
renderCount={folderTabs ? folderTabs.length : undefined} renderCount={folderTabs ? folderTabs.length : undefined}
> >
{renderCurrentTab} {renderCurrentTab}
@ -181,7 +219,9 @@ export default memo(withGlobal(
chatFolders: { chatFolders: {
byId: chatFoldersById, byId: chatFoldersById,
orderedIds: orderedFolderIds, orderedIds: orderedFolderIds,
activeChatFolder,
}, },
currentUserId,
lastSyncTime, lastSyncTime,
} = global; } = global;
@ -193,7 +233,13 @@ export default memo(withGlobal(
lastSyncTime, lastSyncTime,
notifySettings: selectNotifySettings(global), notifySettings: selectNotifySettings(global),
notifyExceptions: selectNotifyExceptions(global), notifyExceptions: selectNotifyExceptions(global),
activeChatFolder,
currentUserId,
}; };
}, },
(setGlobal, actions): DispatchProps => pick(actions, ['loadChatFolders']), (setGlobal, actions): DispatchProps => pick(actions, [
'loadChatFolders',
'setActiveChatFolder',
'openChat',
]),
)(ChatFolders)); )(ChatFolders));

View File

@ -28,6 +28,7 @@ type OwnProps = {
folderType: 'all' | 'archived' | 'folder'; folderType: 'all' | 'archived' | 'folder';
folderId?: number; folderId?: number;
noChatsText?: string; noChatsText?: string;
isActive: boolean;
}; };
type StateProps = { type StateProps = {
@ -43,7 +44,7 @@ type StateProps = {
notifyExceptions?: Record<number, NotifyException>; notifyExceptions?: Record<number, NotifyException>;
}; };
type DispatchProps = Pick<GlobalActions, 'loadMoreChats' | 'preloadTopChatMessages'>; type DispatchProps = Pick<GlobalActions, 'loadMoreChats' | 'preloadTopChatMessages' | 'openChat'>;
enum FolderTypeToListType { enum FolderTypeToListType {
'all' = 'active', 'all' = 'active',
@ -54,6 +55,7 @@ const ChatList: FC<OwnProps & StateProps & DispatchProps> = ({
folderType, folderType,
folderId, folderId,
noChatsText = 'Chat list is empty.', noChatsText = 'Chat list is empty.',
isActive,
chatFolder, chatFolder,
chatsById, chatsById,
usersById, usersById,
@ -66,6 +68,7 @@ const ChatList: FC<OwnProps & StateProps & DispatchProps> = ({
notifyExceptions, notifyExceptions,
loadMoreChats, loadMoreChats,
preloadTopChatMessages, preloadTopChatMessages,
openChat,
}) => { }) => {
const [currentListIds, currentPinnedIds] = useMemo(() => { const [currentListIds, currentPinnedIds] = useMemo(() => {
return folderType === 'folder' && chatFolder return folderType === 'folder' && chatFolder
@ -161,6 +164,49 @@ const ChatList: FC<OwnProps & StateProps & DispatchProps> = ({
); );
} }
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 ( return (
<InfiniteScroll <InfiniteScroll
className="chat-list custom-scroll" className="chat-list custom-scroll"
@ -213,5 +259,9 @@ export default memo(withGlobal<OwnProps>(
notifyExceptions: selectNotifyExceptions(global), notifyExceptions: selectNotifyExceptions(global),
}; };
}, },
(setGlobal, actions): DispatchProps => pick(actions, ['loadMoreChats', 'preloadTopChatMessages']), (setGlobal, actions): DispatchProps => pick(actions, [
'loadMoreChats',
'preloadTopChatMessages',
'openChat',
]),
)(ChatList)); )(ChatList));

View File

@ -123,7 +123,7 @@ const LeftMain: FC<OwnProps & StateProps> = ({
/> />
<ConnectionState /> <ConnectionState />
<Transition name="zoom-fade" renderCount={TRANSITION_RENDER_COUNT} activeKey={content}> <Transition name="zoom-fade" renderCount={TRANSITION_RENDER_COUNT} activeKey={content}>
{() => { {(isActive) => {
switch (content) { switch (content) {
case LeftColumnContent.ChatList: case LeftColumnContent.ChatList:
return <ChatFolders />; return <ChatFolders />;
@ -132,6 +132,7 @@ const LeftMain: FC<OwnProps & StateProps> = ({
<LeftSearch <LeftSearch
searchQuery={searchQuery} searchQuery={searchQuery}
searchDate={searchDate} searchDate={searchDate}
isActive={isActive}
onReset={onReset} onReset={onReset}
/> />
); );

View File

@ -239,6 +239,7 @@ const LeftMainHeader: FC<OwnProps & StateProps & DispatchProps> = ({
</DropdownMenu> </DropdownMenu>
<SearchInput <SearchInput
inputId="telegram-search-input" inputId="telegram-search-input"
parentContainerClassName="LeftSearch"
className={globalSearchChatId || searchDate ? 'with-picker-item' : ''} className={globalSearchChatId || searchDate ? 'with-picker-item' : ''}
value={contactsFilter || searchQuery} value={contactsFilter || searchQuery}
focused={isSearchFocused} focused={isSearchFocused}

View File

@ -24,6 +24,7 @@ import { pick } from '../../../util/iteratees';
import useMedia from '../../../hooks/useMedia'; import useMedia from '../../../hooks/useMedia';
import { formatPastTimeShort } from '../../../util/dateFormat'; import { formatPastTimeShort } from '../../../util/dateFormat';
import useLang, { LangFn } from '../../../hooks/useLang'; import useLang, { LangFn } from '../../../hooks/useLang';
import useSelectWithEnter from '../../../hooks/useSelectWithEnter';
import Avatar from '../../common/Avatar'; import Avatar from '../../common/Avatar';
import VerifiedIcon from '../../common/VerifiedIcon'; import VerifiedIcon from '../../common/VerifiedIcon';
@ -66,6 +67,8 @@ const ChatMessage: FC<OwnProps & StateProps & DispatchProps> = ({
const lang = useLang(); const lang = useLang();
const buttonRef = useSelectWithEnter(handleClick);
if (!chat) { if (!chat) {
return undefined; return undefined;
} }
@ -75,6 +78,7 @@ const ChatMessage: FC<OwnProps & StateProps & DispatchProps> = ({
className="ChatMessage chat-item-clickable" className="ChatMessage chat-item-clickable"
ripple={!IS_MOBILE_SCREEN} ripple={!IS_MOBILE_SCREEN}
onClick={handleClick} onClick={handleClick}
buttonRef={buttonRef}
> >
<Avatar <Avatar
chat={chat} chat={chat}

View File

@ -1,5 +1,5 @@
import React, { import React, {
FC, memo, useCallback, useState, useMemo, FC, memo, useCallback, useState, useMemo, useRef,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn'; import { withGlobal } from '../../../lib/teact/teactn';
@ -8,6 +8,7 @@ import { GlobalSearchContent } from '../../../types';
import { pick } from '../../../util/iteratees'; import { pick } from '../../../util/iteratees';
import { parseDateString } from '../../../util/dateFormat'; import { parseDateString } from '../../../util/dateFormat';
import useKeyboardListNavigation from '../../../hooks/useKeyboardListNavigation';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import TabList from '../../ui/TabList'; import TabList from '../../ui/TabList';
@ -24,6 +25,7 @@ import './LeftSearch.scss';
export type OwnProps = { export type OwnProps = {
searchQuery?: string; searchQuery?: string;
searchDate?: number; searchDate?: number;
isActive: boolean;
onReset: () => void; onReset: () => void;
}; };
@ -53,6 +55,7 @@ const TRANSITION_RENDER_COUNT = Object.keys(GlobalSearchContent).length / 2;
const LeftSearch: FC<OwnProps & StateProps & DispatchProps> = ({ const LeftSearch: FC<OwnProps & StateProps & DispatchProps> = ({
searchQuery, searchQuery,
searchDate, searchDate,
isActive,
currentContent = GlobalSearchContent.ChatList, currentContent = GlobalSearchContent.ChatList,
chatId, chatId,
setGlobalSearchContent, setGlobalSearchContent,
@ -73,8 +76,12 @@ const LeftSearch: FC<OwnProps & StateProps & DispatchProps> = ({
setGlobalSearchDate({ date: value.getTime() / 1000 }); setGlobalSearchDate({ date: value.getTime() / 1000 });
}, [setGlobalSearchDate]); }, [setGlobalSearchDate]);
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useKeyboardListNavigation(containerRef, isActive, undefined, '.ListItem-button', true);
return ( return (
<div className="LeftSearch"> <div className="LeftSearch" ref={containerRef} onKeyDown={handleKeyDown}>
<TabList activeTab={activeTab} tabs={chatId ? CHAT_TABS : TABS} onSwitchTab={handleSwitchTab} /> <TabList activeTab={activeTab} tabs={chatId ? CHAT_TABS : TABS} onSwitchTab={handleSwitchTab} />
<Transition <Transition
name={lang.isRtl ? 'slide-reversed' : 'slide'} name={lang.isRtl ? 'slide-reversed' : 'slide'}

View File

@ -1,4 +1,6 @@
import React, { FC, memo } from '../../../lib/teact/teact'; import React, {
FC, memo,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn'; import { withGlobal } from '../../../lib/teact/teactn';
import { ApiChat, ApiUser } from '../../../api/types'; import { ApiChat, ApiUser } from '../../../api/types';
@ -7,6 +9,7 @@ import useChatContextActions from '../../../hooks/useChatContextActions';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import { isChatPrivate, getPrivateChatUserId } from '../../../modules/helpers'; import { isChatPrivate, getPrivateChatUserId } from '../../../modules/helpers';
import { selectChat, selectUser, selectIsChatPinned } from '../../../modules/selectors'; import { selectChat, selectUser, selectIsChatPinned } from '../../../modules/selectors';
import useSelectWithEnter from '../../../hooks/useSelectWithEnter';
import PrivateChatInfo from '../../common/PrivateChatInfo'; import PrivateChatInfo from '../../common/PrivateChatInfo';
import GroupChatInfo from '../../common/GroupChatInfo'; import GroupChatInfo from '../../common/GroupChatInfo';
@ -42,6 +45,12 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
handleDelete: openDeleteModal, handleDelete: openDeleteModal,
}); });
const handleClick = () => {
onClick(chatId);
};
const buttonRef = useSelectWithEnter(handleClick);
if (!chat) { if (!chat) {
return undefined; return undefined;
} }
@ -49,8 +58,9 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
return ( return (
<ListItem <ListItem
className="chat-item-clickable search-result" className="chat-item-clickable search-result"
onClick={() => onClick(chatId)} onClick={handleClick}
contextActions={contextActions} contextActions={contextActions}
buttonRef={buttonRef}
> >
{isChatPrivate(chatId) ? ( {isChatPrivate(chatId) ? (
<PrivateChatInfo userId={chatId} withUsername={withUsername} avatarSize="large" /> <PrivateChatInfo userId={chatId} withUsername={withUsername} avatarSize="large" />

View File

@ -14,6 +14,7 @@ import searchWords from '../../util/searchWords';
import { pick } from '../../util/iteratees'; import { pick } from '../../util/iteratees';
import useInfiniteScroll from '../../hooks/useInfiniteScroll'; import useInfiniteScroll from '../../hooks/useInfiniteScroll';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation';
import Loading from '../ui/Loading'; import Loading from '../ui/Loading';
import Modal from '../ui/Modal'; import Modal from '../ui/Modal';
@ -114,6 +115,14 @@ const ForwardPicker: FC<OwnProps & StateProps & DispatchProps> = ({
setFilter(e.currentTarget.value); setFilter(e.currentTarget.value);
}, []); }, []);
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useKeyboardListNavigation(containerRef, isOpen, (index) => {
if (viewportIds) {
setForwardChatId({ id: viewportIds[index] });
}
}, '.ListItem-button', true);
const modalHeader = ( const modalHeader = (
<div className="modal-header" dir={lang.isRtl ? 'rtl' : undefined}> <div className="modal-header" dir={lang.isRtl ? 'rtl' : undefined}>
<Button <Button
@ -129,6 +138,7 @@ const ForwardPicker: FC<OwnProps & StateProps & DispatchProps> = ({
ref={inputRef} ref={inputRef}
value={filter} value={filter}
onChange={handleFilterChange} onChange={handleFilterChange}
onKeyDown={handleKeyDown}
placeholder={lang('ForwardTo')} placeholder={lang('ForwardTo')}
/> />
</div> </div>
@ -147,6 +157,8 @@ const ForwardPicker: FC<OwnProps & StateProps & DispatchProps> = ({
items={viewportIds} items={viewportIds}
onLoadMore={getMore} onLoadMore={getMore}
noScrollRestore={Boolean(filter)} noScrollRestore={Boolean(filter)}
ref={containerRef}
onKeyDown={handleKeyDown}
> >
{viewportIds.map((id) => ( {viewportIds.map((id) => (
<ListItem <ListItem

View File

@ -50,7 +50,7 @@ type StateProps = {
messageSendKeyCombo?: ISettings['messageSendKeyCombo']; messageSendKeyCombo?: ISettings['messageSendKeyCombo'];
}; };
type DispatchProps = Pick<GlobalActions, 'editLastMessage'>; type DispatchProps = Pick<GlobalActions, 'editLastMessage' | 'replyToNextMessage'>;
const MAX_INPUT_HEIGHT = IS_MOBILE_SCREEN ? 256 : 416; const MAX_INPUT_HEIGHT = IS_MOBILE_SCREEN ? 256 : 416;
const TAB_INDEX_PRIORITY_TIMEOUT = 2000; const TAB_INDEX_PRIORITY_TIMEOUT = 2000;
@ -87,6 +87,7 @@ const MessageInput: FC<OwnProps & StateProps & DispatchProps> = ({
noTabCapture, noTabCapture,
messageSendKeyCombo, messageSendKeyCombo,
editLastMessage, editLastMessage,
replyToNextMessage,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const inputRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLDivElement>(null);
@ -226,6 +227,16 @@ const MessageInput: FC<OwnProps & StateProps & DispatchProps> = ({
e.target.removeEventListener('keyup', handleKeyUp); 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 (e.key === 'Enter' && !e.shiftKey) {
if ( if (
!(IS_IOS || IS_ANDROID) !(IS_IOS || IS_ANDROID)
@ -239,7 +250,7 @@ const MessageInput: FC<OwnProps & StateProps & DispatchProps> = ({
closeTextFormatter(); closeTextFormatter();
onSend(); onSend();
} }
} else if (e.key === 'ArrowUp' && !html.length) { } else if (e.key === 'ArrowUp' && !html.length && !e.metaKey) {
e.preventDefault(); e.preventDefault();
editLastMessage(); editLastMessage();
} else { } else {
@ -391,5 +402,5 @@ export default memo(withGlobal<OwnProps>(
noTabCapture: global.isPollModalOpen || global.payment.isPaymentModalOpen, noTabCapture: global.isPollModalOpen || global.payment.isPaymentModalOpen,
}; };
}, },
(setGlobal, actions): DispatchProps => pick(actions, ['editLastMessage']), (setGlobal, actions): DispatchProps => pick(actions, ['editLastMessage', 'replyToNextMessage']),
)(MessageInput)); )(MessageInput));

View File

@ -197,6 +197,7 @@ const RightHeader: FC<OwnProps & StateProps & DispatchProps> = ({
return ( return (
<> <>
<SearchInput <SearchInput
parentContainerClassName="RightSearch"
value={messageSearchQuery} value={messageSearchQuery}
onChange={handleMessageSearchQueryChange} onChange={handleMessageSearchQueryChange}
/> />

View File

@ -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 { getGlobal, withGlobal } from '../../lib/teact/teactn';
import { ApiMessage, ApiUser, ApiChat } from '../../api/types'; import { ApiMessage, ApiUser, ApiChat } from '../../api/types';
@ -20,6 +22,7 @@ import renderText from '../common/helpers/renderText';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import { orderBy, pick } from '../../util/iteratees'; import { orderBy, pick } from '../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../util/memo';
import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation';
import InfiniteScroll from '../ui/InfiniteScroll'; import InfiniteScroll from '../ui/InfiniteScroll';
import ListItem from '../ui/ListItem'; import ListItem from '../ui/ListItem';
@ -122,6 +125,14 @@ const RightSearch: FC<OwnProps & StateProps & DispatchProps> = ({
); );
}; };
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useKeyboardListNavigation(containerRef, true, (index) => {
if (foundResults && foundResults[index]) {
foundResults[index].onClick();
}
}, '.ListItem-button', true);
return ( return (
<InfiniteScroll <InfiniteScroll
className="RightSearch custom-scroll" className="RightSearch custom-scroll"
@ -129,6 +140,8 @@ const RightSearch: FC<OwnProps & StateProps & DispatchProps> = ({
preloadBackwards={0} preloadBackwards={0}
onLoadMore={searchTextMessagesLocal} onLoadMore={searchTextMessagesLocal}
noFastList noFastList
onKeyDown={handleKeyDown}
ref={containerRef}
> >
<p className="helper-text" dir="auto"> <p className="helper-text" dir="auto">
{!query ? ( {!query ? (

View File

@ -13,6 +13,7 @@ type OwnProps = {
className?: string; className?: string;
onLoadMore?: ({ direction }: { direction: LoadMoreDirection }) => void; onLoadMore?: ({ direction }: { direction: LoadMoreDirection }) => void;
onScroll?: (e: UIEvent<HTMLDivElement>) => void; onScroll?: (e: UIEvent<HTMLDivElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<any>) => void;
items?: any[]; items?: any[];
itemSelector?: string; itemSelector?: string;
preloadBackwards?: number; preloadBackwards?: number;
@ -33,6 +34,7 @@ const InfiniteScroll: FC<OwnProps> = ({
className, className,
onLoadMore, onLoadMore,
onScroll, onScroll,
onKeyDown,
items, items,
itemSelector = DEFAULT_LIST_SELECTOR, itemSelector = DEFAULT_LIST_SELECTOR,
preloadBackwards = DEFAULT_PRELOAD_BACKWARDS, preloadBackwards = DEFAULT_PRELOAD_BACKWARDS,
@ -205,7 +207,13 @@ const InfiniteScroll: FC<OwnProps> = ({
}, [loadMoreBackwards, loadMoreForwards, onScroll, sensitiveArea]); }, [loadMoreBackwards, loadMoreForwards, onScroll, sensitiveArea]);
return ( return (
<div ref={containerRef} className={className} onScroll={handleScroll} teactFastList={!noFastList}> <div
ref={containerRef}
className={className}
onScroll={handleScroll}
teactFastList={!noFastList}
onKeyDown={onKeyDown}
>
{children} {children}
</div> </div>
); );

View File

@ -24,6 +24,7 @@ type MenuItemContextAction = {
type OwnProps = { type OwnProps = {
ref?: RefObject<HTMLDivElement>; ref?: RefObject<HTMLDivElement>;
buttonRef?: RefObject<HTMLDivElement>;
icon?: string; icon?: string;
className?: string; className?: string;
style?: string; style?: string;
@ -43,6 +44,7 @@ type OwnProps = {
const ListItem: FC<OwnProps> = (props) => { const ListItem: FC<OwnProps> = (props) => {
const { const {
ref, ref,
buttonRef,
icon, icon,
className, className,
style, style,
@ -141,6 +143,7 @@ const ListItem: FC<OwnProps> = (props) => {
<div <div
className="ListItem-button" className="ListItem-button"
role="button" role="button"
ref={buttonRef}
tabIndex={0} tabIndex={0}
onClick={!inactive && IS_TOUCH_ENV ? handleClick : undefined} onClick={!inactive && IS_TOUCH_ENV ? handleClick : undefined}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}

View File

@ -1,6 +1,6 @@
import { RefObject } from 'react'; import { RefObject } from 'react';
import React, { import React, {
FC, useRef, useEffect, memo, FC, useRef, useEffect, memo, useCallback,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
@ -15,6 +15,7 @@ import './SearchInput.scss';
type OwnProps = { type OwnProps = {
ref?: RefObject<HTMLInputElement>; ref?: RefObject<HTMLInputElement>;
children?: any; children?: any;
parentContainerClassName?: string;
className?: string; className?: string;
inputId?: string; inputId?: string;
value?: string; value?: string;
@ -32,6 +33,7 @@ type OwnProps = {
const SearchInput: FC<OwnProps> = ({ const SearchInput: FC<OwnProps> = ({
ref, ref,
children, children,
parentContainerClassName,
value, value,
inputId, inputId,
className, className,
@ -86,6 +88,15 @@ const SearchInput: FC<OwnProps> = ({
} }
} }
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 ( return (
<div <div
className={buildClassName('SearchInput', className, isInputFocused && 'has-focus')} className={buildClassName('SearchInput', className, isInputFocused && 'has-focus')}
@ -104,6 +115,7 @@ const SearchInput: FC<OwnProps> = ({
onChange={handleChange} onChange={handleChange}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={handleKeyDown}
/> />
<i className="icon-search" /> <i className="icon-search" />
{isLoading && ( {isLoading && (

View File

@ -38,6 +38,7 @@ export const INITIAL_STATE: GlobalState = {
chatFolders: { chatFolders: {
byId: {}, byId: {},
activeChatFolder: 0,
}, },
fileUploads: { fileUploads: {

View File

@ -149,6 +149,7 @@ export type GlobalState = {
orderedIds?: number[]; orderedIds?: number[];
byId: Record<number, ApiChatFolder>; byId: Record<number, ApiChatFolder>;
recommended?: ApiChatFolder[]; recommended?: ApiChatFolder[];
activeChatFolder: number;
}; };
focusedMessage?: { focusedMessage?: {
@ -404,14 +405,14 @@ export type ActionTypes = (
'joinChannel' | 'leaveChannel' | 'deleteChannel' | 'toggleChatPinned' | 'toggleChatArchived' | 'toggleChatUnread' | 'joinChannel' | 'leaveChannel' | 'deleteChannel' | 'toggleChatPinned' | 'toggleChatArchived' | 'toggleChatUnread' |
'loadChatFolders' | 'loadRecommendedChatFolders' | 'editChatFolder' | 'addChatFolder' | 'deleteChatFolder' | 'loadChatFolders' | 'loadRecommendedChatFolders' | 'editChatFolder' | 'addChatFolder' | 'deleteChatFolder' |
'updateChat' | 'toggleSignatures' | 'loadGroupsForDiscussion' | 'linkDiscussionGroup' | 'unlinkDiscussionGroup' | 'updateChat' | 'toggleSignatures' | 'loadGroupsForDiscussion' | 'linkDiscussionGroup' | 'unlinkDiscussionGroup' |
'loadProfilePhotos' | 'loadMoreMembers' | 'loadProfilePhotos' | 'loadMoreMembers' | 'setActiveChatFolder' |
// messages // messages
'loadViewportMessages' | 'selectMessage' | 'sendMessage' | 'cancelSendingMessage' | 'pinMessage' | 'deleteMessages' | 'loadViewportMessages' | 'selectMessage' | 'sendMessage' | 'cancelSendingMessage' | 'pinMessage' | 'deleteMessages' |
'markMessageListRead' | 'markMessagesRead' | 'loadMessage' | 'focusMessage' | 'focusLastMessage' | 'sendPollVote' | 'markMessageListRead' | 'markMessagesRead' | 'loadMessage' | 'focusMessage' | 'focusLastMessage' | 'sendPollVote' |
'editMessage' | 'deleteHistory' | 'enterMessageSelectMode' | 'toggleMessageSelection' | 'exitMessageSelectMode' | 'editMessage' | 'deleteHistory' | 'enterMessageSelectMode' | 'toggleMessageSelection' | 'exitMessageSelectMode' |
'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' | 'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' |
'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' | 'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' |
'loadMessageLink' | 'toggleMessageWebPage' | 'loadMessageLink' | 'toggleMessageWebPage' | 'replyToNextMessage' |
// scheduled messages // scheduled messages
'loadScheduledHistory' | 'sendScheduledMessages' | 'rescheduleMessage' | 'deleteScheduledMessages' | 'loadScheduledHistory' | 'sendScheduledMessages' | 'rescheduleMessage' | 'deleteScheduledMessages' |
// poll result // poll result

View File

@ -4,13 +4,21 @@ import { useState, useCallback, useEffect } from '../lib/teact/teact';
export default ( export default (
elementRef: RefObject<HTMLElement>, elementRef: RefObject<HTMLElement>,
isOpen: boolean, isOpen: boolean,
onSelectWithEnter?: () => void, onSelectWithEnter?: (index: number) => void,
itemSelector?: string,
noCaptureFocus?: boolean,
) => { ) => {
const [focusedIndex, setFocusedIndex] = useState(-1); const [focusedIndex, setFocusedIndex] = useState(-1);
useEffect(() => { useEffect(() => {
setFocusedIndex(-1); 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<any>) => { const handleKeyDown = useCallback((e: React.KeyboardEvent<any>) => {
const element = elementRef.current; const element = elementRef.current;
@ -20,7 +28,7 @@ export default (
} }
if (e.keyCode === 13 && onSelectWithEnter) { if (e.keyCode === 13 && onSelectWithEnter) {
onSelectWithEnter(); onSelectWithEnter(focusedIndex);
return; return;
} }
@ -29,7 +37,7 @@ export default (
} }
const focusedElement = document.activeElement; 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; let newIndex = (focusedElement && elementChildren.indexOf(focusedElement)) || focusedIndex;
@ -48,7 +56,7 @@ export default (
setFocusedIndex(newIndex); setFocusedIndex(newIndex);
item.focus(); item.focus();
} }
}, [focusedIndex, elementRef, onSelectWithEnter]); }, [elementRef, onSelectWithEnter, itemSelector, focusedIndex]);
return handleKeyDown; return handleKeyDown;
}; };

View File

@ -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<HTMLDivElement>(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;
};

View File

@ -664,6 +664,17 @@ addReducer('unlinkDiscussionGroup', (global, actions, payload) => {
})(); })();
}); });
addReducer('setActiveChatFolder', (global, actions, payload) => {
return {
...global,
chatFolders: {
...global.chatFolders,
activeChatFolder: payload,
},
};
});
addReducer('loadMoreMembers', (global) => { addReducer('loadMoreMembers', (global) => {
(async () => { (async () => {
const { chatId } = selectCurrentMessageList(global) || {}; const { chatId } = selectCurrentMessageList(global) || {};

View File

@ -21,7 +21,7 @@ import {
selectChatMessages, selectChatMessages,
selectAllowedMessageActions, selectAllowedMessageActions,
selectMessageIdsByGroupId, selectMessageIdsByGroupId,
selectForwardedMessageIdsByGroupId, selectForwardedMessageIdsByGroupId, selectIsViewportNewest, selectReplyingToId,
} from '../../selectors'; } from '../../selectors';
import { findLast } from '../../../util/iteratees'; import { findLast } from '../../../util/iteratees';
@ -83,6 +83,48 @@ addReducer('editLastMessage', (global) => {
return replaceThreadParam(global, chatId, threadId, 'editingId', lastOwnEditableMessageId); 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) => { addReducer('openMediaViewer', (global, actions, payload) => {
const { const {
chatId, threadId, messageId, avatarOwnerId, profilePhotoIndex, origin, chatId, threadId, messageId, avatarOwnerId, profilePhotoIndex, origin,