Peek call links and group calls from Calls Tab

This commit is contained in:
ayumi-signal 2024-05-17 15:02:07 -07:00 committed by GitHub
parent da1425265d
commit fc9c5488c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 762 additions and 71 deletions

View file

@ -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} &middot;{' '}
<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}