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

@ -7076,6 +7076,22 @@
"messageformat": "Call link",
"description": "On the Calls Tab, the subtitle text for a Call Link entry."
},
"icu:CallsList__ItemCallInfo--Active": {
"messageformat": "Active",
"description": "On the Calls Tab, the subtitle text for an active call."
},
"icu:CallsList__LeaveCallDialogTitle": {
"messageformat": "Leave the current call?",
"description": "On the Calls Tab, when trying to join a different call when you're already in another one, this is the title of the confirmation dialog to leave the other call."
},
"icu:CallsList__LeaveCallDialogBody": {
"messageformat": "You must leave the current call before joining a new call.",
"description": "On the Calls Tab, when trying to join a different call when you're already in another one, this is the body of the confirmation dialog to leave the other call."
},
"icu:CallsList__LeaveCallDialogButton--leave": {
"messageformat": "Leave call",
"description": "On the Calls Tab, when trying to join a different call when you're already in another one, this is the button to confirm leaving the other call."
},
"icu:CallsNewCall__EmptyState--noQuery": {
"messageformat": "No recent conversations.",
"description": "Calls Tab > New Call > Conversations List > When no results found > With no search query"
@ -7084,6 +7100,14 @@
"messageformat": "No results for “{query}”",
"description": "Calls Tab > New Call > Conversations List > When no results found > With a search query"
},
"icu:CallsNewCallButton--return": {
"messageformat": "Return",
"description": "Calls Tab button label for returning to the call you're currently in"
},
"icu:CallsNewCallButtonTooltip--in-another-call": {
"messageformat": "You must leave the current call before joining a new call",
"description": "Calls Tab button tooltip for the join call button when you are already in a different call."
},
"icu:CallHistory__Description--Default": {
"messageformat": "{direction, select, Outgoing {Outgoing} other {Incoming}} {type, select, Audio {voice} Video {video} Group {group} other {}} call",
"description": "Call History > Short description of call > When call was not missed or declined (generally accepted)"

View file

@ -884,3 +884,82 @@ $rtl-icon-map: (
-webkit-app-region: no-drag;
}
}
@mixin tooltip {
& {
@include font-body-2;
@include light-theme {
background-color: $color-gray-04;
color: $color-black;
outline: 1px solid $color-gray-20;
}
@include dark-theme {
background-color: $color-gray-80;
color: $color-gray-15;
outline: 1px solid $color-gray-62;
}
padding-block: 5px;
padding-inline: 12px;
border-radius: 6px;
filter: drop-shadow(0px 4px 3px $color-black-alpha-16);
pointer-events: none;
}
& .module-tooltip-arrow::before {
position: absolute;
content: '';
border-style: solid;
border-width: 7px;
}
&[data-placement='bottom'] .module-tooltip-arrow::before {
@include light-theme {
border-color: transparent transparent $color-gray-20 transparent;
}
@include dark-theme {
border-color: transparent transparent $color-gray-62 transparent;
}
margin-top: -14px;
/* stylelint-disable-next-line liberty/use-logical-spec */
margin-left: -7px;
}
&[data-placement='bottom'] .module-tooltip-arrow::after {
@include light-theme {
border-bottom-color: $color-gray-04;
}
@include dark-theme {
border-bottom-color: $color-gray-80;
}
}
&[data-placement='top'] .module-tooltip-arrow::before {
@include light-theme {
border-color: $color-gray-20 transparent transparent transparent;
}
@include dark-theme {
border-color: $color-gray-62 transparent transparent transparent;
}
margin-top: 0;
/* stylelint-disable-next-line liberty/use-logical-spec */
margin-left: -7px;
}
&[data-placement='top'] .module-tooltip-arrow::after {
@include light-theme {
border-top-color: $color-gray-04;
}
@include dark-theme {
border-top-color: $color-gray-80;
}
}
}

View file

