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", "messageformat": "Search",
"description": "Placeholder text in the search input" "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": { "icu:clearSearch": {
"messageformat": "Clear Search", "messageformat": "Clear Search",
"description": "Aria label for clear search button" "description": "Aria label for clear search button"
@ -939,6 +943,14 @@
"messageformat": "No results for \"{searchTerm}\"", "messageformat": "No results for \"{searchTerm}\"",
"description": "Shown in the search left pane when no results were found" "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": { "icu:noSearchResults--sms-only": {
"messageformat": "SMS/MMS contacts are not available on Desktop.", "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" "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}", "messageformat": "No results for \"{searchTerm}\" in {conversationName}",
"description": "Shown in the search left pane when no results were found" "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": { "icu:conversationsHeader": {
"messageformat": "Chats", "messageformat": "Chats",
"description": "Shown to separate the types of search results" "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.", "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" "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": { "icu:CallsList__SearchInputPlaceholder": {
"messageformat": "Search", "messageformat": "Search",
"description": "Calls Tab > Calls List > Search Input > Placeholder" "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": { "icu:CallsList__ToggleFilterByMissedLabel": {
"messageformat": "Filter by missed", "messageformat": "Filter by missed",
"description": "Calls Tab > Calls List > Toggle search filter by missed > Accessibility label" "description": "Calls Tab > Calls List > Toggle search filter by missed > Accessibility label"
@ -7409,17 +7441,17 @@
"messageformat": "Recent calls will appear here.", "messageformat": "Recent calls will appear here.",
"description": "Calls Tab > Calls List > When no results found > With no search query. Message subtitle" "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": { "icu:CallsList__EmptyState--hasQuery": {
"messageformat": "No results for “{query}”", "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": { "icu:CallsList__CreateCallLink": {
"messageformat": "Create a Call Link", "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 { &--header {
@include font-body-1-bold; @include font-body-1-bold;
@ -5520,10 +5524,25 @@ button.module-calling-participants-list__contact {
background-color: $color-gray-75; 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__no-search-results,
.module-left-pane__compose-no-contacts { .module-left-pane__compose-no-contacts {
flex-grow: 1;
margin-top: 27px; margin-top: 27px;
padding-inline: 1em; padding-inline: 1em;
width: 100%; width: 100%;
@ -5531,6 +5550,10 @@ button.module-calling-participants-list__contact {
outline: none; outline: none;
} }
.module-left-pane__compose-no-contacts {
flex-grow: 1;
}
.module-left-pane__no-search-results__sms-only { .module-left-pane__no-search-results__sms-only {
margin-top: 12px; margin-top: 12px;
@include light-theme { @include light-theme {

View file

@ -129,6 +129,21 @@
gap: 0px; 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 { .CallsList__ToggleFilterByMissed {
@include button-reset; @include button-reset;
flex-shrink: 0; 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; 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/ChatColorPicker.scss';
@import './components/Checkbox.scss'; @import './components/Checkbox.scss';
@import './components/CircleCheckbox.scss'; @import './components/CircleCheckbox.scss';
@import './components/ClearFilterButton.scss';
@import './components/CollidingAvatars.scss'; @import './components/CollidingAvatars.scss';
@import './components/ComposeStepButton.scss'; @import './components/ComposeStepButton.scss';
@import './components/CompositionArea.scss'; @import './components/CompositionArea.scss';

View file

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

View file

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

View file

@ -36,11 +36,13 @@ import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } f
import { UsernameSearchResultListItem } from './conversationList/UsernameSearchResultListItem'; import { UsernameSearchResultListItem } from './conversationList/UsernameSearchResultListItem';
import { GroupListItem } from './conversationList/GroupListItem'; import { GroupListItem } from './conversationList/GroupListItem';
import { ListView } from './ListView'; import { ListView } from './ListView';
import { Button, ButtonVariant } from './Button';
export enum RowType { export enum RowType {
ArchiveButton = 'ArchiveButton', ArchiveButton = 'ArchiveButton',
Blank = 'Blank', Blank = 'Blank',
Contact = 'Contact', Contact = 'Contact',
ClearFilterButton = 'ClearFilterButton',
ContactCheckbox = 'ContactCheckbox', ContactCheckbox = 'ContactCheckbox',
PhoneNumberCheckbox = 'PhoneNumberCheckbox', PhoneNumberCheckbox = 'PhoneNumberCheckbox',
UsernameCheckbox = 'UsernameCheckbox', UsernameCheckbox = 'UsernameCheckbox',
@ -72,6 +74,11 @@ type ContactRowType = {
hasContextMenu?: boolean; hasContextMenu?: boolean;
}; };
type ClearFilterButtonRowType = {
type: RowType.ClearFilterButton;
isOnNoResultsPage: boolean;
};
type ContactCheckboxRowType = { type ContactCheckboxRowType = {
type: RowType.ContactCheckbox; type: RowType.ContactCheckbox;
contact: ContactListItemPropsType; contact: ContactListItemPropsType;
@ -158,6 +165,7 @@ export type Row =
| BlankRowType | BlankRowType
| ContactRowType | ContactRowType
| ContactCheckboxRowType | ContactCheckboxRowType
| ClearFilterButtonRowType
| PhoneNumberCheckboxRowType | PhoneNumberCheckboxRowType
| UsernameCheckboxRowType | UsernameCheckboxRowType
| ConversationRowType | ConversationRowType
@ -197,6 +205,7 @@ export type PropsType = {
conversationId: string, conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason disabledReason: undefined | ContactCheckboxDisabledReason
) => void; ) => void;
onClickClearFilterButton: () => void;
onPreloadConversation: (conversationId: string, messageId?: string) => void; onPreloadConversation: (conversationId: string, messageId?: string) => void;
onSelectConversation: (conversationId: string, messageId?: string) => void; onSelectConversation: (conversationId: string, messageId?: string) => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingAudioCallInConversation: (conversationId: string) => void;
@ -221,6 +230,7 @@ export function ConversationList({
blockConversation, blockConversation,
onClickArchiveButton, onClickArchiveButton,
onClickContactCheckbox, onClickContactCheckbox,
onClickClearFilterButton,
onPreloadConversation, onPreloadConversation,
onSelectConversation, onSelectConversation,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
@ -332,6 +342,24 @@ export function ConversationList({
/> />
); );
break; 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: case RowType.PhoneNumberCheckbox:
result = ( result = (
<PhoneNumberCheckboxComponent <PhoneNumberCheckboxComponent
@ -527,6 +555,7 @@ export function ConversationList({
i18n, i18n,
lookupConversationWithoutServiceId, lookupConversationWithoutServiceId,
onClickArchiveButton, onClickArchiveButton,
onClickClearFilterButton,
onClickContactCheckbox, onClickContactCheckbox,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,

View file

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

View file

@ -62,6 +62,7 @@ const defaultConversations: Array<ConversationType> = [
]; ];
const defaultSearchProps = { const defaultSearchProps = {
filterByUnread: false,
isSearchingGlobally: true, isSearchingGlobally: true,
searchConversation: undefined, searchConversation: undefined,
searchDisabled: false, searchDisabled: false,
@ -110,6 +111,7 @@ const pinnedConversations: Array<ConversationType> = [
const defaultModeSpecificProps = { const defaultModeSpecificProps = {
...defaultSearchProps, ...defaultSearchProps,
filterByUnread: false,
mode: LeftPaneMode.Inbox as const, mode: LeftPaneMode.Inbox as const,
pinnedConversations, pinnedConversations,
conversations: defaultConversations, conversations: defaultConversations,
@ -153,7 +155,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
}, },
clearConversationSearch: action('clearConversationSearch'), clearConversationSearch: action('clearConversationSearch'),
clearGroupCreationError: action('clearGroupCreationError'), clearGroupCreationError: action('clearGroupCreationError'),
clearSearch: action('clearSearch'), clearSearchQuery: action('clearSearchQuery'),
closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'), closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'),
closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'), closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'),
composeDeleteAvatarFromDisk: action('composeDeleteAvatarFromDisk'), composeDeleteAvatarFromDisk: action('composeDeleteAvatarFromDisk'),
@ -316,6 +318,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
), ),
toggleNavTabsCollapse: action('toggleNavTabsCollapse'), toggleNavTabsCollapse: action('toggleNavTabsCollapse'),
toggleProfileEditor: action('toggleProfileEditor'), toggleProfileEditor: action('toggleProfileEditor'),
updateFilterByUnread: action('updateFilterByUnread'),
updateSearchTerm: action('updateSearchTerm'), updateSearchTerm: action('updateSearchTerm'),
...overrideProps, ...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 { export function SearchAllResultsLoading(): JSX.Element {
return ( return (
<LeftPaneInContainer <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 { export function ArchiveNoArchivedConversations(): JSX.Element {
return ( return (
<LeftPaneInContainer <LeftPaneInContainer

View file

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

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import classNames from 'classnames';
import type { import type {
ConversationType, ConversationType,
ShowConversationType, ShowConversationType,
@ -10,17 +11,19 @@ import type { LocalizerType } from '../types/Util';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import { SearchInput } from './SearchInput'; import { SearchInput } from './SearchInput';
import { usePrevious } from '../hooks/usePrevious'; import { usePrevious } from '../hooks/usePrevious';
import { Tooltip, TooltipPlacement } from './Tooltip';
import { Theme } from '../util/theme';
type PropsType = { type BasePropsType = {
clearConversationSearch: () => void; clearConversationSearch: () => void;
clearSearch: () => void; clearSearchQuery: () => void;
disabled?: boolean; disabled?: boolean;
endConversationSearch: () => void; endConversationSearch: () => void;
endSearch: () => void; endSearch: () => void;
i18n: LocalizerType; i18n: LocalizerType;
isSearchingGlobally: boolean; isSearchingGlobally: boolean;
onEnterKeyDown?: ( onEnterKeyDown?: (
clearSearch: () => void, clearSearchQuery: () => void,
showConversation: ShowConversationType showConversation: ShowConversationType
) => void; ) => void;
searchConversation?: ConversationType; searchConversation?: ConversationType;
@ -30,9 +33,23 @@ type PropsType = {
updateSearchTerm: (searchTerm: string) => void; 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({ export function LeftPaneSearchInput({
clearConversationSearch, clearConversationSearch,
clearSearch, clearSearchQuery,
disabled, disabled,
endConversationSearch, endConversationSearch,
endSearch, endSearch,
@ -44,6 +61,9 @@ export function LeftPaneSearchInput({
showConversation, showConversation,
startSearchCounter, startSearchCounter,
updateSearchTerm, updateSearchTerm,
filterButtonEnabled = false,
filterPressed = false,
onFilterClick,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const inputRef = useRef<null | HTMLInputElement>(null); const inputRef = useRef<null | HTMLInputElement>(null);
@ -83,7 +103,7 @@ export function LeftPaneSearchInput({
if (searchConversation) { if (searchConversation) {
clearConversationSearch(); clearConversationSearch();
} else { } else {
clearSearch(); clearSearchQuery();
} }
return; 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 ( return (
<SearchInput <>
disabled={disabled} <SearchInput
label={label} disabled={disabled}
hasSearchIcon={!searchConversation} label={label}
i18n={i18n} hasSearchIcon={!searchConversation}
moduleClassName="LeftPaneSearchInput" i18n={i18n}
onBlur={() => { moduleClassName="LeftPaneSearchInput"
if (!searchConversation && !searchTerm) { onBlur={() => {
endSearch(); if (!searchConversation && !searchTerm) {
} endSearch();
}} }
onKeyDown={event => { }}
if (onEnterKeyDown && event.key === 'Enter') { onKeyDown={event => {
onEnterKeyDown(clearSearch, showConversation); if (onEnterKeyDown && event.key === 'Enter') {
event.preventDefault(); onEnterKeyDown(clearSearchQuery, showConversation);
event.stopPropagation(); event.preventDefault();
} event.stopPropagation();
}} }
onChange={event => { }}
changeValue(event.currentTarget.value); onChange={event => {
}} changeValue(event.currentTarget.value);
onClear={() => { }}
if (searchTerm) { onClear={() => {
clearSearch(); if (searchTerm) {
inputRef.current?.focus(); clearSearchQuery();
} 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(); 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 <button
aria-label={i18n('icu:clearSearch')} className={classNames('LeftPaneSearchInput__FilterButton', {
className="LeftPaneSearchInput__in-conversation-pill__x-button" 'LeftPaneSearchInput__FilterButton--pressed': filterPressed,
onClick={endConversationSearch} })}
type="button" type="button"
/> aria-pressed={filterPressed}
</div> 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} i18n={i18n}
lookupConversationWithoutServiceId={asyncShouldNeverBeCalled} lookupConversationWithoutServiceId={asyncShouldNeverBeCalled}
onClickArchiveButton={shouldNeverBeCalled} onClickArchiveButton={shouldNeverBeCalled}
onClickClearFilterButton={shouldNeverBeCalled}
onClickContactCheckbox={(conversationId: string) => { onClickContactCheckbox={(conversationId: string) => {
toggleSelectedConversation(conversationId); toggleSelectedConversation(conversationId);
}} }}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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