signal-desktop/ts/components/CallsNewCall.tsx
2024-05-22 09:24:27 -07:00

335 lines
9.4 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 classNames from 'classnames';
import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/I18N';
import { SearchInput } from './SearchInput';
import { filterAndSortConversations } 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 { I18n } from './I18n';
import { SizeObserver } from '../hooks/useSizeObserver';
import { CallType } from '../types/CallDisposition';
import type { CallsTabSelectedView } from './CallsTab';
import { Tooltip, TooltipPlacement } from './Tooltip';
type CallsNewCallProps = Readonly<{
hasActiveCall: boolean;
allConversations: ReadonlyArray<ConversationType>;
i18n: LocalizerType;
onChangeCallsTabSelectedView: (selectedView: CallsTabSelectedView) => 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,
isEnabled,
isActive,
isInCall,
i18n,
onClick,
}: {
callType: CallType;
isActive: boolean;
isEnabled: boolean;
isInCall: boolean;
i18n: LocalizerType;
onClick: () => void;
}): JSX.Element {
let innerContent: React.ReactNode | string;
let tooltipContent = '';
if (callType === CallType.Audio) {
innerContent = (
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Phone" />
);
} else if (isActive) {
innerContent = isInCall
? i18n('icu:CallsNewCallButton--return')
: i18n('icu:joinOngoingCall');
if (!isEnabled) {
tooltipContent = i18n('icu:CallsNewCallButtonTooltip--in-another-call');
}
} else {
innerContent = (
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Video" />
);
}
const buttonContent = (
<button
type="button"
className={classNames(
'CallsNewCall__ItemActionButton',
isActive ? 'CallsNewCall__ItemActionButton--join-call' : undefined,
isEnabled
? undefined
: 'CallsNewCall__ItemActionButton--join-call-disabled'
)}
aria-label={tooltipContent}
onClick={event => {
event.stopPropagation();
onClick();
}}
>
{innerContent}
</button>
);
return tooltipContent === '' ? (
buttonContent
) : (
<Tooltip
className="CallsNewCall__ItemActionButtonTooltip"
content={tooltipContent}
direction={TooltipPlacement.Top}
>
{buttonContent}
</Tooltip>
);
}
export function CallsNewCall({
hasActiveCall,
allConversations,
i18n,
onChangeCallsTabSelectedView,
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 filterAndSortConversations(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>
);
}
const isNewCallEnabled = !hasActiveCall;
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}
isActive={false}
isEnabled={isNewCallEnabled}
isInCall={false}
onClick={() => {
if (isNewCallEnabled) {
onOutgoingAudioCallInConversation(item.conversation.id);
}
}}
i18n={i18n}
/>
)}
<CallsNewCallButton
// It's okay if this is a group
callType={CallType.Video}
isActive={false}
isEnabled={isNewCallEnabled}
isInCall={false}
onClick={() => {
if (isNewCallEnabled) {
onOutgoingVideoCallInConversation(item.conversation.id);
}
}}
i18n={i18n}
/>
</div>
}
onClick={() => {
onChangeCallsTabSelectedView({
type: 'conversation',
conversationId: item.conversation.id,
callHistoryGroup: null,
});
}}
/>
</div>
);
},
[
rows,
i18n,
hasActiveCall,
onChangeCallsTabSelectedView,
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')
) : (
<I18n
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>
)}
</>
);
}