From b95d8c87be6c0b10061fdde9df712b39cf4288ce Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Fri, 17 May 2024 17:18:20 -0500 Subject: [PATCH] Peek call links and group calls from Calls Tab Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> --- _locales/en/messages.json | 24 ++ stylesheets/_mixins.scss | 79 ++++ stylesheets/components/CallsTab.scss | 61 +++ ts/background.ts | 2 + ts/components/CallsList.tsx | 417 +++++++++++++++++-- ts/components/CallsNewCall.tsx | 86 +++- ts/components/CallsTab.tsx | 23 +- ts/services/calling.ts | 1 + ts/state/ducks/calling.ts | 73 +++- ts/state/ducks/callingHelpers.ts | 9 + ts/state/smart/CallsTab.tsx | 17 +- ts/test-electron/state/ducks/calling_test.ts | 1 + ts/util/lint/exceptions.json | 40 ++ 13 files changed, 762 insertions(+), 71 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 15e890ae3..25c54f459 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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)" diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 2f5da57af..f2dce97d7 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -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; + } + } +} diff --git a/stylesheets/components/CallsTab.scss b/stylesheets/components/CallsTab.scss index 1c632d9f2..02fb08404 100644 --- a/stylesheets/components/CallsTab.scss +++ b/stylesheets/components/CallsTab.scss @@ -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; diff --git a/ts/background.ts b/ts/background.ts index 2998b7dd3..06371538c 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -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 { conversationId ); window.reduxActions.calling.peekNotConnectedGroupCall({ + callMode: CallMode.Group, conversationId, }); if (callId != null) { diff --git a/ts/components/CallsList.tsx b/ts/components/CallsList.tsx index b40334020..97fd29f1a 100644 --- a/ts/components/CallsList.tsx +++ b/ts/components/CallsList.tsx @@ -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; @@ -118,8 +134,11 @@ type CallsListProps = Readonly<{ pagination: CallHistoryPagination ) => Promise>; 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(null); const listRef = useRef(null); const [queryInput, setQueryInput] = useState(''); const [status, setStatus] = useState(CallHistoryFilterStatus.All); const [searchState, setSearchState] = useState(defaultInitState); + const [isLeaveCallDialogVisible, setIsLeaveCallDialogVisible] = + useState(false); const prevOptionsRef = useRef(null); const getCallHistoryGroupsCountRef = useRef(getCallHistoryGroupsCount); const getCallHistoryGroupsRef = useRef(getCallHistoryGroups); + const searchStateItemsRef = useRef | null>( + null + ); + const peekQueueRef = useRef>(new Set()); + const peekQueueArgsRef = useRef>( + new Map() + ); + const inactiveCallLinksPeekedAtRef = useRef>(new Map()); + + const peekQueueTimerRef = useRef(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 => { + const peerIds: Array = []; + 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, 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 ? ( { - 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({ {item.children.length > 1 ? `(${item.children.length}) ` : ''} {statusText} ·{' '} - + {isActiveVisible ? ( + i18n('icu:CallsList__ItemCallInfo--Active') + ) : ( + + )} } 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 && ( + { + 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')} + + )} + void; }): JSX.Element { - return ( + let innerContent: React.ReactNode | string; + let tooltipContent = ''; + if (callType === CallType.Audio) { + innerContent = ( + + ); + } else if (isActive) { + innerContent = isInCall + ? i18n('icu:CallsNewCallButton--return') + : i18n('icu:joinOngoingCall'); + if (!isEnabled) { + tooltipContent = i18n('icu:CallsNewCallButtonTooltip--in-another-call'); + } + } else { + innerContent = ( + + ); + } + + const buttonContent = ( ); + + return tooltipContent === '' ? ( + buttonContent + ) : ( + + {buttonContent} + + ); } export function CallsNewCall({ @@ -173,6 +211,8 @@ export function CallsNewCall({ ); } + const isNewCallEnabled = !hasActiveCall; + return (
{ - onOutgoingAudioCallInConversation(item.conversation.id); + if (isNewCallEnabled) { + onOutgoingAudioCallInConversation(item.conversation.id); + } }} + i18n={i18n} /> )} { - onOutgoingVideoCallInConversation(item.conversation.id); + if (isNewCallEnabled) { + onOutgoingVideoCallInConversation(item.conversation.id); + } }} + i18n={i18n} />
} diff --git a/ts/components/CallsTab.tsx b/ts/components/CallsTab.tsx index 7b03b0025..25228fb7f 100644 --- a/ts/components/CallsTab.tsx +++ b/ts/components/CallsTab.tsx @@ -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>; 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 && ( )} {sidebarView === CallsTabSidebarView.NewCallView && ( diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 9e8057e97..a4f3d9454 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -2369,6 +2369,7 @@ export class CallingClass { if (update === RingUpdate.Requested) { this.reduxInterface?.peekNotConnectedGroupCall({ + callMode: CallMode.Group, conversationId: conversation.id, }); } diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index aefdb013c..45c94201d 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -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 { @@ -1553,10 +1596,10 @@ function peekNotConnectedGroupCall( payload: PeekNotConnectedGroupCallType ): ThunkAction { 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(); diff --git a/ts/state/ducks/callingHelpers.ts b/ts/state/ducks/callingHelpers.ts index e25857890..26c8b9511 100644 --- a/ts/state/ducks/callingHelpers.ts +++ b/ts/state/ducks/callingHelpers.ts @@ -46,3 +46,12 @@ export const isAnybodyElseInGroupCall = ( peekInfo: undefined | Readonly>, ourAci: AciString ): boolean => Boolean(peekInfo?.acis.some(id => id !== ourAci)); + +export const isAnybodyInGroupCall = ( + peekInfo: undefined | Readonly> +): boolean => { + if (!peekInfo?.acis) { + return false; + } + return peekInfo.acis.length > 0; +}; diff --git a/ts/state/smart/CallsTab.tsx b/ts/state/smart/CallsTab.tsx index 63652885d..81ebbc208 100644 --- a/ts/state/smart/CallsTab.tsx +++ b/ts/state/smart/CallsTab.tsx @@ -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} /> ); }); diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index de2554285..927a8f56d 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -1498,6 +1498,7 @@ describe('calling duck', () => { const dispatch = sinon.spy(); await peekNotConnectedGroupCall({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', })( dispatch, diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 95b1340bf..2e7698297 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -3906,5 +3906,45 @@ "line": " const imageDataCache = React.useRef(new Map());", "reasonCategory": "usageTrusted", "updated": "2024-05-06T20:18:59.647Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallsList.tsx", + "line": " const searchStateItemsRef = useRef | null>(", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2024-05-16T02:10:00.652Z", + "reasonDetail": "" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallsList.tsx", + "line": " const peekQueueRef = useRef>(new Set());", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2024-05-16T02:10:00.652Z", + "reasonDetail": "" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallsList.tsx", + "line": " const peekQueueArgsRef = useRef>(", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2024-05-16T02:10:00.652Z", + "reasonDetail": "" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallsList.tsx", + "line": " const inactiveCallLinksPeekedAtRef = useRef>(new Map());", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2024-05-16T02:10:00.652Z", + "reasonDetail": "" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallsList.tsx", + "line": " const peekQueueTimerRef = useRef(null);", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2024-05-16T02:10:00.652Z", + "reasonDetail": "" } ]