Various search UI improvements

This commit is contained in:
Evan Hahn 2021-11-01 13:43:02 -05:00 committed by GitHub
parent 630394d91d
commit a9cb621eb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 835 additions and 577 deletions

View file

@ -1 +0,0 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m12 1a11 11 0 1 0 11 11 11 11 0 0 0 -11-11zm0 1.5a9.485 9.485 0 0 1 7.15 15.735 5.966 5.966 0 0 0 -4.65-2.235h-5a5.966 5.966 0 0 0 -4.65 2.235 9.485 9.485 0 0 1 7.15-15.735zm4 6.971c0 2.623-1.791 5.029-4 5.029s-4-2.406-4-5.029a4.16 4.16 0 0 1 4-4.471 4.16 4.16 0 0 1 4 4.471z"/></svg>

Before

Width:  |  Height:  |  Size: 376 B

View file

@ -2699,158 +2699,6 @@ button.ConversationDetails__action-button {
}
}
&__search {
flex-grow: 1;
position: relative;
display: flex;
flex-direction: row;
-webkit-app-region: no-drag;
&__input {
width: 100%;
height: 28px;
padding-left: 30px;
padding-right: 5px;
border-radius: 14px;
border: none;
@include font-body-2;
@include light-theme {
background-color: $color-gray-05;
color: $color-gray-90;
border: solid 1px $color-gray-02;
}
@include dark-theme {
color: $color-gray-05;
background-color: $color-gray-95;
border: solid 1px $color-gray-80;
}
&:placeholder {
color: $color-gray-45;
}
&:focus {
border: solid 1px $color-ultramarine;
outline: none;
}
&--with-text {
padding-right: 30px;
}
&--in-conversation {
padding-left: 50px;
}
}
&__icon {
position: absolute;
left: 8px;
top: 6px;
height: 16px;
width: 16px;
cursor: text;
@include light-theme {
@include color-svg('../images/icons/v2/search-16.svg', $color-gray-75);
}
@include dark-theme {
@include color-svg('../images/icons/v2/search-16.svg', $color-gray-25);
}
}
&__in-conversation-pill {
position: absolute;
left: 3px;
top: 3px;
bottom: 3px;
border-radius: 14px;
width: 42px;
display: flex;
flex-direction: row;
align-items: center;
// Overriding some default button styling
border: none;
padding: 0;
outline: none;
@include light-theme {
background-color: $color-gray-15;
}
@include dark-theme {
background-color: $color-gray-75;
}
&__avatar-container {
margin-left: 4px;
height: 16px;
width: 16px;
border-radius: 8px;
background-color: $color-ultramarine;
}
&__avatar {
height: 16px;
width: 16px;
@include light-theme {
@include color-svg(
'../images/icons/v2/profile-circle-outline-24.svg',
$color-white
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/profile-circle-solid-24.svg',
$color-gray-05
);
}
}
&__x-button {
margin-left: 2px;
height: 16px;
width: 16px;
@include light-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-60);
}
@include dark-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-25);
}
}
}
&__cancel-icon {
position: absolute;
right: 8px;
top: 5px;
height: 18px;
width: 18px;
@include light-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-60);
}
@include dark-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-25);
}
}
.module-left-pane--width-narrow & {
display: none;
}
}
&__compose-icon {
$icon: '../images/icons/v2/compose-outline-24.svg';
@ -5712,6 +5560,8 @@ button.module-image__border-overlay:focus {
&__text {
@include font-body-1-bold;
flex-grow: 1;
padding-right: 16px;
@include light-theme {
color: $color-gray-90;

View file

@ -0,0 +1,131 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.LeftPaneSearchInput {
-webkit-app-region: no-drag;
display: flex;
flex-direction: row;
flex-grow: 1;
position: relative;
&__input {
@include font-body-2;
@include rounded-corners;
border: none;
height: 28px;
padding-left: 30px;
padding-right: 5px;
width: 100%;
@include light-theme {
background-color: $color-gray-05;
color: $color-gray-90;
border: solid 1px $color-gray-02;
}
@include dark-theme {
color: $color-gray-05;
background-color: $color-gray-95;
border: solid 1px $color-gray-80;
}
&:placeholder {
color: $color-gray-45;
}
&:focus {
border: solid 1px $color-ultramarine;
outline: none;
}
&--with-text {
padding-right: 30px;
}
&--in-conversation {
padding-left: 50px;
}
}
&__icon {
height: 16px;
left: 8px;
pointer-events: none;
position: absolute;
top: 6px;
width: 16px;
@include light-theme {
@include color-svg('../images/icons/v2/search-16.svg', $color-gray-75);
}
@include dark-theme {
@include color-svg('../images/icons/v2/search-16.svg', $color-gray-25);
}
}
&__in-conversation-pill {
@include button-reset;
@include rounded-corners;
align-items: center;
bottom: 3px;
display: flex;
flex-direction: row;
left: 3px;
padding: 1px 3px 1px 4px;
position: absolute;
top: 3px;
@include light-theme {
background-color: $color-gray-15;
}
@include dark-theme {
background-color: $color-gray-75;
}
&__x-button {
height: 16px;
margin-left: 2px;
width: 16px;
@include light-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-60);
}
@include dark-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-25);
}
@include light-theme {
&:hover,
&:focus,
&:active {
background: $color-ultramarine;
}
}
@include dark-theme {
&:hover,
&:focus,
&:active {
background: $color-ultramarine-light;
}
}
}
}
&__cancel {
height: 18px;
position: absolute;
right: 8px;
top: 5px;
width: 18px;
@include light-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-60);
}
@include dark-theme {
@include color-svg('../images/icons/v2/x-24.svg', $color-gray-25);
}
}
.module-left-pane--width-narrow & {
display: none;
}
}

View file

@ -66,6 +66,7 @@
@import './components/IncomingCallBar.scss';
@import './components/Input.scss';
@import './components/LeftPaneDialog.scss';
@import './components/LeftPaneSearchInput.scss';
@import './components/Lightbox.scss';
@import './components/MediaQualitySelector.scss';
@import './components/MessageAudio.scss';

View file

@ -1149,7 +1149,7 @@ export async function startApp(): Promise<void> {
document.querySelector(
'.module-left-pane__header__contents__back-button'
),
document.querySelector('.module-main-header__search__input'),
document.querySelector('.LeftPaneSearchInput__input'),
document.querySelector('.module-main-header__compose-icon'),
document.querySelector(
'.module-left-pane__compose-search-form__input'
@ -1228,8 +1228,8 @@ export async function startApp(): Promise<void> {
return;
}
// MainHeader search box
if (className.includes('module-main-header__search__input')) {
// Search box
if (className.includes('LeftPaneSearchInput__input')) {
return;
}
}

View file

@ -27,6 +27,7 @@ export enum AvatarBlur {
}
export enum AvatarSize {
SIXTEEN = 16,
TWENTY_EIGHT = 28,
THIRTY_TWO = 32,
THIRTY_SIX = 36,

View file

@ -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,
},
})}
/>

View file

@ -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 })}

View 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>
);
}
);

View file

@ -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} />;

View file

@ -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')}

View file

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

View file

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

View file

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

View file

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

View file

@ -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];
}

View 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();
}
}
}

View file

