Improve left pane UI when loading search results

This commit is contained in:
Evan Hahn 2021-04-02 17:32:55 -05:00 committed by Josh Perez
parent f05d45ac9b
commit d81aaf654f
15 changed files with 420 additions and 93 deletions

View file

@ -1,4 +1,4 @@
// Copyright 2016-2020 Signal Messenger, LLC // Copyright 2016-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
// Fonts // Fonts
@ -116,6 +116,42 @@
} }
} }
// Search results loading
@mixin search-results-loading-pulsating-background {
animation: search-results-loading-pulsating-background-animation 2s infinite;
@media (prefers-reduced-motion) {
animation: none;
}
@include light-theme {
background: $color-gray-05;
}
@include dark-theme {
background: $color-gray-65;
}
}
@keyframes search-results-loading-pulsating-background-animation {
0% {
opacity: 1;
}
50% {
opacity: 0.55;
}
100% {
opacity: 1;
}
}
@mixin search-results-loading-box($width) {
width: $width;
height: 12px;
border-radius: 4px;
@include search-results-loading-pulsating-background;
}
// Icons // Icons
@mixin color-svg($svg, $color, $stretch: true) { @mixin color-svg($svg, $color, $stretch: true) {

View file

@ -6911,7 +6911,11 @@ button.module-image__border-overlay:focus {
// Module: conversation list // Module: conversation list
.module-conversation-list { .module-conversation-list {
@include smooth-scroll; &--scroll-behavior {
&-default {
@include smooth-scroll;
}
}
&__item { &__item {
&--archive-button { &--archive-button {

View file

@ -0,0 +1,16 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// These styles should match the "real" header.
.module-SearchResultsLoadingFakeHeader {
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 16px;
&::before {
content: '';
display: block;
@include search-results-loading-box(25%);
}
}

View file

@ -0,0 +1,35 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// These styles should match the "real" contact/conversation row.
.module-SearchResultsLoadingFakeRow {
display: flex;
align-items: center;
justify-content: center;
padding-left: 16px;
padding-right: 16px;
&__avatar {
width: 52px;
height: 52px;
border-radius: 100%;
@include search-results-loading-pulsating-background;
}
&__content {
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
margin-left: 12px;
&__header {
@include search-results-loading-box(50%);
margin-bottom: 8px;
}
&__message {
@include search-results-loading-box(90%);
}
}
}

View file

@ -38,3 +38,5 @@
@import './components/GroupDialog.scss'; @import './components/GroupDialog.scss';
@import './components/GroupTitleInput.scss'; @import './components/GroupTitleInput.scss';
@import './components/MessageAudio.scss'; @import './components/MessageAudio.scss';
@import './components/SearchResultsLoadingFakeHeader.scss';
@import './components/SearchResultsLoadingFakeRow.scss';

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React from 'react';
import { omit } from 'lodash'; import { times, omit } from 'lodash';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
@ -413,7 +413,7 @@ Line 4, well.`,
}); });
story.add('Conversations: Various Times', () => { story.add('Conversations: Various Times', () => {
const times: Array<[number, string]> = [ const pairs: Array<[number, string]> = [
[Date.now() - 5 * 60 * 60 * 1000, 'Five hours ago'], [Date.now() - 5 * 60 * 60 * 1000, 'Five hours ago'],
[Date.now() - 24 * 60 * 60 * 1000, 'One day ago'], [Date.now() - 24 * 60 * 60 * 1000, 'One day ago'],
[Date.now() - 7 * 24 * 60 * 60 * 1000, 'One week ago'], [Date.now() - 7 * 24 * 60 * 60 * 1000, 'One week ago'],
@ -423,7 +423,7 @@ Line 4, well.`,
return ( return (
<ConversationList <ConversationList
{...createProps( {...createProps(
times.map(([lastUpdated, messageText]) => ({ pairs.map(([lastUpdated, messageText]) => ({
type: RowType.Conversation, type: RowType.Conversation,
conversation: createConversation({ conversation: createConversation({
lastUpdated, lastUpdated,
@ -510,6 +510,18 @@ story.add('Start new conversation', () => (
/> />
)); ));
story.add('Search results loading skeleton', () => (
<ConversationList
scrollable={false}
{...createProps([
{ type: RowType.SearchResultsLoadingFakeHeader },
...times(99, () => ({
type: RowType.SearchResultsLoadingFakeRow as const,
})),
])}
/>
));
story.add('Kitchen sink', () => ( story.add('Kitchen sink', () => (
<ConversationList <ConversationList
{...createProps([ {...createProps([
@ -533,7 +545,6 @@ story.add('Kitchen sink', () => (
type: RowType.MessageSearchResult, type: RowType.MessageSearchResult,
messageId: '123', messageId: '123',
}, },
{ type: RowType.Spinner },
{ {
type: RowType.ArchiveButton, type: RowType.ArchiveButton,
archivedConversationsCount: 123, archivedConversationsCount: 123,

View file

@ -3,10 +3,11 @@
import React, { useRef, useEffect, useCallback, CSSProperties } from 'react'; import React, { useRef, useEffect, useCallback, CSSProperties } from 'react';
import { List, ListRowRenderer } from 'react-virtualized'; import { List, ListRowRenderer } from 'react-virtualized';
import classNames from 'classnames';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { assert } from '../util/assert'; import { assert } from '../util/assert';
import { LocalizerType } from '../types/Util'; import { LocalizerType, ScrollBehavior } from '../types/Util';
import { import {
ConversationListItem, ConversationListItem,
@ -21,8 +22,9 @@ import {
ContactCheckboxDisabledReason, ContactCheckboxDisabledReason,
} from './conversationList/ContactCheckbox'; } from './conversationList/ContactCheckbox';
import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton'; import { CreateNewGroupButton } from './conversationList/CreateNewGroupButton';
import { Spinner as SpinnerComponent } from './Spinner';
import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation'; import { StartNewConversation as StartNewConversationComponent } from './conversationList/StartNewConversation';
import { SearchResultsLoadingFakeHeader as SearchResultsLoadingFakeHeaderComponent } from './conversationList/SearchResultsLoadingFakeHeader';
import { SearchResultsLoadingFakeRow as SearchResultsLoadingFakeRowComponent } from './conversationList/SearchResultsLoadingFakeRow';
export enum RowType { export enum RowType {
ArchiveButton, ArchiveButton,
@ -33,7 +35,8 @@ export enum RowType {
CreateNewGroup, CreateNewGroup,
Header, Header,
MessageSearchResult, MessageSearchResult,
Spinner, SearchResultsLoadingFakeHeader,
SearchResultsLoadingFakeRow,
StartNewConversation, StartNewConversation,
} }
@ -76,7 +79,13 @@ type HeaderRowType = {
i18nKey: string; i18nKey: string;
}; };
type SpinnerRowType = { type: RowType.Spinner }; type SearchResultsLoadingFakeHeaderType = {
type: RowType.SearchResultsLoadingFakeHeader;
};
type SearchResultsLoadingFakeRowType = {
type: RowType.SearchResultsLoadingFakeRow;
};
type StartNewConversationRowType = { type StartNewConversationRowType = {
type: RowType.StartNewConversation; type: RowType.StartNewConversation;
@ -92,7 +101,8 @@ export type Row =
| CreateNewGroupRowType | CreateNewGroupRowType
| MessageRowType | MessageRowType
| HeaderRowType | HeaderRowType
| SpinnerRowType | SearchResultsLoadingFakeHeaderType
| SearchResultsLoadingFakeRowType
| StartNewConversationRowType; | StartNewConversationRowType;
export type PropsType = { export type PropsType = {
@ -105,8 +115,10 @@ export type PropsType = {
// this should only happen if there is a bug somewhere. For example, an inaccurate // this should only happen if there is a bug somewhere. For example, an inaccurate
// `rowCount`. // `rowCount`.
getRow: (index: number) => undefined | Row; getRow: (index: number) => undefined | Row;
scrollBehavior?: ScrollBehavior;
scrollToRowIndex?: number; scrollToRowIndex?: number;
shouldRecomputeRowHeights: boolean; shouldRecomputeRowHeights: boolean;
scrollable?: boolean;
i18n: LocalizerType; i18n: LocalizerType;
@ -121,6 +133,9 @@ export type PropsType = {
startNewConversationFromPhoneNumber: (e164: string) => void; startNewConversationFromPhoneNumber: (e164: string) => void;
}; };
const NORMAL_ROW_HEIGHT = 68;
const HEADER_ROW_HEIGHT = 40;
export const ConversationList: React.FC<PropsType> = ({ export const ConversationList: React.FC<PropsType> = ({
dimensions, dimensions,
getRow, getRow,
@ -130,7 +145,9 @@ export const ConversationList: React.FC<PropsType> = ({
onSelectConversation, onSelectConversation,
renderMessageSearchResult, renderMessageSearchResult,
rowCount, rowCount,
scrollBehavior = ScrollBehavior.Default,
scrollToRowIndex, scrollToRowIndex,
scrollable = true,
shouldRecomputeRowHeights, shouldRecomputeRowHeights,
showChooseGroupMembers, showChooseGroupMembers,
startNewConversationFromPhoneNumber, startNewConversationFromPhoneNumber,
@ -149,9 +166,15 @@ export const ConversationList: React.FC<PropsType> = ({
const row = getRow(index); const row = getRow(index);
if (!row) { if (!row) {
assert(false, `Expected a row at index ${index}`); assert(false, `Expected a row at index ${index}`);
return 68; return NORMAL_ROW_HEIGHT;
}
switch (row.type) {
case RowType.Header:
case RowType.SearchResultsLoadingFakeHeader:
return HEADER_ROW_HEIGHT;
default:
return NORMAL_ROW_HEIGHT;
} }
return row.type === RowType.Header ? 40 : 68;
}, },
[getRow] [getRow]
); );
@ -235,22 +258,20 @@ export const ConversationList: React.FC<PropsType> = ({
{i18n(row.i18nKey)} {i18n(row.i18nKey)}
</div> </div>
); );
case RowType.Spinner:
return (
<div
className="module-conversation-list__item--spinner"
key={key}
style={style}
>
<SpinnerComponent size="24px" svgSize="small" />
</div>
);
case RowType.MessageSearchResult: case RowType.MessageSearchResult:
return ( return (
<React.Fragment key={key}> <React.Fragment key={key}>
{renderMessageSearchResult(row.messageId, style)} {renderMessageSearchResult(row.messageId, style)}
</React.Fragment> </React.Fragment>
); );
case RowType.SearchResultsLoadingFakeHeader:
return (
<SearchResultsLoadingFakeHeaderComponent key={key} style={style} />
);
case RowType.SearchResultsLoadingFakeRow:
return (
<SearchResultsLoadingFakeRowComponent key={key} style={style} />
);
case RowType.StartNewConversation: case RowType.StartNewConversation:
return ( return (
<StartNewConversationComponent <StartNewConversationComponent
@ -288,13 +309,17 @@ export const ConversationList: React.FC<PropsType> = ({
return ( return (
<List <List
className="module-conversation-list" className={classNames(
'module-conversation-list',
`module-conversation-list--scroll-behavior-${scrollBehavior}`
)}
height={height} height={height}
ref={listRef} ref={listRef}
rowCount={rowCount} rowCount={rowCount}
rowHeight={calculateRowHeight} rowHeight={calculateRowHeight}
rowRenderer={renderRow} rowRenderer={renderRow}
scrollToIndex={scrollToRowIndex} scrollToIndex={scrollToRowIndex}
style={{ overflow: scrollable ? 'auto' : 'hidden' }}
tabIndex={-1} tabIndex={-1}
width={width} width={width}
/> />

View file

@ -36,7 +36,7 @@ import {
} from './leftPane/LeftPaneSetGroupMetadataHelper'; } from './leftPane/LeftPaneSetGroupMetadataHelper';
import * as OS from '../OS'; import * as OS from '../OS';
import { LocalizerType } from '../types/Util'; import { LocalizerType, ScrollBehavior } from '../types/Util';
import { usePrevious } from '../util/hooks'; import { usePrevious } from '../util/hooks';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
@ -337,10 +337,21 @@ export const LeftPane: React.FC<PropsType> = ({
selectedConversationId, selectedConversationId,
selectedConversationId selectedConversationId
); );
const rowIndexToScrollTo: undefined | number =
previousSelectedConversationId === selectedConversationId const isScrollable = helper.isScrollable();
? undefined
: helper.getRowIndexToScrollTo(selectedConversationId); let rowIndexToScrollTo: undefined | number;
let scrollBehavior: ScrollBehavior;
if (isScrollable) {
rowIndexToScrollTo =
previousSelectedConversationId === selectedConversationId
? undefined
: helper.getRowIndexToScrollTo(selectedConversationId);
scrollBehavior = ScrollBehavior.Default;
} else {
rowIndexToScrollTo = 0;
scrollBehavior = ScrollBehavior.Hard;
}
// We ensure that the listKey differs between some modes (e.g. inbox/archived), ensuring // We ensure that the listKey differs between some modes (e.g. inbox/archived), ensuring
// that AutoSizer properly detects the new size of its slot in the flexbox. The // that AutoSizer properly detects the new size of its slot in the flexbox. The
@ -436,7 +447,9 @@ export const LeftPane: React.FC<PropsType> = ({
}} }}
renderMessageSearchResult={renderMessageSearchResult} renderMessageSearchResult={renderMessageSearchResult}
rowCount={helper.getRowCount()} rowCount={helper.getRowCount()}
scrollBehavior={scrollBehavior}
scrollToRowIndex={rowIndexToScrollTo} scrollToRowIndex={rowIndexToScrollTo}
scrollable={isScrollable}
shouldRecomputeRowHeights={shouldRecomputeRowHeights} shouldRecomputeRowHeights={shouldRecomputeRowHeights}
showChooseGroupMembers={showChooseGroupMembers} showChooseGroupMembers={showChooseGroupMembers}
startNewConversationFromPhoneNumber={ startNewConversationFromPhoneNumber={

View file

@ -0,0 +1,12 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react';
type PropsType = {
style: CSSProperties;
};
export const SearchResultsLoadingFakeHeader: FunctionComponent<PropsType> = ({
style,
}) => <div className="module-SearchResultsLoadingFakeHeader" style={style} />;

View file

@ -0,0 +1,20 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { CSSProperties, FunctionComponent } from 'react';
type PropsType = {
style: CSSProperties;
};
export const SearchResultsLoadingFakeRow: FunctionComponent<PropsType> = ({
style,
}) => (
<div className="module-SearchResultsLoadingFakeRow" style={style}>
<div className="module-SearchResultsLoadingFakeRow__avatar" />
<div className="module-SearchResultsLoadingFakeRow__content">
<div className="module-SearchResultsLoadingFakeRow__content__header" />
<div className="module-SearchResultsLoadingFakeRow__content__message" />
</div>
</div>
);

View file

@ -83,6 +83,10 @@ export abstract class LeftPaneHelper<T> {
return undefined; return undefined;
} }
isScrollable(): boolean {
return true;
}
abstract getConversationAndMessageAtIndex( abstract getConversationAndMessageAtIndex(
conversationIndex: number conversationIndex: number
): undefined | { conversationId: string; messageId?: string }; ): undefined | { conversationId: string; messageId?: string };

View file

@ -10,6 +10,14 @@ import { PropsData as ConversationListItemPropsType } from '../conversationList/
import { Intl } from '../Intl'; import { Intl } from '../Intl';
import { Emojify } from '../conversation/Emojify'; import { Emojify } from '../conversation/Emojify';
import { assert } from '../../util/assert';
// The "correct" thing to do is to measure the size of the left pane and render enough
// search results for the container height. But (1) that's slow (2) the list is
// virtualized (3) 99 rows is over 6000px tall, taller than most monitors (4) it's fine
// if, in some extremely tall window, we have some empty space. So we just hard-code a
// fairly big number.
const SEARCH_RESULTS_FAKE_ROW_COUNT = 99;
type MaybeLoadedSearchResultsType<T> = type MaybeLoadedSearchResultsType<T> =
| { isLoading: true } | { isLoading: true }
@ -106,9 +114,14 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
} }
getRowCount(): number { getRowCount(): number {
if (this.isLoading()) {
// 1 for the header.
return 1 + SEARCH_RESULTS_FAKE_ROW_COUNT;
}
return this.allResults().reduce( return this.allResults().reduce(
(result: number, searchResults) => (result: number, searchResults) =>
result + getRowCountForSearchResult(searchResults), result + getRowCountForLoadedSearchResults(searchResults),
0 0
); );
} }
@ -124,11 +137,21 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
getRow(rowIndex: number): undefined | Row { getRow(rowIndex: number): undefined | Row {
const { conversationResults, contactResults, messageResults } = this; const { conversationResults, contactResults, messageResults } = this;
const conversationRowCount = getRowCountForSearchResult( if (this.isLoading()) {
if (rowIndex === 0) {
return { type: RowType.SearchResultsLoadingFakeHeader };
}
if (rowIndex + 1 <= SEARCH_RESULTS_FAKE_ROW_COUNT) {
return { type: RowType.SearchResultsLoadingFakeRow };
}
return undefined;
}
const conversationRowCount = getRowCountForLoadedSearchResults(
conversationResults conversationResults
); );
const contactRowCount = getRowCountForSearchResult(contactResults); const contactRowCount = getRowCountForLoadedSearchResults(contactResults);
const messageRowCount = getRowCountForSearchResult(messageResults); const messageRowCount = getRowCountForLoadedSearchResults(messageResults);
if (rowIndex < conversationRowCount) { if (rowIndex < conversationRowCount) {
if (rowIndex === 0) { if (rowIndex === 0) {
@ -137,9 +160,10 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
i18nKey: 'conversationsHeader', i18nKey: 'conversationsHeader',
}; };
} }
if (conversationResults.isLoading) { assert(
return { type: RowType.Spinner }; !conversationResults.isLoading,
} "We shouldn't get here with conversation results still loading"
);
const conversation = conversationResults.results[rowIndex - 1]; const conversation = conversationResults.results[rowIndex - 1];
return conversation return conversation
? { ? {
@ -157,9 +181,10 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
i18nKey: 'contactsHeader', i18nKey: 'contactsHeader',
}; };
} }
if (contactResults.isLoading) { assert(
return { type: RowType.Spinner }; !contactResults.isLoading,
} "We shouldn't get here with contact results still loading"
);
const conversation = contactResults.results[localIndex - 1]; const conversation = contactResults.results[localIndex - 1];
return conversation return conversation
? { ? {
@ -180,9 +205,10 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
i18nKey: 'messagesHeader', i18nKey: 'messagesHeader',
}; };
} }
if (messageResults.isLoading) { assert(
return { type: RowType.Spinner }; !messageResults.isLoading,
} "We shouldn't get here with message results still loading"
);
const message = messageResults.results[localIndex - 1]; const message = messageResults.results[localIndex - 1];
return message return message
? { ? {
@ -192,11 +218,23 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
: undefined; : undefined;
} }
isScrollable(): boolean {
return !this.isLoading();
}
shouldRecomputeRowHeights(old: Readonly<LeftPaneSearchPropsType>): boolean { shouldRecomputeRowHeights(old: Readonly<LeftPaneSearchPropsType>): boolean {
const oldIsLoading = new LeftPaneSearchHelper(old).isLoading();
const newIsLoading = this.isLoading();
if (oldIsLoading && newIsLoading) {
return false;
}
if (oldIsLoading !== newIsLoading) {
return true;
}
return searchResultKeys.some( return searchResultKeys.some(
key => key =>
getRowCountForSearchResult(old[key]) !== getRowCountForLoadedSearchResults(old[key]) !==
getRowCountForSearchResult(this[key]) getRowCountForLoadedSearchResults(this[key])
); );
} }
@ -221,20 +259,27 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<
private allResults() { private allResults() {
return [this.conversationResults, this.contactResults, this.messageResults]; return [this.conversationResults, this.contactResults, this.messageResults];
} }
private isLoading(): boolean {
return this.allResults().some(results => results.isLoading);
}
} }
function getRowCountForSearchResult( function getRowCountForLoadedSearchResults(
searchResults: Readonly<MaybeLoadedSearchResultsType<unknown>> searchResults: Readonly<MaybeLoadedSearchResultsType<unknown>>
): number { ): number {
let hasHeader: boolean; // It's possible to call this helper with invalid results (e.g., ones that are loading).
let resultRows: number; // We could change the parameter of this function, but that adds a bunch of redundant
// checks that are, in the author's opinion, less clear.
if (searchResults.isLoading) { if (searchResults.isLoading) {
hasHeader = true; assert(
resultRows = 1; // For the spinner. false,
} else { 'getRowCountForLoadedSearchResults: Expected this to be called with loaded search results. Returning 0'
const resultCount = searchResults.results.length; );
hasHeader = Boolean(resultCount); return 0;
resultRows = resultCount;
} }
const resultRows = searchResults.results.length;
const hasHeader = Boolean(resultRows);
return (hasHeader ? 1 : 0) + resultRows; return (hasHeader ? 1 : 0) + resultRows;
} }

View file

@ -40,6 +40,39 @@ describe('LeftPaneSearchHelper', () => {
}); });
describe('getRowCount', () => { describe('getRowCount', () => {
it('returns 100 if any results are loading', () => {
assert.strictEqual(
new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo',
}).getRowCount(),
100
);
assert.strictEqual(
new LeftPaneSearchHelper({
conversationResults: {
isLoading: false,
results: [fakeConversation(), fakeConversation()],
},
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo',
}).getRowCount(),
100
);
assert.strictEqual(
new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: false, results: [fakeMessage()] },
searchTerm: 'foo',
}).getRowCount(),
100
);
});
it('returns 0 when there are no search results', () => { it('returns 0 when there are no search results', () => {
const helper = new LeftPaneSearchHelper({ const helper = new LeftPaneSearchHelper({
conversationResults: { isLoading: false, results: [] }, conversationResults: { isLoading: false, results: [] },
@ -51,17 +84,6 @@ describe('LeftPaneSearchHelper', () => {
assert.strictEqual(helper.getRowCount(), 0); assert.strictEqual(helper.getRowCount(), 0);
}); });
it("returns 2 rows for each section of search results that's loading", () => {
const helper = new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: false, results: [] },
messageResults: { isLoading: true },
searchTerm: 'foo',
});
assert.strictEqual(helper.getRowCount(), 4);
});
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({
conversationResults: { conversationResults: {
@ -78,34 +100,41 @@ describe('LeftPaneSearchHelper', () => {
}); });
describe('getRow', () => { describe('getRow', () => {
it('returns header + spinner for loading sections', () => { it('returns a "loading search results" row if any results are loading', () => {
const helper = new LeftPaneSearchHelper({ const helpers = [
conversationResults: { isLoading: true }, new LeftPaneSearchHelper({
contactResults: { isLoading: true }, conversationResults: { isLoading: true },
messageResults: { isLoading: true }, contactResults: { isLoading: true },
searchTerm: 'foo', messageResults: { isLoading: true },
}); searchTerm: 'foo',
}),
new LeftPaneSearchHelper({
conversationResults: {
isLoading: false,
results: [fakeConversation(), fakeConversation()],
},
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo',
}),
new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: false, results: [fakeMessage()] },
searchTerm: 'foo',
}),
];
assert.deepEqual(helper.getRow(0), { helpers.forEach(helper => {
type: RowType.Header, assert.deepEqual(helper.getRow(0), {
i18nKey: 'conversationsHeader', type: RowType.SearchResultsLoadingFakeHeader,
}); });
assert.deepEqual(helper.getRow(1), { for (let i = 1; i < 99; i += 1) {
type: RowType.Spinner, assert.deepEqual(helper.getRow(i), {
}); type: RowType.SearchResultsLoadingFakeRow,
assert.deepEqual(helper.getRow(2), { });
type: RowType.Header, }
i18nKey: 'contactsHeader', assert.isUndefined(helper.getRow(100));
});
assert.deepEqual(helper.getRow(3), {
type: RowType.Spinner,
});
assert.deepEqual(helper.getRow(4), {
type: RowType.Header,
i18nKey: 'messagesHeader',
});
assert.deepEqual(helper.getRow(5), {
type: RowType.Spinner,
}); });
}); });
@ -272,6 +301,54 @@ describe('LeftPaneSearchHelper', () => {
assert.isUndefined(helper.getRow(5)); assert.isUndefined(helper.getRow(5));
}); });
describe('isScrollable', () => {
it('returns false if any results are loading', () => {
const helpers = [
new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo',
}),
new LeftPaneSearchHelper({
conversationResults: {
isLoading: false,
results: [fakeConversation(), fakeConversation()],
},
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchTerm: 'foo',
}),
new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: false, results: [fakeMessage()] },
searchTerm: 'foo',
}),
];
helpers.forEach(helper => {
assert.isFalse(helper.isScrollable());
});
});
it('returns true if all results have loaded', () => {
const helper = new LeftPaneSearchHelper({
conversationResults: {
isLoading: false,
results: [fakeConversation(), fakeConversation()],
},
contactResults: { isLoading: false, results: [] },
messageResults: {
isLoading: false,
results: [fakeMessage(), fakeMessage(), fakeMessage()],
},
searchTerm: 'foo',
});
assert.isTrue(helper.isScrollable());
});
});
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({
@ -303,7 +380,7 @@ describe('LeftPaneSearchHelper', () => {
); );
}); });
it('returns false when a section goes from loading to loaded with 1 result', () => { 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({
conversationResults: { isLoading: true }, conversationResults: { isLoading: true },
contactResults: { isLoading: true }, contactResults: { isLoading: true },
@ -324,6 +401,27 @@ describe('LeftPaneSearchHelper', () => {
); );
}); });
it('returns true when all sections finish loading', () => {
const helper = new LeftPaneSearchHelper({
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: false, results: [fakeMessage()] },
searchTerm: 'foo',
});
assert.isTrue(
helper.shouldRecomputeRowHeights({
conversationResults: {
isLoading: false,
results: [fakeConversation(), fakeConversation()],
},
contactResults: { isLoading: false, results: [] },
messageResults: { isLoading: false, results: [fakeMessage()] },
searchTerm: 'foo',
})
);
});
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({
conversationResults: { conversationResults: {

View file

@ -29,3 +29,9 @@ export enum ThemeType {
'light' = 'light', 'light' = 'light',
'dark' = 'dark', 'dark' = 'dark',
} }
// These are strings so they can be interpolated into class names.
export enum ScrollBehavior {
Default = 'default',
Hard = 'hard',
}

View file

@ -14384,7 +14384,7 @@
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/ConversationList.js", "path": "ts/components/ConversationList.js",
"line": " const listRef = react_1.useRef(null);", "line": " const listRef = react_1.useRef(null);",
"lineNumber": 49, "lineNumber": 58,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-02-12T16:25:08.285Z", "updated": "2021-02-12T16:25:08.285Z",
"reasonDetail": "Used for scroll calculations" "reasonDetail": "Used for scroll calculations"