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';
export type OwnProps = {
isActive: boolean;
onReset: () => void;
};
const ArchivedChats: FC<OwnProps> = ({ onReset }) => {
const ArchivedChats: FC<OwnProps> = ({ isActive, onReset }) => {
const lang = useLang();
return (
@ -28,7 +29,7 @@ const ArchivedChats: FC<OwnProps> = ({ onReset }) => {
</Button>
<h3>{lang('ArchivedChats')}</h3>
</div>
<ChatList folderType="archived" noChatsText="Archive is empty." />
<ChatList folderType="archived" noChatsText="Archive is empty." isActive={isActive} />
</div>
);
};

View File

@ -21,6 +21,7 @@ import './LeftColumn.scss';
type StateProps = {
searchQuery?: string;
searchDate?: number;
activeChatFolder: number;
};
type DispatchProps = Pick<GlobalActions, (
@ -45,6 +46,7 @@ const RESET_TRANSITION_DELAY_MS = 250;
const LeftColumn: FC<StateProps & DispatchProps> = ({
searchQuery,
searchDate,
activeChatFolder,
setGlobalSearchQuery,
setGlobalSearchChatId,
resetChatCreation,
@ -188,6 +190,11 @@ const LeftColumn: FC<StateProps & DispatchProps> = ({
}
}
if (content === LeftColumnContent.ChatList && activeChatFolder === 0) {
setContent(LeftColumnContent.GlobalSearch);
return;
}
setContent(LeftColumnContent.ChatList);
setContactsFilter('');
setGlobalSearchQuery({ query: '' });
@ -197,7 +204,10 @@ const LeftColumn: FC<StateProps & DispatchProps> = ({
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<StateProps & DispatchProps> = ({
}, [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<StateProps & DispatchProps> = ({
renderCount={RENDER_COUNT}
activeKey={contentType}
>
{() => {
{(isActive) => {
switch (contentType) {
case ContentType.Archived:
return (
<ArchivedChats
isActive={isActive}
onReset={handleReset}
/>
);
@ -287,8 +300,16 @@ const LeftColumn: FC<StateProps & DispatchProps> = ({
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',

View File

@ -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<number, NotifyException>;
orderedFolderIds?: number[];
activeChatFolder: number;
currentUserId?: number;
lastSyncTime?: number;
};
type DispatchProps = Pick<GlobalActions, 'loadChatFolders'>;
type DispatchProps = Pick<GlobalActions, 'loadChatFolders' | 'setActiveChatFolder' | 'openChat'>;
const INFO_THROTTLE = 3000;
const SAVED_MESSAGES_HOTKEY = '0';
const ChatFolders: FC<StateProps & DispatchProps> = ({
chatsById,
@ -43,16 +46,18 @@ const ChatFolders: FC<StateProps & DispatchProps> = ({
notifySettings,
notifyExceptions,
orderedFolderIds,
activeChatFolder,
currentUserId,
lastSyncTime,
loadChatFolders,
setActiveChatFolder,
openChat,
}) => {
// eslint-disable-next-line no-null/no-null
const transitionRef = useRef<HTMLDivElement>(null);
const lang = useLang();
const [activeTab, setActiveTab] = useState(0);
useEffect(() => {
if (lastSyncTime) {
loadChatFolders();
@ -101,8 +106,8 @@ const ChatFolders: FC<StateProps & DispatchProps> = ({
}, [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<StateProps & DispatchProps> = ({
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<StateProps & DispatchProps> = ({
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 <ChatList folderType="all" />;
if (!activeFolder || activeChatFolder === 0) {
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 (
<div className="ChatFolders">
{folderTabs && folderTabs.length ? (
<TabList tabs={folderTabs} activeTab={activeTab} onSwitchTab={handleSwitchTab} />
<TabList tabs={folderTabs} activeTab={activeChatFolder} onSwitchTab={handleSwitchTab} />
) : shouldRenderPlaceholder ? (
<div className={buildClassName('tabs-placeholder', transitionClassNames)} />
) : undefined}
<Transition
ref={transitionRef}
name={lang.isRtl ? 'slide-reversed' : 'slide'}
activeKey={activeTab}
activeKey={activeChatFolder}
renderCount={folderTabs ? folderTabs.length : 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));

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import React, {
FC, memo, useCallback, useState, useMemo,
FC, memo, useCallback, useState, useMemo, useRef,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn';
@ -8,6 +8,7 @@ import { GlobalSearchContent } from '../../../types';
import { pick } from '../../../util/iteratees';
import { parseDateString } from '../../../util/dateFormat';
import useKeyboardListNavigation from '../../../hooks/useKeyboardListNavigation';
import useLang from '../../../hooks/useLang';
import TabList from '../../ui/TabList';
@ -24,6 +25,7 @@ import './LeftSearch.scss';
export type OwnProps = {
searchQuery?: string;
searchDate?: number;
isActive: boolean;
onReset: () => void;
};
@ -53,6 +55,7 @@ const TRANSITION_RENDER_COUNT = Object.keys(GlobalSearchContent).length / 2;
const LeftSearch: FC<OwnProps & StateProps & DispatchProps> = ({
searchQuery,
searchDate,
isActive,
currentContent = GlobalSearchContent.ChatList,
chatId,
setGlobalSearchContent,
@ -73,8 +76,12 @@ const LeftSearch: FC<OwnProps & StateProps & DispatchProps> = ({
setGlobalSearchDate({ date: value.getTime() / 1000 });
}, [setGlobalSearchDate]);
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useKeyboardListNavigation(containerRef, isActive, undefined, '.ListItem-button', true);
return (
<div className="LeftSearch">
<div className="LeftSearch" ref={containerRef} onKeyDown={handleKeyDown}>
<TabList activeTab={activeTab} tabs={chatId ? CHAT_TABS : TABS} onSwitchTab={handleSwitchTab} />
<Transition
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 { ApiChat, ApiUser } from '../../../api/types';
@ -7,6 +9,7 @@ import useChatContextActions from '../../../hooks/useChatContextActions';
import useFlag from '../../../hooks/useFlag';
import { isChatPrivate, getPrivateChatUserId } from '../../../modules/helpers';
import { selectChat, selectUser, selectIsChatPinned } from '../../../modules/selectors';
import useSelectWithEnter from '../../../hooks/useSelectWithEnter';
import PrivateChatInfo from '../../common/PrivateChatInfo';
import GroupChatInfo from '../../common/GroupChatInfo';
@ -42,6 +45,12 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
handleDelete: openDeleteModal,
});
const handleClick = () => {
onClick(chatId);
};
const buttonRef = useSelectWithEnter(handleClick);
if (!chat) {
return undefined;
}
@ -49,8 +58,9 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
return (
<ListItem
className="chat-item-clickable search-result"
onClick={() => onClick(chatId)}
onClick={handleClick}
contextActions={contextActions}
buttonRef={buttonRef}
>
{isChatPrivate(chatId) ? (
<PrivateChatInfo userId={chatId} withUsername={withUsername} avatarSize="large" />

View File

@ -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<OwnProps & StateProps & DispatchProps> = ({
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 = (
<div className="modal-header" dir={lang.isRtl ? 'rtl' : undefined}>
<Button
@ -129,6 +138,7 @@ const ForwardPicker: FC<OwnProps & StateProps & DispatchProps> = ({
ref={inputRef}
value={filter}
onChange={handleFilterChange}
onKeyDown={handleKeyDown}
placeholder={lang('ForwardTo')}
/>
</div>
@ -147,6 +157,8 @@ const ForwardPicker: FC<OwnProps & StateProps & DispatchProps> = ({
items={viewportIds}
onLoadMore={getMore}
noScrollRestore={Boolean(filter)}
ref={containerRef}
onKeyDown={handleKeyDown}
>
{viewportIds.map((id) => (
<ListItem

View File

@ -50,7 +50,7 @@ type StateProps = {
messageSendKeyCombo?: ISettings['messageSendKeyCombo'];
};
type DispatchProps = Pick<GlobalActions, 'editLastMessage'>;
type DispatchProps = Pick<GlobalActions, 'editLastMessage' | 'replyToNextMessage'>;
const MAX_INPUT_HEIGHT = IS_MOBILE_SCREEN ? 256 : 416;
const TAB_INDEX_PRIORITY_TIMEOUT = 2000;
@ -87,6 +87,7 @@ const MessageInput: FC<OwnProps & StateProps & DispatchProps> = ({
noTabCapture,
messageSendKeyCombo,
editLastMessage,
replyToNextMessage,
}) => {
// eslint-disable-next-line no-null/no-null
const inputRef = useRef<HTMLDivElement>(null);
@ -226,6 +227,16 @@ const MessageInput: FC<OwnProps & StateProps & DispatchProps> = ({
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<OwnProps & StateProps & DispatchProps> = ({
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<OwnProps>(
noTabCapture: global.isPollModalOpen || global.payment.isPaymentModalOpen,
};
},
(setGlobal, actions): DispatchProps => pick(actions, ['editLastMessage']),
(setGlobal, actions): DispatchProps => pick(actions, ['editLastMessage', 'replyToNextMessage']),
)(MessageInput));

View File

@ -197,6 +197,7 @@ const RightHeader: FC<OwnProps & StateProps & DispatchProps> = ({
return (
<>
<SearchInput
parentContainerClassName="RightSearch"
value={messageSearchQuery}
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 { 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<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 (
<InfiniteScroll
className="RightSearch custom-scroll"
@ -129,6 +140,8 @@ const RightSearch: FC<OwnProps & StateProps & DispatchProps> = ({
preloadBackwards={0}
onLoadMore={searchTextMessagesLocal}
noFastList
onKeyDown={handleKeyDown}
ref={containerRef}
>
<p className="helper-text" dir="auto">
{!query ? (

View File

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

View File

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

View File

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

View File

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

View File

@ -149,6 +149,7 @@ export type GlobalState = {
orderedIds?: number[];
byId: Record<number, ApiChatFolder>;
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

View File

@ -4,13 +4,21 @@ import { useState, useCallback, useEffect } from '../lib/teact/teact';
export default (
elementRef: RefObject<HTMLElement>,
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<any>) => {
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;
};

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) => {
(async () => {
const { chatId } = selectCurrentMessageList(global) || {};

View File

@ -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,