Various search UI improvements
This commit is contained in:
parent
630394d91d
commit
a9cb621eb6
25 changed files with 835 additions and 577 deletions
|
@ -27,6 +27,7 @@ export enum AvatarBlur {
|
|||
}
|
||||
|
||||
export enum AvatarSize {
|
||||
SIXTEEN = 16,
|
||||
TWENTY_EIGHT = 28,
|
||||
THIRTY_TWO = 32,
|
||||
THIRTY_SIX = 36,
|
||||
|
|
|
@ -83,6 +83,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
cantAddContactToGroup: action('cantAddContactToGroup'),
|
||||
canResizeLeftPane: true,
|
||||
clearGroupCreationError: action('clearGroupCreationError'),
|
||||
clearSearch: action('clearSearch'),
|
||||
closeCantAddContactToGroupModal: action('closeCantAddContactToGroupModal'),
|
||||
closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'),
|
||||
closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'),
|
||||
|
@ -131,6 +132,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
selectedConversationId: undefined,
|
||||
selectedMessageId: undefined,
|
||||
savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'),
|
||||
searchInConversation: action('searchInConversation'),
|
||||
setComposeSearchTerm: action('setComposeSearchTerm'),
|
||||
setComposeGroupAvatar: action('setComposeGroupAvatar'),
|
||||
setComposeGroupName: action('setComposeGroupName'),
|
||||
|
@ -142,11 +144,13 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
startNewConversationFromPhoneNumber: action(
|
||||
'startNewConversationFromPhoneNumber'
|
||||
),
|
||||
startSearch: action('startSearch'),
|
||||
startSettingGroupMetadata: action('startSettingGroupMetadata'),
|
||||
toggleComposeEditingAvatar: action('toggleComposeEditingAvatar'),
|
||||
toggleConversationInChooseMembers: action(
|
||||
'toggleConversationInChooseMembers'
|
||||
),
|
||||
updateSearchTerm: action('updateSearchTerm'),
|
||||
|
||||
...overrideProps,
|
||||
});
|
||||
|
@ -393,6 +397,8 @@ story.add('Archive: no archived conversations', () => (
|
|||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Archive,
|
||||
archivedConversations: [],
|
||||
searchConversation: undefined,
|
||||
searchTerm: '',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
@ -404,6 +410,25 @@ story.add('Archive: archived conversations', () => (
|
|||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Archive,
|
||||
archivedConversations: defaultConversations,
|
||||
searchConversation: undefined,
|
||||
searchTerm: '',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
));
|
||||
|
||||
story.add('Archive: searching a conversation', () => (
|
||||
<LeftPane
|
||||
{...createProps({
|
||||
modeSpecificProps: {
|
||||
mode: LeftPaneMode.Archive,
|
||||
archivedConversations: defaultConversations,
|
||||
searchConversation: defaultConversations[0],
|
||||
searchTerm: 'foo bar',
|
||||
conversationResults: { isLoading: true },
|
||||
contactResults: { isLoading: true },
|
||||
messageResults: { isLoading: true },
|
||||
primarySendsSms: false,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
|
|
@ -94,6 +94,7 @@ export type PropsType = {
|
|||
// Action Creators
|
||||
cantAddContactToGroup: (conversationId: string) => void;
|
||||
clearGroupCreationError: () => void;
|
||||
clearSearch: () => void;
|
||||
closeCantAddContactToGroupModal: () => void;
|
||||
closeMaximumGroupSizeModal: () => void;
|
||||
closeRecommendedGroupSizeModal: () => void;
|
||||
|
@ -105,6 +106,7 @@ export type PropsType = {
|
|||
switchToAssociatedView?: boolean;
|
||||
}) => void;
|
||||
savePreferredLeftPaneWidth: (_: number) => void;
|
||||
searchInConversation: (conversationId: string) => unknown;
|
||||
setComposeSearchTerm: (composeSearchTerm: string) => void;
|
||||
setComposeGroupAvatar: (_: undefined | Uint8Array) => void;
|
||||
setComposeGroupName: (_: string) => void;
|
||||
|
@ -112,6 +114,7 @@ export type PropsType = {
|
|||
showArchivedConversations: () => void;
|
||||
showInbox: () => void;
|
||||
startComposing: () => void;
|
||||
startSearch: () => unknown;
|
||||
showChooseGroupMembers: () => void;
|
||||
startSettingGroupMetadata: () => void;
|
||||
toggleConversationInChooseMembers: (conversationId: string) => void;
|
||||
|
@ -119,6 +122,7 @@ export type PropsType = {
|
|||
composeReplaceAvatar: ReplaceAvatarActionType;
|
||||
composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
|
||||
toggleComposeEditingAvatar: () => unknown;
|
||||
updateSearchTerm: (_: string) => void;
|
||||
|
||||
// Render Props
|
||||
renderExpiredBuildDialog: (
|
||||
|
@ -143,6 +147,7 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
canResizeLeftPane,
|
||||
challengeStatus,
|
||||
clearGroupCreationError,
|
||||
clearSearch,
|
||||
closeCantAddContactToGroupModal,
|
||||
closeMaximumGroupSizeModal,
|
||||
closeRecommendedGroupSizeModal,
|
||||
|
@ -162,6 +167,7 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
renderRelinkDialog,
|
||||
renderUpdateDialog,
|
||||
savePreferredLeftPaneWidth,
|
||||
searchInConversation,
|
||||
selectedConversationId,
|
||||
selectedMessageId,
|
||||
setChallengeStatus,
|
||||
|
@ -173,10 +179,12 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
showChooseGroupMembers,
|
||||
showInbox,
|
||||
startComposing,
|
||||
startSearch,
|
||||
startNewConversationFromPhoneNumber,
|
||||
startSettingGroupMetadata,
|
||||
toggleComposeEditingAvatar,
|
||||
toggleConversationInChooseMembers,
|
||||
updateSearchTerm,
|
||||
}) => {
|
||||
const [preferredWidth, setPreferredWidth] = useState(
|
||||
// This clamp is present just in case we get a bogus value from storage.
|
||||
|
@ -354,6 +362,12 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
helper.onKeyDown(event, {
|
||||
searchInConversation,
|
||||
selectedConversationId,
|
||||
startSearch,
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
|
@ -363,11 +377,13 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
}, [
|
||||
helper,
|
||||
openConversationInternal,
|
||||
searchInConversation,
|
||||
selectedConversationId,
|
||||
selectedMessageId,
|
||||
showChooseGroupMembers,
|
||||
showInbox,
|
||||
startComposing,
|
||||
startSearch,
|
||||
]);
|
||||
|
||||
const requiresFullWidth = helper.requiresFullWidth();
|
||||
|
@ -524,10 +540,12 @@ export const LeftPane: React.FC<PropsType> = ({
|
|||
{/* eslint-enable jsx-a11y/no-static-element-interactions */}
|
||||
<div className="module-left-pane__header">
|
||||
{helper.getHeaderContents({
|
||||
clearSearch,
|
||||
i18n,
|
||||
showInbox,
|
||||
startComposing,
|
||||
showChooseGroupMembers,
|
||||
updateSearchTerm,
|
||||
}) || renderMainHeader()}
|
||||
</div>
|
||||
{renderExpiredBuildDialog({ containerWidthBreakpoint: widthBreakpoint })}
|
||||
|
|
127
ts/components/LeftPaneSearchInput.tsx
Normal file
127
ts/components/LeftPaneSearchInput.tsx
Normal file
|
@ -0,0 +1,127 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { FocusEventHandler } from 'react';
|
||||
import React, { forwardRef, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { refMerger } from '../util/refMerger';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
|
||||
type PropsType = {
|
||||
disabled?: boolean;
|
||||
i18n: LocalizerType;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onChangeValue: (newValue: string) => unknown;
|
||||
onClear: () => unknown;
|
||||
searchConversation?: ConversationType;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const LeftPaneSearchInput = forwardRef<HTMLInputElement, PropsType>(
|
||||
(
|
||||
{
|
||||
disabled,
|
||||
i18n,
|
||||
onBlur,
|
||||
onChangeValue,
|
||||
onClear,
|
||||
searchConversation,
|
||||
value,
|
||||
},
|
||||
outerRef
|
||||
) => {
|
||||
const inputRef = useRef<null | HTMLInputElement>(null);
|
||||
|
||||
const emptyOrClear =
|
||||
searchConversation && value ? () => onChangeValue('') : onClear;
|
||||
|
||||
const label = searchConversation
|
||||
? i18n('searchIn', [
|
||||
searchConversation.isMe
|
||||
? i18n('noteToSelf')
|
||||
: searchConversation.title,
|
||||
])
|
||||
: i18n('search');
|
||||
|
||||
return (
|
||||
<div className="LeftPaneSearchInput">
|
||||
{searchConversation ? (
|
||||
// Clicking the non-X part of the pill should focus the input but have a normal
|
||||
// cursor. This effectively simulates `pointer-events: none` while still
|
||||
// letting us change the cursor.
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className="LeftPaneSearchInput__in-conversation-pill"
|
||||
onClick={() => {
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
acceptedMessageRequest={searchConversation.acceptedMessageRequest}
|
||||
avatarPath={searchConversation.avatarPath}
|
||||
color={searchConversation.color}
|
||||
conversationType={searchConversation.type}
|
||||
i18n={i18n}
|
||||
isMe={searchConversation.isMe}
|
||||
noteToSelf={searchConversation.isMe}
|
||||
sharedGroupNames={searchConversation.sharedGroupNames}
|
||||
size={AvatarSize.SIXTEEN}
|
||||
title={searchConversation.title}
|
||||
unblurredAvatarPath={searchConversation.unblurredAvatarPath}
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('clearSearch')}
|
||||
className="LeftPaneSearchInput__in-conversation-pill__x-button"
|
||||
onClick={onClear}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="LeftPaneSearchInput__icon" />
|
||||
)}
|
||||
<input
|
||||
aria-label={label}
|
||||
className={classNames(
|
||||
'LeftPaneSearchInput__input',
|
||||
value && 'LeftPaneSearchInput__input--with-text',
|
||||
searchConversation && 'LeftPaneSearchInput__input--in-conversation'
|
||||
)}
|
||||
dir="auto"
|
||||
disabled={disabled}
|
||||
onBlur={onBlur}
|
||||
onChange={event => {
|
||||
onChangeValue(event.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
const { ctrlKey, key } = event;
|
||||
|
||||
// On Linux, this key combo selects all text.
|
||||
if (window.platform === 'linux' && ctrlKey && key === '/') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
} else if (key === 'Escape') {
|
||||
emptyOrClear();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
placeholder={label}
|
||||
ref={refMerger(inputRef, outerRef)}
|
||||
value={value}
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
aria-label={i18n('cancel')}
|
||||
className="LeftPaneSearchInput__cancel"
|
||||
onClick={emptyOrClear}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -3,13 +3,14 @@
|
|||
|
||||
import * as React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { text, withKnobs } from '@storybook/addon-knobs';
|
||||
import { text } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import type { PropsType } from './MainHeader';
|
||||
import { MainHeader } from './MainHeader';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -20,28 +21,12 @@ const requiredText = (name: string, value: string | undefined) =>
|
|||
const optionalText = (name: string, value: string | undefined) =>
|
||||
text(name, value || '') || undefined;
|
||||
|
||||
// Storybook types are incorrect
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
story.addDecorator((withKnobs as any)({ escapeHTML: false }));
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
searchTerm: requiredText('searchTerm', overrideProps.searchTerm),
|
||||
searchConversationName: optionalText(
|
||||
'searchConversationName',
|
||||
overrideProps.searchConversationName
|
||||
),
|
||||
searchConversationId: optionalText(
|
||||
'searchConversationId',
|
||||
overrideProps.searchConversationId
|
||||
),
|
||||
searchConversation: overrideProps.searchConversation,
|
||||
selectedConversation: undefined,
|
||||
startSearchCounter: 0,
|
||||
|
||||
ourConversationId: '',
|
||||
ourUuid: '',
|
||||
ourNumber: '',
|
||||
regionCode: '',
|
||||
|
||||
phoneNumber: optionalText('phoneNumber', overrideProps.phoneNumber),
|
||||
title: requiredText('title', overrideProps.title),
|
||||
name: optionalText('name', overrideProps.name),
|
||||
|
@ -51,10 +36,6 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
i18n,
|
||||
|
||||
updateSearchTerm: action('updateSearchTerm'),
|
||||
searchMessages: action('searchMessages'),
|
||||
searchDiscussions: action('searchDiscussions'),
|
||||
startSearch: action('startSearch'),
|
||||
searchInConversation: action('searchInConversation'),
|
||||
clearConversationSearch: action('clearConversationSearch'),
|
||||
clearSearch: action('clearSearch'),
|
||||
startUpdate: action('startUpdate'),
|
||||
|
@ -101,8 +82,7 @@ story.add('Search Term', () => {
|
|||
story.add('Searching Conversation', () => {
|
||||
const props = createProps({
|
||||
name: 'John Smith',
|
||||
searchConversationId: 'group-id-1',
|
||||
searchConversationName: 'Everyone',
|
||||
searchConversation: getDefaultConversation(),
|
||||
});
|
||||
|
||||
return <MainHeader {...props} />;
|
||||
|
@ -112,8 +92,7 @@ story.add('Searching Conversation with Term', () => {
|
|||
const props = createProps({
|
||||
name: 'John Smith',
|
||||
searchTerm: 'address',
|
||||
searchConversationId: 'group-id-1',
|
||||
searchConversationName: 'Everyone',
|
||||
searchConversation: getDefaultConversation(),
|
||||
});
|
||||
|
||||
return <MainHeader {...props} />;
|
||||
|
|
|
@ -2,8 +2,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { debounce, get } from 'lodash';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
|
@ -13,20 +11,14 @@ import { AvatarPopup } from './AvatarPopup';
|
|||
import type { LocalizerType } from '../types/Util';
|
||||
import type { AvatarColorType } from '../types/Colors';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import { LeftPaneSearchInput } from './LeftPaneSearchInput';
|
||||
|
||||
export type PropsType = {
|
||||
searchTerm: string;
|
||||
searchConversationName?: string;
|
||||
searchConversationId?: string;
|
||||
searchConversation: undefined | ConversationType;
|
||||
startSearchCounter: number;
|
||||
selectedConversation: undefined | ConversationType;
|
||||
|
||||
// To be used as an ID
|
||||
ourConversationId: string;
|
||||
ourUuid: string;
|
||||
ourNumber: string;
|
||||
regionCode: string;
|
||||
|
||||
// For display
|
||||
phoneNumber?: string;
|
||||
isMe?: boolean;
|
||||
|
@ -42,24 +34,6 @@ export type PropsType = {
|
|||
i18n: LocalizerType;
|
||||
|
||||
updateSearchTerm: (searchTerm: string) => void;
|
||||
startSearch: () => void;
|
||||
searchInConversation: (id: string, name: string) => void;
|
||||
searchMessages: (
|
||||
query: string,
|
||||
options: {
|
||||
searchConversationId?: string;
|
||||
regionCode: string;
|
||||
}
|
||||
) => void;
|
||||
searchDiscussions: (
|
||||
query: string,
|
||||
options: {
|
||||
ourConversationId: string;
|
||||
ourNumber: string;
|
||||
ourUuid: string;
|
||||
noteToSelf: string;
|
||||
}
|
||||
) => void;
|
||||
startUpdate: () => unknown;
|
||||
clearConversationSearch: () => void;
|
||||
clearSearch: () => void;
|
||||
|
@ -89,12 +63,12 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
}
|
||||
|
||||
public componentDidUpdate(prevProps: PropsType): void {
|
||||
const { searchConversationId, startSearchCounter } = this.props;
|
||||
const { searchConversation, startSearchCounter } = this.props;
|
||||
|
||||
// When user chooses to search in a given conversation we focus the field for them
|
||||
if (
|
||||
searchConversationId &&
|
||||
searchConversationId !== prevProps.searchConversationId
|
||||
searchConversation &&
|
||||
searchConversation.id !== prevProps.searchConversation?.id
|
||||
) {
|
||||
this.setFocus();
|
||||
}
|
||||
|
@ -157,46 +131,16 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
}
|
||||
}
|
||||
|
||||
public search = debounce((searchTerm: string): void => {
|
||||
const {
|
||||
i18n,
|
||||
ourConversationId,
|
||||
ourNumber,
|
||||
ourUuid,
|
||||
regionCode,
|
||||
searchDiscussions,
|
||||
searchMessages,
|
||||
searchConversationId,
|
||||
} = this.props;
|
||||
|
||||
if (searchDiscussions && !searchConversationId) {
|
||||
searchDiscussions(searchTerm, {
|
||||
noteToSelf: i18n('noteToSelf').toLowerCase(),
|
||||
ourConversationId,
|
||||
ourNumber,
|
||||
ourUuid,
|
||||
});
|
||||
}
|
||||
|
||||
if (searchMessages) {
|
||||
searchMessages(searchTerm, {
|
||||
searchConversationId,
|
||||
regionCode,
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
|
||||
public updateSearch = (event: React.FormEvent<HTMLInputElement>): void => {
|
||||
private updateSearch = (searchTerm: string): void => {
|
||||
const {
|
||||
updateSearchTerm,
|
||||
clearConversationSearch,
|
||||
clearSearch,
|
||||
searchConversationId,
|
||||
searchConversation,
|
||||
} = this.props;
|
||||
const searchTerm = event.currentTarget.value;
|
||||
|
||||
if (!searchTerm) {
|
||||
if (searchConversationId) {
|
||||
if (searchConversation) {
|
||||
clearConversationSearch();
|
||||
} else {
|
||||
clearSearch();
|
||||
|
@ -208,132 +152,30 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
if (updateSearchTerm) {
|
||||
updateSearchTerm(searchTerm);
|
||||
}
|
||||
|
||||
if (searchTerm.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.search(searchTerm);
|
||||
};
|
||||
|
||||
public clearSearch = (): void => {
|
||||
const { clearSearch } = this.props;
|
||||
|
||||
clearSearch();
|
||||
this.setFocus();
|
||||
};
|
||||
|
||||
public clearConversationSearch = (): void => {
|
||||
const { clearConversationSearch } = this.props;
|
||||
|
||||
clearConversationSearch();
|
||||
this.setFocus();
|
||||
};
|
||||
|
||||
private handleInputBlur = (): void => {
|
||||
const { clearSearch, searchConversationId, searchTerm } = this.props;
|
||||
if (!searchConversationId && !searchTerm) {
|
||||
const { clearSearch, searchConversation, searchTerm } = this.props;
|
||||
if (!searchConversation && !searchTerm) {
|
||||
clearSearch();
|
||||
}
|
||||
};
|
||||
|
||||
public handleInputKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLInputElement>
|
||||
): void => {
|
||||
const {
|
||||
clearConversationSearch,
|
||||
clearSearch,
|
||||
searchConversationId,
|
||||
searchTerm,
|
||||
} = this.props;
|
||||
|
||||
const { ctrlKey, metaKey, key } = event;
|
||||
const commandKey = get(window, 'platform') === 'darwin' && metaKey;
|
||||
const controlKey = get(window, 'platform') !== 'darwin' && ctrlKey;
|
||||
const commandOrCtrl = commandKey || controlKey;
|
||||
|
||||
// On linux, this keyboard combination selects all text
|
||||
if (commandOrCtrl && key === '/') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchConversationId && searchTerm) {
|
||||
clearConversationSearch();
|
||||
} else {
|
||||
clearSearch();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
public handleGlobalKeyDown = (event: KeyboardEvent): void => {
|
||||
const { showingAvatarPopup } = this.state;
|
||||
const {
|
||||
i18n,
|
||||
selectedConversation,
|
||||
startSearch,
|
||||
searchInConversation,
|
||||
} = this.props;
|
||||
|
||||
const { ctrlKey, metaKey, shiftKey, key } = event;
|
||||
const commandKey = get(window, 'platform') === 'darwin' && metaKey;
|
||||
const controlKey = get(window, 'platform') !== 'darwin' && ctrlKey;
|
||||
const commandOrCtrl = commandKey || controlKey;
|
||||
const commandAndCtrl = commandKey && ctrlKey;
|
||||
const { key } = event;
|
||||
|
||||
if (showingAvatarPopup && key === 'Escape') {
|
||||
this.hideAvatarPopup();
|
||||
} else if (
|
||||
commandOrCtrl &&
|
||||
!commandAndCtrl &&
|
||||
!shiftKey &&
|
||||
(key === 'f' || key === 'F')
|
||||
) {
|
||||
startSearch();
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
} else if (
|
||||
selectedConversation &&
|
||||
commandOrCtrl &&
|
||||
!commandAndCtrl &&
|
||||
shiftKey &&
|
||||
(key === 'f' || key === 'F')
|
||||
) {
|
||||
const name = selectedConversation.isMe
|
||||
? i18n('noteToSelf')
|
||||
: selectedConversation.title;
|
||||
searchInConversation(selectedConversation.id, name);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
public handleXButton = (): void => {
|
||||
const {
|
||||
searchConversationId,
|
||||
clearConversationSearch,
|
||||
clearSearch,
|
||||
} = this.props;
|
||||
|
||||
if (searchConversationId) {
|
||||
clearConversationSearch();
|
||||
} else {
|
||||
clearSearch();
|
||||
}
|
||||
|
||||
this.setFocus();
|
||||
};
|
||||
|
||||
public setFocus = (): void => {
|
||||
if (this.inputRef.current) {
|
||||
this.inputRef.current.focus();
|
||||
|
@ -356,8 +198,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
name,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
searchConversationId,
|
||||
searchConversationName,
|
||||
searchConversation,
|
||||
searchTerm,
|
||||
showArchivedConversations,
|
||||
startComposing,
|
||||
|
@ -367,13 +208,7 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
} = this.props;
|
||||
const { showingAvatarPopup, popperRoot } = this.state;
|
||||
|
||||
const placeholder = searchConversationName
|
||||
? i18n('searchIn', [searchConversationName])
|
||||
: i18n('search');
|
||||
|
||||
const isSearching = Boolean(
|
||||
searchConversationId || searchTerm.trim().length
|
||||
);
|
||||
const isSearching = Boolean(searchConversation || searchTerm.trim().length);
|
||||
|
||||
return (
|
||||
<div className="module-main-header">
|
||||
|
@ -447,59 +282,16 @@ export class MainHeader extends React.Component<PropsType, StateType> {
|
|||
)
|
||||
: null}
|
||||
</Manager>
|
||||
<div className="module-main-header__search">
|
||||
{searchConversationId ? (
|
||||
<button
|
||||
className="module-main-header__search__in-conversation-pill"
|
||||
onClick={this.clearSearch}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
aria-label={i18n('clearSearch')}
|
||||
>
|
||||
<div className="module-main-header__search__in-conversation-pill__avatar-container">
|
||||
<div className="module-main-header__search__in-conversation-pill__avatar" />
|
||||
</div>
|
||||
<div className="module-main-header__search__in-conversation-pill__x-button" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="module-main-header__search__icon"
|
||||
onClick={this.setFocus}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
aria-label={i18n('search')}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
disabled={disabled}
|
||||
type="text"
|
||||
ref={this.inputRef}
|
||||
className={classNames(
|
||||
'module-main-header__search__input',
|
||||
searchTerm
|
||||
? 'module-main-header__search__input--with-text'
|
||||
: null,
|
||||
searchConversationId
|
||||
? 'module-main-header__search__input--in-conversation'
|
||||
: null
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
dir="auto"
|
||||
onBlur={this.handleInputBlur}
|
||||
onKeyDown={this.handleInputKeyDown}
|
||||
value={searchTerm}
|
||||
onChange={this.updateSearch}
|
||||
/>
|
||||
{searchTerm ? (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="module-main-header__search__cancel-icon"
|
||||
onClick={this.handleXButton}
|
||||
type="button"
|
||||
aria-label={i18n('cancel')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<LeftPaneSearchInput
|
||||
disabled={disabled}
|
||||
i18n={i18n}
|
||||
onBlur={this.handleInputBlur}
|
||||
onChangeValue={this.updateSearch}
|
||||
onClear={this.clearSearch}
|
||||
ref={this.inputRef}
|
||||
searchConversation={searchConversation}
|
||||
value={searchTerm}
|
||||
/>
|
||||
{!isSearching && (
|
||||
<button
|
||||
aria-label={i18n('newConversation')}
|
||||
|
|
|
@ -97,7 +97,7 @@ type ActionProps = {
|
|||
saveAvatarToDisk: SaveAvatarToDiskActionType;
|
||||
showContactModal: (contactId: string, conversationId: string) => void;
|
||||
toggleSafetyNumberModal: (conversationId: string) => unknown;
|
||||
searchInConversation: (id: string, title: string) => unknown;
|
||||
searchInConversation: (id: string) => unknown;
|
||||
};
|
||||
|
||||
export type Props = StateProps & ActionProps;
|
||||
|
@ -365,10 +365,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
|
|||
<Button
|
||||
icon={ButtonIconType.search}
|
||||
onClick={() => {
|
||||
searchInConversation(
|
||||
conversation.id,
|
||||
conversation.isMe ? i18n('noteToSelf') : conversation.title
|
||||
);
|
||||
searchInConversation(conversation.id);
|
||||
}}
|
||||
variant={ButtonVariant.Details}
|
||||
>
|
||||
|
|
|
@ -12,28 +12,54 @@ import type { Row } from '../ConversationList';
|
|||
import { RowType } from '../ConversationList';
|
||||
import type { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import type { ConversationType } from '../../state/ducks/conversations';
|
||||
import { LeftPaneSearchInput } from '../LeftPaneSearchInput';
|
||||
import type { LeftPaneSearchPropsType } from './LeftPaneSearchHelper';
|
||||
import { LeftPaneSearchHelper } from './LeftPaneSearchHelper';
|
||||
|
||||
export type LeftPaneArchivePropsType = {
|
||||
type LeftPaneArchiveBasePropsType = {
|
||||
archivedConversations: ReadonlyArray<ConversationListItemPropsType>;
|
||||
searchConversation: undefined | ConversationType;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export type LeftPaneArchivePropsType =
|
||||
| LeftPaneArchiveBasePropsType
|
||||
| (LeftPaneArchiveBasePropsType & LeftPaneSearchPropsType);
|
||||
|
||||
/* eslint-disable class-methods-use-this */
|
||||
|
||||
export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsType> {
|
||||
private readonly archivedConversations: ReadonlyArray<ConversationListItemPropsType>;
|
||||
|
||||
constructor({ archivedConversations }: Readonly<LeftPaneArchivePropsType>) {
|
||||
private readonly searchConversation: undefined | ConversationType;
|
||||
|
||||
private readonly searchTerm: string;
|
||||
|
||||
private readonly searchHelper: undefined | LeftPaneSearchHelper;
|
||||
|
||||
constructor(props: Readonly<LeftPaneArchivePropsType>) {
|
||||
super();
|
||||
|
||||
this.archivedConversations = archivedConversations;
|
||||
this.archivedConversations = props.archivedConversations;
|
||||
this.searchConversation = props.searchConversation;
|
||||
this.searchTerm = props.searchTerm;
|
||||
|
||||
if ('conversationResults' in props) {
|
||||
this.searchHelper = new LeftPaneSearchHelper(props);
|
||||
}
|
||||
}
|
||||
|
||||
getHeaderContents({
|
||||
clearSearch,
|
||||
i18n,
|
||||
showInbox,
|
||||
updateSearchTerm,
|
||||
}: Readonly<{
|
||||
clearSearch: () => void;
|
||||
i18n: LocalizerType;
|
||||
showInbox: () => void;
|
||||
updateSearchTerm: (query: string) => void;
|
||||
}>): ReactChild {
|
||||
return (
|
||||
<div className="module-left-pane__header__contents">
|
||||
|
@ -45,7 +71,24 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
|
|||
type="button"
|
||||
/>
|
||||
<div className="module-left-pane__header__contents__text">
|
||||
{i18n('archivedConversations')}
|
||||
{this.searchConversation ? (
|
||||
<LeftPaneSearchInput
|
||||
i18n={i18n}
|
||||
onChangeValue={newValue => {
|
||||
updateSearchTerm(newValue);
|
||||
}}
|
||||
onClear={() => {
|
||||
clearSearch();
|
||||
}}
|
||||
ref={el => {
|
||||
el?.focus();
|
||||
}}
|
||||
searchConversation={this.searchConversation}
|
||||
value={this.searchTerm}
|
||||
/>
|
||||
) : (
|
||||
i18n('archivedConversations')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -55,7 +98,13 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
|
|||
return showInbox;
|
||||
}
|
||||
|
||||
getPreRowsNode({ i18n }: Readonly<{ i18n: LocalizerType }>): ReactChild {
|
||||
getPreRowsNode({
|
||||
i18n,
|
||||
}: Readonly<{ i18n: LocalizerType }>): ReactChild | null {
|
||||
if (this.searchHelper) {
|
||||
return this.searchHelper.getPreRowsNode({ i18n });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-left-pane__archive-helper-text">
|
||||
{i18n('archiveHelperText')}
|
||||
|
@ -64,10 +113,16 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
|
|||
}
|
||||
|
||||
getRowCount(): number {
|
||||
return this.archivedConversations.length;
|
||||
return (
|
||||
this.searchHelper?.getRowCount() ?? this.archivedConversations.length
|
||||
);
|
||||
}
|
||||
|
||||
getRow(rowIndex: number): undefined | Row {
|
||||
if (this.searchHelper) {
|
||||
return this.searchHelper.getRow(rowIndex);
|
||||
}
|
||||
|
||||
const conversation = this.archivedConversations[rowIndex];
|
||||
return conversation
|
||||
? {
|
||||
|
@ -80,6 +135,10 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
|
|||
getRowIndexToScrollTo(
|
||||
selectedConversationId: undefined | string
|
||||
): undefined | number {
|
||||
if (this.searchHelper) {
|
||||
return this.searchHelper.getRowIndexToScrollTo(selectedConversationId);
|
||||
}
|
||||
|
||||
if (!selectedConversationId) {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -92,7 +151,12 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
|
|||
getConversationAndMessageAtIndex(
|
||||
conversationIndex: number
|
||||
): undefined | { conversationId: string } {
|
||||
const { archivedConversations } = this;
|
||||
const { archivedConversations, searchHelper } = this;
|
||||
|
||||
if (searchHelper) {
|
||||
return searchHelper.getConversationAndMessageAtIndex(conversationIndex);
|
||||
}
|
||||
|
||||
const conversation =
|
||||
archivedConversations[conversationIndex] || last(archivedConversations);
|
||||
return conversation ? { conversationId: conversation.id } : undefined;
|
||||
|
@ -101,8 +165,16 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
|
|||
getConversationAndMessageInDirection(
|
||||
toFind: Readonly<ToFindType>,
|
||||
selectedConversationId: undefined | string,
|
||||
_selectedMessageId: unknown
|
||||
selectedMessageId: unknown
|
||||
): undefined | { conversationId: string } {
|
||||
if (this.searchHelper) {
|
||||
return this.searchHelper.getConversationAndMessageInDirection(
|
||||
toFind,
|
||||
selectedConversationId,
|
||||
selectedMessageId
|
||||
);
|
||||
}
|
||||
|
||||
return getConversationInDirection(
|
||||
this.archivedConversations,
|
||||
toFind,
|
||||
|
@ -110,7 +182,51 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
|
|||
);
|
||||
}
|
||||
|
||||
shouldRecomputeRowHeights(_old: unknown): boolean {
|
||||
shouldRecomputeRowHeights(old: Readonly<LeftPaneArchivePropsType>): boolean {
|
||||
const hasSearchingChanged =
|
||||
'conversationResults' in old !== Boolean(this.searchHelper);
|
||||
if (hasSearchingChanged) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ('conversationResults' in old && this.searchHelper) {
|
||||
return this.searchHelper.shouldRecomputeRowHeights(old);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
onKeyDown(
|
||||
event: KeyboardEvent,
|
||||
{
|
||||
searchInConversation,
|
||||
selectedConversationId,
|
||||
}: Readonly<{
|
||||
searchInConversation: (conversationId: string) => unknown;
|
||||
selectedConversationId: undefined | string;
|
||||
}>
|
||||
): void {
|
||||
if (!selectedConversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ctrlKey, metaKey, shiftKey, key } = event;
|
||||
const commandKey = window.platform === 'darwin' && metaKey;
|
||||
const controlKey = window.platform !== 'darwin' && ctrlKey;
|
||||
const commandOrCtrl = commandKey || controlKey;
|
||||
const commandAndCtrl = commandKey && ctrlKey;
|
||||
|
||||
if (
|
||||
commandOrCtrl &&
|
||||
!commandAndCtrl &&
|
||||
shiftKey &&
|
||||
key.toLowerCase() === 'f' &&
|
||||
this.archivedConversations.some(({ id }) => id === selectedConversationId)
|
||||
) {
|
||||
searchInConversation(selectedConversationId);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,10 +26,12 @@ export type ToFindType = {
|
|||
export abstract class LeftPaneHelper<T> {
|
||||
getHeaderContents(
|
||||
_: Readonly<{
|
||||
clearSearch: () => void;
|
||||
i18n: LocalizerType;
|
||||
showInbox: () => void;
|
||||
startComposing: () => void;
|
||||
showChooseGroupMembers: () => void;
|
||||
updateSearchTerm: (query: string) => void;
|
||||
}>
|
||||
): null | ReactChild {
|
||||
return null;
|
||||
|
@ -97,6 +99,17 @@ export abstract class LeftPaneHelper<T> {
|
|||
return true;
|
||||
}
|
||||
|
||||
onKeyDown(
|
||||
_event: KeyboardEvent,
|
||||
_options: Readonly<{
|
||||
searchInConversation: (conversationId: string) => unknown;
|
||||
selectedConversationId: undefined | string;
|
||||
startSearch: () => unknown;
|
||||
}>
|
||||
): void {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
abstract getConversationAndMessageAtIndex(
|
||||
conversationIndex: number
|
||||
): undefined | { conversationId: string; messageId?: string };
|
||||
|
|
|
@ -13,6 +13,7 @@ import type { Row } from '../ConversationList';
|
|||
import { RowType } from '../ConversationList';
|
||||
import type { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import { handleKeydownForSearch } from './handleKeydownForSearch';
|
||||
|
||||
export type LeftPaneInboxPropsType = {
|
||||
conversations: ReadonlyArray<ConversationListItemPropsType>;
|
||||
|
@ -229,6 +230,17 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
|
|||
);
|
||||
}
|
||||
|
||||
onKeyDown(
|
||||
event: KeyboardEvent,
|
||||
options: Readonly<{
|
||||
searchInConversation: (conversationId: string) => unknown;
|
||||
selectedConversationId: undefined | string;
|
||||
startSearch: () => unknown;
|
||||
}>
|
||||
): void {
|
||||
handleKeydownForSearch(event, options);
|
||||
}
|
||||
|
||||
private hasPinnedAndNonpinned(): boolean {
|
||||
return Boolean(
|
||||
this.pinnedConversations.length && this.conversations.length
|
||||
|
|
|
@ -10,6 +10,7 @@ import type { LocalizerType } from '../../types/Util';
|
|||
import type { Row } from '../ConversationList';
|
||||
import { RowType } from '../ConversationList';
|
||||
import type { PropsData as ConversationListItemPropsType } from '../conversationList/ConversationListItem';
|
||||
import { handleKeydownForSearch } from './handleKeydownForSearch';
|
||||
|
||||
import { Intl } from '../Intl';
|
||||
import { Emojify } from '../conversation/Emojify';
|
||||
|
@ -42,6 +43,8 @@ const searchResultKeys: Array<
|
|||
'conversationResults' | 'contactResults' | 'messageResults'
|
||||
> = ['conversationResults', 'contactResults', 'messageResults'];
|
||||
|
||||
/* eslint-disable class-methods-use-this */
|
||||
|
||||
export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType> {
|
||||
private readonly conversationResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>;
|
||||
|
||||
|
@ -270,6 +273,17 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
|
|||
return undefined;
|
||||
}
|
||||
|
||||
onKeyDown(
|
||||
event: KeyboardEvent,
|
||||
options: Readonly<{
|
||||
searchInConversation: (conversationId: string) => unknown;
|
||||
selectedConversationId: undefined | string;
|
||||
startSearch: () => unknown;
|
||||
}>
|
||||
): void {
|
||||
handleKeydownForSearch(event, options);
|
||||
}
|
||||
|
||||
private allResults() {
|
||||
return [this.conversationResults, this.contactResults, this.messageResults];
|
||||
}
|
||||
|
|
33
ts/components/leftPane/handleKeydownForSearch.ts
Normal file
33
ts/components/leftPane/handleKeydownForSearch.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export function handleKeydownForSearch(
|
||||
event: Readonly<KeyboardEvent>,
|
||||
{
|
||||
searchInConversation,
|
||||
selectedConversationId,
|
||||
startSearch,
|
||||
}: Readonly<{
|
||||
searchInConversation: (conversationId: string) => unknown;
|
||||
selectedConversationId: undefined | string;
|
||||
startSearch: () => unknown;
|
||||
}>
|
||||
): void {
|
||||
const { ctrlKey, metaKey, shiftKey, key } = event;
|
||||
const commandKey = window.platform === 'darwin' && metaKey;
|
||||
const controlKey = window.platform !== 'darwin' && ctrlKey;
|
||||
const commandOrCtrl = commandKey || controlKey;
|
||||
const commandAndCtrl = commandKey && ctrlKey;
|
||||
|
||||
if (commandOrCtrl && !commandAndCtrl && key.toLowerCase() === 'f') {
|
||||
if (!shiftKey) {
|
||||
startSearch();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
} else if (selectedConversationId) {
|
||||
searchInConversation(selectedConversationId);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue