Filter chats by Unread

This commit is contained in:
yash-signal 2024-11-13 13:33:41 -06:00 committed by GitHub
parent 45e9c07125
commit a56e7d0ade
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 883 additions and 438 deletions

View file

@ -927,6 +927,10 @@
"messageformat": "Search",
"description": "Placeholder text in the search input"
},
"icu:searchUnreadChats": {
"messageformat": "Search unread chats",
"description": "Placeholder text in the search input when unread filter is enabled"
},
"icu:clearSearch": {
"messageformat": "Clear Search",
"description": "Aria label for clear search button"
@ -939,6 +943,14 @@
"messageformat": "No results for \"{searchTerm}\"",
"description": "Shown in the search left pane when no results were found"
},
"icu:noSearchResultsWithUnreadFilter": {
"messageformat": "No results for \"{searchTerm}\" in unread chats",
"description": "Shown in the search left pane when no results were found with a search query and filter by unread enabled"
},
"icu:noSearchResultsOnlyUnreadFilter": {
"messageformat": "No unread chats",
"description": "Shown in the search left pane when no results were found with only filter by unread enabled"
},
"icu:noSearchResults--sms-only": {
"messageformat": "SMS/MMS contacts are not available on Desktop.",
"description": "Shown in the search left pane when no results were found and primary device has SMS/MMS handling enabled"
@ -947,6 +959,18 @@
"messageformat": "No results for \"{searchTerm}\" in {conversationName}",
"description": "Shown in the search left pane when no results were found"
},
"icu:conversationsUnreadHeader": {
"messageformat": "Filtered by unread",
"description": "Shown to inform the user that the results are based off of filter by unread"
},
"icu:filterByUnreadButtonLabel": {
"messageformat": "Filter by unread",
"description": "Shown when you hover over the filter by unread button"
},
"icu:clearFilterButton": {
"messageformat": "Clear filter",
"description": "Action button for clearing a filter"
},
"icu:conversationsHeader": {
"messageformat": "Chats",
"description": "Shown to separate the types of search results"
@ -7389,10 +7413,18 @@
"messageformat": "Click <newCallButtonIcon></newCallButtonIcon> to start a new voice or video call.",
"description": "Calls Tab > When no call is selected > Empty state > Call to action text"
},
"icu:CallsList__SearchInputPlaceholder--missed-calls": {
"messageformat": "Search missed calls",
"description": "Calls Tab > Calls List > Search Input > Enable missed calls filter > Placeholder"
},
"icu:CallsList__SearchInputPlaceholder": {
"messageformat": "Search",
"description": "Calls Tab > Calls List > Search Input > Placeholder"
},
"icu:CallsList__FilteredByMissedHeader": {
"messageformat": "Filtered by missed",
"description": "Calls Tab > Calls List > Toggle search filter by missed > Header"
},
"icu:CallsList__ToggleFilterByMissedLabel": {
"messageformat": "Filter by missed",
"description": "Calls Tab > Calls List > Toggle search filter by missed > Accessibility label"
@ -7409,17 +7441,17 @@
"messageformat": "Recent calls will appear here.",
"description": "Calls Tab > Calls List > When no results found > With no search query. Message subtitle"
},
"icu:CallsList__EmptyState--noQuery--missed__title": {
"messageformat": "No missed calls",
"description": "Calls Tab > Calls List > When no missed calls found > With no search query. Message title"
},
"icu:CallsList__EmptyState--noQuery--missed__subtitle": {
"messageformat": "Missed calls will appear here.",
"description": "Calls Tab > Calls List > When no missed calls found > With no search query. Message subtitle"
},
"icu:CallsList__EmptyState--hasQuery": {
"messageformat": "No results for “{query}”",
"description": "Calls Tab > Calls List > When no results found > With a search query"
"description": "Calls Tab > Calls List > When no results found > With just a search query"
},
"icu:CallsList__EmptyState--hasQueryAndMissedCalls": {
"messageformat": "No results for “{query}” in missed calls",
"description": "Calls Tab > Calls List > When no results found > With a search query and missed call filter enabled"
},
"icu:CallsList__EmptyState--missedCalls": {
"messageformat": "No missed calls",
"description": "Calls Tab > Calls List > When no results found > With only missed call filter enabled"
},
"icu:CallsList__CreateCallLink": {
"messageformat": "Create a Call Link",

View file

@ -5270,6 +5270,10 @@ button.module-calling-participants-list__contact {
}
}
&--clear-filter-button {
height: $normal-row-height;
}
&--header {
@include font-body-1-bold;
@ -5520,10 +5524,25 @@ button.module-calling-participants-list__contact {
background-color: $color-gray-75;
}
}
.module-left-pane__no-search-results__unread-header {
margin-bottom: 50px;
}
.module-left-pane__no-search-results--withHeader {
display: flex;
flex-direction: column;
margin-top: 15px;
// This applies only for filter by unread, set margin
// for clear filter button
margin-bottom: 20px;
padding-inline: 1em;
width: 100%;
text-align: center;
outline: none;
}
.module-left-pane__no-search-results,
.module-left-pane__compose-no-contacts {
flex-grow: 1;
margin-top: 27px;
padding-inline: 1em;
width: 100%;
@ -5531,6 +5550,10 @@ button.module-calling-participants-list__contact {
outline: none;
}
.module-left-pane__compose-no-contacts {
flex-grow: 1;
}
.module-left-pane__no-search-results__sms-only {
margin-top: 12px;
@include light-theme {

View file

@ -129,6 +129,21 @@
gap: 0px;
}
.CallsList__FilterHeader {
display: flex;
align-items: center;
@include font-body-1-bold;
@include dark-theme {
color: $color-gray-05;
}
padding-inline-start: 24px;
text-overflow: ellipsis;
user-select: none;
white-space: nowrap;
}
.CallsList__ToggleFilterByMissed {
@include button-reset;
flex-shrink: 0;

View file

@ -0,0 +1,39 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.ClearFilterButton {
display: flex;
justify-content: center;
align-items: flex-start;
&__inner-vertical-center {
align-self: center;
}
&__inner {
border-radius: 50px;
padding-block: 5px;
padding-inline: 15px;
@include dark-theme {
background-color: mix($color-gray-80, $color-gray-65, 40%);
color: $color-white;
&:hover {
@include not-disabled {
background-color: $color-gray-65;
}
}
}
@include light-theme {
background-color: mix($color-gray-04, $color-white, 15%);
color: $color-black;
&:hover {
@include not-disabled {
background-color: $color-white;
}
}
}
}
}

View file

@ -60,4 +60,64 @@
display: none;
}
}
&__FilterButton {
@include button-reset;
flex-shrink: 0;
padding: 4px;
margin-inline-end: 8px;
border-radius: 4px;
&:not(.LeftPaneSearchInput__FilterButton--pressed):hover {
@include light-theme {
background-color: $color-black-alpha-06;
}
@include dark-theme {
background-color: $color-white-alpha-06;
}
}
&:focus {
outline: none;
@include keyboard-mode {
box-shadow:
0 0 0 2px $color-white,
0 0 0 4px $color-ultramarine;
}
}
&::before {
content: '';
display: block;
width: 20px;
height: 20px;
@include light-theme {
@include color-svg(
'../images/icons/v3/filter/filter.svg',
$color-black
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/filter/filter.svg',
$color-gray-15
);
}
}
&--pressed {
border-radius: 9999px;
background: $color-accent-blue;
&::before {
@include color-svg(
'../images/icons/v3/filter/filter.svg',
$color-white
);
}
}
}
&__FilterLabel {
@include sr-only;
}
}

View file

@ -63,6 +63,7 @@
@import './components/ChatColorPicker.scss';
@import './components/Checkbox.scss';
@import './components/CircleCheckbox.scss';
@import './components/ClearFilterButton.scss';
@import './components/CollidingAvatars.scss';
@import './components/ComposeStepButton.scss';
@import './components/CompositionArea.scss';

View file

@ -66,6 +66,8 @@ import type {
} from '../state/ducks/calling';
import { DAY, MINUTE, SECOND } from '../util/durations';
import type { StartCallData } from './ConfirmLeaveCallModal';
import { Button, ButtonVariant } from './Button';
import type { ICUJSXMessageParamsByKeyType } from '../types/Util';
function Timestamp({
i18n,
@ -153,6 +155,7 @@ type CallsListProps = Readonly<{
togglePip: () => void;
}>;
const FILTER_HEADER_ROW_HEIGHT = 50;
const CALL_LIST_ITEM_ROW_HEIGHT = 62;
const INACTIVE_CALL_LINKS_TO_PEEK = 10;
const INACTIVE_CALL_LINK_AGE_THRESHOLD = 10 * DAY;
@ -167,7 +170,11 @@ function isSameOptions(
return a.query === b.query && a.status === b.status;
}
type SpecialRows = 'CreateCallLink' | 'EmptyState';
type SpecialRows =
| 'CreateCallLink'
| 'EmptyState'
| 'FilterHeader'
| 'ClearFilterButton';
type Row = CallHistoryGroup | SpecialRows;
export function CallsList({
@ -206,25 +213,34 @@ export function CallsList({
const searchStateStatus =
searchState.options?.status ?? CallHistoryFilterStatus.All;
const hasSearchStateQuery = searchStateQuery !== '';
const searchFiltering =
hasSearchStateQuery || searchStateStatus !== CallHistoryFilterStatus.All;
const hasMissedCallFilter =
searchStateStatus === CallHistoryFilterStatus.Missed;
const searchFiltering = hasSearchStateQuery || hasMissedCallFilter;
const searchPending = searchState.state === 'pending';
const isEmpty = !searchState.results?.items?.length;
const rows = useMemo(() => {
let results: ReadonlyArray<Row> = searchState.results?.items ?? [];
if (results.length === 0 && hasSearchStateQuery) {
results = ['EmptyState'];
const rows = useMemo<ReadonlyArray<Row>>(() => {
const results: ReadonlyArray<Row> = searchState.results?.items ?? [];
if (results.length === 0 && searchFiltering) {
return hasMissedCallFilter
? ['FilterHeader', 'EmptyState', 'ClearFilterButton']
: ['EmptyState'];
}
if (!searchFiltering && canCreateCallLinks) {
results = ['CreateCallLink', ...results];
return ['CreateCallLink', ...results];
}
if (hasMissedCallFilter) {
return ['FilterHeader', ...results, 'ClearFilterButton'];
}
return results;
}, [
searchState.results?.items,
hasSearchStateQuery,
searchFiltering,
canCreateCallLinks,
hasMissedCallFilter,
]);
const rowCount = rows.length;
@ -675,10 +691,8 @@ export function CallsList({
({ index }: Index) => {
const item = rows.at(index) ?? null;
if (item === 'EmptyState') {
// arbitary large number so the empty state can be as big as it wants,
// scrolling should always be locked when the list is empty
return 9999;
if (item === 'FilterHeader') {
return FILTER_HEADER_ROW_HEIGHT;
}
return CALL_LIST_ITEM_ROW_HEIGHT;
@ -710,11 +724,23 @@ export function CallsList({
}
if (item === 'EmptyState') {
let i18nId: keyof ICUJSXMessageParamsByKeyType;
if (hasSearchStateQuery && hasMissedCallFilter) {
i18nId = 'icu:CallsList__EmptyState--hasQueryAndMissedCalls';
} else if (hasSearchStateQuery) {
i18nId = 'icu:CallsList__EmptyState--hasQuery';
} else if (hasMissedCallFilter) {
i18nId = 'icu:CallsList__EmptyState--missedCalls';
} else {
// This should never happen
i18nId = 'icu:CallsList__EmptyState--hasQuery';
}
return (
<div key={key} className="CallsList__EmptyState" style={style}>
<I18n
i18n={i18n}
id="icu:CallsList__EmptyState--hasQuery"
id={i18nId}
components={{
query: <UserText text={searchStateQuery} />,
}}
@ -723,6 +749,32 @@ export function CallsList({
);
}
if (item === 'FilterHeader') {
return (
<div key={key} style={style} className="CallsList__FilterHeader">
{i18n('icu:CallsList__FilteredByMissedHeader')}
</div>
);
}
if (item === 'ClearFilterButton') {
return (
<div key={key} style={style} className="ClearFilterButton">
<Button
variant={ButtonVariant.SecondaryAffirmative}
className={classNames('ClearFilterButton__inner', {
// The clear filter button should be closer to the emty state
// text than to the search results.
'ClearFilterButton__inner-vertical-center': !isEmpty,
})}
onClick={() => setStatusInput(CallHistoryFilterStatus.All)}
>
{i18n('icu:clearFilterButton')}
</Button>
</div>
);
}
const conversation = getConversationForItem(item);
const activeCallConversationId = activeCall?.conversationId;
@ -918,6 +970,8 @@ export function CallsList({
getIsAnybodyInCall,
getIsCallActive,
getIsInCall,
hasMissedCallFilter,
hasSearchStateQuery,
selectedCallHistoryGroup,
onChangeCallsTabSelectedView,
onCreateCallLink,
@ -927,6 +981,7 @@ export function CallsList({
toggleConfirmLeaveCallModal,
togglePip,
i18n,
isEmpty,
]
);
@ -957,20 +1012,14 @@ export function CallsList({
subtitle={i18n('icu:CallsList__EmptyState--noQuery__subtitle')}
/>
)}
{isEmpty &&
statusInput === CallHistoryFilterStatus.Missed &&
!hasSearchStateQuery && (
<NavSidebarEmpty
title={i18n('icu:CallsList__EmptyState--noQuery--missed__title')}
subtitle={i18n(
'icu:CallsList__EmptyState--noQuery--missed__subtitle'
)}
/>
)}
<NavSidebarSearchHeader>
<SearchInput
i18n={i18n}
placeholder={i18n('icu:CallsList__SearchInputPlaceholder')}
placeholder={
searchFiltering
? i18n('icu:CallsList__SearchInputPlaceholder--missed-calls')
: i18n('icu:CallsList__SearchInputPlaceholder')
}
onChange={handleSearchInputChange}
onClear={handleSearchInputClear}
value={queryInput}
@ -983,11 +1032,10 @@ export function CallsList({
>
<button
className={classNames('CallsList__ToggleFilterByMissed', {
'CallsList__ToggleFilterByMissed--pressed':
statusInput === CallHistoryFilterStatus.Missed,
'CallsList__ToggleFilterByMissed--pressed': hasMissedCallFilter,
})}
type="button"
aria-pressed={statusInput === CallHistoryFilterStatus.Missed}
aria-pressed={hasMissedCallFilter}
aria-roledescription={i18n(
'icu:CallsList__ToggleFilterByMissed__RoleDescription'
)}

View file

@ -77,6 +77,7 @@ function Wrapper({
'onOutgoingVideoCallInConversation'
)}
onClickArchiveButton={action('onClickArchiveButton')}
onClickClearFilterButton={action('onClickClearFilterButton')}
onClickContactCheckbox={action('onClickContactCheckbox')}
removeConversation={action('removeConversation')}
renderMessageSearchResult={(id: string) => (

View file

@ -36,11 +36,13 @@ import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } f
import { UsernameSearchResultListItem } from './conversationList/UsernameSearchResultListItem';
import { GroupListItem } from './conversationList/GroupListItem';
import { ListView } from './ListView';
import { Button, ButtonVariant } from './Button';
export enum RowType {
ArchiveButton = 'ArchiveButton',
Blank = 'Blank',
Contact = 'Contact',
ClearFilterButton = 'ClearFilterButton',
ContactCheckbox = 'ContactCheckbox',
PhoneNumberCheckbox = 'PhoneNumberCheckbox',
UsernameCheckbox = 'UsernameCheckbox',
@ -72,6 +74,11 @@ type ContactRowType = {
hasContextMenu?: boolean;
};
type ClearFilterButtonRowType = {
type: RowType.ClearFilterButton;
isOnNoResultsPage: boolean;
};
type ContactCheckboxRowType = {
type: RowType.ContactCheckbox;
contact: ContactListItemPropsType;
@ -158,6 +165,7 @@ export type Row =
| BlankRowType
| ContactRowType
| ContactCheckboxRowType
| ClearFilterButtonRowType
| PhoneNumberCheckboxRowType
| UsernameCheckboxRowType
| ConversationRowType
@ -197,6 +205,7 @@ export type PropsType = {
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => void;
onClickClearFilterButton: () => void;
onPreloadConversation: (conversationId: string, messageId?: string) => void;
onSelectConversation: (conversationId: string, messageId?: string) => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
@ -221,6 +230,7 @@ export function ConversationList({
blockConversation,
onClickArchiveButton,
onClickContactCheckbox,
onClickClearFilterButton,
onPreloadConversation,
onSelectConversation,
onOutgoingAudioCallInConversation,
@ -332,6 +342,24 @@ export function ConversationList({
/>
);
break;
case RowType.ClearFilterButton:
result = (
<div className="ClearFilterButton module-conversation-list__item--clear-filter-button">
<Button
variant={ButtonVariant.SecondaryAffirmative}
className={classNames('ClearFilterButton__inner', {
// The clear filter button should be closer to the empty state
// text than to the search results.
'ClearFilterButton__inner-vertical-center':
!row.isOnNoResultsPage,
})}
onClick={onClickClearFilterButton}
>
{i18n('icu:clearFilterButton')}
</Button>
</div>
);
break;
case RowType.PhoneNumberCheckbox:
result = (
<PhoneNumberCheckboxComponent
@ -527,6 +555,7 @@ export function ConversationList({
i18n,
lookupConversationWithoutServiceId,
onClickArchiveButton,
onClickClearFilterButton,
onClickContactCheckbox,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,

View file

@ -400,6 +400,7 @@ export function ForwardMessagesModal({
showConversation={shouldNeverBeCalled}
showUserNotFoundModal={shouldNeverBeCalled}
setIsFetchingUUID={shouldNeverBeCalled}
onClickClearFilterButton={shouldNeverBeCalled}
onPreloadConversation={shouldNeverBeCalled}
onSelectConversation={shouldNeverBeCalled}
blockConversation={shouldNeverBeCalled}

View file

@ -62,6 +62,7 @@ const defaultConversations: Array<ConversationType> = [
];
const defaultSearchProps = {
filterByUnread: false,
isSearchingGlobally: true,
searchConversation: undefined,
searchDisabled: false,
@ -110,6 +111,7 @@ const pinnedConversations: Array<ConversationType> = [
const defaultModeSpecificProps = {
...defaultSearchProps,
filterByUnread: false,
mode: LeftPaneMode.Inbox as const,
pinnedConversations,
conversations: defaultConversations,
@ -153,7 +155,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
},
clearConversationSearch: action('clearConversationSearch'),
clearGroupCreationError: action('clearGroupCreationError'),
clearSearch: action('clearSearch'),
clearSearchQuery: action('clearSearchQuery'),
closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'),
closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'),
composeDeleteAvatarFromDisk: action('composeDeleteAvatarFromDisk'),
@ -316,6 +318,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
),
toggleNavTabsCollapse: action('toggleNavTabsCollapse'),
toggleProfileEditor: action('toggleProfileEditor'),
updateFilterByUnread: action('updateFilterByUnread'),
updateSearchTerm: action('updateSearchTerm'),
...overrideProps,
@ -600,6 +603,43 @@ export function SearchNoResultsWhenSearchingInAConversation(): JSX.Element {
);
}
export function SearchNoResultsUnreadFilterAndQuery(): JSX.Element {
return (
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
filterByUnread: true,
mode: LeftPaneMode.Search,
conversationResults: emptySearchResultsGroup,
contactResults: emptySearchResultsGroup,
messageResults: emptySearchResultsGroup,
primarySendsSms: false,
},
})}
/>
);
}
export function SearchNoResultsUnreadFilterWithoutQuery(): JSX.Element {
return (
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
searchTerm: '',
filterByUnread: true,
mode: LeftPaneMode.Search,
conversationResults: emptySearchResultsGroup,
contactResults: emptySearchResultsGroup,
messageResults: emptySearchResultsGroup,
primarySendsSms: false,
},
})}
/>
);
}
export function SearchAllResultsLoading(): JSX.Element {
return (
<LeftPaneInContainer
@ -683,6 +723,30 @@ export function SearchAllResults(): JSX.Element {
);
}
export function SearchAllResultsUnreadFilter(): JSX.Element {
return (
<LeftPaneInContainer
{...useProps({
modeSpecificProps: {
...defaultSearchProps,
filterByUnread: true,
mode: LeftPaneMode.Search,
conversationResults: {
isLoading: false,
results: defaultConversations,
},
contactResults: { isLoading: false, results: [] },
messageResults: {
isLoading: false,
results: [],
},
primarySendsSms: false,
},
})}
/>
);
}
export function ArchiveNoArchivedConversations(): JSX.Element {
return (
<LeftPaneInContainer

View file

@ -121,7 +121,7 @@ export type PropsType = {
blockConversation: (conversationId: string) => void;
clearConversationSearch: () => void;
clearGroupCreationError: () => void;
clearSearch: () => void;
clearSearchQuery: () => void;
closeMaximumGroupSizeModal: () => void;
closeRecommendedGroupSizeModal: () => void;
composeDeleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
@ -160,7 +160,8 @@ export type PropsType = {
toggleConversationInChooseMembers: (conversationId: string) => void;
toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
toggleProfileEditor: (initialEditState?: ProfileEditorEditState) => void;
updateSearchTerm: (_: string) => void;
updateSearchTerm: (query: string) => void;
updateFilterByUnread: (filterByUnread: boolean) => void;
// Render Props
renderMessageSearchResult: (id: string) => JSX.Element;
@ -192,7 +193,7 @@ export function LeftPane({
challengeStatus,
clearConversationSearch,
clearGroupCreationError,
clearSearch,
clearSearchQuery,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
composeDeleteAvatarFromDisk,
@ -264,6 +265,7 @@ export function LeftPane({
usernameLinkCorrupted,
updateSearchTerm,
dismissBackupMediaDownloadBanner,
updateFilterByUnread,
}: PropsType): JSX.Element {
const previousModeSpecificProps = usePrevious(
modeSpecificProps,
@ -460,7 +462,7 @@ export function LeftPane({
const { conversationId, messageId } = conversationToOpen;
showConversation({ conversationId, messageId });
if (openedByNumber) {
clearSearch();
clearSearchQuery();
}
event.preventDefault();
event.stopPropagation();
@ -478,7 +480,7 @@ export function LeftPane({
document.removeEventListener('keydown', onKeyDown);
};
}, [
clearSearch,
clearSearchQuery,
helper,
isMacOS,
searchInConversation,
@ -498,7 +500,7 @@ export function LeftPane({
const preRowsNode = helper.getPreRowsNode({
clearConversationSearch,
clearGroupCreationError,
clearSearch,
clearSearchQuery,
closeMaximumGroupSizeModal,
closeRecommendedGroupSizeModal,
composeDeleteAvatarFromDisk,
@ -758,7 +760,7 @@ export function LeftPane({
<NavSidebarSearchHeader>
{helper.getSearchInput({
clearConversationSearch,
clearSearch,
clearSearchQuery,
endConversationSearch,
endSearch,
i18n,
@ -772,6 +774,7 @@ export function LeftPane({
showUserNotFoundModal,
setIsFetchingUUID,
showInbox,
updateFilterByUnread,
})}
</NavSidebarSearchHeader>
)}
@ -826,6 +829,9 @@ export function LeftPane({
throw missingCaseError(disabledReason);
}
}}
onClickClearFilterButton={() => {
updateFilterByUnread(false);
}}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
lookupConversationWithoutServiceId={

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useRef } from 'react';
import classNames from 'classnames';
import type {
ConversationType,
ShowConversationType,
@ -10,17 +11,19 @@ import type { LocalizerType } from '../types/Util';
import { Avatar, AvatarSize } from './Avatar';
import { SearchInput } from './SearchInput';
import { usePrevious } from '../hooks/usePrevious';
import { Tooltip, TooltipPlacement } from './Tooltip';
import { Theme } from '../util/theme';
type PropsType = {
type BasePropsType = {
clearConversationSearch: () => void;
clearSearch: () => void;
clearSearchQuery: () => void;
disabled?: boolean;
endConversationSearch: () => void;
endSearch: () => void;
i18n: LocalizerType;
isSearchingGlobally: boolean;
onEnterKeyDown?: (
clearSearch: () => void,
clearSearchQuery: () => void,
showConversation: ShowConversationType
) => void;
searchConversation?: ConversationType;
@ -30,9 +33,23 @@ type PropsType = {
updateSearchTerm: (searchTerm: string) => void;
};
type NoFilterPropsType = BasePropsType & {
filterButtonEnabled?: false;
filterPressed?: false;
onFilterClick?: () => void;
};
type WithFilterPropsType = BasePropsType & {
filterButtonEnabled: boolean;
filterPressed: boolean;
onFilterClick: (enabled: boolean) => void;
};
type PropsType = NoFilterPropsType | WithFilterPropsType;
export function LeftPaneSearchInput({
clearConversationSearch,
clearSearch,
clearSearchQuery,
disabled,
endConversationSearch,
endSearch,
@ -44,6 +61,9 @@ export function LeftPaneSearchInput({
showConversation,
startSearchCounter,
updateSearchTerm,
filterButtonEnabled = false,
filterPressed = false,
onFilterClick,
}: PropsType): JSX.Element {
const inputRef = useRef<null | HTMLInputElement>(null);
@ -83,7 +103,7 @@ export function LeftPaneSearchInput({
if (searchConversation) {
clearConversationSearch();
} else {
clearSearch();
clearSearchQuery();
}
return;
@ -94,79 +114,109 @@ export function LeftPaneSearchInput({
}
};
const label = searchConversation ? i18n('icu:searchIn') : i18n('icu:search');
let label: string;
if (searchConversation) {
label = i18n('icu:searchIn');
} else if (filterPressed) {
label = i18n('icu:searchUnreadChats');
} else {
label = i18n('icu:search');
}
return (
<SearchInput
disabled={disabled}
label={label}
hasSearchIcon={!searchConversation}
i18n={i18n}
moduleClassName="LeftPaneSearchInput"
onBlur={() => {
if (!searchConversation && !searchTerm) {
endSearch();
}
}}
onKeyDown={event => {
if (onEnterKeyDown && event.key === 'Enter') {
onEnterKeyDown(clearSearch, showConversation);
event.preventDefault();
event.stopPropagation();
}
}}
onChange={event => {
changeValue(event.currentTarget.value);
}}
onClear={() => {
if (searchTerm) {
clearSearch();
inputRef.current?.focus();
} else if (searchConversation) {
endConversationSearch();
inputRef.current?.focus();
} else {
inputRef.current?.blur();
}
}}
ref={inputRef}
placeholder={label}
value={searchTerm}
>
{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={() => {
<>
<SearchInput
disabled={disabled}
label={label}
hasSearchIcon={!searchConversation}
i18n={i18n}
moduleClassName="LeftPaneSearchInput"
onBlur={() => {
if (!searchConversation && !searchTerm) {
endSearch();
}
}}
onKeyDown={event => {
if (onEnterKeyDown && event.key === 'Enter') {
onEnterKeyDown(clearSearchQuery, showConversation);
event.preventDefault();
event.stopPropagation();
}
}}
onChange={event => {
changeValue(event.currentTarget.value);
}}
onClear={() => {
if (searchTerm) {
clearSearchQuery();
inputRef.current?.focus();
}}
} else if (searchConversation) {
endConversationSearch();
inputRef.current?.focus();
} else {
inputRef.current?.blur();
}
}}
ref={inputRef}
placeholder={label}
value={searchTerm}
>
{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}
avatarUrl={searchConversation.avatarUrl}
badge={undefined}
color={searchConversation.color}
conversationType={searchConversation.type}
i18n={i18n}
isMe={searchConversation.isMe}
noteToSelf={searchConversation.isMe}
sharedGroupNames={searchConversation.sharedGroupNames}
size={AvatarSize.TWENTY}
title={searchConversation.title}
unblurredAvatarUrl={searchConversation.unblurredAvatarUrl}
/>
<button
aria-label={i18n('icu:clearSearch')}
className="LeftPaneSearchInput__in-conversation-pill__x-button"
onClick={endConversationSearch}
type="button"
/>
</div>
)}
</SearchInput>
{filterButtonEnabled && (
<Tooltip
direction={TooltipPlacement.Bottom}
content={i18n('icu:filterByUnreadButtonLabel')}
theme={Theme.Dark}
delay={600}
>
<Avatar
acceptedMessageRequest={searchConversation.acceptedMessageRequest}
avatarUrl={searchConversation.avatarUrl}
badge={undefined}
color={searchConversation.color}
conversationType={searchConversation.type}
i18n={i18n}
isMe={searchConversation.isMe}
noteToSelf={searchConversation.isMe}
sharedGroupNames={searchConversation.sharedGroupNames}
size={AvatarSize.TWENTY}
title={searchConversation.title}
unblurredAvatarUrl={searchConversation.unblurredAvatarUrl}
/>
<button
aria-label={i18n('icu:clearSearch')}
className="LeftPaneSearchInput__in-conversation-pill__x-button"
onClick={endConversationSearch}
className={classNames('LeftPaneSearchInput__FilterButton', {
'LeftPaneSearchInput__FilterButton--pressed': filterPressed,
})}
type="button"
/>
</div>
aria-pressed={filterPressed}
onClick={() => onFilterClick?.(!filterPressed)}
>
<span className="LeftPaneSearchInput__FilterLabel">
{i18n('icu:filterByUnreadButtonLabel')}
</span>
</button>
</Tooltip>
)}
</SearchInput>
</>
);
}

View file

@ -1217,6 +1217,7 @@ export function EditDistributionListModal({
i18n={i18n}
lookupConversationWithoutServiceId={asyncShouldNeverBeCalled}
onClickArchiveButton={shouldNeverBeCalled}
onClickClearFilterButton={shouldNeverBeCalled}
onClickContactCheckbox={(conversationId: string) => {
toggleSelectedConversation(conversationId);
}}

View file

@ -85,7 +85,7 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
override getSearchInput({
clearConversationSearch,
clearSearch,
clearSearchQuery,
endConversationSearch,
endSearch,
i18n,
@ -93,7 +93,7 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
showConversation,
}: Readonly<{
clearConversationSearch: () => unknown;
clearSearch: () => unknown;
clearSearchQuery: () => unknown;
endConversationSearch: () => unknown;
endSearch: () => unknown;
i18n: LocalizerType;
@ -107,7 +107,7 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
return (
<LeftPaneSearchInput
clearConversationSearch={clearConversationSearch}
clearSearch={clearSearch}
clearSearchQuery={clearSearchQuery}
endConversationSearch={endConversationSearch}
endSearch={endSearch}
i18n={i18n}

View file

@ -39,7 +39,7 @@ export abstract class LeftPaneHelper<T> {
getSearchInput(
_: Readonly<{
clearConversationSearch: () => unknown;
clearSearch: () => unknown;
clearSearchQuery: () => unknown;
endConversationSearch: () => unknown;
endSearch: () => unknown;
i18n: LocalizerType;
@ -50,6 +50,7 @@ export abstract class LeftPaneHelper<T> {
updateSearchTerm: (searchTerm: string) => unknown;
showConversation: ShowConversationType;
showInbox: () => void;
updateFilterByUnread: (filterByUnread: boolean) => void;
}> &
LookupConversationWithoutServiceIdActionsType
): null | ReactChild {
@ -78,7 +79,7 @@ export abstract class LeftPaneHelper<T> {
_: Readonly<{
clearConversationSearch: () => unknown;
clearGroupCreationError: () => void;
clearSearch: () => unknown;
clearSearchQuery: () => unknown;
closeMaximumGroupSizeModal: () => unknown;
closeRecommendedGroupSizeModal: () => unknown;
composeDeleteAvatarFromDisk: DeleteAvatarFromDiskActionType;

View file

@ -30,6 +30,7 @@ export type LeftPaneInboxPropsType = {
searchDisabled: boolean;
searchTerm: string;
searchConversation: undefined | ConversationType;
filterByUnread: boolean;
};
export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType> {
@ -51,6 +52,8 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
private readonly searchConversation: undefined | ConversationType;
private readonly filterByUnread: boolean;
constructor({
conversations,
archivedConversations,
@ -61,6 +64,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
searchDisabled,
searchTerm,
searchConversation,
filterByUnread,
}: Readonly<LeftPaneInboxPropsType>) {
super();
@ -73,6 +77,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
this.searchDisabled = searchDisabled;
this.searchTerm = searchTerm;
this.searchConversation = searchConversation;
this.filterByUnread = filterByUnread;
}
getRowCount(): number {
@ -88,25 +93,27 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
override getSearchInput({
clearConversationSearch,
clearSearch,
clearSearchQuery,
endConversationSearch,
endSearch,
i18n,
showConversation,
updateSearchTerm,
updateFilterByUnread,
}: Readonly<{
clearConversationSearch: () => unknown;
clearSearch: () => unknown;
clearSearchQuery: () => unknown;
endConversationSearch: () => unknown;
endSearch: () => unknown;
i18n: LocalizerType;
showConversation: ShowConversationType;
updateSearchTerm: (searchTerm: string) => unknown;
updateFilterByUnread: (filterByUnread: boolean) => void;
}>): ReactChild {
return (
<LeftPaneSearchInput
clearConversationSearch={clearConversationSearch}
clearSearch={clearSearch}
clearSearchQuery={clearSearchQuery}
endConversationSearch={endConversationSearch}
endSearch={endSearch}
disabled={this.searchDisabled}
@ -117,6 +124,9 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
showConversation={showConversation}
startSearchCounter={this.startSearchCounter}
updateSearchTerm={updateSearchTerm}
onFilterClick={updateFilterByUnread}
filterButtonEnabled={!this.searchConversation}
filterPressed={this.filterByUnread}
/>
);
}

View file

@ -43,6 +43,7 @@ export type LeftPaneSearchPropsType = {
searchConversationName?: string;
primarySendsSms: boolean;
searchTerm: string;
filterByUnread: boolean;
startSearchCounter: number;
isSearchingGlobally: boolean;
searchDisabled: boolean;
@ -78,6 +79,8 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
private readonly searchConversation: undefined | ConversationType;
private readonly filterByUnread: boolean;
constructor({
contactResults,
conversationResults,
@ -89,6 +92,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
searchDisabled,
searchTerm,
startSearchCounter,
filterByUnread,
}: Readonly<LeftPaneSearchPropsType>) {
super();
@ -102,30 +106,33 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
this.searchDisabled = searchDisabled;
this.searchTerm = searchTerm;
this.startSearchCounter = startSearchCounter;
this.filterByUnread = filterByUnread;
this.onEnterKeyDown = this.onEnterKeyDown.bind(this);
}
override getSearchInput({
clearConversationSearch,
clearSearch,
clearSearchQuery,
endConversationSearch,
endSearch,
i18n,
showConversation,
updateSearchTerm,
updateFilterByUnread,
}: Readonly<{
clearConversationSearch: () => unknown;
clearSearch: () => unknown;
clearSearchQuery: () => unknown;
endConversationSearch: () => unknown;
endSearch: () => unknown;
i18n: LocalizerType;
showConversation: ShowConversationType;
updateSearchTerm: (searchTerm: string) => unknown;
updateFilterByUnread: (filterByUnread: boolean) => void;
}>): ReactChild {
return (
<LeftPaneSearchInput
clearConversationSearch={clearConversationSearch}
clearSearch={clearSearch}
clearSearchQuery={clearSearchQuery}
endConversationSearch={endConversationSearch}
endSearch={endSearch}
disabled={this.searchDisabled}
@ -137,6 +144,9 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
showConversation={showConversation}
startSearchCounter={this.startSearchCounter}
updateSearchTerm={updateSearchTerm}
filterButtonEnabled={!this.searchConversation}
filterPressed={this.filterByUnread}
onFilterClick={updateFilterByUnread}
/>
);
}
@ -171,13 +181,29 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
/>
);
} else {
let noResultsMessage: string;
if (this.filterByUnread && this.searchTerm.length > 0) {
noResultsMessage = i18n('icu:noSearchResultsWithUnreadFilter', {
searchTerm,
});
} else if (this.filterByUnread) {
noResultsMessage = i18n('icu:noSearchResultsOnlyUnreadFilter');
} else {
noResultsMessage = i18n('icu:noSearchResults', {
searchTerm,
});
}
noResults = (
<>
<div>
{i18n('icu:noSearchResults', {
searchTerm,
})}
</div>
{this.filterByUnread && (
<div
className="module-conversation-list__item--header module-left-pane__no-search-results__unread-header"
aria-label={i18n('icu:conversationsUnreadHeader')}
>
{i18n('icu:conversationsUnreadHeader')}
</div>
)}
<div>{noResultsMessage}</div>
{primarySendsSms && (
<div className="module-left-pane__no-search-results__sms-only">
{i18n('icu:noSearchResults--sms-only')}
@ -191,7 +217,11 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
<div
// We need this for Ctrl-T shortcut cycling through parts of app
tabIndex={-1}
className="module-left-pane__no-search-results"
className={
this.filterByUnread
? 'module-left-pane__no-search-results--withHeader'
: 'module-left-pane__no-search-results'
}
key={searchTerm}
>
{noResults}
@ -205,11 +235,18 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
return 1 + SEARCH_RESULTS_FAKE_ROW_COUNT;
}
return this.allResults().reduce(
let count = this.allResults().reduce(
(result: number, searchResults) =>
result + getRowCountForLoadedSearchResults(searchResults),
0
);
// The clear unread filter button adds an extra row
if (this.filterByUnread) {
count += 1;
}
return count;
}
// This is currently unimplemented. See DESKTOP-1170.
@ -236,12 +273,19 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
getRowCountForLoadedSearchResults(conversationResults);
const contactRowCount = getRowCountForLoadedSearchResults(contactResults);
const messageRowCount = getRowCountForLoadedSearchResults(messageResults);
const clearFilterButtonRowCount = this.filterByUnread ? 1 : 0;
if (rowIndex < conversationRowCount) {
let rowOffset = 0;
rowOffset += conversationRowCount;
if (rowIndex < rowOffset) {
if (rowIndex === 0) {
return {
type: RowType.Header,
getHeaderText: i18n => i18n('icu:conversationsHeader'),
getHeaderText: i18n =>
this.filterByUnread
? i18n('icu:conversationsUnreadHeader')
: i18n('icu:conversationsHeader'),
};
}
assertDev(
@ -257,7 +301,9 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
: undefined;
}
if (rowIndex < conversationRowCount + contactRowCount) {
rowOffset += contactRowCount;
if (rowIndex < rowOffset) {
const localIndex = rowIndex - conversationRowCount;
if (localIndex === 0) {
return {
@ -278,28 +324,40 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
: undefined;
}
if (rowIndex >= conversationRowCount + contactRowCount + messageRowCount) {
return undefined;
rowOffset += messageRowCount;
if (rowIndex < rowOffset) {
const localIndex = rowIndex - conversationRowCount - contactRowCount;
if (localIndex === 0) {
return {
type: RowType.Header,
getHeaderText: i18n => i18n('icu:messagesHeader'),
};
}
assertDev(
!messageResults.isLoading,
"We shouldn't get here with message results still loading"
);
const message = messageResults.results[localIndex - 1];
return message
? {
type: RowType.MessageSearchResult,
messageId: message.id,
}
: undefined;
}
const localIndex = rowIndex - conversationRowCount - contactRowCount;
if (localIndex === 0) {
rowOffset += clearFilterButtonRowCount;
if (rowIndex < rowOffset) {
return {
type: RowType.Header,
getHeaderText: i18n => i18n('icu:messagesHeader'),
type: RowType.ClearFilterButton,
isOnNoResultsPage: this.allResults().every(
searchResult =>
searchResult.isLoading || searchResult.results.length === 0
),
};
}
assertDev(
!messageResults.isLoading,
"We shouldn't get here with message results still loading"
);
const message = messageResults.results[localIndex - 1];
return message
? {
type: RowType.MessageSearchResult,
messageId: message.id,
}
: undefined;
return undefined;
}
override isScrollable(): boolean {
@ -307,7 +365,8 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
}
shouldRecomputeRowHeights(old: Readonly<LeftPaneSearchPropsType>): boolean {
const oldIsLoading = new LeftPaneSearchHelper(old).isLoading();
const oldSearchPaneHelper = new LeftPaneSearchHelper(old);
const oldIsLoading = oldSearchPaneHelper.isLoading();
const newIsLoading = this.isLoading();
if (oldIsLoading && newIsLoading) {
return false;
@ -376,7 +435,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
}
private onEnterKeyDown(
clearSearch: () => unknown,
clearSearchQuery: () => unknown,
showConversation: ShowConversationType
): void {
const conversation = this.getConversationAndMessageAtIndex(0);
@ -384,7 +443,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
return;
}
showConversation(conversation);
clearSearch();
clearSearchQuery();
}
}

View file

@ -199,6 +199,7 @@ import {
import { MAX_MESSAGE_COUNT } from '../../util/deleteForMe.types';
import { markCallHistoryReadInConversation } from './callHistory';
import type { CapabilitiesType } from '../../textsecure/WebAPI';
import { actions as searchActions } from './search';
import type { SearchActionType } from './search';
// State
@ -2683,18 +2684,33 @@ function setPreJoinConversation(
function conversationsUpdated(
data: Array<ConversationType>
): ThunkAction<void, RootStateType, unknown, ConversationsUpdatedActionType> {
return dispatch => {
return (dispatch, getState) => {
for (const conversation of data) {
calling.groupMembersChanged(conversation.id);
}
const { conversationLookup } = getState().conversations;
const someConversationsHaveNewMessages = data.some(conversation => {
return (
conversationLookup[conversation.id]?.lastMessageReceivedAt !==
conversation.lastMessageReceivedAt
);
});
dispatch({
type: 'CONVERSATIONS_UPDATED',
payload: {
data,
},
});
if (someConversationsHaveNewMessages) {
dispatch(searchActions.refreshSearch());
}
};
}
function conversationRemoved(id: string): ConversationRemovedActionType {
return {
type: 'CONVERSATION_REMOVED',

View file

@ -24,7 +24,12 @@ import type {
ShowArchivedConversationsActionType,
MessageType,
} from './conversations';
import { getQuery, getSearchConversation } from '../selectors/search';
import {
getFilterByUnread,
getIsActivelySearching,
getQuery,
getSearchConversation,
} from '../selectors/search';
import { getAllConversations } from '../selectors/conversations';
import {
getIntl,
@ -62,6 +67,7 @@ export type SearchStateType = ReadonlyDeep<{
contactIds: Array<string>;
conversationIds: Array<string>;
query: string;
filterByUnread: boolean;
messageIds: Array<string>;
// We do store message data to pass through the selector
messageLookup: MessageSearchResultLookupType;
@ -98,8 +104,8 @@ type StartSearchActionType = ReadonlyDeep<{
type: 'SEARCH_START';
payload: null;
}>;
type ClearSearchActionType = ReadonlyDeep<{
type: 'SEARCH_CLEAR';
type ClearSearchQueryActionType = ReadonlyDeep<{
type: 'SEARCH_QUERY_CLEAR';
payload: null;
}>;
type ClearConversationSearchActionType = ReadonlyDeep<{
@ -119,12 +125,22 @@ type SearchInConversationActionType = ReadonlyDeep<{
payload: { searchConversationId: string };
}>;
type UpdateFilterByUnreadActionType = ReadonlyDeep<{
type: 'FILTER_BY_UNREAD_UPDATE';
payload: { enabled: boolean };
}>;
type RefreshSearchActionType = ReadonlyDeep<{
type: 'SEARCH_REFRESH';
payload: null;
}>;
export type SearchActionType = ReadonlyDeep<
| SearchMessagesResultsFulfilledActionType
| SearchDiscussionsResultsFulfilledActionType
| UpdateSearchTermActionType
| StartSearchActionType
| ClearSearchActionType
| ClearSearchQueryActionType
| ClearConversationSearchActionType
| EndSearchActionType
| EndConversationSearchActionType
@ -134,18 +150,22 @@ export type SearchActionType = ReadonlyDeep<
| TargetedConversationChangedActionType
| ShowArchivedConversationsActionType
| ConversationUnloadedActionType
| UpdateFilterByUnreadActionType
| RefreshSearchActionType
>;
// Action Creators
export const actions = {
startSearch,
clearSearch,
clearSearchQuery,
clearConversationSearch,
endSearch,
endConversationSearch,
searchInConversation,
updateSearchTerm,
updateFilterByUnread,
refreshSearch,
};
export const useSearchActions = (): BoundActionCreatorsMapObject<
@ -158,10 +178,22 @@ function startSearch(): StartSearchActionType {
payload: null,
};
}
function clearSearch(): ClearSearchActionType {
return {
type: 'SEARCH_CLEAR',
payload: null,
function clearSearchQuery(): ThunkAction<
void,
RootStateType,
unknown,
ClearSearchQueryActionType
> {
return async (dispatch, getState) => {
dispatch({
type: 'SEARCH_QUERY_CLEAR',
payload: null,
});
doSearch({
dispatch,
state: getState(),
});
};
}
function clearConversationSearch(): ClearConversationSearchActionType {
@ -191,6 +223,49 @@ function searchInConversation(
};
}
function refreshSearch(): ThunkAction<
void,
RootStateType,
unknown,
RefreshSearchActionType
> {
return (dispatch, getState) => {
const state = getState();
if (!getIsActivelySearching(state)) {
return;
}
dispatch({
type: 'SEARCH_REFRESH',
payload: null,
});
doSearch({
dispatch,
state,
});
};
}
function updateFilterByUnread(
filterByUnread: boolean
): ThunkAction<void, RootStateType, unknown, UpdateFilterByUnreadActionType> {
return (dispatch, getState) => {
dispatch({
type: 'FILTER_BY_UNREAD_UPDATE',
payload: {
enabled: filterByUnread,
},
});
doSearch({
dispatch,
state: getState(),
});
};
}
function updateSearchTerm(
query: string
): ThunkAction<void, RootStateType, unknown, UpdateSearchTermActionType> {
@ -200,23 +275,9 @@ function updateSearchTerm(
payload: { query },
});
const state = getState();
const ourConversationId = getUserConversationId(state);
strictAssert(
ourConversationId,
'updateSearchTerm our conversation is missing'
);
const i18n = getIntl(state);
doSearch({
dispatch,
allConversations: getAllConversations(state),
regionCode: getRegionCode(state),
noteToSelf: i18n('icu:noteToSelf').toLowerCase(),
ourConversationId,
query: getQuery(state),
searchConversationId: getSearchConversation(state)?.id,
state: getState(),
});
};
}
@ -224,12 +285,7 @@ function updateSearchTerm(
const doSearch = debounce(
({
dispatch,
allConversations,
regionCode,
noteToSelf,
ourConversationId,
query,
searchConversationId,
state,
}: Readonly<{
dispatch: ThunkDispatch<
RootStateType,
@ -237,21 +293,37 @@ const doSearch = debounce(
| SearchMessagesResultsFulfilledActionType
| SearchDiscussionsResultsFulfilledActionType
>;
allConversations: ReadonlyArray<ConversationType>;
noteToSelf: string;
regionCode: string | undefined;
ourConversationId: string;
query: string;
searchConversationId: undefined | string;
state: RootStateType;
}>) => {
if (!query) {
if (!getIsActivelySearching(state)) {
return;
}
const query = getQuery(state);
const filterByUnread = getFilterByUnread(state);
const i18n = getIntl(state);
const allConversations = getAllConversations(state);
const regionCode = getRegionCode(state);
const noteToSelf = i18n('icu:noteToSelf').toLowerCase();
const ourConversationId = getUserConversationId(state);
const searchConversationId = getSearchConversation(state)?.id;
strictAssert(ourConversationId, 'doSearch our conversation is missing');
// Limit the number of contacts to something reasonable
const MAX_MATCHING_CONTACTS = 100;
void (async () => {
if (filterByUnread) {
dispatch({
type: 'SEARCH_MESSAGES_RESULTS_FULFILLED',
payload: {
messages: [],
query,
},
});
return;
}
const segmenter = new Intl.Segmenter([], { granularity: 'word' });
const queryWords = [...segmenter.segment(query)]
.filter(word => word.isWordLike)
@ -284,6 +356,7 @@ const doSearch = debounce(
void (async () => {
const { conversationIds, contactIds } =
await queryConversationsAndContacts(query, {
filterByUnread,
ourConversationId,
noteToSelf,
regionCode,
@ -314,7 +387,7 @@ async function queryMessages({
contactServiceIdsMatchingQuery?: Array<ServiceIdString>;
}): Promise<Array<ClientSearchResultMessageType>> {
try {
if (query.length === 0) {
if (query.trim().length === 0) {
return [];
}
@ -338,6 +411,7 @@ async function queryMessages({
async function queryConversationsAndContacts(
query: string,
options: {
filterByUnread: boolean;
ourConversationId: string;
noteToSelf: string;
regionCode: string | undefined;
@ -347,8 +421,13 @@ async function queryConversationsAndContacts(
contactIds: Array<string>;
conversationIds: Array<string>;
}> {
const { ourConversationId, noteToSelf, regionCode, allConversations } =
options;
const {
filterByUnread,
ourConversationId,
noteToSelf,
regionCode,
allConversations,
} = options;
const normalizedQuery = removeDiacritics(query);
@ -382,7 +461,8 @@ async function queryConversationsAndContacts(
const searchResults: Array<ConversationType> = filterAndSortConversations(
visibleConversations,
normalizedQuery,
regionCode
regionCode,
filterByUnread
);
// Split into two groups - active conversations and items just from address book
@ -408,6 +488,11 @@ async function queryConversationsAndContacts(
contactIds.unshift(ourConversationId);
}
// Don't show contacts in the left pane if we're filtering by unread
if (filterByUnread) {
contactIds = [];
}
return { conversationIds, contactIds };
}
@ -417,6 +502,7 @@ export function getEmptyState(): SearchStateType {
return {
startSearchCounter: 0,
query: '',
filterByUnread: false,
messageIds: [],
messageLookup: {},
conversationIds: [],
@ -426,10 +512,51 @@ export function getEmptyState(): SearchStateType {
};
}
function handleSearchUpdate(
state: SearchStateType,
params: { query?: string; filterByUnread?: boolean }
): SearchStateType {
const { query, filterByUnread } = params;
// Determine the new state values, falling back to existing state if not provided
const newQuery = query ?? state.query;
const newFilterByUnread = filterByUnread ?? state.filterByUnread;
const isValidSearch = newQuery.length > 0 || newFilterByUnread;
const isWithinConversation = Boolean(state.searchConversationId);
if (isValidSearch) {
return {
...state,
query: newQuery,
filterByUnread: newFilterByUnread,
messagesLoading: true,
messageIds: [],
messageLookup: {},
discussionsLoading: !isWithinConversation,
contactIds: [],
conversationIds: [],
};
}
return {
...getEmptyState(),
startSearchCounter: state.startSearchCounter,
searchConversationId: state.searchConversationId,
globalSearch: state.globalSearch,
};
}
export function reducer(
state: Readonly<SearchStateType> = getEmptyState(),
action: Readonly<SearchActionType>
): SearchStateType {
if (action.type === 'FILTER_BY_UNREAD_UPDATE') {
return handleSearchUpdate(state, {
filterByUnread: action.payload.enabled,
});
}
if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') {
log.info('search: show archived conversations, clearing message lookup');
return getEmptyState();
@ -444,15 +571,8 @@ export function reducer(
};
}
if (action.type === 'SEARCH_CLEAR') {
log.info('search: cleared, clearing message lookup');
return {
...getEmptyState(),
startSearchCounter: state.startSearchCounter,
searchConversationId: state.searchConversationId,
globalSearch: state.globalSearch,
};
if (action.type === 'SEARCH_QUERY_CLEAR') {
return handleSearchUpdate(state, { query: '' });
}
if (action.type === 'SEARCH_END') {
@ -463,26 +583,7 @@ export function reducer(
}
if (action.type === 'SEARCH_UPDATE') {
const { payload } = action;
const { query } = payload;
const hasQuery = Boolean(query);
const isWithinConversation = Boolean(state.searchConversationId);
return {
...state,
query,
messagesLoading: hasQuery,
...(hasQuery
? {
messageIds: [],
messageLookup: {},
discussionsLoading: !isWithinConversation,
contactIds: [],
conversationIds: [],
}
: {}),
};
return handleSearchUpdate(state, { query: action.payload.query });
}
if (action.type === 'SEARCH_IN_CONVERSATION') {

View file

@ -774,7 +774,9 @@ export const getFilteredCandidateContactsForNewGroup = createSelector(
getCandidateContactsForNewGroup,
getNormalizedComposerConversationSearchTerm,
getRegionCode,
filterAndSortConversations
(contacts, searchTerm, regionCode): Array<ConversationType> => {
return filterAndSortConversations(contacts, searchTerm, regionCode);
}
);
const getGroupCreationComposerState = createSelector(

View file

@ -34,6 +34,11 @@ import { getOwn } from '../../util/getOwn';
export const getSearch = (state: StateType): SearchStateType => state.search;
export const getFilterByUnread = createSelector(
getSearch,
(state: SearchStateType): boolean => state.filterByUnread
);
export const getQuery = createSelector(
getSearch,
(state: SearchStateType): string => state.query
@ -96,6 +101,12 @@ export const getHasSearchQuery = createSelector(
(query: string): boolean => query.trim().length > 0
);
export const getIsActivelySearching = createSelector(
[getFilterByUnread, getHasSearchQuery],
(filterByUnread: boolean, hasSearchQuery: boolean): boolean =>
filterByUnread || hasSearchQuery
);
export const getMessageSearchResultLookup = createSelector(
getSearch,
(state: SearchStateType) => state.messageLookup
@ -114,6 +125,7 @@ export const getSearchResults = createSelector(
| 'messageResults'
| 'searchConversationName'
| 'searchTerm'
| 'filterByUnread'
> => {
const {
contactIds,
@ -145,6 +157,7 @@ export const getSearchResults = createSelector(
},
searchConversationName,
searchTerm: state.query,
filterByUnread: state.filterByUnread,
};
}
);

View file

@ -69,7 +69,9 @@ import {
hasNetworkDialog as getHasNetworkDialog,
} from '../selectors/network';
import {
getFilterByUnread,
getHasSearchQuery,
getIsActivelySearching,
getIsSearching,
getIsSearchingGlobally,
getQuery,
@ -172,7 +174,7 @@ const getModeSpecificProps = (
...(searchConversation && searchTerm ? getSearchResults(state) : {}),
};
}
if (getHasSearchQuery(state)) {
if (getIsActivelySearching(state)) {
const primarySendsSms = Boolean(
get(state.items, ['primarySendsSms'], false)
);
@ -195,6 +197,7 @@ const getModeSpecificProps = (
searchDisabled: state.network.challengeStatus !== 'idle',
searchTerm: getQuery(state),
startSearchCounter: getStartSearchCounter(state),
filterByUnread: getFilterByUnread(state),
...getLeftPaneLists(state),
};
case ComposerStep.StartDirectConversation:
@ -329,12 +332,13 @@ export const SmartLeftPane = memo(function SmartLeftPane({
} = useConversationsActions();
const {
clearConversationSearch,
clearSearch,
clearSearchQuery,
endConversationSearch,
endSearch,
searchInConversation,
startSearch,
updateSearchTerm,
updateFilterByUnread,
} = useSearchActions();
const {
onOutgoingAudioCallInConversation,
@ -376,7 +380,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
challengeStatus={challengeStatus}
clearConversationSearch={clearConversationSearch}
clearGroupCreationError={clearGroupCreationError}
clearSearch={clearSearch}
clearSearchQuery={clearSearchQuery}
closeMaximumGroupSizeModal={closeMaximumGroupSizeModal}
closeRecommendedGroupSizeModal={closeRecommendedGroupSizeModal}
composeDeleteAvatarFromDisk={composeDeleteAvatarFromDisk}
@ -448,6 +452,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
updateSearchTerm={updateSearchTerm}
usernameCorrupted={usernameCorrupted}
usernameLinkCorrupted={usernameLinkCorrupted}
updateFilterByUnread={updateFilterByUnread}
/>
);
});

View file

@ -395,6 +395,7 @@ describe('both/state/selectors/search', () => {
messageResults: { isLoading: true },
searchConversationName: undefined,
searchTerm: 'foo bar',
filterByUnread: false,
});
});
@ -450,6 +451,7 @@ describe('both/state/selectors/search', () => {
},
searchConversationName: undefined,
searchTerm: 'foo bar',
filterByUnread: false,
});
});
});

View file

@ -14,6 +14,7 @@ describe('LeftPaneInboxHelper', () => {
const defaultProps: LeftPaneInboxPropsType = {
archivedConversations: [],
conversations: [],
filterByUnread: false,
isSearchingGlobally: false,
isAboutToSearch: false,
pinnedConversations: [],

View file

@ -9,6 +9,18 @@ import { getDefaultConversation } from '../../../test-both/helpers/getDefaultCon
import { LeftPaneSearchHelper } from '../../../components/leftPane/LeftPaneSearchHelper';
const baseSearchHelperArgs = {
conversationResults: { isLoading: false, results: [] },
contactResults: { isLoading: false, results: [] },
filterByUnread: false,
messageResults: { isLoading: false, results: [] },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
};
describe('LeftPaneSearchHelper', () => {
const fakeMessage = () => ({
id: uuid(),
@ -18,17 +30,7 @@ describe('LeftPaneSearchHelper', () => {
describe('getBackAction', () => {
it('returns undefined; going back is handled elsewhere in the app', () => {
const helper = new LeftPaneSearchHelper({
conversationResults: { isLoading: false, results: [] },
contactResults: { isLoading: false, results: [] },
messageResults: { isLoading: false, results: [] },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
const helper = new LeftPaneSearchHelper(baseSearchHelperArgs);
assert.isUndefined(
helper.getBackAction({
@ -44,46 +46,31 @@ describe('LeftPaneSearchHelper', () => {
it('returns 100 if any results are loading', () => {
assert.strictEqual(
new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: true },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
}).getRowCount(),
100
);
assert.strictEqual(
new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation(), getDefaultConversation()],
},
contactResults: { isLoading: true },
messageResults: { isLoading: true },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
}).getRowCount(),
100
);
assert.strictEqual(
new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: false, results: [fakeMessage()] },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
}).getRowCount(),
100
);
@ -100,6 +87,7 @@ describe('LeftPaneSearchHelper', () => {
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
filterByUnread: false,
});
assert.strictEqual(helper.getRowCount(), 0);
@ -107,18 +95,13 @@ describe('LeftPaneSearchHelper', () => {
it('returns 1 + the number of results, dropping empty sections', () => {
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation(), getDefaultConversation()],
},
contactResults: { isLoading: false, results: [] },
messageResults: { isLoading: false, results: [fakeMessage()] },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.strictEqual(helper.getRowCount(), 5);
@ -129,40 +112,25 @@ describe('LeftPaneSearchHelper', () => {
it('returns a "loading search results" row if any results are loading', () => {
const helpers = [
new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: true },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
}),
new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation(), getDefaultConversation()],
},
contactResults: { isLoading: true },
messageResults: { isLoading: true },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
}),
new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: false, results: [fakeMessage()] },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
}),
];
@ -188,18 +156,13 @@ describe('LeftPaneSearchHelper', () => {
const messages = [fakeMessage(), fakeMessage()];
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: conversations,
},
contactResults: { isLoading: false, results: contacts },
messageResults: { isLoading: false, results: messages },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.deepEqual(
@ -235,18 +198,9 @@ describe('LeftPaneSearchHelper', () => {
const messages = [fakeMessage(), fakeMessage()];
const helper = new LeftPaneSearchHelper({
conversationResults: {
isLoading: false,
results: [],
},
...baseSearchHelperArgs,
contactResults: { isLoading: false, results: contacts },
messageResults: { isLoading: false, results: messages },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.deepEqual(_testHeaderText(helper.getRow(0)), 'icu:contactsHeader');
@ -273,18 +227,12 @@ describe('LeftPaneSearchHelper', () => {
const messages = [fakeMessage(), fakeMessage()];
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: conversations,
},
contactResults: { isLoading: false, results: [] },
messageResults: { isLoading: false, results: messages },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.deepEqual(
@ -316,18 +264,12 @@ describe('LeftPaneSearchHelper', () => {
const contacts = [getDefaultConversation()];
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: conversations,
},
contactResults: { isLoading: false, results: contacts },
messageResults: { isLoading: false, results: [] },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.deepEqual(
@ -354,40 +296,25 @@ describe('LeftPaneSearchHelper', () => {
it('returns false if any results are loading', () => {
const helpers = [
new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: true },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
}),
new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation(), getDefaultConversation()],
},
contactResults: { isLoading: true },
messageResults: { isLoading: true },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
}),
new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: false, results: [fakeMessage()] },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
}),
];
@ -398,21 +325,15 @@ describe('LeftPaneSearchHelper', () => {
it('returns true if all results have loaded', () => {
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation(), getDefaultConversation()],
},
contactResults: { isLoading: false, results: [] },
messageResults: {
isLoading: false,
results: [fakeMessage(), fakeMessage(), fakeMessage()],
},
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.isTrue(helper.isScrollable());
});
@ -421,25 +342,20 @@ describe('LeftPaneSearchHelper', () => {
describe('shouldRecomputeRowHeights', () => {
it("returns false if the number of results doesn't change", () => {
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation(), getDefaultConversation()],
},
contactResults: { isLoading: false, results: [] },
messageResults: {
isLoading: false,
results: [fakeMessage(), fakeMessage(), fakeMessage()],
},
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.isFalse(
helper.shouldRecomputeRowHeights({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation(), getDefaultConversation()],
@ -449,108 +365,68 @@ describe('LeftPaneSearchHelper', () => {
isLoading: false,
results: [fakeMessage(), fakeMessage(), fakeMessage()],
},
isSearchingGlobally: true,
searchTerm: 'bar',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
})
);
});
it('returns false when a section completes loading, but not all sections are done (because the pane is still loading overall)', () => {
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: true },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.isFalse(
helper.shouldRecomputeRowHeights({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation()],
},
contactResults: { isLoading: true },
messageResults: { isLoading: true },
isSearchingGlobally: true,
searchTerm: 'bar',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
})
);
});
it('returns true when all sections finish loading', () => {
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: false, results: [fakeMessage()] },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.isTrue(
helper.shouldRecomputeRowHeights({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation(), getDefaultConversation()],
},
contactResults: { isLoading: false, results: [] },
messageResults: { isLoading: false, results: [fakeMessage()] },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
})
);
});
it('returns true if the number of results in a section changes', () => {
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation(), getDefaultConversation()],
},
contactResults: { isLoading: false, results: [] },
messageResults: { isLoading: false, results: [] },
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.isTrue(
helper.shouldRecomputeRowHeights({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation()],
},
contactResults: { isLoading: true },
messageResults: { isLoading: true },
isSearchingGlobally: true,
searchTerm: 'bar',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
})
);
});
@ -560,21 +436,15 @@ describe('LeftPaneSearchHelper', () => {
it('returns correct conversation at given index', () => {
const expected = getDefaultConversation();
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [expected, getDefaultConversation()],
},
contactResults: { isLoading: false, results: [] },
messageResults: {
isLoading: false,
results: [fakeMessage(), fakeMessage(), fakeMessage()],
},
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.strictEqual(
helper.getConversationAndMessageAtIndex(0)?.conversationId,
@ -585,6 +455,7 @@ describe('LeftPaneSearchHelper', () => {
it('returns correct contact at given index', () => {
const expected = getDefaultConversation();
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation(), getDefaultConversation()],
@ -597,12 +468,6 @@ describe('LeftPaneSearchHelper', () => {
isLoading: false,
results: [fakeMessage(), fakeMessage(), fakeMessage()],
},
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.strictEqual(
helper.getConversationAndMessageAtIndex(2)?.conversationId,
@ -613,21 +478,15 @@ describe('LeftPaneSearchHelper', () => {
it('returns correct message at given index', () => {
const expected = fakeMessage();
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation(), getDefaultConversation()],
},
contactResults: { isLoading: false, results: [] },
messageResults: {
isLoading: false,
results: [fakeMessage(), fakeMessage(), expected],
},
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.strictEqual(
helper.getConversationAndMessageAtIndex(4)?.messageId,
@ -638,18 +497,13 @@ describe('LeftPaneSearchHelper', () => {
it('returns correct message at given index skipping not loaded results', () => {
const expected = fakeMessage();
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: {
isLoading: false,
results: [fakeMessage(), expected, fakeMessage()],
},
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.strictEqual(
helper.getConversationAndMessageAtIndex(1)?.messageId,
@ -659,21 +513,15 @@ describe('LeftPaneSearchHelper', () => {
it('returns undefined if search candidate with given index does not exist', () => {
const helper = new LeftPaneSearchHelper({
...baseSearchHelperArgs,
conversationResults: {
isLoading: false,
results: [getDefaultConversation(), getDefaultConversation()],
},
contactResults: { isLoading: false, results: [] },
messageResults: {
isLoading: false,
results: [fakeMessage(), fakeMessage(), fakeMessage()],
},
isSearchingGlobally: true,
searchTerm: 'foo',
primarySendsSms: false,
searchConversation: undefined,
searchDisabled: false,
startSearchCounter: 0,
});
assert.isUndefined(
helper.getConversationAndMessageAtIndex(100)?.messageId

View file

@ -64,6 +64,17 @@ type CommandRunnerType = (
const COMMANDS = new Map<string, CommandRunnerType>();
function filterConversationsByUnread(
conversations: ReadonlyArray<ConversationType>,
includeMuted: boolean
): Array<ConversationType> {
return conversations.filter(conversation => {
return hasUnread(
countConversationUnreadStats(conversation, { includeMuted })
);
});
}
COMMANDS.set('serviceIdEndsWith', (conversations, query) => {
return conversations.filter(convo => convo.serviceId?.endsWith(query));
});
@ -95,11 +106,7 @@ COMMANDS.set('unread', (conversations, query) => {
/^(?:m|muted)$/i.test(query) ||
window.storage.get('badge-count-muted-conversations') ||
false;
return conversations.filter(conversation => {
return hasUnread(
countConversationUnreadStats(conversation, { includeMuted })
);
});
return filterConversationsByUnread(conversations, includeMuted);
});
// See https://fusejs.io/examples.html#extended-search for
@ -157,14 +164,24 @@ function sortAlphabetically(a: ConversationType, b: ConversationType) {
export function filterAndSortConversations(
conversations: ReadonlyArray<ConversationType>,
searchTerm: string,
regionCode: string | undefined
regionCode: string | undefined,
filterByUnread: boolean = false
): Array<ConversationType> {
const filteredConversations = filterByUnread
? filterConversationsByUnread(conversations, true)
: conversations;
if (searchTerm.length) {
const now = Date.now();
const withoutUnknownAndFiltered = filteredConversations.filter(
item => item.titleNoDefault
);
const withoutUnknown = conversations.filter(item => item.titleNoDefault);
return searchConversations(withoutUnknown, searchTerm, regionCode)
return searchConversations(
withoutUnknownAndFiltered,
searchTerm,
regionCode
)
.slice()
.sort((a, b) => {
const { activeAt: aActiveAt = 0, left: aLeft = false } = a.item;
@ -190,7 +207,7 @@ export function filterAndSortConversations(
.map(result => result.item);
}
return conversations.concat().sort((a, b) => {
return filteredConversations.concat().sort((a, b) => {
const aScore = a.activeAt ?? 0;
const bScore = b.activeAt ?? 0;
const score = bScore - aScore;