From a56e7d0ade22786e2e6e3e4508d0cf19a1cd50b0 Mon Sep 17 00:00:00 2001 From: yash-signal Date: Wed, 13 Nov 2024 13:33:41 -0600 Subject: [PATCH] Filter chats by Unread --- _locales/en/messages.json | 50 +++- stylesheets/_modules.scss | 25 +- stylesheets/components/CallsTab.scss | 15 ++ stylesheets/components/ClearFilterButton.scss | 39 +++ .../components/LeftPaneSearchInput.scss | 60 +++++ stylesheets/manifest.scss | 1 + ts/components/CallsList.tsx | 104 ++++++-- ts/components/ConversationList.stories.tsx | 1 + ts/components/ConversationList.tsx | 29 +++ ts/components/ForwardMessagesModal.tsx | 1 + ts/components/LeftPane.stories.tsx | 66 ++++- ts/components/LeftPane.tsx | 20 +- ts/components/LeftPaneSearchInput.tsx | 194 ++++++++------ ts/components/StoriesSettingsModal.tsx | 1 + .../leftPane/LeftPaneArchiveHelper.tsx | 6 +- ts/components/leftPane/LeftPaneHelper.tsx | 5 +- .../leftPane/LeftPaneInboxHelper.tsx | 16 +- .../leftPane/LeftPaneSearchHelper.tsx | 125 ++++++--- ts/state/ducks/conversations.ts | 18 +- ts/state/ducks/search.ts | 241 +++++++++++++----- ts/state/selectors/conversations.ts | 4 +- ts/state/selectors/search.ts | 13 + ts/state/smart/LeftPane.tsx | 11 +- ts/test-both/state/selectors/search_test.ts | 2 + .../leftPane/LeftPaneInboxHelper_test.tsx | 1 + .../leftPane/LeftPaneSearchHelper_test.ts | 236 +++-------------- ts/util/filterAndSortConversations.ts | 37 ++- 27 files changed, 883 insertions(+), 438 deletions(-) create mode 100644 stylesheets/components/ClearFilterButton.scss diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e5e9b8fd2447..400c88651a61 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -927,6 +927,10 @@ "messageformat": "Search", "description": "Placeholder text in the search input" }, + "icu:searchUnreadChats": { + "messageformat": "Search unread chats", + "description": "Placeholder text in the search input when unread filter is enabled" + }, "icu:clearSearch": { "messageformat": "Clear Search", "description": "Aria label for clear search button" @@ -939,6 +943,14 @@ "messageformat": "No results for \"{searchTerm}\"", "description": "Shown in the search left pane when no results were found" }, + "icu:noSearchResultsWithUnreadFilter": { + "messageformat": "No results for \"{searchTerm}\" in unread chats", + "description": "Shown in the search left pane when no results were found with a search query and filter by unread enabled" + }, + "icu:noSearchResultsOnlyUnreadFilter": { + "messageformat": "No unread chats", + "description": "Shown in the search left pane when no results were found with only filter by unread enabled" + }, "icu:noSearchResults--sms-only": { "messageformat": "SMS/MMS contacts are not available on Desktop.", "description": "Shown in the search left pane when no results were found and primary device has SMS/MMS handling enabled" @@ -947,6 +959,18 @@ "messageformat": "No results for \"{searchTerm}\" in {conversationName}", "description": "Shown in the search left pane when no results were found" }, + "icu:conversationsUnreadHeader": { + "messageformat": "Filtered by unread", + "description": "Shown to inform the user that the results are based off of filter by unread" + }, + "icu:filterByUnreadButtonLabel": { + "messageformat": "Filter by unread", + "description": "Shown when you hover over the filter by unread button" + }, + "icu:clearFilterButton": { + "messageformat": "Clear filter", + "description": "Action button for clearing a filter" + }, "icu:conversationsHeader": { "messageformat": "Chats", "description": "Shown to separate the types of search results" @@ -7389,10 +7413,18 @@ "messageformat": "Click to start a new voice or video call.", "description": "Calls Tab > When no call is selected > Empty state > Call to action text" }, + "icu:CallsList__SearchInputPlaceholder--missed-calls": { + "messageformat": "Search missed calls", + "description": "Calls Tab > Calls List > Search Input > Enable missed calls filter > Placeholder" + }, "icu:CallsList__SearchInputPlaceholder": { "messageformat": "Search", "description": "Calls Tab > Calls List > Search Input > Placeholder" }, + "icu:CallsList__FilteredByMissedHeader": { + "messageformat": "Filtered by missed", + "description": "Calls Tab > Calls List > Toggle search filter by missed > Header" + }, "icu:CallsList__ToggleFilterByMissedLabel": { "messageformat": "Filter by missed", "description": "Calls Tab > Calls List > Toggle search filter by missed > Accessibility label" @@ -7409,17 +7441,17 @@ "messageformat": "Recent calls will appear here.", "description": "Calls Tab > Calls List > When no results found > With no search query. Message subtitle" }, - "icu:CallsList__EmptyState--noQuery--missed__title": { - "messageformat": "No missed calls", - "description": "Calls Tab > Calls List > When no missed calls found > With no search query. Message title" - }, - "icu:CallsList__EmptyState--noQuery--missed__subtitle": { - "messageformat": "Missed calls will appear here.", - "description": "Calls Tab > Calls List > When no missed calls found > With no search query. Message subtitle" - }, "icu:CallsList__EmptyState--hasQuery": { "messageformat": "No results for “{query}”", - "description": "Calls Tab > Calls List > When no results found > With a search query" + "description": "Calls Tab > Calls List > When no results found > With just a search query" + }, + "icu:CallsList__EmptyState--hasQueryAndMissedCalls": { + "messageformat": "No results for “{query}” in missed calls", + "description": "Calls Tab > Calls List > When no results found > With a search query and missed call filter enabled" + }, + "icu:CallsList__EmptyState--missedCalls": { + "messageformat": "No missed calls", + "description": "Calls Tab > Calls List > When no results found > With only missed call filter enabled" }, "icu:CallsList__CreateCallLink": { "messageformat": "Create a Call Link", diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 5c56fe9a49af..c3a6b939d543 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5270,6 +5270,10 @@ button.module-calling-participants-list__contact { } } + &--clear-filter-button { + height: $normal-row-height; + } + &--header { @include font-body-1-bold; @@ -5520,10 +5524,25 @@ button.module-calling-participants-list__contact { background-color: $color-gray-75; } } +.module-left-pane__no-search-results__unread-header { + margin-bottom: 50px; +} + +.module-left-pane__no-search-results--withHeader { + display: flex; + flex-direction: column; + margin-top: 15px; + // This applies only for filter by unread, set margin + // for clear filter button + margin-bottom: 20px; + padding-inline: 1em; + width: 100%; + text-align: center; + outline: none; +} .module-left-pane__no-search-results, .module-left-pane__compose-no-contacts { - flex-grow: 1; margin-top: 27px; padding-inline: 1em; width: 100%; @@ -5531,6 +5550,10 @@ button.module-calling-participants-list__contact { outline: none; } +.module-left-pane__compose-no-contacts { + flex-grow: 1; +} + .module-left-pane__no-search-results__sms-only { margin-top: 12px; @include light-theme { diff --git a/stylesheets/components/CallsTab.scss b/stylesheets/components/CallsTab.scss index 921591403f28..95053980cf44 100644 --- a/stylesheets/components/CallsTab.scss +++ b/stylesheets/components/CallsTab.scss @@ -129,6 +129,21 @@ gap: 0px; } +.CallsList__FilterHeader { + display: flex; + align-items: center; + + @include font-body-1-bold; + + @include dark-theme { + color: $color-gray-05; + } + padding-inline-start: 24px; + text-overflow: ellipsis; + user-select: none; + white-space: nowrap; +} + .CallsList__ToggleFilterByMissed { @include button-reset; flex-shrink: 0; diff --git a/stylesheets/components/ClearFilterButton.scss b/stylesheets/components/ClearFilterButton.scss new file mode 100644 index 000000000000..e2fb9d799de1 --- /dev/null +++ b/stylesheets/components/ClearFilterButton.scss @@ -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; + } + } + } + } +} diff --git a/stylesheets/components/LeftPaneSearchInput.scss b/stylesheets/components/LeftPaneSearchInput.scss index aeb2d83bd395..ec495ddb795f 100644 --- a/stylesheets/components/LeftPaneSearchInput.scss +++ b/stylesheets/components/LeftPaneSearchInput.scss @@ -60,4 +60,64 @@ display: none; } } + + &__FilterButton { + @include button-reset; + flex-shrink: 0; + padding: 4px; + margin-inline-end: 8px; + border-radius: 4px; + + &:not(.LeftPaneSearchInput__FilterButton--pressed):hover { + @include light-theme { + background-color: $color-black-alpha-06; + } + @include dark-theme { + background-color: $color-white-alpha-06; + } + } + + &:focus { + outline: none; + @include keyboard-mode { + box-shadow: + 0 0 0 2px $color-white, + 0 0 0 4px $color-ultramarine; + } + } + + &::before { + content: ''; + display: block; + width: 20px; + height: 20px; + @include light-theme { + @include color-svg( + '../images/icons/v3/filter/filter.svg', + $color-black + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v3/filter/filter.svg', + $color-gray-15 + ); + } + } + + &--pressed { + border-radius: 9999px; + background: $color-accent-blue; + &::before { + @include color-svg( + '../images/icons/v3/filter/filter.svg', + $color-white + ); + } + } + } + + &__FilterLabel { + @include sr-only; + } } diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 748b2be9e569..4e15e40b832b 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -63,6 +63,7 @@ @import './components/ChatColorPicker.scss'; @import './components/Checkbox.scss'; @import './components/CircleCheckbox.scss'; +@import './components/ClearFilterButton.scss'; @import './components/CollidingAvatars.scss'; @import './components/ComposeStepButton.scss'; @import './components/CompositionArea.scss'; diff --git a/ts/components/CallsList.tsx b/ts/components/CallsList.tsx index 9c107d7220fd..c5f83247a9ac 100644 --- a/ts/components/CallsList.tsx +++ b/ts/components/CallsList.tsx @@ -66,6 +66,8 @@ import type { } from '../state/ducks/calling'; import { DAY, MINUTE, SECOND } from '../util/durations'; import type { StartCallData } from './ConfirmLeaveCallModal'; +import { Button, ButtonVariant } from './Button'; +import type { ICUJSXMessageParamsByKeyType } from '../types/Util'; function Timestamp({ i18n, @@ -153,6 +155,7 @@ type CallsListProps = Readonly<{ togglePip: () => void; }>; +const FILTER_HEADER_ROW_HEIGHT = 50; const CALL_LIST_ITEM_ROW_HEIGHT = 62; const INACTIVE_CALL_LINKS_TO_PEEK = 10; const INACTIVE_CALL_LINK_AGE_THRESHOLD = 10 * DAY; @@ -167,7 +170,11 @@ function isSameOptions( return a.query === b.query && a.status === b.status; } -type SpecialRows = 'CreateCallLink' | 'EmptyState'; +type SpecialRows = + | 'CreateCallLink' + | 'EmptyState' + | 'FilterHeader' + | 'ClearFilterButton'; type Row = CallHistoryGroup | SpecialRows; export function CallsList({ @@ -206,25 +213,34 @@ export function CallsList({ const searchStateStatus = searchState.options?.status ?? CallHistoryFilterStatus.All; const hasSearchStateQuery = searchStateQuery !== ''; - const searchFiltering = - hasSearchStateQuery || searchStateStatus !== CallHistoryFilterStatus.All; + const hasMissedCallFilter = + searchStateStatus === CallHistoryFilterStatus.Missed; + const searchFiltering = hasSearchStateQuery || hasMissedCallFilter; const searchPending = searchState.state === 'pending'; const isEmpty = !searchState.results?.items?.length; - const rows = useMemo(() => { - let results: ReadonlyArray = searchState.results?.items ?? []; - if (results.length === 0 && hasSearchStateQuery) { - results = ['EmptyState']; + const rows = useMemo>(() => { + const results: ReadonlyArray = searchState.results?.items ?? []; + + if (results.length === 0 && searchFiltering) { + return hasMissedCallFilter + ? ['FilterHeader', 'EmptyState', 'ClearFilterButton'] + : ['EmptyState']; } + if (!searchFiltering && canCreateCallLinks) { - results = ['CreateCallLink', ...results]; + return ['CreateCallLink', ...results]; + } + + if (hasMissedCallFilter) { + return ['FilterHeader', ...results, 'ClearFilterButton']; } return results; }, [ searchState.results?.items, - hasSearchStateQuery, searchFiltering, canCreateCallLinks, + hasMissedCallFilter, ]); const rowCount = rows.length; @@ -675,10 +691,8 @@ export function CallsList({ ({ index }: Index) => { const item = rows.at(index) ?? null; - if (item === 'EmptyState') { - // arbitary large number so the empty state can be as big as it wants, - // scrolling should always be locked when the list is empty - return 9999; + if (item === 'FilterHeader') { + return FILTER_HEADER_ROW_HEIGHT; } return CALL_LIST_ITEM_ROW_HEIGHT; @@ -710,11 +724,23 @@ export function CallsList({ } if (item === 'EmptyState') { + let i18nId: keyof ICUJSXMessageParamsByKeyType; + + if (hasSearchStateQuery && hasMissedCallFilter) { + i18nId = 'icu:CallsList__EmptyState--hasQueryAndMissedCalls'; + } else if (hasSearchStateQuery) { + i18nId = 'icu:CallsList__EmptyState--hasQuery'; + } else if (hasMissedCallFilter) { + i18nId = 'icu:CallsList__EmptyState--missedCalls'; + } else { + // This should never happen + i18nId = 'icu:CallsList__EmptyState--hasQuery'; + } return (
, }} @@ -723,6 +749,32 @@ export function CallsList({ ); } + if (item === 'FilterHeader') { + return ( +
+ {i18n('icu:CallsList__FilteredByMissedHeader')} +
+ ); + } + + if (item === 'ClearFilterButton') { + return ( +
+ +
+ ); + } + const conversation = getConversationForItem(item); const activeCallConversationId = activeCall?.conversationId; @@ -918,6 +970,8 @@ export function CallsList({ getIsAnybodyInCall, getIsCallActive, getIsInCall, + hasMissedCallFilter, + hasSearchStateQuery, selectedCallHistoryGroup, onChangeCallsTabSelectedView, onCreateCallLink, @@ -927,6 +981,7 @@ export function CallsList({ toggleConfirmLeaveCallModal, togglePip, i18n, + isEmpty, ] ); @@ -957,20 +1012,14 @@ export function CallsList({ subtitle={i18n('icu:CallsList__EmptyState--noQuery__subtitle')} /> )} - {isEmpty && - statusInput === CallHistoryFilterStatus.Missed && - !hasSearchStateQuery && ( - - )} +
+ ); + break; case RowType.PhoneNumberCheckbox: result = ( = [ ]; const defaultSearchProps = { + filterByUnread: false, isSearchingGlobally: true, searchConversation: undefined, searchDisabled: false, @@ -110,6 +111,7 @@ const pinnedConversations: Array = [ const defaultModeSpecificProps = { ...defaultSearchProps, + filterByUnread: false, mode: LeftPaneMode.Inbox as const, pinnedConversations, conversations: defaultConversations, @@ -153,7 +155,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { }, clearConversationSearch: action('clearConversationSearch'), clearGroupCreationError: action('clearGroupCreationError'), - clearSearch: action('clearSearch'), + clearSearchQuery: action('clearSearchQuery'), closeMaximumGroupSizeModal: action('closeMaximumGroupSizeModal'), closeRecommendedGroupSizeModal: action('closeRecommendedGroupSizeModal'), composeDeleteAvatarFromDisk: action('composeDeleteAvatarFromDisk'), @@ -316,6 +318,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { ), toggleNavTabsCollapse: action('toggleNavTabsCollapse'), toggleProfileEditor: action('toggleProfileEditor'), + updateFilterByUnread: action('updateFilterByUnread'), updateSearchTerm: action('updateSearchTerm'), ...overrideProps, @@ -600,6 +603,43 @@ export function SearchNoResultsWhenSearchingInAConversation(): JSX.Element { ); } +export function SearchNoResultsUnreadFilterAndQuery(): JSX.Element { + return ( + + ); +} + +export function SearchNoResultsUnreadFilterWithoutQuery(): JSX.Element { + return ( + + ); +} + export function SearchAllResultsLoading(): JSX.Element { return ( + ); +} + export function ArchiveNoArchivedConversations(): JSX.Element { return ( void; clearConversationSearch: () => void; clearGroupCreationError: () => void; - clearSearch: () => void; + clearSearchQuery: () => void; closeMaximumGroupSizeModal: () => void; closeRecommendedGroupSizeModal: () => void; composeDeleteAvatarFromDisk: DeleteAvatarFromDiskActionType; @@ -160,7 +160,8 @@ export type PropsType = { toggleConversationInChooseMembers: (conversationId: string) => void; toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void; toggleProfileEditor: (initialEditState?: ProfileEditorEditState) => void; - updateSearchTerm: (_: string) => void; + updateSearchTerm: (query: string) => void; + updateFilterByUnread: (filterByUnread: boolean) => void; // Render Props renderMessageSearchResult: (id: string) => JSX.Element; @@ -192,7 +193,7 @@ export function LeftPane({ challengeStatus, clearConversationSearch, clearGroupCreationError, - clearSearch, + clearSearchQuery, closeMaximumGroupSizeModal, closeRecommendedGroupSizeModal, composeDeleteAvatarFromDisk, @@ -264,6 +265,7 @@ export function LeftPane({ usernameLinkCorrupted, updateSearchTerm, dismissBackupMediaDownloadBanner, + updateFilterByUnread, }: PropsType): JSX.Element { const previousModeSpecificProps = usePrevious( modeSpecificProps, @@ -460,7 +462,7 @@ export function LeftPane({ const { conversationId, messageId } = conversationToOpen; showConversation({ conversationId, messageId }); if (openedByNumber) { - clearSearch(); + clearSearchQuery(); } event.preventDefault(); event.stopPropagation(); @@ -478,7 +480,7 @@ export function LeftPane({ document.removeEventListener('keydown', onKeyDown); }; }, [ - clearSearch, + clearSearchQuery, helper, isMacOS, searchInConversation, @@ -498,7 +500,7 @@ export function LeftPane({ const preRowsNode = helper.getPreRowsNode({ clearConversationSearch, clearGroupCreationError, - clearSearch, + clearSearchQuery, closeMaximumGroupSizeModal, closeRecommendedGroupSizeModal, composeDeleteAvatarFromDisk, @@ -758,7 +760,7 @@ export function LeftPane({ {helper.getSearchInput({ clearConversationSearch, - clearSearch, + clearSearchQuery, endConversationSearch, endSearch, i18n, @@ -772,6 +774,7 @@ export function LeftPane({ showUserNotFoundModal, setIsFetchingUUID, showInbox, + updateFilterByUnread, })} )} @@ -826,6 +829,9 @@ export function LeftPane({ throw missingCaseError(disabledReason); } }} + onClickClearFilterButton={() => { + updateFilterByUnread(false); + }} showUserNotFoundModal={showUserNotFoundModal} setIsFetchingUUID={setIsFetchingUUID} lookupConversationWithoutServiceId={ diff --git a/ts/components/LeftPaneSearchInput.tsx b/ts/components/LeftPaneSearchInput.tsx index 2a8b6d80707e..e361f2bb15ec 100644 --- a/ts/components/LeftPaneSearchInput.tsx +++ b/ts/components/LeftPaneSearchInput.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import React, { useEffect, useRef } from 'react'; +import classNames from 'classnames'; import type { ConversationType, ShowConversationType, @@ -10,17 +11,19 @@ import type { LocalizerType } from '../types/Util'; import { Avatar, AvatarSize } from './Avatar'; import { SearchInput } from './SearchInput'; import { usePrevious } from '../hooks/usePrevious'; +import { Tooltip, TooltipPlacement } from './Tooltip'; +import { Theme } from '../util/theme'; -type PropsType = { +type BasePropsType = { clearConversationSearch: () => void; - clearSearch: () => void; + clearSearchQuery: () => void; disabled?: boolean; endConversationSearch: () => void; endSearch: () => void; i18n: LocalizerType; isSearchingGlobally: boolean; onEnterKeyDown?: ( - clearSearch: () => void, + clearSearchQuery: () => void, showConversation: ShowConversationType ) => void; searchConversation?: ConversationType; @@ -30,9 +33,23 @@ type PropsType = { updateSearchTerm: (searchTerm: string) => void; }; +type NoFilterPropsType = BasePropsType & { + filterButtonEnabled?: false; + filterPressed?: false; + onFilterClick?: () => void; +}; + +type WithFilterPropsType = BasePropsType & { + filterButtonEnabled: boolean; + filterPressed: boolean; + onFilterClick: (enabled: boolean) => void; +}; + +type PropsType = NoFilterPropsType | WithFilterPropsType; + export function LeftPaneSearchInput({ clearConversationSearch, - clearSearch, + clearSearchQuery, disabled, endConversationSearch, endSearch, @@ -44,6 +61,9 @@ export function LeftPaneSearchInput({ showConversation, startSearchCounter, updateSearchTerm, + filterButtonEnabled = false, + filterPressed = false, + onFilterClick, }: PropsType): JSX.Element { const inputRef = useRef(null); @@ -83,7 +103,7 @@ export function LeftPaneSearchInput({ if (searchConversation) { clearConversationSearch(); } else { - clearSearch(); + clearSearchQuery(); } return; @@ -94,79 +114,109 @@ export function LeftPaneSearchInput({ } }; - const label = searchConversation ? i18n('icu:searchIn') : i18n('icu:search'); + let label: string; + if (searchConversation) { + label = i18n('icu:searchIn'); + } else if (filterPressed) { + label = i18n('icu:searchUnreadChats'); + } else { + label = i18n('icu:search'); + } return ( - { - if (!searchConversation && !searchTerm) { - endSearch(); - } - }} - onKeyDown={event => { - if (onEnterKeyDown && event.key === 'Enter') { - onEnterKeyDown(clearSearch, showConversation); - event.preventDefault(); - event.stopPropagation(); - } - }} - onChange={event => { - changeValue(event.currentTarget.value); - }} - onClear={() => { - if (searchTerm) { - clearSearch(); - inputRef.current?.focus(); - } else if (searchConversation) { - endConversationSearch(); - inputRef.current?.focus(); - } else { - inputRef.current?.blur(); - } - }} - ref={inputRef} - placeholder={label} - value={searchTerm} - > - {searchConversation && ( - // Clicking the non-X part of the pill should focus the input but have a normal - // cursor. This effectively simulates `pointer-events: none` while still - // letting us change the cursor. - // eslint-disable-next-line max-len - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
{ + <> + { + if (!searchConversation && !searchTerm) { + endSearch(); + } + }} + onKeyDown={event => { + if (onEnterKeyDown && event.key === 'Enter') { + onEnterKeyDown(clearSearchQuery, showConversation); + event.preventDefault(); + event.stopPropagation(); + } + }} + onChange={event => { + changeValue(event.currentTarget.value); + }} + onClear={() => { + if (searchTerm) { + clearSearchQuery(); inputRef.current?.focus(); - }} + } else if (searchConversation) { + endConversationSearch(); + inputRef.current?.focus(); + } else { + inputRef.current?.blur(); + } + }} + ref={inputRef} + placeholder={label} + value={searchTerm} + > + {searchConversation && ( + // Clicking the non-X part of the pill should focus the input but have a normal + // cursor. This effectively simulates `pointer-events: none` while still + // letting us change the cursor. + // eslint-disable-next-line max-len + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
{ + inputRef.current?.focus(); + }} + > + +
+ )} +
+ {filterButtonEnabled && ( + -
+ aria-pressed={filterPressed} + onClick={() => onFilterClick?.(!filterPressed)} + > + + {i18n('icu:filterByUnreadButtonLabel')} + + + )} -
+ ); } diff --git a/ts/components/StoriesSettingsModal.tsx b/ts/components/StoriesSettingsModal.tsx index e46e70f27771..20dfcd3263b4 100644 --- a/ts/components/StoriesSettingsModal.tsx +++ b/ts/components/StoriesSettingsModal.tsx @@ -1217,6 +1217,7 @@ export function EditDistributionListModal({ i18n={i18n} lookupConversationWithoutServiceId={asyncShouldNeverBeCalled} onClickArchiveButton={shouldNeverBeCalled} + onClickClearFilterButton={shouldNeverBeCalled} onClickContactCheckbox={(conversationId: string) => { toggleSelectedConversation(conversationId); }} diff --git a/ts/components/leftPane/LeftPaneArchiveHelper.tsx b/ts/components/leftPane/LeftPaneArchiveHelper.tsx index 2f4783c37da2..32e5e06b78a1 100644 --- a/ts/components/leftPane/LeftPaneArchiveHelper.tsx +++ b/ts/components/leftPane/LeftPaneArchiveHelper.tsx @@ -85,7 +85,7 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper unknown; - clearSearch: () => unknown; + clearSearchQuery: () => unknown; endConversationSearch: () => unknown; endSearch: () => unknown; i18n: LocalizerType; @@ -107,7 +107,7 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper { getSearchInput( _: Readonly<{ clearConversationSearch: () => unknown; - clearSearch: () => unknown; + clearSearchQuery: () => unknown; endConversationSearch: () => unknown; endSearch: () => unknown; i18n: LocalizerType; @@ -50,6 +50,7 @@ export abstract class LeftPaneHelper { updateSearchTerm: (searchTerm: string) => unknown; showConversation: ShowConversationType; showInbox: () => void; + updateFilterByUnread: (filterByUnread: boolean) => void; }> & LookupConversationWithoutServiceIdActionsType ): null | ReactChild { @@ -78,7 +79,7 @@ export abstract class LeftPaneHelper { _: Readonly<{ clearConversationSearch: () => unknown; clearGroupCreationError: () => void; - clearSearch: () => unknown; + clearSearchQuery: () => unknown; closeMaximumGroupSizeModal: () => unknown; closeRecommendedGroupSizeModal: () => unknown; composeDeleteAvatarFromDisk: DeleteAvatarFromDiskActionType; diff --git a/ts/components/leftPane/LeftPaneInboxHelper.tsx b/ts/components/leftPane/LeftPaneInboxHelper.tsx index 90d182366efc..60f923042b7c 100644 --- a/ts/components/leftPane/LeftPaneInboxHelper.tsx +++ b/ts/components/leftPane/LeftPaneInboxHelper.tsx @@ -30,6 +30,7 @@ export type LeftPaneInboxPropsType = { searchDisabled: boolean; searchTerm: string; searchConversation: undefined | ConversationType; + filterByUnread: boolean; }; export class LeftPaneInboxHelper extends LeftPaneHelper { @@ -51,6 +52,8 @@ export class LeftPaneInboxHelper extends LeftPaneHelper private readonly searchConversation: undefined | ConversationType; + private readonly filterByUnread: boolean; + constructor({ conversations, archivedConversations, @@ -61,6 +64,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper searchDisabled, searchTerm, searchConversation, + filterByUnread, }: Readonly) { super(); @@ -73,6 +77,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper this.searchDisabled = searchDisabled; this.searchTerm = searchTerm; this.searchConversation = searchConversation; + this.filterByUnread = filterByUnread; } getRowCount(): number { @@ -88,25 +93,27 @@ export class LeftPaneInboxHelper extends LeftPaneHelper override getSearchInput({ clearConversationSearch, - clearSearch, + clearSearchQuery, endConversationSearch, endSearch, i18n, showConversation, updateSearchTerm, + updateFilterByUnread, }: Readonly<{ clearConversationSearch: () => unknown; - clearSearch: () => unknown; + clearSearchQuery: () => unknown; endConversationSearch: () => unknown; endSearch: () => unknown; i18n: LocalizerType; showConversation: ShowConversationType; updateSearchTerm: (searchTerm: string) => unknown; + updateFilterByUnread: (filterByUnread: boolean) => void; }>): ReactChild { return ( showConversation={showConversation} startSearchCounter={this.startSearchCounter} updateSearchTerm={updateSearchTerm} + onFilterClick={updateFilterByUnread} + filterButtonEnabled={!this.searchConversation} + filterPressed={this.filterByUnread} /> ); } diff --git a/ts/components/leftPane/LeftPaneSearchHelper.tsx b/ts/components/leftPane/LeftPaneSearchHelper.tsx index f30077019c1e..b4fd1f85b1c2 100644 --- a/ts/components/leftPane/LeftPaneSearchHelper.tsx +++ b/ts/components/leftPane/LeftPaneSearchHelper.tsx @@ -43,6 +43,7 @@ export type LeftPaneSearchPropsType = { searchConversationName?: string; primarySendsSms: boolean; searchTerm: string; + filterByUnread: boolean; startSearchCounter: number; isSearchingGlobally: boolean; searchDisabled: boolean; @@ -78,6 +79,8 @@ export class LeftPaneSearchHelper extends LeftPaneHelper) { super(); @@ -102,30 +106,33 @@ export class LeftPaneSearchHelper extends LeftPaneHelper unknown; - clearSearch: () => unknown; + clearSearchQuery: () => unknown; endConversationSearch: () => unknown; endSearch: () => unknown; i18n: LocalizerType; showConversation: ShowConversationType; updateSearchTerm: (searchTerm: string) => unknown; + updateFilterByUnread: (filterByUnread: boolean) => void; }>): ReactChild { return ( ); } @@ -171,13 +181,29 @@ export class LeftPaneSearchHelper extends LeftPaneHelper ); } 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 = ( <> -
- {i18n('icu:noSearchResults', { - searchTerm, - })} -
+ {this.filterByUnread && ( +
+ {i18n('icu:conversationsUnreadHeader')} +
+ )} +
{noResultsMessage}
{primarySendsSms && (
{i18n('icu:noSearchResults--sms-only')} @@ -191,7 +217,11 @@ export class LeftPaneSearchHelper extends LeftPaneHelper {noResults} @@ -205,11 +235,18 @@ export class LeftPaneSearchHelper extends LeftPaneHelper result + getRowCountForLoadedSearchResults(searchResults), 0 ); + + // The clear unread filter button adds an extra row + if (this.filterByUnread) { + count += 1; + } + + return count; } // This is currently unimplemented. See DESKTOP-1170. @@ -236,12 +273,19 @@ export class LeftPaneSearchHelper extends LeftPaneHelper i18n('icu:conversationsHeader'), + getHeaderText: i18n => + this.filterByUnread + ? i18n('icu:conversationsUnreadHeader') + : i18n('icu:conversationsHeader'), }; } assertDev( @@ -257,7 +301,9 @@ export class LeftPaneSearchHelper extends LeftPaneHelper= conversationRowCount + contactRowCount + messageRowCount) { - return undefined; + rowOffset += messageRowCount; + if (rowIndex < rowOffset) { + const localIndex = rowIndex - conversationRowCount - contactRowCount; + if (localIndex === 0) { + return { + type: RowType.Header, + getHeaderText: i18n => i18n('icu:messagesHeader'), + }; + } + assertDev( + !messageResults.isLoading, + "We shouldn't get here with message results still loading" + ); + const message = messageResults.results[localIndex - 1]; + return message + ? { + type: RowType.MessageSearchResult, + messageId: message.id, + } + : undefined; } - const localIndex = rowIndex - conversationRowCount - contactRowCount; - if (localIndex === 0) { + rowOffset += clearFilterButtonRowCount; + if (rowIndex < rowOffset) { return { - type: RowType.Header, - getHeaderText: i18n => i18n('icu:messagesHeader'), + type: RowType.ClearFilterButton, + isOnNoResultsPage: this.allResults().every( + searchResult => + searchResult.isLoading || searchResult.results.length === 0 + ), }; } - assertDev( - !messageResults.isLoading, - "We shouldn't get here with message results still loading" - ); - const message = messageResults.results[localIndex - 1]; - return message - ? { - type: RowType.MessageSearchResult, - messageId: message.id, - } - : undefined; + + return undefined; } override isScrollable(): boolean { @@ -307,7 +365,8 @@ export class LeftPaneSearchHelper extends LeftPaneHelper): boolean { - const oldIsLoading = new LeftPaneSearchHelper(old).isLoading(); + const oldSearchPaneHelper = new LeftPaneSearchHelper(old); + const oldIsLoading = oldSearchPaneHelper.isLoading(); const newIsLoading = this.isLoading(); if (oldIsLoading && newIsLoading) { return false; @@ -376,7 +435,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper unknown, + clearSearchQuery: () => unknown, showConversation: ShowConversationType ): void { const conversation = this.getConversationAndMessageAtIndex(0); @@ -384,7 +443,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper ): ThunkAction { - return dispatch => { + return (dispatch, getState) => { for (const conversation of data) { calling.groupMembersChanged(conversation.id); } + + const { conversationLookup } = getState().conversations; + + const someConversationsHaveNewMessages = data.some(conversation => { + return ( + conversationLookup[conversation.id]?.lastMessageReceivedAt !== + conversation.lastMessageReceivedAt + ); + }); + dispatch({ type: 'CONVERSATIONS_UPDATED', payload: { data, }, }); + + if (someConversationsHaveNewMessages) { + dispatch(searchActions.refreshSearch()); + } }; } + function conversationRemoved(id: string): ConversationRemovedActionType { return { type: 'CONVERSATION_REMOVED', diff --git a/ts/state/ducks/search.ts b/ts/state/ducks/search.ts index 6e6e7c1c7713..f96c734d0f49 100644 --- a/ts/state/ducks/search.ts +++ b/ts/state/ducks/search.ts @@ -24,7 +24,12 @@ import type { ShowArchivedConversationsActionType, MessageType, } from './conversations'; -import { getQuery, getSearchConversation } from '../selectors/search'; +import { + getFilterByUnread, + getIsActivelySearching, + getQuery, + getSearchConversation, +} from '../selectors/search'; import { getAllConversations } from '../selectors/conversations'; import { getIntl, @@ -62,6 +67,7 @@ export type SearchStateType = ReadonlyDeep<{ contactIds: Array; conversationIds: Array; query: string; + filterByUnread: boolean; messageIds: Array; // We do store message data to pass through the selector messageLookup: MessageSearchResultLookupType; @@ -98,8 +104,8 @@ type StartSearchActionType = ReadonlyDeep<{ type: 'SEARCH_START'; payload: null; }>; -type ClearSearchActionType = ReadonlyDeep<{ - type: 'SEARCH_CLEAR'; +type ClearSearchQueryActionType = ReadonlyDeep<{ + type: 'SEARCH_QUERY_CLEAR'; payload: null; }>; type ClearConversationSearchActionType = ReadonlyDeep<{ @@ -119,12 +125,22 @@ type SearchInConversationActionType = ReadonlyDeep<{ payload: { searchConversationId: string }; }>; +type UpdateFilterByUnreadActionType = ReadonlyDeep<{ + type: 'FILTER_BY_UNREAD_UPDATE'; + payload: { enabled: boolean }; +}>; + +type RefreshSearchActionType = ReadonlyDeep<{ + type: 'SEARCH_REFRESH'; + payload: null; +}>; + export type SearchActionType = ReadonlyDeep< | SearchMessagesResultsFulfilledActionType | SearchDiscussionsResultsFulfilledActionType | UpdateSearchTermActionType | StartSearchActionType - | ClearSearchActionType + | ClearSearchQueryActionType | ClearConversationSearchActionType | EndSearchActionType | EndConversationSearchActionType @@ -134,18 +150,22 @@ export type SearchActionType = ReadonlyDeep< | TargetedConversationChangedActionType | ShowArchivedConversationsActionType | ConversationUnloadedActionType + | UpdateFilterByUnreadActionType + | RefreshSearchActionType >; // Action Creators export const actions = { startSearch, - clearSearch, + clearSearchQuery, clearConversationSearch, endSearch, endConversationSearch, searchInConversation, updateSearchTerm, + updateFilterByUnread, + refreshSearch, }; export const useSearchActions = (): BoundActionCreatorsMapObject< @@ -158,10 +178,22 @@ function startSearch(): StartSearchActionType { payload: null, }; } -function clearSearch(): ClearSearchActionType { - return { - type: 'SEARCH_CLEAR', - payload: null, +function clearSearchQuery(): ThunkAction< + void, + RootStateType, + unknown, + ClearSearchQueryActionType +> { + return async (dispatch, getState) => { + dispatch({ + type: 'SEARCH_QUERY_CLEAR', + payload: null, + }); + + doSearch({ + dispatch, + state: getState(), + }); }; } function clearConversationSearch(): ClearConversationSearchActionType { @@ -191,6 +223,49 @@ function searchInConversation( }; } +function refreshSearch(): ThunkAction< + void, + RootStateType, + unknown, + RefreshSearchActionType +> { + return (dispatch, getState) => { + const state = getState(); + + if (!getIsActivelySearching(state)) { + return; + } + + dispatch({ + type: 'SEARCH_REFRESH', + payload: null, + }); + + doSearch({ + dispatch, + state, + }); + }; +} + +function updateFilterByUnread( + filterByUnread: boolean +): ThunkAction { + return (dispatch, getState) => { + dispatch({ + type: 'FILTER_BY_UNREAD_UPDATE', + payload: { + enabled: filterByUnread, + }, + }); + + doSearch({ + dispatch, + state: getState(), + }); + }; +} + function updateSearchTerm( query: string ): ThunkAction { @@ -200,23 +275,9 @@ function updateSearchTerm( payload: { query }, }); - const state = getState(); - const ourConversationId = getUserConversationId(state); - strictAssert( - ourConversationId, - 'updateSearchTerm our conversation is missing' - ); - - const i18n = getIntl(state); - doSearch({ dispatch, - allConversations: getAllConversations(state), - regionCode: getRegionCode(state), - noteToSelf: i18n('icu:noteToSelf').toLowerCase(), - ourConversationId, - query: getQuery(state), - searchConversationId: getSearchConversation(state)?.id, + state: getState(), }); }; } @@ -224,12 +285,7 @@ function updateSearchTerm( const doSearch = debounce( ({ dispatch, - allConversations, - regionCode, - noteToSelf, - ourConversationId, - query, - searchConversationId, + state, }: Readonly<{ dispatch: ThunkDispatch< RootStateType, @@ -237,21 +293,37 @@ const doSearch = debounce( | SearchMessagesResultsFulfilledActionType | SearchDiscussionsResultsFulfilledActionType >; - allConversations: ReadonlyArray; - noteToSelf: string; - regionCode: string | undefined; - ourConversationId: string; - query: string; - searchConversationId: undefined | string; + state: RootStateType; }>) => { - if (!query) { + if (!getIsActivelySearching(state)) { return; } + const query = getQuery(state); + const filterByUnread = getFilterByUnread(state); + const i18n = getIntl(state); + const allConversations = getAllConversations(state); + const regionCode = getRegionCode(state); + const noteToSelf = i18n('icu:noteToSelf').toLowerCase(); + const ourConversationId = getUserConversationId(state); + const searchConversationId = getSearchConversation(state)?.id; + + strictAssert(ourConversationId, 'doSearch our conversation is missing'); + // Limit the number of contacts to something reasonable const MAX_MATCHING_CONTACTS = 100; void (async () => { + if (filterByUnread) { + dispatch({ + type: 'SEARCH_MESSAGES_RESULTS_FULFILLED', + payload: { + messages: [], + query, + }, + }); + return; + } const segmenter = new Intl.Segmenter([], { granularity: 'word' }); const queryWords = [...segmenter.segment(query)] .filter(word => word.isWordLike) @@ -284,6 +356,7 @@ const doSearch = debounce( void (async () => { const { conversationIds, contactIds } = await queryConversationsAndContacts(query, { + filterByUnread, ourConversationId, noteToSelf, regionCode, @@ -314,7 +387,7 @@ async function queryMessages({ contactServiceIdsMatchingQuery?: Array; }): Promise> { try { - if (query.length === 0) { + if (query.trim().length === 0) { return []; } @@ -338,6 +411,7 @@ async function queryMessages({ async function queryConversationsAndContacts( query: string, options: { + filterByUnread: boolean; ourConversationId: string; noteToSelf: string; regionCode: string | undefined; @@ -347,8 +421,13 @@ async function queryConversationsAndContacts( contactIds: Array; conversationIds: Array; }> { - const { ourConversationId, noteToSelf, regionCode, allConversations } = - options; + const { + filterByUnread, + ourConversationId, + noteToSelf, + regionCode, + allConversations, + } = options; const normalizedQuery = removeDiacritics(query); @@ -382,7 +461,8 @@ async function queryConversationsAndContacts( const searchResults: Array = filterAndSortConversations( visibleConversations, normalizedQuery, - regionCode + regionCode, + filterByUnread ); // Split into two groups - active conversations and items just from address book @@ -408,6 +488,11 @@ async function queryConversationsAndContacts( contactIds.unshift(ourConversationId); } + // Don't show contacts in the left pane if we're filtering by unread + if (filterByUnread) { + contactIds = []; + } + return { conversationIds, contactIds }; } @@ -417,6 +502,7 @@ export function getEmptyState(): SearchStateType { return { startSearchCounter: 0, query: '', + filterByUnread: false, messageIds: [], messageLookup: {}, conversationIds: [], @@ -426,10 +512,51 @@ export function getEmptyState(): SearchStateType { }; } +function handleSearchUpdate( + state: SearchStateType, + params: { query?: string; filterByUnread?: boolean } +): SearchStateType { + const { query, filterByUnread } = params; + + // Determine the new state values, falling back to existing state if not provided + const newQuery = query ?? state.query; + const newFilterByUnread = filterByUnread ?? state.filterByUnread; + + const isValidSearch = newQuery.length > 0 || newFilterByUnread; + const isWithinConversation = Boolean(state.searchConversationId); + + if (isValidSearch) { + return { + ...state, + query: newQuery, + filterByUnread: newFilterByUnread, + messagesLoading: true, + messageIds: [], + messageLookup: {}, + discussionsLoading: !isWithinConversation, + contactIds: [], + conversationIds: [], + }; + } + + return { + ...getEmptyState(), + startSearchCounter: state.startSearchCounter, + searchConversationId: state.searchConversationId, + globalSearch: state.globalSearch, + }; +} + export function reducer( state: Readonly = getEmptyState(), action: Readonly ): SearchStateType { + if (action.type === 'FILTER_BY_UNREAD_UPDATE') { + return handleSearchUpdate(state, { + filterByUnread: action.payload.enabled, + }); + } + if (action.type === 'SHOW_ARCHIVED_CONVERSATIONS') { log.info('search: show archived conversations, clearing message lookup'); return getEmptyState(); @@ -444,15 +571,8 @@ export function reducer( }; } - if (action.type === 'SEARCH_CLEAR') { - log.info('search: cleared, clearing message lookup'); - - return { - ...getEmptyState(), - startSearchCounter: state.startSearchCounter, - searchConversationId: state.searchConversationId, - globalSearch: state.globalSearch, - }; + if (action.type === 'SEARCH_QUERY_CLEAR') { + return handleSearchUpdate(state, { query: '' }); } if (action.type === 'SEARCH_END') { @@ -463,26 +583,7 @@ export function reducer( } if (action.type === 'SEARCH_UPDATE') { - const { payload } = action; - const { query } = payload; - - const hasQuery = Boolean(query); - const isWithinConversation = Boolean(state.searchConversationId); - - return { - ...state, - query, - messagesLoading: hasQuery, - ...(hasQuery - ? { - messageIds: [], - messageLookup: {}, - discussionsLoading: !isWithinConversation, - contactIds: [], - conversationIds: [], - } - : {}), - }; + return handleSearchUpdate(state, { query: action.payload.query }); } if (action.type === 'SEARCH_IN_CONVERSATION') { diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 03367eeaea78..f0e674d9c4e2 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -774,7 +774,9 @@ export const getFilteredCandidateContactsForNewGroup = createSelector( getCandidateContactsForNewGroup, getNormalizedComposerConversationSearchTerm, getRegionCode, - filterAndSortConversations + (contacts, searchTerm, regionCode): Array => { + return filterAndSortConversations(contacts, searchTerm, regionCode); + } ); const getGroupCreationComposerState = createSelector( diff --git a/ts/state/selectors/search.ts b/ts/state/selectors/search.ts index 09c50353fafb..5ed07a074949 100644 --- a/ts/state/selectors/search.ts +++ b/ts/state/selectors/search.ts @@ -34,6 +34,11 @@ import { getOwn } from '../../util/getOwn'; export const getSearch = (state: StateType): SearchStateType => state.search; +export const getFilterByUnread = createSelector( + getSearch, + (state: SearchStateType): boolean => state.filterByUnread +); + export const getQuery = createSelector( getSearch, (state: SearchStateType): string => state.query @@ -96,6 +101,12 @@ export const getHasSearchQuery = createSelector( (query: string): boolean => query.trim().length > 0 ); +export const getIsActivelySearching = createSelector( + [getFilterByUnread, getHasSearchQuery], + (filterByUnread: boolean, hasSearchQuery: boolean): boolean => + filterByUnread || hasSearchQuery +); + export const getMessageSearchResultLookup = createSelector( getSearch, (state: SearchStateType) => state.messageLookup @@ -114,6 +125,7 @@ export const getSearchResults = createSelector( | 'messageResults' | 'searchConversationName' | 'searchTerm' + | 'filterByUnread' > => { const { contactIds, @@ -145,6 +157,7 @@ export const getSearchResults = createSelector( }, searchConversationName, searchTerm: state.query, + filterByUnread: state.filterByUnread, }; } ); diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index fa6d7a692b14..0dc9374db514 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -69,7 +69,9 @@ import { hasNetworkDialog as getHasNetworkDialog, } from '../selectors/network'; import { + getFilterByUnread, getHasSearchQuery, + getIsActivelySearching, getIsSearching, getIsSearchingGlobally, getQuery, @@ -172,7 +174,7 @@ const getModeSpecificProps = ( ...(searchConversation && searchTerm ? getSearchResults(state) : {}), }; } - if (getHasSearchQuery(state)) { + if (getIsActivelySearching(state)) { const primarySendsSms = Boolean( get(state.items, ['primarySendsSms'], false) ); @@ -195,6 +197,7 @@ const getModeSpecificProps = ( searchDisabled: state.network.challengeStatus !== 'idle', searchTerm: getQuery(state), startSearchCounter: getStartSearchCounter(state), + filterByUnread: getFilterByUnread(state), ...getLeftPaneLists(state), }; case ComposerStep.StartDirectConversation: @@ -329,12 +332,13 @@ export const SmartLeftPane = memo(function SmartLeftPane({ } = useConversationsActions(); const { clearConversationSearch, - clearSearch, + clearSearchQuery, endConversationSearch, endSearch, searchInConversation, startSearch, updateSearchTerm, + updateFilterByUnread, } = useSearchActions(); const { onOutgoingAudioCallInConversation, @@ -376,7 +380,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({ challengeStatus={challengeStatus} clearConversationSearch={clearConversationSearch} clearGroupCreationError={clearGroupCreationError} - clearSearch={clearSearch} + clearSearchQuery={clearSearchQuery} closeMaximumGroupSizeModal={closeMaximumGroupSizeModal} closeRecommendedGroupSizeModal={closeRecommendedGroupSizeModal} composeDeleteAvatarFromDisk={composeDeleteAvatarFromDisk} @@ -448,6 +452,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({ updateSearchTerm={updateSearchTerm} usernameCorrupted={usernameCorrupted} usernameLinkCorrupted={usernameLinkCorrupted} + updateFilterByUnread={updateFilterByUnread} /> ); }); diff --git a/ts/test-both/state/selectors/search_test.ts b/ts/test-both/state/selectors/search_test.ts index f95eccb3464f..e13646b517c6 100644 --- a/ts/test-both/state/selectors/search_test.ts +++ b/ts/test-both/state/selectors/search_test.ts @@ -395,6 +395,7 @@ describe('both/state/selectors/search', () => { messageResults: { isLoading: true }, searchConversationName: undefined, searchTerm: 'foo bar', + filterByUnread: false, }); }); @@ -450,6 +451,7 @@ describe('both/state/selectors/search', () => { }, searchConversationName: undefined, searchTerm: 'foo bar', + filterByUnread: false, }); }); }); diff --git a/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.tsx b/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.tsx index d50ce5cf4c31..8e98048b200a 100644 --- a/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.tsx +++ b/ts/test-node/components/leftPane/LeftPaneInboxHelper_test.tsx @@ -14,6 +14,7 @@ describe('LeftPaneInboxHelper', () => { const defaultProps: LeftPaneInboxPropsType = { archivedConversations: [], conversations: [], + filterByUnread: false, isSearchingGlobally: false, isAboutToSearch: false, pinnedConversations: [], diff --git a/ts/test-node/components/leftPane/LeftPaneSearchHelper_test.ts b/ts/test-node/components/leftPane/LeftPaneSearchHelper_test.ts index 62cd330c1267..792ffc361735 100644 --- a/ts/test-node/components/leftPane/LeftPaneSearchHelper_test.ts +++ b/ts/test-node/components/leftPane/LeftPaneSearchHelper_test.ts @@ -9,6 +9,18 @@ import { getDefaultConversation } from '../../../test-both/helpers/getDefaultCon import { LeftPaneSearchHelper } from '../../../components/leftPane/LeftPaneSearchHelper'; +const baseSearchHelperArgs = { + conversationResults: { isLoading: false, results: [] }, + contactResults: { isLoading: false, results: [] }, + filterByUnread: false, + messageResults: { isLoading: false, results: [] }, + isSearchingGlobally: true, + searchTerm: 'foo', + primarySendsSms: false, + searchConversation: undefined, + searchDisabled: false, + startSearchCounter: 0, +}; describe('LeftPaneSearchHelper', () => { const fakeMessage = () => ({ id: uuid(), @@ -18,17 +30,7 @@ describe('LeftPaneSearchHelper', () => { describe('getBackAction', () => { it('returns undefined; going back is handled elsewhere in the app', () => { - const helper = new LeftPaneSearchHelper({ - conversationResults: { isLoading: false, results: [] }, - contactResults: { isLoading: false, results: [] }, - messageResults: { isLoading: false, results: [] }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, - }); + const helper = new LeftPaneSearchHelper(baseSearchHelperArgs); assert.isUndefined( helper.getBackAction({ @@ -44,46 +46,31 @@ describe('LeftPaneSearchHelper', () => { it('returns 100 if any results are loading', () => { assert.strictEqual( new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: true }, contactResults: { isLoading: true }, messageResults: { isLoading: true }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }).getRowCount(), 100 ); assert.strictEqual( new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation(), getDefaultConversation()], }, contactResults: { isLoading: true }, messageResults: { isLoading: true }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }).getRowCount(), 100 ); assert.strictEqual( new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: true }, contactResults: { isLoading: true }, messageResults: { isLoading: false, results: [fakeMessage()] }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }).getRowCount(), 100 ); @@ -100,6 +87,7 @@ describe('LeftPaneSearchHelper', () => { searchConversation: undefined, searchDisabled: false, startSearchCounter: 0, + filterByUnread: false, }); assert.strictEqual(helper.getRowCount(), 0); @@ -107,18 +95,13 @@ describe('LeftPaneSearchHelper', () => { it('returns 1 + the number of results, dropping empty sections', () => { const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation(), getDefaultConversation()], }, contactResults: { isLoading: false, results: [] }, messageResults: { isLoading: false, results: [fakeMessage()] }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.strictEqual(helper.getRowCount(), 5); @@ -129,40 +112,25 @@ describe('LeftPaneSearchHelper', () => { it('returns a "loading search results" row if any results are loading', () => { const helpers = [ new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: true }, contactResults: { isLoading: true }, messageResults: { isLoading: true }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }), new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation(), getDefaultConversation()], }, contactResults: { isLoading: true }, messageResults: { isLoading: true }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }), new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: true }, contactResults: { isLoading: true }, messageResults: { isLoading: false, results: [fakeMessage()] }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }), ]; @@ -188,18 +156,13 @@ describe('LeftPaneSearchHelper', () => { const messages = [fakeMessage(), fakeMessage()]; const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: conversations, }, contactResults: { isLoading: false, results: contacts }, messageResults: { isLoading: false, results: messages }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.deepEqual( @@ -235,18 +198,9 @@ describe('LeftPaneSearchHelper', () => { const messages = [fakeMessage(), fakeMessage()]; const helper = new LeftPaneSearchHelper({ - conversationResults: { - isLoading: false, - results: [], - }, + ...baseSearchHelperArgs, contactResults: { isLoading: false, results: contacts }, messageResults: { isLoading: false, results: messages }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.deepEqual(_testHeaderText(helper.getRow(0)), 'icu:contactsHeader'); @@ -273,18 +227,12 @@ describe('LeftPaneSearchHelper', () => { const messages = [fakeMessage(), fakeMessage()]; const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: conversations, }, - contactResults: { isLoading: false, results: [] }, messageResults: { isLoading: false, results: messages }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.deepEqual( @@ -316,18 +264,12 @@ describe('LeftPaneSearchHelper', () => { const contacts = [getDefaultConversation()]; const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: conversations, }, contactResults: { isLoading: false, results: contacts }, - messageResults: { isLoading: false, results: [] }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.deepEqual( @@ -354,40 +296,25 @@ describe('LeftPaneSearchHelper', () => { it('returns false if any results are loading', () => { const helpers = [ new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: true }, contactResults: { isLoading: true }, messageResults: { isLoading: true }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }), new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation(), getDefaultConversation()], }, contactResults: { isLoading: true }, messageResults: { isLoading: true }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }), new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: true }, contactResults: { isLoading: true }, messageResults: { isLoading: false, results: [fakeMessage()] }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }), ]; @@ -398,21 +325,15 @@ describe('LeftPaneSearchHelper', () => { it('returns true if all results have loaded', () => { const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation(), getDefaultConversation()], }, - contactResults: { isLoading: false, results: [] }, messageResults: { isLoading: false, results: [fakeMessage(), fakeMessage(), fakeMessage()], }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.isTrue(helper.isScrollable()); }); @@ -421,25 +342,20 @@ describe('LeftPaneSearchHelper', () => { describe('shouldRecomputeRowHeights', () => { it("returns false if the number of results doesn't change", () => { const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation(), getDefaultConversation()], }, - contactResults: { isLoading: false, results: [] }, messageResults: { isLoading: false, results: [fakeMessage(), fakeMessage(), fakeMessage()], }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.isFalse( helper.shouldRecomputeRowHeights({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation(), getDefaultConversation()], @@ -449,108 +365,68 @@ describe('LeftPaneSearchHelper', () => { isLoading: false, results: [fakeMessage(), fakeMessage(), fakeMessage()], }, - isSearchingGlobally: true, - searchTerm: 'bar', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }) ); }); it('returns false when a section completes loading, but not all sections are done (because the pane is still loading overall)', () => { const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: true }, contactResults: { isLoading: true }, messageResults: { isLoading: true }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.isFalse( helper.shouldRecomputeRowHeights({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation()], }, contactResults: { isLoading: true }, messageResults: { isLoading: true }, - isSearchingGlobally: true, - searchTerm: 'bar', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }) ); }); it('returns true when all sections finish loading', () => { const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: true }, contactResults: { isLoading: true }, messageResults: { isLoading: false, results: [fakeMessage()] }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.isTrue( helper.shouldRecomputeRowHeights({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation(), getDefaultConversation()], }, contactResults: { isLoading: false, results: [] }, messageResults: { isLoading: false, results: [fakeMessage()] }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }) ); }); it('returns true if the number of results in a section changes', () => { const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation(), getDefaultConversation()], }, - contactResults: { isLoading: false, results: [] }, - messageResults: { isLoading: false, results: [] }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.isTrue( helper.shouldRecomputeRowHeights({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation()], }, - contactResults: { isLoading: true }, - messageResults: { isLoading: true }, - isSearchingGlobally: true, - searchTerm: 'bar', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }) ); }); @@ -560,21 +436,15 @@ describe('LeftPaneSearchHelper', () => { it('returns correct conversation at given index', () => { const expected = getDefaultConversation(); const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [expected, getDefaultConversation()], }, - contactResults: { isLoading: false, results: [] }, messageResults: { isLoading: false, results: [fakeMessage(), fakeMessage(), fakeMessage()], }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.strictEqual( helper.getConversationAndMessageAtIndex(0)?.conversationId, @@ -585,6 +455,7 @@ describe('LeftPaneSearchHelper', () => { it('returns correct contact at given index', () => { const expected = getDefaultConversation(); const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation(), getDefaultConversation()], @@ -597,12 +468,6 @@ describe('LeftPaneSearchHelper', () => { isLoading: false, results: [fakeMessage(), fakeMessage(), fakeMessage()], }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.strictEqual( helper.getConversationAndMessageAtIndex(2)?.conversationId, @@ -613,21 +478,15 @@ describe('LeftPaneSearchHelper', () => { it('returns correct message at given index', () => { const expected = fakeMessage(); const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation(), getDefaultConversation()], }, - contactResults: { isLoading: false, results: [] }, messageResults: { isLoading: false, results: [fakeMessage(), fakeMessage(), expected], }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.strictEqual( helper.getConversationAndMessageAtIndex(4)?.messageId, @@ -638,18 +497,13 @@ describe('LeftPaneSearchHelper', () => { it('returns correct message at given index skipping not loaded results', () => { const expected = fakeMessage(); const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: true }, contactResults: { isLoading: true }, messageResults: { isLoading: false, results: [fakeMessage(), expected, fakeMessage()], }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.strictEqual( helper.getConversationAndMessageAtIndex(1)?.messageId, @@ -659,21 +513,15 @@ describe('LeftPaneSearchHelper', () => { it('returns undefined if search candidate with given index does not exist', () => { const helper = new LeftPaneSearchHelper({ + ...baseSearchHelperArgs, conversationResults: { isLoading: false, results: [getDefaultConversation(), getDefaultConversation()], }, - contactResults: { isLoading: false, results: [] }, messageResults: { isLoading: false, results: [fakeMessage(), fakeMessage(), fakeMessage()], }, - isSearchingGlobally: true, - searchTerm: 'foo', - primarySendsSms: false, - searchConversation: undefined, - searchDisabled: false, - startSearchCounter: 0, }); assert.isUndefined( helper.getConversationAndMessageAtIndex(100)?.messageId diff --git a/ts/util/filterAndSortConversations.ts b/ts/util/filterAndSortConversations.ts index d85abd7c82e8..fe69dcdfd614 100644 --- a/ts/util/filterAndSortConversations.ts +++ b/ts/util/filterAndSortConversations.ts @@ -64,6 +64,17 @@ type CommandRunnerType = ( const COMMANDS = new Map(); +function filterConversationsByUnread( + conversations: ReadonlyArray, + includeMuted: boolean +): Array { + return conversations.filter(conversation => { + return hasUnread( + countConversationUnreadStats(conversation, { includeMuted }) + ); + }); +} + COMMANDS.set('serviceIdEndsWith', (conversations, query) => { return conversations.filter(convo => convo.serviceId?.endsWith(query)); }); @@ -95,11 +106,7 @@ COMMANDS.set('unread', (conversations, query) => { /^(?:m|muted)$/i.test(query) || window.storage.get('badge-count-muted-conversations') || false; - return conversations.filter(conversation => { - return hasUnread( - countConversationUnreadStats(conversation, { includeMuted }) - ); - }); + return filterConversationsByUnread(conversations, includeMuted); }); // See https://fusejs.io/examples.html#extended-search for @@ -157,14 +164,24 @@ function sortAlphabetically(a: ConversationType, b: ConversationType) { export function filterAndSortConversations( conversations: ReadonlyArray, searchTerm: string, - regionCode: string | undefined + regionCode: string | undefined, + filterByUnread: boolean = false ): Array { + const filteredConversations = filterByUnread + ? filterConversationsByUnread(conversations, true) + : conversations; + if (searchTerm.length) { const now = Date.now(); + const withoutUnknownAndFiltered = filteredConversations.filter( + item => item.titleNoDefault + ); - const withoutUnknown = conversations.filter(item => item.titleNoDefault); - - return searchConversations(withoutUnknown, searchTerm, regionCode) + return searchConversations( + withoutUnknownAndFiltered, + searchTerm, + regionCode + ) .slice() .sort((a, b) => { const { activeAt: aActiveAt = 0, left: aLeft = false } = a.item; @@ -190,7 +207,7 @@ export function filterAndSortConversations( .map(result => result.item); } - return conversations.concat().sort((a, b) => { + return filteredConversations.concat().sort((a, b) => { const aScore = a.activeAt ?? 0; const bScore = b.activeAt ?? 0; const score = bScore - aScore;