Support more hotkeys (#1137)
This commit is contained in:
parent
45a3064153
commit
a09b488846
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -197,6 +197,7 @@ const RightHeader: FC<OwnProps & StateProps & DispatchProps> = ({
|
||||
return (
|
||||
<>
|
||||
<SearchInput
|
||||
parentContainerClassName="RightSearch"
|
||||
value={messageSearchQuery}
|
||||
onChange={handleMessageSearchQueryChange}
|
||||
/>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -38,6 +38,7 @@ export const INITIAL_STATE: GlobalState = {
|
||||
|
||||
chatFolders: {
|
||||
byId: {},
|
||||
activeChatFolder: 0,
|
||||
},
|
||||
|
||||
fileUploads: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
25
src/hooks/useSelectWithEnter.ts
Normal file
25
src/hooks/useSelectWithEnter.ts
Normal 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;
|
||||
};
|
||||
@ -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) || {};
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user