@ -104,6 +104,7 @@
@include button-reset;
flex-shrink: 0;
padding: 4px;
margin-inline-end: 8px;
border-radius: 4px;
&:not(.CallsList__ToggleFilterByMissed--pressed):hover {
@ -322,6 +323,66 @@
}
}
.CallsNewCall__ItemActionButton--join-call {
$background: $color-accent-green;
@include font-body-2-bold;
@include rounded-corners;
display: flex;
width: auto;
height: 26px;
padding-block: 4px;
padding-inline: 10px;
align-items: center;
background-color: $background;
color: $color-white;
outline: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
&:before {
$icon-size: 16px;
@include color-svg(
'../images/icons/v3/video/video-compact-fill.svg',
$color-white
);
content: '';
display: block;
height: $icon-size;
margin-inline-end: 4px;
min-width: $icon-size;
width: $icon-size;
}
&:not(:disabled) {
&:hover {
@include any-theme {
background-color: darken($background, 16%);
}
}
&:focus {
@include keyboard-mode {
background-color: darken($background, 16%);
}
}
}
}
.CallsNewCall__ItemActionButton--join-call-disabled {
cursor: default;
opacity: 0.5;
}
.CallsNewCall__ItemActionButtonTooltip {
@include tooltip;
max-width: 212px;
}
.CallsNewCall__ItemIcon {
display: block;
width: 20px;

View file

@ -200,6 +200,7 @@ import { deriveStorageServiceKey } from './Crypto';
import { getThemeType } from './util/getThemeType';
import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager';
import { onCallLinkUpdateSync } from './util/onCallLinkUpdateSync';
import { CallMode } from './types/Calling';
export function isOverHourIntoPast(timestamp: number): boolean {
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
@ -2923,6 +2924,7 @@ export async function startApp(): Promise<void> {
conversationId
);
window.reduxActions.calling.peekNotConnectedGroupCall({
callMode: CallMode.Group,
conversationId,
});
if (callId != null) {

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}

View file

@ -6,6 +6,7 @@ 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';
@ -18,6 +19,7 @@ import { Avatar, AvatarSize } from './Avatar';
import { I18n } from './I18n';
import { SizeObserver } from '../hooks/useSizeObserver';
import { CallType } from '../types/CallDisposition';
import { Tooltip, TooltipPlacement } from './Tooltip';
type CallsNewCallProps = Readonly<{
hasActiveCall: boolean;
@ -35,33 +37,69 @@ type Row =
export function CallsNewCallButton({
callType,
hasActiveCall,
isEnabled,
isActive,
isInCall,
i18n,
onClick,
}: {
callType: CallType;
hasActiveCall: boolean;
isActive: boolean;
isEnabled: boolean;
isInCall: boolean;
i18n: LocalizerType;
onClick: () => void;
}): JSX.Element {
return (
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="CallsNewCall__ItemActionButton"
aria-disabled={hasActiveCall}
className={classNames(
'CallsNewCall__ItemActionButton',
isActive ? 'CallsNewCall__ItemActionButton--join-call' : undefined,
isEnabled
? undefined
: 'CallsNewCall__ItemActionButton--join-call-disabled'
)}
aria-label={tooltipContent}
onClick={event => {
event.stopPropagation();
if (!hasActiveCall) {
onClick();
}
onClick();
}}
>
{callType === CallType.Audio && (
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Phone" />
)}
{callType !== CallType.Audio && (
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Video" />
)}
{innerContent}
</button>
);
return tooltipContent === '' ? (
buttonContent
) : (
<Tooltip
className="CallsNewCall__ItemActionButtonTooltip"
content={tooltipContent}
direction={TooltipPlacement.Top}
>
{buttonContent}
</Tooltip>
);
}
export function CallsNewCall({
@ -173,6 +211,8 @@ export function CallsNewCall({
);
}
const isNewCallEnabled = !hasActiveCall;
return (
<div key={key} style={style}>
<ListTile
@ -195,19 +235,29 @@ export function CallsNewCall({
{item.conversation.type === 'direct' && (
<CallsNewCallButton
callType={CallType.Audio}
hasActiveCall={hasActiveCall}
isActive={false}
isEnabled={isNewCallEnabled}
isInCall={false}
onClick={() => {
onOutgoingAudioCallInConversation(item.conversation.id);
if (isNewCallEnabled) {
onOutgoingAudioCallInConversation(item.conversation.id);
}
}}
i18n={i18n}
/>
)}
<CallsNewCallButton
// It's okay if this is a group
callType={CallType.Video}
hasActiveCall={hasActiveCall}
isActive={false}
isEnabled={isNewCallEnabled}
isInCall={false}
onClick={() => {
onOutgoingVideoCallInConversation(item.conversation.id);
if (isNewCallEnabled) {
onOutgoingVideoCallInConversation(item.conversation.id);
}
}}
i18n={i18n}
/>
</div>
}

View file

@ -13,12 +13,16 @@ import type {
} from '../types/CallDisposition';
import { CallsNewCall } from './CallsNewCall';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
import type { ActiveCallStateType } from '../state/ducks/calling';
import type {
ActiveCallStateType,
PeekNotConnectedGroupCallType,
} from '../state/ducks/calling';
import { ContextMenu } from './ContextMenu';
import { ConfirmationDialog } from './ConfirmationDialog';
import type { UnreadStats } from '../util/countUnreadStats';
import type { WidthBreakpoint } from './_util';
import type { CallLinkType } from '../types/CallLink';
import type { CallStateType } from '../state/selectors/calling';
enum CallsTabSidebarView {
CallsListView,
@ -37,8 +41,11 @@ type CallsTabProps = 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;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
i18n: LocalizerType;
@ -48,6 +55,7 @@ type CallsTabProps = Readonly<{
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void;
peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void;
preferredLeftPaneWidth: number;
renderConversationDetails: (
conversationId: string,
@ -59,6 +67,7 @@ type CallsTabProps = Readonly<{
regionCode: string | undefined;
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
startCallLinkLobbyByRoomId: (roomId: string) => void;
togglePip: () => void;
}>;
export function CallsTab({
@ -68,8 +77,11 @@ export function CallsTab({
getCallHistoryGroupsCount,
getCallHistoryGroups,
callHistoryEdition,
getAdhocCall,
getCall,
getCallLink,
getConversation,
hangUpActiveCall,
hasFailedStorySends,
hasPendingUpdate,
i18n,
@ -79,12 +91,14 @@ export function CallsTab({
onToggleNavTabsCollapse,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
peekNotConnectedGroupCall,
preferredLeftPaneWidth,
renderConversationDetails,
renderToastManager,
regionCode,
savePreferredLeftPaneWidth,
startCallLinkLobbyByRoomId,
togglePip,
}: CallsTabProps): JSX.Element {
const [sidebarView, setSidebarView] = useState(
CallsTabSidebarView.CallsListView
@ -231,12 +245,15 @@ export function CallsTab({
{sidebarView === CallsTabSidebarView.CallsListView && (
<CallsList
key={CallsTabSidebarView.CallsListView}
hasActiveCall={activeCall != null}
activeCall={activeCall}
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
getCallHistoryGroups={getCallHistoryGroups}
callHistoryEdition={callHistoryEdition}
getAdhocCall={getAdhocCall}
getCall={getCall}
getCallLink={getCallLink}
getConversation={getConversation}
hangUpActiveCall={hangUpActiveCall}
i18n={i18n}
selectedCallHistoryGroup={selected?.callHistoryGroup ?? null}
onSelectCallHistoryGroup={handleSelectCallHistoryGroup}
@ -246,7 +263,9 @@ export function CallsTab({
onOutgoingVideoCallInConversation={
handleOutgoingVideoCallInConversation
}
peekNotConnectedGroupCall={peekNotConnectedGroupCall}
startCallLinkLobbyByRoomId={startCallLinkLobbyByRoomId}
togglePip={togglePip}
/>
)}
{sidebarView === CallsTabSidebarView.NewCallView && (

View file

@ -2369,6 +2369,7 @@ export class CallingClass {
if (update === RingUpdate.Requested) {
this.reduxInterface?.peekNotConnectedGroupCall({
callMode: CallMode.Group,
conversationId: conversation.id,
});
}

View file

@ -285,7 +285,8 @@ type SendGroupCallReactionLocalCopyType = ReadonlyDeep<{
timestamp: number;
}>;
type PeekNotConnectedGroupCallType = ReadonlyDeep<{
export type PeekNotConnectedGroupCallType = ReadonlyDeep<{
callMode: CallMode.Group | CallMode.Adhoc;
conversationId: string;
}>;
@ -1368,6 +1369,48 @@ function handleCallLinkUpdate(
};
}
/**
* When starting a lobby and there's an active call, if we're already in call then
* focus it (toggle pip), otherwise show an error.
* @returns {boolean} `true` if there was an active call and we handled it.
*/
function handleActiveCallOnStartLobby({
conversationId,
state,
dispatch,
}: {
conversationId: string;
state: RootStateType;
dispatch: ThunkDispatch<
RootStateType,
unknown,
ShowErrorModalActionType | TogglePipActionType
>;
}): boolean {
const { activeCallState } = state.calling;
if (!activeCallState) {
return false;
}
if (activeCallState.conversationId === conversationId) {
dispatch({
type: TOGGLE_PIP,
});
} else {
const i18n = getIntl(state);
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: i18n('icu:calling__cant-join'),
description: i18n('icu:calling__dialog-already-in-call'),
buttonVariant: ButtonVariant.Primary,
},
});
}
return true;
}
function hangUpActiveCall(
reason: string
): ThunkAction<void, RootStateType, unknown, HangUpActionType> {
@ -1553,10 +1596,10 @@ function peekNotConnectedGroupCall(
payload: PeekNotConnectedGroupCallType
): ThunkAction<void, RootStateType, unknown, PeekGroupCallFulfilledActionType> {
return (dispatch, getState) => {
const { conversationId } = payload;
const { callMode, conversationId } = payload;
doGroupCallPeek({
conversationId,
callMode: CallMode.Group,
callMode,
dispatch,
getState,
});
@ -1868,27 +1911,22 @@ const _startCallLinkLobby = async ({
dispatch: ThunkDispatch<
RootStateType,
unknown,
StartCallLinkLobbyActionType | ShowErrorModalActionType
| StartCallLinkLobbyActionType
| ShowErrorModalActionType
| TogglePipActionType
>;
getState: () => RootStateType;
}) => {
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
const roomId = getRoomIdFromRootKey(callLinkRootKey);
const state = getState();
if (state.calling.activeCallState) {
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: i18n('icu:calling__cant-join'),
description: i18n('icu:calling__dialog-already-in-call'),
buttonVariant: ButtonVariant.Primary,
},
});
if (
handleActiveCallOnStartLobby({ conversationId: roomId, state, dispatch })
) {
return;
}
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
const readResult = await calling.readCallLink({ callLinkRootKey });
const { callLinkState } = readResult;
if (!callLinkState) {
@ -1919,7 +1957,6 @@ const _startCallLinkLobby = async ({
return;
}
const roomId = getRoomIdFromRootKey(callLinkRootKey);
try {
const callLinkExists = await dataInterface.callLinkExists(roomId);
if (callLinkExists) {
@ -1983,7 +2020,7 @@ function startCallingLobby({
void,
RootStateType,
unknown,
StartCallingLobbyActionType
StartCallingLobbyActionType | TogglePipActionType
> {
return async (dispatch, getState) => {
const state = getState();

View file

@ -46,3 +46,12 @@ export const isAnybodyElseInGroupCall = (
peekInfo: undefined | Readonly<Pick<GroupCallPeekInfoType, 'acis'>>,
ourAci: AciString
): boolean => Boolean(peekInfo?.acis.some(id => id !== ourAci));
export const isAnybodyInGroupCall = (
peekInfo: undefined | Readonly<Pick<GroupCallPeekInfoType, 'acis'>>
): boolean => {
if (!peekInfo?.acis) {
return false;
}
return peekInfo.acis.length > 0;
};

View file

@ -25,7 +25,12 @@ import type { ConversationType } from '../ducks/conversations';
import { SmartConversationDetails } from './ConversationDetails';
import { SmartToastManager } from './ToastManager';
import { useCallingActions } from '../ducks/calling';
import { getActiveCallState, getCallLinkSelector } from '../selectors/calling';
import {
getActiveCallState,
getAdhocCallSelector,
getCallSelector,
getCallLinkSelector,
} from '../selectors/calling';
import { useCallHistoryActions } from '../ducks/callHistory';
import { getCallHistoryEdition } from '../selectors/callHistory';
import { getHasPendingUpdate } from '../selectors/updates';
@ -97,6 +102,8 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
const allConversations = useSelector(getAllConversations);
const regionCode = useSelector(getRegionCode);
const getConversation = useSelector(getConversationSelector);
const getAdhocCall = useSelector(getAdhocCallSelector);
const getCall = useSelector(getCallSelector);
const getCallLink = useSelector(getCallLinkSelector);
const activeCall = useSelector(getActiveCallState);
@ -107,9 +114,12 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
const otherTabsUnreadStats = useSelector(getOtherTabsUnreadStats);
const {
hangUpActiveCall,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
peekNotConnectedGroupCall,
startCallLinkLobbyByRoomId,
togglePip,
} = useCallingActions();
const {
clearAllCallHistory: clearCallHistory,
@ -169,8 +179,11 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
getConversation={getConversation}
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
getCallHistoryGroups={getCallHistoryGroups}
getAdhocCall={getAdhocCall}
getCall={getCall}
getCallLink={getCallLink}
callHistoryEdition={callHistoryEdition}
hangUpActiveCall={hangUpActiveCall}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
i18n={i18n}
@ -180,12 +193,14 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
onToggleNavTabsCollapse={toggleNavTabsCollapse}
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
peekNotConnectedGroupCall={peekNotConnectedGroupCall}
preferredLeftPaneWidth={preferredLeftPaneWidth}
renderConversationDetails={renderConversationDetails}
renderToastManager={renderToastManager}
regionCode={regionCode}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
startCallLinkLobbyByRoomId={startCallLinkLobbyByRoomId}
togglePip={togglePip}
/>
);
});

View file

@ -1498,6 +1498,7 @@ describe('calling duck', () => {
const dispatch = sinon.spy();
await peekNotConnectedGroupCall({
callMode: CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
})(
dispatch,

View file

@ -3906,5 +3906,45 @@
"line": " const imageDataCache = React.useRef<CallingImageDataCache>(new Map());",
"reasonCategory": "usageTrusted",
"updated": "2024-05-06T20:18:59.647Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CallsList.tsx",
"line": " const searchStateItemsRef = useRef<ReadonlyArray<CallHistoryGroup> | null>(",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2024-05-16T02:10:00.652Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/components/CallsList.tsx",
"line": " const peekQueueRef = useRef<Set<string>>(new Set());",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2024-05-16T02:10:00.652Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/components/CallsList.tsx",
"line": " const peekQueueArgsRef = useRef<Map<string, PeekNotConnectedGroupCallType>>(",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2024-05-16T02:10:00.652Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/components/CallsList.tsx",
"line": " const inactiveCallLinksPeekedAtRef = useRef<Map<string, number>>(new Map());",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2024-05-16T02:10:00.652Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/components/CallsList.tsx",
"line": " const peekQueueTimerRef = useRef<NodeJS.Timeout | null>(null);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2024-05-16T02:10:00.652Z",
"reasonDetail": "<optional>"
}
]