Peek call links and group calls from Calls Tab
This commit is contained in:
parent
da1425265d
commit
fc9c5488c5
13 changed files with 762 additions and 71 deletions
|
@ -29,7 +29,7 @@ import {
|
|||
GroupCallStatus,
|
||||
isSameCallHistoryGroup,
|
||||
} from '../types/CallDisposition';
|
||||
import { formatDateTimeShort } from '../util/timestamp';
|
||||
import { formatDateTimeShort, isMoreRecentThan } from '../util/timestamp';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import * as log from '../logging/log';
|
||||
import { refMerger } from '../util/refMerger';
|
||||
|
@ -39,16 +39,32 @@ import { UserText } from './UserText';
|
|||
import { I18n } from './I18n';
|
||||
import { NavSidebarSearchHeader } from './NavSidebar';
|
||||
import { SizeObserver } from '../hooks/useSizeObserver';
|
||||
import { formatCallHistoryGroup } from '../util/callDisposition';
|
||||
import {
|
||||
formatCallHistoryGroup,
|
||||
getCallIdFromEra,
|
||||
} from '../util/callDisposition';
|
||||
import { CallsNewCallButton } from './CallsNewCall';
|
||||
import { Tooltip, TooltipPlacement } from './Tooltip';
|
||||
import { Theme } from '../util/theme';
|
||||
import type { CallingConversationType } from '../types/Calling';
|
||||
import { CallMode } from '../types/Calling';
|
||||
import type { CallLinkType } from '../types/CallLink';
|
||||
import {
|
||||
callLinkToConversation,
|
||||
getPlaceholderCallLinkConversation,
|
||||
} from '../util/callLinks';
|
||||
import type { CallStateType } from '../state/selectors/calling';
|
||||
import {
|
||||
isGroupOrAdhocCallMode,
|
||||
isGroupOrAdhocCallState,
|
||||
} from '../util/isGroupOrAdhocCall';
|
||||
import { isAnybodyInGroupCall } from '../state/ducks/callingHelpers';
|
||||
import type {
|
||||
ActiveCallStateType,
|
||||
PeekNotConnectedGroupCallType,
|
||||
} from '../state/ducks/calling';
|
||||
import { DAY, MINUTE, SECOND } from '../util/durations';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
|
||||
function Timestamp({
|
||||
i18n,
|
||||
|
@ -109,7 +125,7 @@ const defaultPendingState: SearchState = {
|
|||
};
|
||||
|
||||
type CallsListProps = Readonly<{
|
||||
hasActiveCall: boolean;
|
||||
activeCall: ActiveCallStateType | undefined;
|
||||
getCallHistoryGroupsCount: (
|
||||
options: CallHistoryFilterOptions
|
||||
) => Promise<number>;
|
||||
|
@ -118,8 +134,11 @@ type CallsListProps = Readonly<{
|
|||
pagination: CallHistoryPagination
|
||||
) => Promise<Array<CallHistoryGroup>>;
|
||||
callHistoryEdition: number;
|
||||
getAdhocCall: (roomId: string) => CallStateType | undefined;
|
||||
getCall: (id: string) => CallStateType | undefined;
|
||||
getCallLink: (id: string) => CallLinkType | undefined;
|
||||
getConversation: (id: string) => ConversationType | void;
|
||||
hangUpActiveCall: (reason: string) => void;
|
||||
i18n: LocalizerType;
|
||||
selectedCallHistoryGroup: CallHistoryGroup | null;
|
||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||
|
@ -128,10 +147,17 @@ type CallsListProps = Readonly<{
|
|||
conversationId: string,
|
||||
selectedCallHistoryGroup: CallHistoryGroup
|
||||
) => void;
|
||||
peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void;
|
||||
startCallLinkLobbyByRoomId: (roomId: string) => void;
|
||||
togglePip: () => void;
|
||||
}>;
|
||||
|
||||
const CALL_LIST_ITEM_ROW_HEIGHT = 62;
|
||||
const INACTIVE_CALL_LINKS_TO_PEEK = 10;
|
||||
const INACTIVE_CALL_LINK_AGE_THRESHOLD = 10 * DAY;
|
||||
const INACTIVE_CALL_LINK_PEEK_INTERVAL = 5 * MINUTE;
|
||||
const PEEK_BATCH_COUNT = 10;
|
||||
const PEEK_QUEUE_INTERVAL = 30 * SECOND;
|
||||
|
||||
function rowHeight() {
|
||||
return CALL_LIST_ITEM_ROW_HEIGHT;
|
||||
|
@ -145,35 +171,320 @@ function isSameOptions(
|
|||
}
|
||||
|
||||
export function CallsList({
|
||||
hasActiveCall,
|
||||
activeCall,
|
||||
getCallHistoryGroupsCount,
|
||||
getCallHistoryGroups,
|
||||
callHistoryEdition,
|
||||
getAdhocCall,
|
||||
getCall,
|
||||
getCallLink,
|
||||
getConversation,
|
||||
hangUpActiveCall,
|
||||
i18n,
|
||||
selectedCallHistoryGroup,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
onSelectCallHistoryGroup,
|
||||
peekNotConnectedGroupCall,
|
||||
startCallLinkLobbyByRoomId,
|
||||
togglePip,
|
||||
}: 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);
|
||||
const [isLeaveCallDialogVisible, setIsLeaveCallDialogVisible] =
|
||||
useState(false);
|
||||
|
||||
const prevOptionsRef = useRef<CallHistoryFilterOptions | null>(null);
|
||||
|
||||
const getCallHistoryGroupsCountRef = useRef(getCallHistoryGroupsCount);
|
||||
const getCallHistoryGroupsRef = useRef(getCallHistoryGroups);
|
||||
|
||||
const searchStateItemsRef = useRef<ReadonlyArray<CallHistoryGroup> | null>(
|
||||
null
|
||||
);
|
||||
const peekQueueRef = useRef<Set<string>>(new Set());
|
||||
const peekQueueArgsRef = useRef<Map<string, PeekNotConnectedGroupCallType>>(
|
||||
new Map()
|
||||
);
|
||||
const inactiveCallLinksPeekedAtRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const peekQueueTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
function clearPeekQueueTimer() {
|
||||
if (peekQueueTimerRef.current != null) {
|
||||
clearInterval(peekQueueTimerRef.current);
|
||||
peekQueueTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPeekQueueTimer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getCallHistoryGroupsCountRef.current = getCallHistoryGroupsCount;
|
||||
getCallHistoryGroupsRef.current = getCallHistoryGroups;
|
||||
}, [getCallHistoryGroupsCount, getCallHistoryGroups]);
|
||||
|
||||
const getConversationForItem = useCallback(
|
||||
(item: CallHistoryGroup | null): CallingConversationType | null => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAdhoc = item?.type === CallType.Adhoc;
|
||||
if (isAdhoc) {
|
||||
const callLink = isAdhoc ? getCallLink(item.peerId) : null;
|
||||
if (callLink) {
|
||||
return callLinkToConversation(callLink, i18n);
|
||||
}
|
||||
return getPlaceholderCallLinkConversation(item.peerId, i18n);
|
||||
}
|
||||
|
||||
return getConversation(item.peerId) ?? null;
|
||||
},
|
||||
[getCallLink, getConversation, i18n]
|
||||
);
|
||||
|
||||
const getCallByPeerId = useCallback(
|
||||
({
|
||||
mode,
|
||||
peerId,
|
||||
}: {
|
||||
mode: CallMode | undefined;
|
||||
peerId: string | undefined;
|
||||
}): CallStateType | undefined => {
|
||||
if (!peerId || !mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === CallMode.Adhoc) {
|
||||
return getAdhocCall(peerId);
|
||||
}
|
||||
|
||||
const conversation = getConversation(peerId);
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
return getCall(conversation.id);
|
||||
},
|
||||
[getAdhocCall, getCall, getConversation]
|
||||
);
|
||||
|
||||
const getIsCallActive = useCallback(
|
||||
({
|
||||
callHistoryGroup,
|
||||
}: {
|
||||
callHistoryGroup: CallHistoryGroup | null;
|
||||
}): boolean => {
|
||||
if (!callHistoryGroup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { mode, peerId } = callHistoryGroup;
|
||||
const call = getCallByPeerId({ mode, peerId });
|
||||
if (!call) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isGroupOrAdhocCallState(call)) {
|
||||
if (!isAnybodyInGroupCall(call.peekInfo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mode === CallMode.Group) {
|
||||
const eraId = call.peekInfo?.eraId;
|
||||
if (!eraId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const callId = getCallIdFromEra(eraId);
|
||||
return callHistoryGroup.children.some(
|
||||
groupItem => groupItem.callId === callId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Direct is not supported currently
|
||||
return false;
|
||||
},
|
||||
[getCallByPeerId]
|
||||
);
|
||||
|
||||
const getIsInCall = useCallback(
|
||||
({
|
||||
activeCallConversationId,
|
||||
callHistoryGroup,
|
||||
conversation,
|
||||
isActive,
|
||||
}: {
|
||||
activeCallConversationId: string | undefined;
|
||||
callHistoryGroup: CallHistoryGroup | null;
|
||||
conversation: CallingConversationType | null;
|
||||
isActive: boolean;
|
||||
}): boolean => {
|
||||
if (!callHistoryGroup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { mode, peerId } = callHistoryGroup;
|
||||
|
||||
if (mode === CallMode.Adhoc) {
|
||||
return peerId === activeCallConversationId;
|
||||
}
|
||||
|
||||
// Not supported currently
|
||||
if (mode === CallMode.Direct) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Group
|
||||
return Boolean(
|
||||
isActive &&
|
||||
conversation &&
|
||||
conversation?.id === activeCallConversationId
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// If the call is already enqueued then this is a no op.
|
||||
const maybeEnqueueCallPeek = useCallback((item: CallHistoryGroup): void => {
|
||||
const { mode: callMode, peerId } = item;
|
||||
const queue = peekQueueRef.current;
|
||||
if (queue.has(peerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGroupOrAdhocCallMode(callMode)) {
|
||||
peekQueueArgsRef.current.set(peerId, {
|
||||
callMode,
|
||||
conversationId: peerId,
|
||||
});
|
||||
queue.add(peerId);
|
||||
} else {
|
||||
log.error(`Trying to peek unsupported call mode ${callMode}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get the oldest inserted peerIds by iterating the Set in insertion order.
|
||||
const getPeerIdsToPeek = useCallback((): ReadonlyArray<string> => {
|
||||
const peerIds: Array<string> = [];
|
||||
for (const peerId of peekQueueRef.current) {
|
||||
peerIds.push(peerId);
|
||||
if (peerIds.length === PEEK_BATCH_COUNT) {
|
||||
return peerIds;
|
||||
}
|
||||
}
|
||||
|
||||
return peerIds;
|
||||
}, []);
|
||||
|
||||
const doCallPeeks = useCallback((): void => {
|
||||
const peerIds = getPeerIdsToPeek();
|
||||
for (const peerId of peerIds) {
|
||||
const peekArgs = peekQueueArgsRef.current.get(peerId);
|
||||
if (peekArgs) {
|
||||
inactiveCallLinksPeekedAtRef.current.set(peerId, new Date().getTime());
|
||||
peekNotConnectedGroupCall(peekArgs);
|
||||
}
|
||||
|
||||
peekQueueRef.current.delete(peerId);
|
||||
peekQueueArgsRef.current.delete(peerId);
|
||||
}
|
||||
}, [getPeerIdsToPeek, peekNotConnectedGroupCall]);
|
||||
|
||||
const enqueueCallPeeks = useCallback(
|
||||
(callItems: ReadonlyArray<CallHistoryGroup>, isFirstRun: boolean): void => {
|
||||
let peekCount = 0;
|
||||
let inactiveCallLinksToPeek = 0;
|
||||
for (const item of callItems) {
|
||||
const { mode } = item;
|
||||
if (isGroupOrAdhocCallMode(mode)) {
|
||||
const isActive = getIsCallActive({ callHistoryGroup: item });
|
||||
|
||||
if (isActive) {
|
||||
// Don't peek if you're already in the call.
|
||||
const activeCallConversationId = activeCall?.conversationId;
|
||||
if (activeCallConversationId) {
|
||||
const conversation = getConversationForItem(item);
|
||||
const isInCall = getIsInCall({
|
||||
activeCallConversationId,
|
||||
callHistoryGroup: item,
|
||||
conversation,
|
||||
isActive,
|
||||
});
|
||||
if (isInCall) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
maybeEnqueueCallPeek(item);
|
||||
peekCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
mode === CallMode.Adhoc &&
|
||||
isFirstRun &&
|
||||
inactiveCallLinksToPeek < INACTIVE_CALL_LINKS_TO_PEEK &&
|
||||
isMoreRecentThan(item.timestamp, INACTIVE_CALL_LINK_AGE_THRESHOLD)
|
||||
) {
|
||||
const peekedAt = inactiveCallLinksPeekedAtRef.current.get(
|
||||
item.peerId
|
||||
);
|
||||
if (
|
||||
peekedAt &&
|
||||
isMoreRecentThan(peekedAt, INACTIVE_CALL_LINK_PEEK_INTERVAL)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
maybeEnqueueCallPeek(item);
|
||||
inactiveCallLinksToPeek += 1;
|
||||
peekCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (peekCount === 0) {
|
||||
return;
|
||||
}
|
||||
log.info(`Found ${peekCount} calls to peek.`);
|
||||
|
||||
if (peekQueueTimerRef.current != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('Starting background call peek.');
|
||||
peekQueueTimerRef.current = setInterval(() => {
|
||||
if (searchStateItemsRef.current) {
|
||||
enqueueCallPeeks(searchStateItemsRef.current, false);
|
||||
}
|
||||
|
||||
if (peekQueueRef.current.size > 0) {
|
||||
doCallPeeks();
|
||||
}
|
||||
}, PEEK_QUEUE_INTERVAL);
|
||||
|
||||
doCallPeeks();
|
||||
},
|
||||
[
|
||||
activeCall?.conversationId,
|
||||
doCallPeeks,
|
||||
getConversationForItem,
|
||||
getIsCallActive,
|
||||
getIsInCall,
|
||||
maybeEnqueueCallPeek,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
|
@ -219,6 +530,11 @@ export function CallsList({
|
|||
return;
|
||||
}
|
||||
|
||||
if (results) {
|
||||
enqueueCallPeeks(results.items, true);
|
||||
searchStateItemsRef.current = results.items;
|
||||
}
|
||||
|
||||
// Only commit the new search state once the results are ready
|
||||
setSearchState({
|
||||
state: results == null ? 'rejected' : 'fulfilled',
|
||||
|
@ -246,7 +562,7 @@ export function CallsList({
|
|||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [queryInput, status, callHistoryEdition]);
|
||||
}, [queryInput, status, callHistoryEdition, enqueueCallPeeks]);
|
||||
|
||||
const loadMoreRows = useCallback(
|
||||
async (props: IndexRange) => {
|
||||
|
@ -279,6 +595,8 @@ export function CallsList({
|
|||
return;
|
||||
}
|
||||
|
||||
enqueueCallPeeks(groups, false);
|
||||
|
||||
setSearchState(prevSearchState => {
|
||||
strictAssert(
|
||||
prevSearchState.results != null,
|
||||
|
@ -286,6 +604,7 @@ export function CallsList({
|
|||
);
|
||||
const newItems = prevSearchState.results.items.slice();
|
||||
newItems.splice(startIndex, stopIndex, ...groups);
|
||||
searchStateItemsRef.current = newItems;
|
||||
return {
|
||||
...prevSearchState,
|
||||
results: {
|
||||
|
@ -298,27 +617,7 @@ export function CallsList({
|
|||
log.error('CallsList#loadMoreRows error fetching', error);
|
||||
}
|
||||
},
|
||||
[searchState]
|
||||
);
|
||||
|
||||
const getConversationForItem = useCallback(
|
||||
(item: CallHistoryGroup | null): CallingConversationType | null => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAdhoc = item?.type === CallType.Adhoc;
|
||||
if (isAdhoc) {
|
||||
const callLink = isAdhoc ? getCallLink(item.peerId) : null;
|
||||
if (callLink) {
|
||||
return callLinkToConversation(callLink, i18n);
|
||||
}
|
||||
return getPlaceholderCallLinkConversation(item.peerId, i18n);
|
||||
}
|
||||
|
||||
return getConversation(item.peerId) ?? null;
|
||||
},
|
||||
[getCallLink, getConversation, i18n]
|
||||
[enqueueCallPeeks, searchState]
|
||||
);
|
||||
|
||||
const isRowLoaded = useCallback(
|
||||
|
@ -332,10 +631,23 @@ export function CallsList({
|
|||
({ key, index, style }: ListRowProps) => {
|
||||
const item = searchState.results?.items.at(index) ?? null;
|
||||
const conversation = getConversationForItem(item);
|
||||
const activeCallConversationId = activeCall?.conversationId;
|
||||
|
||||
const isActive = getIsCallActive({
|
||||
callHistoryGroup: item,
|
||||
});
|
||||
const isInCall = getIsInCall({
|
||||
activeCallConversationId,
|
||||
callHistoryGroup: item,
|
||||
conversation,
|
||||
isActive,
|
||||
});
|
||||
|
||||
const isAdhoc = item?.type === CallType.Adhoc;
|
||||
const isNewCallVisible = Boolean(
|
||||
const isCallButtonVisible = Boolean(
|
||||
!isAdhoc || (isAdhoc && getCallLink(item.peerId))
|
||||
);
|
||||
const isActiveVisible = Boolean(isCallButtonVisible && item && isActive);
|
||||
|
||||
if (
|
||||
searchState.state === 'pending' ||
|
||||
|
@ -410,12 +722,20 @@ export function CallsList({
|
|||
/>
|
||||
}
|
||||
trailing={
|
||||
isNewCallVisible ? (
|
||||
isCallButtonVisible ? (
|
||||
<CallsNewCallButton
|
||||
callType={item.type}
|
||||
hasActiveCall={hasActiveCall}
|
||||
isActive={isActiveVisible}
|
||||
isInCall={isInCall}
|
||||
isEnabled={isInCall || !activeCall}
|
||||
onClick={() => {
|
||||
if (isAdhoc) {
|
||||
if (isInCall) {
|
||||
togglePip();
|
||||
} else if (activeCall) {
|
||||
if (isActiveVisible) {
|
||||
setIsLeaveCallDialogVisible(true);
|
||||
}
|
||||
} else if (isAdhoc) {
|
||||
startCallLinkLobbyByRoomId(item.peerId);
|
||||
} else if (conversation) {
|
||||
if (item.type === CallType.Audio) {
|
||||
|
@ -425,6 +745,7 @@ export function CallsList({
|
|||
}
|
||||
}
|
||||
}}
|
||||
i18n={i18n}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
|
@ -441,7 +762,11 @@ export function CallsList({
|
|||
<span className="CallsList__ItemCallInfo">
|
||||
{item.children.length > 1 ? `(${item.children.length}) ` : ''}
|
||||
{statusText} ·{' '}
|
||||
<Timestamp i18n={i18n} timestamp={item.timestamp} />
|
||||
{isActiveVisible ? (
|
||||
i18n('icu:CallsList__ItemCallInfo--Active')
|
||||
) : (
|
||||
<Timestamp i18n={i18n} timestamp={item.timestamp} />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
onClick={() => {
|
||||
|
@ -459,15 +784,18 @@ export function CallsList({
|
|||
);
|
||||
},
|
||||
[
|
||||
hasActiveCall,
|
||||
activeCall,
|
||||
searchState,
|
||||
getCallLink,
|
||||
getConversationForItem,
|
||||
getIsCallActive,
|
||||
getIsInCall,
|
||||
selectedCallHistoryGroup,
|
||||
onSelectCallHistoryGroup,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
startCallLinkLobbyByRoomId,
|
||||
togglePip,
|
||||
i18n,
|
||||
]
|
||||
);
|
||||
|
@ -498,6 +826,31 @@ export function CallsList({
|
|||
|
||||
return (
|
||||
<>
|
||||
{isLeaveCallDialogVisible && (
|
||||
<ConfirmationDialog
|
||||
dialogName="GroupCallRemoteParticipant.blockInfo"
|
||||
cancelText={i18n('icu:cancel')}
|
||||
i18n={i18n}
|
||||
onClose={() => {
|
||||
setIsLeaveCallDialogVisible(false);
|
||||
}}
|
||||
title={i18n('icu:CallsList__LeaveCallDialogTitle')}
|
||||
actions={[
|
||||
{
|
||||
text: i18n('icu:CallsList__LeaveCallDialogButton--leave'),
|
||||
style: 'affirmative',
|
||||
action: () => {
|
||||
hangUpActiveCall(
|
||||
'Calls Tab leave active call to join different call'
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{i18n('icu:CallsList__LeaveCallDialogBody')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
||||
<NavSidebarSearchHeader>
|
||||
<SearchInput
|
||||
i18n={i18n}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue