// 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'; import { CallsNewCallButton } from './CallsNewCall'; import { Tooltip, TooltipPlacement } from './Tooltip'; import { Theme } from '../util/theme'; 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 ; } type SearchResults = Readonly<{ count: number; items: ReadonlyArray; }>; 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<{ hasActiveCall: boolean; getCallHistoryGroupsCount: ( options: CallHistoryFilterOptions ) => Promise; getCallHistoryGroups: ( options: CallHistoryFilterOptions, pagination: CallHistoryPagination ) => Promise>; callHistoryEdition: number; getConversation: (id: string) => ConversationType | void; i18n: LocalizerType; selectedCallHistoryGroup: CallHistoryGroup | null; onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void; onSelectCallHistoryGroup: ( conversationId: string, selectedCallHistoryGroup: CallHistoryGroup ) => void; }>; const CALL_LIST_ITEM_ROW_HEIGHT = 62; function rowHeight() { return CALL_LIST_ITEM_ROW_HEIGHT; } function isSameOptions( a: CallHistoryFilterOptions, b: CallHistoryFilterOptions ) { return a.query === b.query && a.status === b.status; } export function CallsList({ hasActiveCall, getCallHistoryGroupsCount, getCallHistoryGroups, callHistoryEdition, getConversation, i18n, selectedCallHistoryGroup, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, onSelectCallHistoryGroup, }: CallsListProps): JSX.Element { const infiniteLoaderRef = useRef(null); const listRef = useRef(null); const [queryInput, setQueryInput] = useState(''); const [status, setStatus] = useState(CallHistoryFilterStatus.All); const [searchState, setSearchState] = useState(defaultInitState); const prevOptionsRef = useRef(null); const getCallHistoryGroupsCountRef = useRef(getCallHistoryGroupsCount); const getCallHistoryGroupsRef = useRef(getCallHistoryGroups); useEffect(() => { getCallHistoryGroupsCountRef.current = getCallHistoryGroupsCount; getCallHistoryGroupsRef.current = getCallHistoryGroups; }, [getCallHistoryGroupsCount, getCallHistoryGroups]); useEffect(() => { const controller = new AbortController(); async function search() { const options: CallHistoryFilterOptions = { 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([ getCallHistoryGroupsCountRef.current(options), getCallHistoryGroupsRef.current(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, }); const isUpdatingSameSearch = prevOptionsRef.current != null && isSameOptions(options, prevOptionsRef.current); // Commit only at the end in case the search was aborted. prevOptionsRef.current = options; // Only reset the scroll position to the top when the user has changed the // search parameters if (!isUpdatingSameSearch) { infiniteLoaderRef.current?.resetLoadMoreRowsCache(true); listRef.current?.scrollToPosition(0); } } drop(search()); return () => { controller.abort(); }; }, [queryInput, status, callHistoryEdition]); 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 getCallHistoryGroupsRef.current(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); } }, [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 (
} title={ } subtitleMaxLines={1} subtitle={ } />
); } 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 (
} trailing={ { if (item.type === CallType.Audio) { onOutgoingAudioCallInConversation(conversation.id); } else { onOutgoingVideoCallInConversation(conversation.id); } }} /> } title={ } subtitleMaxLines={1} subtitle={ {item.children.length > 1 ? `(${item.children.length}) ` : ''} {statusText} ·{' '} } onClick={() => { onSelectCallHistoryGroup(conversation.id, item); }} />
); }, [ hasActiveCall, searchState, getConversation, selectedCallHistoryGroup, onSelectCallHistoryGroup, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, i18n, ] ); const handleSearchInputChange = useCallback( (event: ChangeEvent) => { 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 ( <> {hasEmptyResults && (

{currentQuery === '' ? ( i18n('icu:CallsList__EmptyState--noQuery') ) : ( , }} /> )}

)} {(ref, size) => { return (
{size != null && ( {({ onRowsRendered, registerChild }) => { return ( ); }} )}
); }}
); }