@ -1,9 +1,10 @@
// Copyright 2019-2020 Signal Messenger, LLC
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { omit, reject } from 'lodash';
import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { debounce, omit, reject } from 'lodash';
import { normalize } from '../../types/PhoneNumber';
import type { StateType as RootStateType } from '../reducer';
import { cleanSearchTerm } from '../../util/cleanSearchTerm';
import type {
ClientSearchResultMessageType,
@ -21,6 +22,8 @@ import type {
SelectedConversationChangedActionType,
ShowArchivedConversationsActionType,
} from './conversations';
import { getQuery, getSearchConversation } from '../selectors/search';
import { getIntl, getUserConversationId } from '../selectors/user';
const {
searchConversations: dataSearchConversations,
@ -41,11 +44,9 @@ export type MessageSearchResultLookupType = {
export type SearchStateType = {
startSearchCounter: number;
searchConversationId?: string;
searchConversationName?: string;
contactIds: Array<string>;
conversationIds: Array<string>;
query: string;
normalizedPhoneNumber?: string;
messageIds: Array<string>;
// We do store message data to pass through the selector
messageLookup: MessageSearchResultLookupType;
@ -57,33 +58,20 @@ export type SearchStateType = {
// Actions
type SearchResultsBaseType = {
query: string;
normalizedPhoneNumber?: string;
};
type SearchMessagesResultsPayloadType = SearchResultsBaseType & {
messages: Array<MessageSearchResultType>;
};
type SearchDiscussionsResultsPayloadType = SearchResultsBaseType & {
conversationIds: Array<string>;
contactIds: Array<string>;
};
type SearchMessagesResultsKickoffActionType = {
type: 'SEARCH_MESSAGES_RESULTS';
payload: Promise<SearchMessagesResultsPayloadType>;
};
type SearchDiscussionsResultsKickoffActionType = {
type: 'SEARCH_DISCUSSIONS_RESULTS';
payload: Promise<SearchDiscussionsResultsPayloadType>;
};
type SearchMessagesResultsFulfilledActionType = {
type: 'SEARCH_MESSAGES_RESULTS_FULFILLED';
payload: SearchMessagesResultsPayloadType;
payload: {
messages: Array<MessageSearchResultType>;
query: string;
};
};
type SearchDiscussionsResultsFulfilledActionType = {
type: 'SEARCH_DISCUSSIONS_RESULTS_FULFILLED';
payload: SearchDiscussionsResultsPayloadType;
payload: {
conversationIds: Array<string>;
contactIds: Array<string>;
query: string;
};
};
type UpdateSearchTermActionType = {
type: 'SEARCH_UPDATE';
@ -105,15 +93,10 @@ type ClearConversationSearchActionType = {
};
type SearchInConversationActionType = {
type: 'SEARCH_IN_CONVERSATION';
payload: {
searchConversationId: string;
searchConversationName: string;
};
payload: { searchConversationId: string };
};
export type SearchActionType =
| SearchMessagesResultsKickoffActionType
| SearchDiscussionsResultsKickoffActionType
| SearchMessagesResultsFulfilledActionType
| SearchDiscussionsResultsFulfilledActionType
| UpdateSearchTermActionType
@ -130,8 +113,6 @@ export type SearchActionType =
// Action Creators
export const actions = {
searchMessages,
searchDiscussions,
startSearch,
clearSearch,
clearConversationSearch,
@ -139,72 +120,6 @@ export const actions = {
updateSearchTerm,
};
function searchMessages(
query: string,
options: {
regionCode: string;
}
): SearchMessagesResultsKickoffActionType {
return {
type: 'SEARCH_MESSAGES_RESULTS',
payload: doSearchMessages(query, options),
};
}
function searchDiscussions(
query: string,
options: {
ourConversationId: string;
noteToSelf: string;
}
): SearchDiscussionsResultsKickoffActionType {
return {
type: 'SEARCH_DISCUSSIONS_RESULTS',
payload: doSearchDiscussions(query, options),
};
}
async function doSearchMessages(
query: string,
options: {
searchConversationId?: string;
regionCode: string;
}
): Promise<SearchMessagesResultsPayloadType> {
const { regionCode, searchConversationId } = options;
const normalizedPhoneNumber = normalize(query, { regionCode });
const messages = await queryMessages(query, searchConversationId);
return {
messages,
normalizedPhoneNumber,
query,
};
}
async function doSearchDiscussions(
query: string,
options: {
ourConversationId: string;
noteToSelf: string;
}
): Promise<SearchDiscussionsResultsPayloadType> {
const { ourConversationId, noteToSelf } = options;
const { conversationIds, contactIds } = await queryConversationsAndContacts(
query,
{
ourConversationId,
noteToSelf,
}
);
return {
conversationIds,
contactIds,
query,
};
}
function startSearch(): StartSearchActionType {
return {
type: 'SEARCH_START',
@ -224,27 +139,92 @@ function clearConversationSearch(): ClearConversationSearchActionType {
};
}
function searchInConversation(
searchConversationId: string,
searchConversationName: string
searchConversationId: string
): SearchInConversationActionType {
return {
type: 'SEARCH_IN_CONVERSATION',
payload: {
searchConversationId,
searchConversationName,
},
payload: { searchConversationId },
};
}
function updateSearchTerm(query: string): UpdateSearchTermActionType {
return {
type: 'SEARCH_UPDATE',
payload: {
query,
},
function updateSearchTerm(
query: string
): ThunkAction<void, RootStateType, unknown, UpdateSearchTermActionType> {
return (dispatch, getState) => {
dispatch({
type: 'SEARCH_UPDATE',
payload: { query },
});
const state = getState();
doSearch({
dispatch,
noteToSelf: getIntl(state)('noteToSelf').toLowerCase(),
ourConversationId: getUserConversationId(state),
query: getQuery(state),
searchConversationId: getSearchConversation(state)?.id,
});
};
}
const doSearch = debounce(
({
dispatch,
noteToSelf,
ourConversationId,
query,
searchConversationId,
}: Readonly<{
dispatch: ThunkDispatch<
RootStateType,
unknown,
| SearchMessagesResultsFulfilledActionType
| SearchDiscussionsResultsFulfilledActionType
>;
noteToSelf: string;
ourConversationId: string;
query: string;
searchConversationId: undefined | string;
}>) => {
if (!query) {
return;
}
(async () => {
dispatch({
type: 'SEARCH_MESSAGES_RESULTS_FULFILLED',
payload: {
messages: await queryMessages(query, searchConversationId),
query,
},
});
})();
if (!searchConversationId) {
(async () => {
const {
conversationIds,
contactIds,
} = await queryConversationsAndContacts(query, {
ourConversationId,
noteToSelf,
});
dispatch({
type: 'SEARCH_DISCUSSIONS_RESULTS_FULFILLED',
payload: {
conversationIds,
contactIds,
query,
},
});
})();
}
},
200
);
async function queryMessages(
query: string,
searchConversationId?: string
@ -342,7 +322,6 @@ export function reducer(
return {
...state,
searchConversationId: undefined,
searchConversationName: undefined,
startSearchCounter: state.startSearchCounter + 1,
};
}
@ -376,7 +355,7 @@ export function reducer(
if (action.type === 'SEARCH_IN_CONVERSATION') {
const { payload } = action;
const { searchConversationId, searchConversationName } = payload;
const { searchConversationId } = payload;
if (searchConversationId === state.searchConversationId) {
return {
@ -388,23 +367,21 @@ export function reducer(
return {
...getEmptyState(),
searchConversationId,
searchConversationName,
startSearchCounter: state.startSearchCounter + 1,
};
}
if (action.type === 'CLEAR_CONVERSATION_SEARCH') {
const { searchConversationId, searchConversationName } = state;
const { searchConversationId } = state;
return {
...getEmptyState(),
searchConversationId,
searchConversationName,
};
}
if (action.type === 'SEARCH_MESSAGES_RESULTS_FULFILLED') {
const { payload } = action;
const { messages, normalizedPhoneNumber, query } = payload;
const { messages, query } = payload;
// Reject if the associated query is not the most recent user-provided query
if (state.query !== query) {
@ -415,7 +392,6 @@ export function reducer(
return {
...state,
normalizedPhoneNumber,
query,
messageIds,
messageLookup: makeLookup(messages, 'id'),
@ -425,7 +401,12 @@ export function reducer(
if (action.type === 'SEARCH_DISCUSSIONS_RESULTS_FULFILLED') {
const { payload } = action;
const { contactIds, conversationIds } = payload;
const { contactIds, conversationIds, query } = payload;
// Reject if the associated query is not the most recent user-provided query
if (state.query !== query) {
return state;
}
return {
...state,

View file

@ -21,7 +21,7 @@ import type {
import type { LeftPaneSearchPropsType } from '../../components/leftPane/LeftPaneSearchHelper';
import type { PropsDataType as MessageSearchResultPropsDataType } from '../../components/conversationList/MessageSearchResult';
import { getUserConversationId } from './user';
import { getIntl, getUserConversationId } from './user';
import type { GetConversationByIdType } from './conversations';
import {
getConversationLookup,
@ -30,6 +30,7 @@ import {
import type { BodyRangeType } from '../../types/Util';
import * as log from '../../logging/log';
import { getOwn } from '../../util/getOwn';
export const getSearch = (state: StateType): SearchStateType => state.search;
@ -43,7 +44,7 @@ export const getSelectedMessage = createSelector(
(state: SearchStateType): string | undefined => state.selectedMessage
);
export const getSearchConversationId = createSelector(
const getSearchConversationId = createSelector(
getSearch,
(state: SearchStateType): string | undefined => state.searchConversationId
);
@ -53,9 +54,24 @@ export const getIsSearchingInAConversation = createSelector(
Boolean
);
export const getSearchConversation = createSelector(
getSearchConversationId,
getConversationLookup,
(searchConversationId, conversationLookup): undefined | ConversationType =>
searchConversationId
? getOwn(conversationLookup, searchConversationId)
: undefined
);
export const getSearchConversationName = createSelector(
getSearch,
(state: SearchStateType): string | undefined => state.searchConversationName
getSearchConversation,
getIntl,
(conversation, i18n): undefined | string => {
if (!conversation) {
return undefined;
}
return conversation.isMe ? i18n('noteToSelf') : conversation.title;
}
);
export const getStartSearchCounter = createSelector(
@ -74,9 +90,10 @@ export const getMessageSearchResultLookup = createSelector(
);
export const getSearchResults = createSelector(
[getSearch, getConversationLookup],
[getSearch, getSearchConversationName, getConversationLookup],
(
state: SearchStateType,
searchConversationName,
conversationLookup: ConversationLookupType
): Omit<LeftPaneSearchPropsType, 'primarySendsSms'> => {
const {
@ -86,7 +103,6 @@ export const getSearchResults = createSelector(
messageIds,
messageLookup,
messagesLoading,
searchConversationName,
} = state;
return {

View file

@ -13,6 +13,8 @@ import { missingCaseError } from '../../util/missingCaseError';
import { ComposerStep, OneTimeModalState } from '../ducks/conversations';
import {
getIsSearchingInAConversation,
getQuery,
getSearchConversation,
getSearchResults,
getStartSearchCounter,
isSearching,
@ -91,9 +93,14 @@ const getModeSpecificProps = (
case undefined:
if (getShowArchived(state)) {
const { archivedConversations } = getLeftPaneLists(state);
const searchConversation = getSearchConversation(state);
const searchTerm = getQuery(state);
return {
mode: LeftPaneMode.Archive,
archivedConversations,
searchConversation,
searchTerm,
...(searchConversation && searchTerm ? getSearchResults(state) : {}),
};
}
if (isSearching(state)) {

View file

@ -1,4 +1,4 @@
// Copyright 2019-2020 Signal Messenger, LLC
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
@ -9,8 +9,7 @@ import type { StateType } from '../reducer';
import {
getQuery,
getSearchConversationId,
getSearchConversationName,
getSearchConversation,
getStartSearchCounter,
} from '../selectors/search';
import {
@ -27,8 +26,7 @@ const mapStateToProps = (state: StateType) => {
disabled: state.network.challengeStatus !== 'idle',
hasPendingUpdate: Boolean(state.updates.didSnooze),
searchTerm: getQuery(state),
searchConversationId: getSearchConversationId(state),
searchConversationName: getSearchConversationName(state),
searchConversation: getSearchConversation(state),
selectedConversation: getSelectedConversation(state),
startSearchCounter: getStartSearchCounter(state),
regionCode: getRegionCode(state),

View file

@ -7,14 +7,41 @@ import { v4 as uuid } from 'uuid';
import { RowType } from '../../../components/ConversationList';
import { FindDirection } from '../../../components/leftPane/LeftPaneHelper';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import { LeftPaneSearchHelper } from '../../../components/leftPane/LeftPaneSearchHelper';
import { LeftPaneArchiveHelper } from '../../../components/leftPane/LeftPaneArchiveHelper';
describe('LeftPaneArchiveHelper', () => {
let sandbox: sinon.SinonSandbox;
const defaults = {
archivedConversations: [],
searchConversation: undefined,
searchTerm: '',
};
const searchingDefaults = {
...defaults,
searchConversation: getDefaultConversation(),
conversationResults: { isLoading: false, results: [] },
contactResults: { isLoading: false, results: [] },
messageResults: { isLoading: false, results: [] },
searchTerm: 'foo',
primarySendsSms: false,
};
beforeEach(() => {
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
});
describe('getBackAction', () => {
it('returns the "show inbox" action', () => {
const showInbox = sinon.fake();
const helper = new LeftPaneArchiveHelper({ archivedConversations: [] });
const helper = new LeftPaneArchiveHelper(defaults);
assert.strictEqual(helper.getBackAction({ showInbox }), showInbox);
});
@ -22,12 +49,10 @@ describe('LeftPaneArchiveHelper', () => {
describe('getRowCount', () => {
it('returns the number of archived conversations', () => {
assert.strictEqual(
new LeftPaneArchiveHelper({ archivedConversations: [] }).getRowCount(),
0
);
assert.strictEqual(new LeftPaneArchiveHelper(defaults).getRowCount(), 0);
assert.strictEqual(
new LeftPaneArchiveHelper({
...defaults,
archivedConversations: [
getDefaultConversation(),
getDefaultConversation(),
@ -36,11 +61,20 @@ describe('LeftPaneArchiveHelper', () => {
2
);
});
it('defers to the search helper if searching', () => {
sandbox.stub(LeftPaneSearchHelper.prototype, 'getRowCount').returns(123);
assert.strictEqual(
new LeftPaneArchiveHelper(searchingDefaults).getRowCount(),
123
);
});
});
describe('getRowIndexToScrollTo', () => {
it('returns undefined if no conversation is selected', () => {
const helper = new LeftPaneArchiveHelper({
...defaults,
archivedConversations: [
getDefaultConversation(),
getDefaultConversation(),
@ -52,6 +86,7 @@ describe('LeftPaneArchiveHelper', () => {
it('returns undefined if the selected conversation is not pinned or non-pinned', () => {
const helper = new LeftPaneArchiveHelper({
...defaults,
archivedConversations: [
getDefaultConversation(),
getDefaultConversation(),
@ -66,7 +101,10 @@ describe('LeftPaneArchiveHelper', () => {
getDefaultConversation(),
getDefaultConversation(),
];
const helper = new LeftPaneArchiveHelper({ archivedConversations });
const helper = new LeftPaneArchiveHelper({
...defaults,
archivedConversations,
});
assert.strictEqual(
helper.getRowIndexToScrollTo(archivedConversations[0].id),
@ -77,6 +115,23 @@ describe('LeftPaneArchiveHelper', () => {
1
);
});
it('defers to the search helper if searching', () => {
sandbox
.stub(LeftPaneSearchHelper.prototype, 'getRowIndexToScrollTo')
.returns(123);
const archivedConversations = [
getDefaultConversation(),
getDefaultConversation(),
];
const helper = new LeftPaneArchiveHelper(searchingDefaults);
assert.strictEqual(
helper.getRowIndexToScrollTo(archivedConversations[0].id),
123
);
});
});
describe('getRow', () => {
@ -85,7 +140,10 @@ describe('LeftPaneArchiveHelper', () => {
getDefaultConversation(),
getDefaultConversation(),
];
const helper = new LeftPaneArchiveHelper({ archivedConversations });
const helper = new LeftPaneArchiveHelper({
...defaults,
archivedConversations,
});
assert.deepEqual(helper.getRow(0), {
type: RowType.Conversation,
@ -96,6 +154,18 @@ describe('LeftPaneArchiveHelper', () => {
conversation: archivedConversations[1],
});
});
it('defers to the search helper if searching', () => {
sandbox
.stub(LeftPaneSearchHelper.prototype, 'getRow')
.returns({ type: RowType.SearchResultsLoadingFakeHeader });
const helper = new LeftPaneArchiveHelper(searchingDefaults);
assert.deepEqual(helper.getRow(0), {
type: RowType.SearchResultsLoadingFakeHeader,
});
});
});
describe('getConversationAndMessageAtIndex', () => {
@ -104,7 +174,10 @@ describe('LeftPaneArchiveHelper', () => {
getDefaultConversation(),
getDefaultConversation(),
];
const helper = new LeftPaneArchiveHelper({ archivedConversations });
const helper = new LeftPaneArchiveHelper({
...defaults,
archivedConversations,
});
assert.strictEqual(
helper.getConversationAndMessageAtIndex(0)?.conversationId,
@ -121,7 +194,10 @@ describe('LeftPaneArchiveHelper', () => {
getDefaultConversation(),
getDefaultConversation(),
];
const helper = new LeftPaneArchiveHelper({ archivedConversations });
const helper = new LeftPaneArchiveHelper({
...defaults,
archivedConversations,
});
assert.strictEqual(
helper.getConversationAndMessageAtIndex(2)?.conversationId,
@ -141,12 +217,27 @@ describe('LeftPaneArchiveHelper', () => {
});
it('returns undefined if there are no archived conversations', () => {
const helper = new LeftPaneArchiveHelper({ archivedConversations: [] });
const helper = new LeftPaneArchiveHelper(defaults);
assert.isUndefined(helper.getConversationAndMessageAtIndex(0));
assert.isUndefined(helper.getConversationAndMessageAtIndex(1));
assert.isUndefined(helper.getConversationAndMessageAtIndex(-1));
});
it('defers to the search helper if searching', () => {
sandbox
.stub(
LeftPaneSearchHelper.prototype,
'getConversationAndMessageAtIndex'
)
.returns({ conversationId: 'abc123' });
const helper = new LeftPaneArchiveHelper(searchingDefaults);
assert.deepEqual(helper.getConversationAndMessageAtIndex(999), {
conversationId: 'abc123',
});
});
});
describe('getConversationAndMessageInDirection', () => {
@ -155,7 +246,10 @@ describe('LeftPaneArchiveHelper', () => {
getDefaultConversation(),
getDefaultConversation(),
];
const helper = new LeftPaneArchiveHelper({ archivedConversations });
const helper = new LeftPaneArchiveHelper({
...defaults,
archivedConversations,
});
assert.deepEqual(
helper.getConversationAndMessageInDirection(
@ -168,11 +262,37 @@ describe('LeftPaneArchiveHelper', () => {
});
// Additional tests are found with `getConversationInDirection`.
it('defers to the search helper if searching', () => {
sandbox
.stub(
LeftPaneSearchHelper.prototype,
'getConversationAndMessageInDirection'
)
.returns({ conversationId: 'abc123' });
const helper = new LeftPaneArchiveHelper(searchingDefaults);
assert.deepEqual(
helper.getConversationAndMessageInDirection(
{
direction: FindDirection.Down,
unreadOnly: false,
},
getDefaultConversation().id,
undefined
),
{
conversationId: 'abc123',
}
);
});
});
describe('shouldRecomputeRowHeights', () => {
it('always returns false because row heights are constant', () => {
it('returns false when not searching because row heights are constant', () => {
const helper = new LeftPaneArchiveHelper({
...defaults,
archivedConversations: [
getDefaultConversation(),
getDefaultConversation(),
@ -181,11 +301,13 @@ describe('LeftPaneArchiveHelper', () => {
assert.isFalse(
helper.shouldRecomputeRowHeights({
...defaults,
archivedConversations: [getDefaultConversation()],
})
);
assert.isFalse(
helper.shouldRecomputeRowHeights({
...defaults,
archivedConversations: [
getDefaultConversation(),
getDefaultConversation(),
@ -193,5 +315,27 @@ describe('LeftPaneArchiveHelper', () => {
})
);
});
it('returns true when going from searching → not searching', () => {
const helper = new LeftPaneArchiveHelper(defaults);
assert.isTrue(helper.shouldRecomputeRowHeights(searchingDefaults));
});
it('returns true when going from not searching → searching', () => {
const helper = new LeftPaneArchiveHelper(searchingDefaults);
assert.isTrue(helper.shouldRecomputeRowHeights(defaults));
});
it('defers to the search helper if searching', () => {
sandbox
.stub(LeftPaneSearchHelper.prototype, 'shouldRecomputeRowHeights')
.returns(true);
const helper = new LeftPaneArchiveHelper(searchingDefaults);
assert.isTrue(helper.shouldRecomputeRowHeights(searchingDefaults));
});
});
});

View file

@ -12660,6 +12660,14 @@
"reasonCategory": "falseMatch",
"updated": "2020-07-21T18:34:59.251Z"
},
{
"rule": "React-useRef",
"path": "ts/components/LeftPaneSearchInput.tsx",
"line": " const inputRef = useRef<null | HTMLInputElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-10-29T22:48:58.354Z",
"reasonDetail": "Only used to focus the input."
},
{
"rule": "React-useRef",
"path": "ts/components/Lightbox.tsx",

View file

@ -39,7 +39,6 @@ import type { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameC
import {
isDirectConversation,
isGroupV1,
isMe,
} from '../util/whatTypeOfConversation';
import { findAndFormatContact } from '../util/findAndFormatContact';
import * as Bytes from '../Bytes';
@ -383,10 +382,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
onDeleteMessages: () => this.destroyMessages(),
onSearchInConversation: () => {
const { searchInConversation } = window.reduxActions.search;
const name = isMe(this.model.attributes)
? window.i18n('noteToSelf')
: this.model.getTitle();
searchInConversation(this.model.id, name);
searchInConversation(this.model.id);
},
onSetMuteNotifications: this.setMuteExpiration.bind(this),
onSetPin: this.setPin.bind(this),

View file

@ -220,7 +220,7 @@ Whisper.InboxView = Whisper.View.extend({
view.remove();
const searchInput = document.querySelector(
'.module-main-header__search__input'
'.LeftPaneSearchInput__input'
) as HTMLElement;
searchInput?.focus?.();
}