signal-desktop/ts/components/CallsNewCall.tsx
2023-08-21 09:30:32 -07:00

284 lines
7.9 KiB
TypeScript

// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ChangeEvent } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { partition } from 'lodash';
import type { ListRowProps } from 'react-virtualized';
import { List } from 'react-virtualized';
import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/I18N';
import { SearchInput } from './SearchInput';
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
import { NavSidebarSearchHeader } from './NavSidebar';
import { ListTile } from './ListTile';
import { strictAssert } from '../util/assert';
import { UserText } from './UserText';
import { Avatar, AvatarSize } from './Avatar';
import { Intl } from './Intl';
import { SizeObserver } from '../hooks/useSizeObserver';
import { CallType } from '../types/CallDisposition';
type CallsNewCallProps = Readonly<{
hasActiveCall: boolean;
allConversations: ReadonlyArray<ConversationType>;
i18n: LocalizerType;
onSelectConversation: (conversationId: string) => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void;
regionCode: string | undefined;
}>;
type Row =
| { kind: 'header'; title: string }
| { kind: 'conversation'; conversation: ConversationType };
export function CallsNewCallButton({
callType,
hasActiveCall,
onClick,
}: {
callType: CallType;
hasActiveCall: boolean;
onClick: () => void;
}): JSX.Element {
return (
<button
type="button"
className="CallsNewCall__ItemActionButton"
aria-disabled={hasActiveCall}
onClick={event => {
event.stopPropagation();
if (!hasActiveCall) {
onClick();
}
}}
>
{callType === CallType.Audio && (
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Phone" />
)}
{callType !== CallType.Audio && (
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Video" />
)}
</button>
);
}
export function CallsNewCall({
hasActiveCall,
allConversations,
i18n,
onSelectConversation,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
regionCode,
}: CallsNewCallProps): JSX.Element {
const [queryInput, setQueryInput] = useState('');
const query = useMemo(() => {
return queryInput.toLowerCase().normalize().trim();
}, [queryInput]);
const activeConversations = useMemo(() => {
return allConversations.filter(conversation => {
return conversation.activeAt != null && conversation.isArchived !== true;
});
}, [allConversations]);
const filteredConversations = useMemo(() => {
if (query === '') {
return activeConversations;
}
return filterAndSortConversationsByRecent(
activeConversations,
query,
regionCode
);
}, [activeConversations, query, regionCode]);
const [groupConversations, directConversations] = useMemo(() => {
return partition(filteredConversations, conversation => {
return conversation.type === 'group';
});
}, [filteredConversations]);
const handleSearchInputChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setQueryInput(event.currentTarget.value);
},
[]
);
const handleSearchInputClear = useCallback(() => {
setQueryInput('');
}, []);
const rows = useMemo((): ReadonlyArray<Row> => {
let result: Array<Row> = [];
if (directConversations.length > 0) {
result.push({
kind: 'header',
title: 'Contacts',
});
result = result.concat(
directConversations.map(conversation => {
return {
kind: 'conversation',
conversation,
};
})
);
}
if (groupConversations.length > 0) {
result.push({
kind: 'header',
title: 'Groups',
});
result = result.concat(
groupConversations.map((conversation): Row => {
return {
kind: 'conversation',
conversation,
};
})
);
}
return result;
}, [directConversations, groupConversations]);
const isRowLoaded = useCallback(
({ index }) => {
return rows.at(index) != null;
},
[rows]
);
const rowHeight = useCallback(
({ index }) => {
if (rows.at(index)?.kind === 'conversation') {
return ListTile.heightCompact;
}
// Height of .CallsNewCall__ListHeaderItem
return 40;
},
[rows]
);
const rowRenderer = useCallback(
({ key, index, style }: ListRowProps) => {
const item = rows.at(index);
strictAssert(item != null, 'Rendered non-existent row');
if (item.kind === 'header') {
return (
<div key={key} style={style} className="CallsNewCall__ListHeaderItem">
{item.title}
</div>
);
}
return (
<div key={key} style={style}>
<ListTile
leading={
<Avatar
acceptedMessageRequest
avatarPath={item.conversation.avatarPath}
conversationType="group"
i18n={i18n}
isMe={false}
title={item.conversation.title}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}
badge={undefined}
/>
}
title={<UserText text={item.conversation.title} />}
trailing={
<div className="CallsNewCall__ItemActions">
{item.conversation.type === 'direct' && (
<CallsNewCallButton
callType={CallType.Audio}
hasActiveCall={hasActiveCall}
onClick={() => {
onOutgoingAudioCallInConversation(item.conversation.id);
}}
/>
)}
<CallsNewCallButton
// It's okay if this is a group
callType={CallType.Video}
hasActiveCall={hasActiveCall}
onClick={() => {
onOutgoingVideoCallInConversation(item.conversation.id);
}}
/>
</div>
}
onClick={() => {
onSelectConversation(item.conversation.id);
}}
/>
</div>
);
},
[
rows,
i18n,
hasActiveCall,
onSelectConversation,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
]
);
return (
<>
<NavSidebarSearchHeader>
<SearchInput
i18n={i18n}
placeholder="Search"
onChange={handleSearchInputChange}
onClear={handleSearchInputClear}
value={queryInput}
/>
</NavSidebarSearchHeader>
{rows.length === 0 && (
<div className="CallsNewCall__EmptyState">
{query === '' ? (
i18n('icu:CallsNewCall__EmptyState--noQuery')
) : (
<Intl
i18n={i18n}
id="icu:CallsNewCall__EmptyState--hasQuery"
components={{
query: <UserText text={query} />,
}}
/>
)}
</div>
)}
{rows.length > 0 && (
<SizeObserver>
{(ref, size) => {
return (
<div ref={ref} className="CallsNewCall__ListContainer">
{size != null && (
<List
className="CallsNewCall__List"
width={size.width}
height={size.height}
isRowLoaded={isRowLoaded}
rowCount={rows.length}
rowHeight={rowHeight}
rowRenderer={rowRenderer}
/>
)}
</div>
);
}}
</SizeObserver>
)}
</>
);
}