Improve left pane UI when loading search results
This commit is contained in:
parent
f05d45ac9b
commit
d81aaf654f
15 changed files with 420 additions and 93 deletions
|
@ -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) {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
16
stylesheets/components/SearchResultsLoadingFakeHeader.scss
Normal file
16
stylesheets/components/SearchResultsLoadingFakeHeader.scss
Normal 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%);
|
||||||
|
}
|
||||||
|
}
|
35
stylesheets/components/SearchResultsLoadingFakeRow.scss
Normal file
35
stylesheets/components/SearchResultsLoadingFakeRow.scss
Normal 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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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';
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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={
|
||||||
|
|
|
@ -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} />;
|
|
@ -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>
|
||||||
|
);
|
|
@ -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 };
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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',
|
||||||
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue