Calls Tab & Group Call Disposition
This commit is contained in:
parent
620e85ca01
commit
1eaabb6734
139 changed files with 9182 additions and 2721 deletions
476
ts/components/CallsList.tsx
Normal file
476
ts/components/CallsList.tsx
Normal file
|
@ -0,0 +1,476 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ChangeEvent } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { Index, IndexRange, ListRowProps } from 'react-virtualized';
|
||||
import { InfiniteLoader, List } from 'react-virtualized';
|
||||
import classNames from 'classnames';
|
||||
import type { LocalizerType } from '../types/I18N';
|
||||
import { ListTile } from './ListTile';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import type {
|
||||
CallHistoryFilterOptions,
|
||||
CallHistoryGroup,
|
||||
CallHistoryPagination,
|
||||
} from '../types/CallDisposition';
|
||||
import {
|
||||
CallHistoryFilterStatus,
|
||||
CallDirection,
|
||||
CallType,
|
||||
DirectCallStatus,
|
||||
GroupCallStatus,
|
||||
isSameCallHistoryGroup,
|
||||
} from '../types/CallDisposition';
|
||||
import { formatDateTimeShort } from '../util/timestamp';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import * as log from '../logging/log';
|
||||
import { refMerger } from '../util/refMerger';
|
||||
import { drop } from '../util/drop';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { UserText } from './UserText';
|
||||
import { Intl } from './Intl';
|
||||
import { NavSidebarSearchHeader } from './NavSidebar';
|
||||
import { SizeObserver } from '../hooks/useSizeObserver';
|
||||
import { formatCallHistoryGroup } from '../util/callDisposition';
|
||||
|
||||
function Timestamp({
|
||||
i18n,
|
||||
timestamp,
|
||||
}: {
|
||||
i18n: LocalizerType;
|
||||
timestamp: number;
|
||||
}): JSX.Element {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
}, 1_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dateTime = useMemo(() => {
|
||||
return new Date(timestamp).toISOString();
|
||||
}, [timestamp]);
|
||||
|
||||
const formatted = useMemo(() => {
|
||||
void now; // Use this as a dep so we update
|
||||
return formatDateTimeShort(i18n, timestamp);
|
||||
}, [i18n, timestamp, now]);
|
||||
|
||||
return <time dateTime={dateTime}>{formatted}</time>;
|
||||
}
|
||||
|
||||
type SearchResults = Readonly<{
|
||||
count: number;
|
||||
items: ReadonlyArray<CallHistoryGroup>;
|
||||
}>;
|
||||
|
||||
type SearchState = Readonly<{
|
||||
state: 'init' | 'pending' | 'rejected' | 'fulfilled';
|
||||
// Note these fields shouldnt be updated until the search is fulfilled or rejected.
|
||||
options: null | { query: string; status: CallHistoryFilterStatus };
|
||||
results: null | SearchResults;
|
||||
}>;
|
||||
|
||||
const defaultInitState: SearchState = {
|
||||
state: 'init',
|
||||
options: null,
|
||||
results: null,
|
||||
};
|
||||
|
||||
const defaultPendingState: SearchState = {
|
||||
state: 'pending',
|
||||
options: null,
|
||||
results: {
|
||||
count: 100,
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
|
||||
type CallsListProps = Readonly<{
|
||||
getCallHistoryGroupsCount: (
|
||||
options: CallHistoryFilterOptions
|
||||
) => Promise<number>;
|
||||
getCallHistoryGroups: (
|
||||
options: CallHistoryFilterOptions,
|
||||
pagination: CallHistoryPagination
|
||||
) => Promise<Array<CallHistoryGroup>>;
|
||||
getConversation: (id: string) => ConversationType | void;
|
||||
i18n: LocalizerType;
|
||||
selectedCallHistoryGroup: CallHistoryGroup | null;
|
||||
onSelectCallHistoryGroup: (
|
||||
conversationId: string,
|
||||
selectedCallHistoryGroup: CallHistoryGroup
|
||||
) => void;
|
||||
}>;
|
||||
|
||||
function rowHeight() {
|
||||
return ListTile.heightCompact;
|
||||
}
|
||||
|
||||
export function CallsList({
|
||||
getCallHistoryGroupsCount,
|
||||
getCallHistoryGroups,
|
||||
getConversation,
|
||||
i18n,
|
||||
selectedCallHistoryGroup,
|
||||
onSelectCallHistoryGroup,
|
||||
}: CallsListProps): JSX.Element {
|
||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||
const listRef = useRef<List>(null);
|
||||
const [queryInput, setQueryInput] = useState('');
|
||||
const [status, setStatus] = useState(CallHistoryFilterStatus.All);
|
||||
const [searchState, setSearchState] = useState(defaultInitState);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function search() {
|
||||
const options = {
|
||||
query: queryInput.toLowerCase().normalize().trim(),
|
||||
status,
|
||||
};
|
||||
|
||||
let timer = setTimeout(() => {
|
||||
setSearchState(prevSearchState => {
|
||||
if (prevSearchState.state === 'init') {
|
||||
return defaultPendingState;
|
||||
}
|
||||
return prevSearchState;
|
||||
});
|
||||
timer = setTimeout(() => {
|
||||
// Show loading indicator after a delay
|
||||
setSearchState(defaultPendingState);
|
||||
}, 300);
|
||||
}, 50);
|
||||
|
||||
let results: SearchResults | null = null;
|
||||
|
||||
try {
|
||||
const [count, items] = await Promise.all([
|
||||
getCallHistoryGroupsCount(options),
|
||||
getCallHistoryGroups(options, {
|
||||
offset: 0,
|
||||
limit: 100, // preloaded rows
|
||||
}),
|
||||
]);
|
||||
results = { count, items };
|
||||
} catch (error) {
|
||||
log.error('CallsList#fetchTotal error fetching', error);
|
||||
}
|
||||
|
||||
// Clear the loading indicator timeout
|
||||
clearTimeout(timer);
|
||||
|
||||
// Ignore old requests
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only commit the new search state once the results are ready
|
||||
setSearchState({
|
||||
state: results == null ? 'rejected' : 'fulfilled',
|
||||
options,
|
||||
results,
|
||||
});
|
||||
infiniteLoaderRef.current?.resetLoadMoreRowsCache(true);
|
||||
listRef.current?.scrollToPosition(0);
|
||||
}
|
||||
|
||||
drop(search());
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [getCallHistoryGroupsCount, getCallHistoryGroups, queryInput, status]);
|
||||
|
||||
const loadMoreRows = useCallback(
|
||||
async (props: IndexRange) => {
|
||||
const { state, options } = searchState;
|
||||
if (state !== 'fulfilled') {
|
||||
return;
|
||||
}
|
||||
strictAssert(
|
||||
options != null,
|
||||
'options should never be null when status is fulfilled'
|
||||
);
|
||||
|
||||
let { startIndex, stopIndex } = props;
|
||||
|
||||
if (startIndex > stopIndex) {
|
||||
// flip
|
||||
[startIndex, stopIndex] = [stopIndex, startIndex];
|
||||
}
|
||||
|
||||
const offset = startIndex;
|
||||
const limit = stopIndex - startIndex + 1;
|
||||
|
||||
try {
|
||||
const groups = await getCallHistoryGroups(options, { offset, limit });
|
||||
|
||||
if (searchState.options !== options) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchState(prevSearchState => {
|
||||
strictAssert(
|
||||
prevSearchState.results != null,
|
||||
'results should never be null here'
|
||||
);
|
||||
const newItems = prevSearchState.results.items.slice();
|
||||
newItems.splice(startIndex, stopIndex, ...groups);
|
||||
return {
|
||||
...prevSearchState,
|
||||
results: {
|
||||
...prevSearchState.results,
|
||||
items: newItems,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('CallsList#loadMoreRows error fetching', error);
|
||||
}
|
||||
},
|
||||
[getCallHistoryGroups, searchState]
|
||||
);
|
||||
|
||||
const isRowLoaded = useCallback(
|
||||
(props: Index) => {
|
||||
return searchState.results?.items[props.index] != null;
|
||||
},
|
||||
[searchState]
|
||||
);
|
||||
|
||||
const rowRenderer = useCallback(
|
||||
({ key, index, style }: ListRowProps) => {
|
||||
const item = searchState.results?.items.at(index) ?? null;
|
||||
const conversation = item != null ? getConversation(item.peerId) : null;
|
||||
|
||||
if (
|
||||
searchState.state === 'pending' ||
|
||||
item == null ||
|
||||
conversation == null
|
||||
) {
|
||||
return (
|
||||
<div key={key} style={style}>
|
||||
<ListTile
|
||||
leading={<div className="CallsList__LoadingAvatar" />}
|
||||
title={
|
||||
<span className="CallsList__LoadingText CallsList__LoadingText--title" />
|
||||
}
|
||||
subtitle={
|
||||
<span className="CallsList__LoadingText CallsList__LoadingText--subtitle" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSelected =
|
||||
selectedCallHistoryGroup != null &&
|
||||
isSameCallHistoryGroup(item, selectedCallHistoryGroup);
|
||||
|
||||
const wasMissed =
|
||||
item.direction === CallDirection.Incoming &&
|
||||
(item.status === DirectCallStatus.Missed ||
|
||||
item.status === GroupCallStatus.Missed);
|
||||
|
||||
let statusText;
|
||||
if (wasMissed) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--Missed');
|
||||
} else if (item.type === CallType.Group) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--GroupCall');
|
||||
} else if (item.direction === CallDirection.Outgoing) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--Outgoing');
|
||||
} else if (item.direction === CallDirection.Incoming) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--Incoming');
|
||||
} else {
|
||||
strictAssert(false, 'Cannot format call');
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={style}
|
||||
className={classNames('CallsList__Item', {
|
||||
'CallsList__Item--selected': isSelected,
|
||||
})}
|
||||
>
|
||||
<ListTile
|
||||
moduleClassName="CallsList__ItemTile"
|
||||
aria-selected={isSelected}
|
||||
leading={
|
||||
<Avatar
|
||||
acceptedMessageRequest
|
||||
avatarPath={conversation.avatarPath}
|
||||
conversationType="group"
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
title={conversation.title}
|
||||
sharedGroupNames={[]}
|
||||
size={AvatarSize.THIRTY_TWO}
|
||||
badge={undefined}
|
||||
className="CallsList__ItemAvatar"
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
<span
|
||||
className={classNames('CallsList__ItemIcon', {
|
||||
'CallsList__ItemIcon--Phone': item.type === CallType.Audio,
|
||||
'CallsList__ItemIcon--Video': item.type !== CallType.Audio,
|
||||
})}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<span
|
||||
className="CallsList__ItemTitle"
|
||||
data-call={formatCallHistoryGroup(item)}
|
||||
>
|
||||
<UserText text={conversation.title} />
|
||||
</span>
|
||||
}
|
||||
subtitle={
|
||||
<span
|
||||
className={classNames('CallsList__ItemCallInfo', {
|
||||
'CallsList__ItemCallInfo--missed': wasMissed,
|
||||
})}
|
||||
>
|
||||
{item.children.length > 1 ? `(${item.children.length}) ` : ''}
|
||||
{statusText} ·{' '}
|
||||
<Timestamp i18n={i18n} timestamp={item.timestamp} />
|
||||
</span>
|
||||
}
|
||||
onClick={() => {
|
||||
onSelectCallHistoryGroup(conversation.id, item);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
searchState,
|
||||
getConversation,
|
||||
selectedCallHistoryGroup,
|
||||
onSelectCallHistoryGroup,
|
||||
i18n,
|
||||
]
|
||||
);
|
||||
|
||||
const handleSearchInputChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
setQueryInput(event.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchInputClear = useCallback(() => {
|
||||
setQueryInput('');
|
||||
}, []);
|
||||
|
||||
const handleStatusToggle = useCallback(() => {
|
||||
setStatus(prevStatus => {
|
||||
return prevStatus === CallHistoryFilterStatus.All
|
||||
? CallHistoryFilterStatus.Missed
|
||||
: CallHistoryFilterStatus.All;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const filteringByMissed = status === CallHistoryFilterStatus.Missed;
|
||||
|
||||
const hasEmptyResults = searchState.results?.count === 0;
|
||||
const currentQuery = searchState.options?.query ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavSidebarSearchHeader>
|
||||
<SearchInput
|
||||
i18n={i18n}
|
||||
placeholder={i18n('icu:CallsList__SearchInputPlaceholder')}
|
||||
onChange={handleSearchInputChange}
|
||||
onClear={handleSearchInputClear}
|
||||
value={queryInput}
|
||||
/>
|
||||
<button
|
||||
className={classNames('CallsList__ToggleFilterByMissed', {
|
||||
'CallsList__ToggleFilterByMissed--pressed': filteringByMissed,
|
||||
})}
|
||||
type="button"
|
||||
aria-pressed={filteringByMissed}
|
||||
aria-roledescription={i18n(
|
||||
'icu:CallsList__ToggleFilterByMissed__RoleDescription'
|
||||
)}
|
||||
onClick={handleStatusToggle}
|
||||
>
|
||||
<span className="CallsList__ToggleFilterByMissedLabel">
|
||||
{i18n('icu:CallsList__ToggleFilterByMissedLabel')}
|
||||
</span>
|
||||
</button>
|
||||
</NavSidebarSearchHeader>
|
||||
|
||||
{hasEmptyResults && (
|
||||
<p className="CallsList__EmptyState">
|
||||
{currentQuery === '' ? (
|
||||
i18n('icu:CallsList__EmptyState--noQuery')
|
||||
) : (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:CallsList__EmptyState--hasQuery"
|
||||
components={{
|
||||
query: <UserText text={currentQuery} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SizeObserver>
|
||||
{(ref, size) => {
|
||||
return (
|
||||
<div className="CallsList__ListContainer" ref={ref}>
|
||||
{size != null && (
|
||||
<InfiniteLoader
|
||||
ref={infiniteLoaderRef}
|
||||
isRowLoaded={isRowLoaded}
|
||||
loadMoreRows={loadMoreRows}
|
||||
rowCount={searchState.results?.count}
|
||||
minimumBatchSize={100}
|
||||
threshold={30}
|
||||
>
|
||||
{({ onRowsRendered, registerChild }) => {
|
||||
return (
|
||||
<List
|
||||
className={classNames('CallsList__List', {
|
||||
'CallsList__List--loading':
|
||||
searchState.state === 'pending',
|
||||
})}
|
||||
ref={refMerger(listRef, registerChild)}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
rowCount={searchState.results?.count ?? 0}
|
||||
rowHeight={rowHeight}
|
||||
rowRenderer={rowRenderer}
|
||||
onRowsRendered={onRowsRendered}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</InfiniteLoader>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</SizeObserver>
|
||||
</>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue