Basic call link join support
This commit is contained in:
parent
2bfb6e7481
commit
96b3413feb
75 changed files with 2438 additions and 509 deletions
|
@ -15,6 +15,7 @@ import { HashType } from './types/Crypto';
|
|||
import { getCountryCode } from './types/PhoneNumber';
|
||||
|
||||
export type ConfigKeyType =
|
||||
| 'desktop.calling.adhoc'
|
||||
| 'desktop.clientExpiration'
|
||||
| 'desktop.groupMultiTypingIndicators'
|
||||
| 'desktop.internalUser'
|
||||
|
|
|
@ -57,7 +57,7 @@ export type Props = {
|
|||
loading?: boolean;
|
||||
|
||||
acceptedMessageRequest: boolean;
|
||||
conversationType: 'group' | 'direct';
|
||||
conversationType: 'group' | 'direct' | 'callLink';
|
||||
isMe: boolean;
|
||||
noteToSelf?: boolean;
|
||||
phoneNumber?: string;
|
||||
|
|
|
@ -74,6 +74,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
|
|||
hangUpActiveCall: action('hang-up-active-call'),
|
||||
i18n,
|
||||
incomingCall: null,
|
||||
callLink: undefined,
|
||||
isGroupCallRaiseHandEnabled: true,
|
||||
isGroupCallReactionsEnabled: true,
|
||||
keyChangeOk: action('key-change-ok'),
|
||||
|
@ -101,6 +102,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
|
|||
setPresenting: action('toggle-presenting'),
|
||||
setRendererCanvas: action('set-renderer-canvas'),
|
||||
setOutgoingRing: action('set-outgoing-ring'),
|
||||
showToast: action('show-toast'),
|
||||
startCall: action('start-call'),
|
||||
stopRingtone: action('stop-ringtone'),
|
||||
switchToPresentationView: action('switch-to-presentation-view'),
|
||||
|
|
|
@ -15,6 +15,7 @@ import type { SafetyNumberProps } from './SafetyNumberChangeDialog';
|
|||
import { SafetyNumberChangeDialog } from './SafetyNumberChangeDialog';
|
||||
import type {
|
||||
ActiveCallType,
|
||||
CallingConversationType,
|
||||
CallViewMode,
|
||||
GroupCallVideoRequest,
|
||||
PresentedSource,
|
||||
|
@ -43,12 +44,19 @@ import type {
|
|||
SetRendererCanvasType,
|
||||
StartCallType,
|
||||
} from '../state/ducks/calling';
|
||||
import { CallLinkRestrictions } from '../types/CallLink';
|
||||
import type { CallLinkType } from '../types/CallLink';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { CallingToastProvider } from './CallingToast';
|
||||
import type { SmartReactionPicker } from '../state/smart/ReactionPicker';
|
||||
import type { Props as ReactionPickerProps } from './conversation/ReactionPicker';
|
||||
import * as log from '../logging/log';
|
||||
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
|
||||
import { CallingAdhocCallInfo } from './CallingAdhocCallInfo';
|
||||
import { callLinkRootKeyToUrl } from '../util/callLinkRootKeyToUrl';
|
||||
import { ToastType } from '../types/Toast';
|
||||
import type { ShowToastAction } from '../state/ducks/toast';
|
||||
|
||||
const GROUP_CALL_RING_DURATION = 60 * 1000;
|
||||
|
||||
|
@ -73,6 +81,7 @@ export type GroupIncomingCall = Readonly<{
|
|||
export type PropsType = {
|
||||
activeCall?: ActiveCallType;
|
||||
availableCameras: Array<MediaDeviceInfo>;
|
||||
callLink: CallLinkType | undefined;
|
||||
cancelCall: (_: CancelCallType) => void;
|
||||
changeCallView: (mode: CallViewMode) => void;
|
||||
closeNeedPermissionScreen: () => void;
|
||||
|
@ -116,6 +125,7 @@ export type PropsType = {
|
|||
setOutgoingRing: (_: boolean) => void;
|
||||
setPresenting: (_?: PresentedSource) => void;
|
||||
setRendererCanvas: (_: SetRendererCanvasType) => void;
|
||||
showToast: ShowToastAction;
|
||||
stopRingtone: () => unknown;
|
||||
switchToPresentationView: () => void;
|
||||
switchFromPresentationView: () => void;
|
||||
|
@ -135,6 +145,7 @@ type ActiveCallManagerPropsType = PropsType & {
|
|||
function ActiveCallManager({
|
||||
activeCall,
|
||||
availableCameras,
|
||||
callLink,
|
||||
cancelCall,
|
||||
changeCallView,
|
||||
closeNeedPermissionScreen,
|
||||
|
@ -161,6 +172,7 @@ function ActiveCallManager({
|
|||
setPresenting,
|
||||
setRendererCanvas,
|
||||
setOutgoingRing,
|
||||
showToast,
|
||||
startCall,
|
||||
switchToPresentationView,
|
||||
switchFromPresentationView,
|
||||
|
@ -224,6 +236,18 @@ function ActiveCallManager({
|
|||
[setGroupCallVideoRequest, conversation.id]
|
||||
);
|
||||
|
||||
const onCopyCallLink = useCallback(async () => {
|
||||
if (!callLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = callLinkRootKeyToUrl(callLink.rootKey);
|
||||
if (link) {
|
||||
await window.navigator.clipboard.writeText(link);
|
||||
showToast({ toastType: ToastType.CopiedCallLink });
|
||||
}
|
||||
}, [callLink, showToast]);
|
||||
|
||||
const onSafetyNumberDialogCancel = useCallback(() => {
|
||||
hangUpActiveCall('safety number dialog cancel');
|
||||
}, [hangUpActiveCall]);
|
||||
|
@ -234,6 +258,7 @@ function ActiveCallManager({
|
|||
| undefined
|
||||
| Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
|
||||
let isConvoTooBigToRing = false;
|
||||
let isAdhocJoinRequestPending = false;
|
||||
|
||||
switch (activeCall.callMode) {
|
||||
case CallMode.Direct: {
|
||||
|
@ -256,11 +281,15 @@ function ActiveCallManager({
|
|||
groupMembers = undefined;
|
||||
break;
|
||||
}
|
||||
case CallMode.Group: {
|
||||
case CallMode.Group:
|
||||
case CallMode.Adhoc: {
|
||||
showCallLobby = activeCall.joinState !== GroupCallJoinState.Joined;
|
||||
isCallFull = activeCall.deviceCount >= activeCall.maxDevices;
|
||||
isConvoTooBigToRing = activeCall.isConversationTooBigToRing;
|
||||
({ groupMembers } = activeCall);
|
||||
isAdhocJoinRequestPending =
|
||||
callLink?.restrictions === CallLinkRestrictions.AdminApproval &&
|
||||
activeCall.joinState === GroupCallJoinState.Pending;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
@ -272,12 +301,13 @@ function ActiveCallManager({
|
|||
<>
|
||||
<CallingLobby
|
||||
availableCameras={availableCameras}
|
||||
callMode={activeCall.callMode}
|
||||
conversation={conversation}
|
||||
groupMembers={groupMembers}
|
||||
hasLocalAudio={hasLocalAudio}
|
||||
hasLocalVideo={hasLocalVideo}
|
||||
i18n={i18n}
|
||||
isGroupCall={activeCall.callMode === CallMode.Group}
|
||||
isAdhocJoinRequestPending={isAdhocJoinRequestPending}
|
||||
isCallFull={isCallFull}
|
||||
isConversationTooBigToRing={isConvoTooBigToRing}
|
||||
me={me}
|
||||
|
@ -294,14 +324,24 @@ function ActiveCallManager({
|
|||
toggleSettings={toggleSettings}
|
||||
/>
|
||||
{settingsDialogOpen && renderDeviceSelection()}
|
||||
{showParticipantsList && activeCall.callMode === CallMode.Group ? (
|
||||
<CallingParticipantsList
|
||||
i18n={i18n}
|
||||
onClose={toggleParticipants}
|
||||
ourServiceId={me.serviceId}
|
||||
participants={peekedParticipants}
|
||||
/>
|
||||
) : null}
|
||||
{showParticipantsList &&
|
||||
(activeCall.callMode === CallMode.Adhoc && callLink ? (
|
||||
<CallingAdhocCallInfo
|
||||
callLink={callLink}
|
||||
i18n={i18n}
|
||||
ourServiceId={me.serviceId}
|
||||
participants={peekedParticipants}
|
||||
onClose={toggleParticipants}
|
||||
onCopyCallLink={onCopyCallLink}
|
||||
/>
|
||||
) : (
|
||||
<CallingParticipantsList
|
||||
i18n={i18n}
|
||||
onClose={toggleParticipants}
|
||||
ourServiceId={me.serviceId}
|
||||
participants={peekedParticipants}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -325,31 +365,27 @@ function ActiveCallManager({
|
|||
}
|
||||
|
||||
let isHandRaised = false;
|
||||
if (activeCall.callMode === CallMode.Group) {
|
||||
if (isGroupOrAdhocActiveCall(activeCall)) {
|
||||
const { raisedHands, localDemuxId } = activeCall;
|
||||
if (localDemuxId) {
|
||||
isHandRaised = raisedHands.has(localDemuxId);
|
||||
}
|
||||
}
|
||||
|
||||
const groupCallParticipantsForParticipantsList =
|
||||
activeCall.callMode === CallMode.Group
|
||||
? [
|
||||
...activeCall.remoteParticipants.map(participant => ({
|
||||
...participant,
|
||||
hasRemoteAudio: participant.hasRemoteAudio,
|
||||
hasRemoteVideo: participant.hasRemoteVideo,
|
||||
presenting: participant.presenting,
|
||||
})),
|
||||
{
|
||||
...me,
|
||||
hasRemoteAudio: hasLocalAudio,
|
||||
hasRemoteVideo: hasLocalVideo,
|
||||
isHandRaised,
|
||||
presenting: Boolean(activeCall.presentingSource),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const groupCallParticipantsForParticipantsList = isGroupOrAdhocActiveCall(
|
||||
activeCall
|
||||
)
|
||||
? [
|
||||
...activeCall.remoteParticipants,
|
||||
{
|
||||
...me,
|
||||
hasRemoteAudio: hasLocalAudio,
|
||||
hasRemoteVideo: hasLocalVideo,
|
||||
isHandRaised,
|
||||
presenting: Boolean(activeCall.presentingSource),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -393,15 +429,25 @@ function ActiveCallManager({
|
|||
/>
|
||||
) : null}
|
||||
{settingsDialogOpen && renderDeviceSelection()}
|
||||
{showParticipantsList && activeCall.callMode === CallMode.Group ? (
|
||||
<CallingParticipantsList
|
||||
i18n={i18n}
|
||||
onClose={toggleParticipants}
|
||||
ourServiceId={me.serviceId}
|
||||
participants={groupCallParticipantsForParticipantsList}
|
||||
/>
|
||||
) : null}
|
||||
{activeCall.callMode === CallMode.Group &&
|
||||
{showParticipantsList &&
|
||||
(activeCall.callMode === CallMode.Adhoc && callLink ? (
|
||||
<CallingAdhocCallInfo
|
||||
callLink={callLink}
|
||||
i18n={i18n}
|
||||
ourServiceId={me.serviceId}
|
||||
participants={groupCallParticipantsForParticipantsList}
|
||||
onClose={toggleParticipants}
|
||||
onCopyCallLink={onCopyCallLink}
|
||||
/>
|
||||
) : (
|
||||
<CallingParticipantsList
|
||||
i18n={i18n}
|
||||
onClose={toggleParticipants}
|
||||
ourServiceId={me.serviceId}
|
||||
participants={groupCallParticipantsForParticipantsList}
|
||||
/>
|
||||
))}
|
||||
{isGroupOrAdhocActiveCall(activeCall) &&
|
||||
activeCall.conversationsWithSafetyNumberChanges.length ? (
|
||||
<SafetyNumberChangeDialog
|
||||
confirmText={i18n('icu:continueCall')}
|
||||
|
@ -462,7 +508,7 @@ export function CallManager(props: PropsType): JSX.Element | null {
|
|||
}, [shouldRing, playRingtone, stopRingtone]);
|
||||
|
||||
const mightBeRingingOutgoingGroupCall =
|
||||
activeCall?.callMode === CallMode.Group &&
|
||||
isGroupOrAdhocActiveCall(activeCall) &&
|
||||
activeCall.outgoingRing &&
|
||||
activeCall.joinState !== GroupCallJoinState.NotJoined;
|
||||
useEffect(() => {
|
||||
|
@ -527,7 +573,7 @@ function hasRemoteParticipants(
|
|||
return remoteParticipants.length > 0;
|
||||
}
|
||||
|
||||
function isLonelyGroup(conversation: ConversationType): boolean {
|
||||
function isLonelyGroup(conversation: CallingConversationType): boolean {
|
||||
return (conversation.sortedGroupMembers?.length ?? 0) < 2;
|
||||
}
|
||||
|
||||
|
@ -563,28 +609,30 @@ function getShouldRing({
|
|||
);
|
||||
}
|
||||
|
||||
// Adhoc calls can't be incoming.
|
||||
|
||||
throw missingCaseError(incomingCall);
|
||||
}
|
||||
|
||||
if (activeCall != null) {
|
||||
if (activeCall.callMode === CallMode.Direct) {
|
||||
return (
|
||||
activeCall.callState === CallState.Prering ||
|
||||
activeCall.callState === CallState.Ringing
|
||||
);
|
||||
switch (activeCall.callMode) {
|
||||
case CallMode.Direct:
|
||||
return (
|
||||
activeCall.callState === CallState.Prering ||
|
||||
activeCall.callState === CallState.Ringing
|
||||
);
|
||||
case CallMode.Group:
|
||||
case CallMode.Adhoc:
|
||||
return (
|
||||
activeCall.outgoingRing &&
|
||||
isConnected(activeCall.connectionState) &&
|
||||
isJoined(activeCall.joinState) &&
|
||||
!hasRemoteParticipants(activeCall.remoteParticipants) &&
|
||||
!isLonelyGroup(activeCall.conversation)
|
||||
);
|
||||
default:
|
||||
throw missingCaseError(activeCall);
|
||||
}
|
||||
|
||||
if (activeCall.callMode === CallMode.Group) {
|
||||
return (
|
||||
activeCall.outgoingRing &&
|
||||
isConnected(activeCall.connectionState) &&
|
||||
isJoined(activeCall.joinState) &&
|
||||
!hasRemoteParticipants(activeCall.remoteParticipants) &&
|
||||
!isLonelyGroup(activeCall.conversation)
|
||||
);
|
||||
}
|
||||
|
||||
throw missingCaseError(activeCall);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
|
@ -3,27 +3,46 @@
|
|||
|
||||
import React from 'react';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { CallMode } from '../types/Calling';
|
||||
|
||||
export type PropsType = {
|
||||
callMode: CallMode;
|
||||
i18n: LocalizerType;
|
||||
isAdhocJoinRequestPending?: boolean;
|
||||
groupMemberCount?: number;
|
||||
participantCount: number;
|
||||
toggleParticipants: () => void;
|
||||
};
|
||||
|
||||
export function CallParticipantCount({
|
||||
callMode,
|
||||
i18n,
|
||||
isAdhocJoinRequestPending,
|
||||
groupMemberCount,
|
||||
participantCount,
|
||||
toggleParticipants,
|
||||
}: PropsType): JSX.Element {
|
||||
const isToggleVisible =
|
||||
Boolean(participantCount) || callMode === CallMode.Adhoc;
|
||||
const count = participantCount || groupMemberCount || 1;
|
||||
const innerText = i18n('icu:CallControls__InfoDisplay--participants', {
|
||||
count: String(count),
|
||||
});
|
||||
let innerText: string | undefined;
|
||||
if (callMode === CallMode.Adhoc) {
|
||||
if (isAdhocJoinRequestPending) {
|
||||
innerText = i18n(
|
||||
'icu:CallControls__InfoDisplay--adhoc-join-request-pending'
|
||||
);
|
||||
} else if (!participantCount) {
|
||||
innerText = i18n('icu:CallControls__InfoDisplay--adhoc-call');
|
||||
}
|
||||
}
|
||||
if (!innerText) {
|
||||
innerText = i18n('icu:CallControls__InfoDisplay--participants', {
|
||||
count: String(count),
|
||||
});
|
||||
}
|
||||
|
||||
// Call not started, can't click to show participants
|
||||
if (!participantCount) {
|
||||
if (!isToggleVisible) {
|
||||
return (
|
||||
<span
|
||||
aria-label={i18n('icu:calling__participants', {
|
||||
|
|
|
@ -85,6 +85,8 @@ import {
|
|||
CallReactionBurstProvider,
|
||||
useCallReactionBursts,
|
||||
} from './CallReactionBurst';
|
||||
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
|
||||
import { assertDev } from '../util/assert';
|
||||
|
||||
export type PropsType = {
|
||||
activeCall: ActiveCallType;
|
||||
|
@ -378,6 +380,7 @@ export function CallScreen({
|
|||
break;
|
||||
}
|
||||
case CallMode.Group:
|
||||
case CallMode.Adhoc:
|
||||
isRinging =
|
||||
activeCall.outgoingRing &&
|
||||
!activeCall.remoteParticipants.length &&
|
||||
|
@ -475,7 +478,7 @@ export function CallScreen({
|
|||
'module-ongoing-call__controls--fadeOut': controlsFadedOut,
|
||||
});
|
||||
|
||||
const isGroupCall = activeCall.callMode === CallMode.Group;
|
||||
const isGroupCall = isGroupOrAdhocActiveCall(activeCall);
|
||||
|
||||
let presentingButtonType: CallingButtonType;
|
||||
if (presentingSource) {
|
||||
|
@ -486,8 +489,9 @@ export function CallScreen({
|
|||
presentingButtonType = CallingButtonType.PRESENTING_OFF;
|
||||
}
|
||||
|
||||
const raisedHands =
|
||||
activeCall.callMode === CallMode.Group ? activeCall.raisedHands : undefined;
|
||||
const raisedHands = isGroupOrAdhocActiveCall(activeCall)
|
||||
? activeCall.raisedHands
|
||||
: undefined;
|
||||
|
||||
// This is the value of our hand raised as seen by remote clients. We should prefer
|
||||
// to use it in UI so the user understands what remote clients see.
|
||||
|
@ -614,6 +618,7 @@ export function CallScreen({
|
|||
if (isGroupCall) {
|
||||
return (
|
||||
<CallParticipantCount
|
||||
callMode={activeCall.callMode}
|
||||
i18n={i18n}
|
||||
participantCount={participantCount}
|
||||
toggleParticipants={toggleParticipants}
|
||||
|
@ -635,6 +640,7 @@ export function CallScreen({
|
|||
i18n,
|
||||
isRinging,
|
||||
isConnected,
|
||||
activeCall.callMode,
|
||||
activeCall.joinedAt,
|
||||
isReconnecting,
|
||||
isGroupCall,
|
||||
|
@ -647,6 +653,10 @@ export function CallScreen({
|
|||
let remoteParticipantsElement: ReactNode;
|
||||
switch (activeCall.callMode) {
|
||||
case CallMode.Direct: {
|
||||
assertDev(
|
||||
conversation.type === 'direct',
|
||||
'direct call must have direct conversation'
|
||||
);
|
||||
remoteParticipantsElement = hasCallStarted ? (
|
||||
<DirectCallRemoteParticipant
|
||||
conversation={conversation}
|
||||
|
@ -661,6 +671,7 @@ export function CallScreen({
|
|||
break;
|
||||
}
|
||||
case CallMode.Group:
|
||||
case CallMode.Adhoc:
|
||||
remoteParticipantsElement = (
|
||||
<GroupCallRemoteParticipants
|
||||
callViewMode={activeCall.viewMode}
|
||||
|
@ -846,6 +857,7 @@ export function CallScreen({
|
|||
onPick: emoji => {
|
||||
setShowReactionPicker(false);
|
||||
sendGroupCallReaction({
|
||||
callMode: activeCall.callMode,
|
||||
conversationId: conversation.id,
|
||||
value: emoji,
|
||||
});
|
||||
|
@ -932,12 +944,13 @@ export function CallScreen({
|
|||
}
|
||||
|
||||
function getCallModeClassSuffix(
|
||||
callMode: CallMode.Direct | CallMode.Group
|
||||
callMode: CallMode.Direct | CallMode.Group | CallMode.Adhoc
|
||||
): string {
|
||||
switch (callMode) {
|
||||
case CallMode.Direct:
|
||||
return 'direct';
|
||||
case CallMode.Group:
|
||||
case CallMode.Adhoc:
|
||||
return 'group';
|
||||
default:
|
||||
throw missingCaseError(callMode);
|
||||
|
|
136
ts/components/CallingAdhocCallInfo.stories.tsx
Normal file
136
ts/components/CallingAdhocCallInfo.stories.tsx
Normal file
|
@ -0,0 +1,136 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import { sample } from 'lodash';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import type { Meta } from '@storybook/react';
|
||||
import type { PropsType } from './CallingAdhocCallInfo';
|
||||
import { CallingAdhocCallInfo } from './CallingAdhocCallInfo';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import type { GroupCallRemoteParticipantType } from '../types/Calling';
|
||||
import { generateAci } from '../types/ServiceId';
|
||||
import { getDefaultConversationWithServiceId } from '../test-both/helpers/getDefaultConversation';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import type { CallLinkType } from '../types/CallLink';
|
||||
import { CallLinkRestrictions } from '../types/CallLink';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
function createParticipant(
|
||||
participantProps: Partial<GroupCallRemoteParticipantType>
|
||||
): GroupCallRemoteParticipantType {
|
||||
return {
|
||||
aci: generateAci(),
|
||||
demuxId: 2,
|
||||
hasRemoteAudio: Boolean(participantProps.hasRemoteAudio),
|
||||
hasRemoteVideo: Boolean(participantProps.hasRemoteVideo),
|
||||
isHandRaised: Boolean(participantProps.isHandRaised),
|
||||
mediaKeysReceived: Boolean(participantProps.mediaKeysReceived),
|
||||
presenting: Boolean(participantProps.presenting),
|
||||
sharingScreen: Boolean(participantProps.sharingScreen),
|
||||
videoAspectRatio: 1.3,
|
||||
...getDefaultConversationWithServiceId({
|
||||
avatarPath: participantProps.avatarPath,
|
||||
color: sample(AvatarColors),
|
||||
isBlocked: Boolean(participantProps.isBlocked),
|
||||
name: participantProps.name,
|
||||
profileName: participantProps.title,
|
||||
title: String(participantProps.title),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function getCallLink(overrideProps: Partial<CallLinkType> = {}): CallLinkType {
|
||||
// Normally, roomId would be derived from rootKey however we don't want to import
|
||||
// ringrtc in storybook
|
||||
return {
|
||||
roomId: 'abcd1234abcd1234abcd1234abcd1234abcd1234',
|
||||
rootKey: 'abcd-abcd-abcd-abcd-abcd-abcd-abcd-abcd',
|
||||
name: 'Axolotl Discuss',
|
||||
restrictions: CallLinkRestrictions.None,
|
||||
expiration: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
...overrideProps,
|
||||
};
|
||||
}
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
callLink: getCallLink(overrideProps.callLink || {}),
|
||||
i18n,
|
||||
ourServiceId: generateAci(),
|
||||
participants: overrideProps.participants || [],
|
||||
onClose: action('on-close'),
|
||||
onCopyCallLink: action('on-copy-call-link'),
|
||||
});
|
||||
|
||||
export default {
|
||||
title: 'Components/CallingAdhocCallInfo',
|
||||
} satisfies Meta<PropsType>;
|
||||
|
||||
export function NoOne(): JSX.Element {
|
||||
const props = createProps();
|
||||
return <CallingAdhocCallInfo {...props} />;
|
||||
}
|
||||
|
||||
export function SoloCall(): JSX.Element {
|
||||
const props = createProps({
|
||||
participants: [
|
||||
createParticipant({
|
||||
title: 'Bardock',
|
||||
}),
|
||||
],
|
||||
});
|
||||
return <CallingAdhocCallInfo {...props} />;
|
||||
}
|
||||
|
||||
export function ManyParticipants(): JSX.Element {
|
||||
const props = createProps({
|
||||
participants: [
|
||||
createParticipant({
|
||||
title: 'Son Goku',
|
||||
}),
|
||||
createParticipant({
|
||||
hasRemoteAudio: true,
|
||||
hasRemoteVideo: true,
|
||||
presenting: true,
|
||||
name: 'Rage Trunks',
|
||||
title: 'Rage Trunks',
|
||||
}),
|
||||
createParticipant({
|
||||
hasRemoteAudio: true,
|
||||
title: 'Prince Vegeta',
|
||||
}),
|
||||
createParticipant({
|
||||
hasRemoteAudio: true,
|
||||
hasRemoteVideo: true,
|
||||
name: 'Goku Black',
|
||||
title: 'Goku Black',
|
||||
}),
|
||||
createParticipant({
|
||||
isHandRaised: true,
|
||||
title: 'Supreme Kai Zamasu',
|
||||
}),
|
||||
createParticipant({
|
||||
hasRemoteAudio: false,
|
||||
hasRemoteVideo: true,
|
||||
isHandRaised: true,
|
||||
title: 'Chi Chi',
|
||||
}),
|
||||
createParticipant({
|
||||
title: 'Someone With A Really Long Name',
|
||||
}),
|
||||
],
|
||||
});
|
||||
return <CallingAdhocCallInfo {...props} />;
|
||||
}
|
||||
|
||||
export function Overflow(): JSX.Element {
|
||||
const props = createProps({
|
||||
participants: Array(50)
|
||||
.fill(null)
|
||||
.map(() => createParticipant({ title: 'Kirby' })),
|
||||
});
|
||||
return <CallingAdhocCallInfo {...props} />;
|
||||
}
|
162
ts/components/CallingAdhocCallInfo.tsx
Normal file
162
ts/components/CallingAdhocCallInfo.tsx
Normal file
|
@ -0,0 +1,162 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/* eslint-disable react/no-array-index-key */
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
import { InContactsIcon } from './InContactsIcon';
|
||||
import type { CallLinkType } from '../types/CallLink';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { ServiceIdString } from '../types/ServiceId';
|
||||
import { sortByTitle } from '../util/sortByTitle';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import { ModalHost } from './ModalHost';
|
||||
import { isInSystemContacts } from '../util/isInSystemContacts';
|
||||
|
||||
type ParticipantType = ConversationType & {
|
||||
hasRemoteAudio?: boolean;
|
||||
hasRemoteVideo?: boolean;
|
||||
isHandRaised?: boolean;
|
||||
presenting?: boolean;
|
||||
};
|
||||
|
||||
export type PropsType = {
|
||||
readonly callLink: CallLinkType;
|
||||
readonly i18n: LocalizerType;
|
||||
readonly ourServiceId: ServiceIdString | undefined;
|
||||
readonly participants: Array<ParticipantType>;
|
||||
readonly onClose: () => void;
|
||||
readonly onCopyCallLink: () => void;
|
||||
};
|
||||
|
||||
export function CallingAdhocCallInfo({
|
||||
i18n,
|
||||
ourServiceId,
|
||||
participants,
|
||||
onClose,
|
||||
onCopyCallLink,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const sortedParticipants = React.useMemo<Array<ParticipantType>>(
|
||||
() => sortByTitle(participants),
|
||||
[participants]
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalHost
|
||||
modalName="CallingAdhocCallInfo"
|
||||
moduleClassName="CallingAdhocCallInfo"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="CallingAdhocCallInfo module-calling-participants-list">
|
||||
<div className="module-calling-participants-list__header">
|
||||
<div className="module-calling-participants-list__title">
|
||||
{!participants.length && i18n('icu:calling__in-this-call--zero')}
|
||||
{participants.length === 1 &&
|
||||
i18n('icu:calling__in-this-call--one')}
|
||||
{participants.length > 1 &&
|
||||
i18n('icu:calling__in-this-call--many', {
|
||||
people: String(participants.length),
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="module-calling-participants-list__close"
|
||||
onClick={onClose}
|
||||
tabIndex={0}
|
||||
aria-label={i18n('icu:close')}
|
||||
/>
|
||||
</div>
|
||||
<ul className="module-calling-participants-list__list">
|
||||
{sortedParticipants.map(
|
||||
(participant: ParticipantType, index: number) => (
|
||||
<li
|
||||
className="module-calling-participants-list__contact"
|
||||
// It's tempting to use `participant.serviceId` as the `key`
|
||||
// here, but that can result in duplicate keys for
|
||||
// participants who have joined on multiple devices.
|
||||
key={index}
|
||||
>
|
||||
<div className="module-calling-participants-list__avatar-and-name">
|
||||
<Avatar
|
||||
acceptedMessageRequest={participant.acceptedMessageRequest}
|
||||
avatarPath={participant.avatarPath}
|
||||
badge={undefined}
|
||||
color={participant.color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={participant.isMe}
|
||||
profileName={participant.profileName}
|
||||
title={participant.title}
|
||||
sharedGroupNames={participant.sharedGroupNames}
|
||||
size={AvatarSize.THIRTY_TWO}
|
||||
/>
|
||||
{ourServiceId && participant.serviceId === ourServiceId ? (
|
||||
<span className="module-calling-participants-list__name">
|
||||
{i18n('icu:you')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<ContactName
|
||||
module="module-calling-participants-list__name"
|
||||
title={participant.title}
|
||||
/>
|
||||
{isInSystemContacts(participant) ? (
|
||||
<span>
|
||||
{' '}
|
||||
<InContactsIcon
|
||||
className="module-calling-participants-list__contact-icon"
|
||||
i18n={i18n}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={classNames(
|
||||
'module-calling-participants-list__status-icon',
|
||||
participant.isHandRaised &&
|
||||
'module-calling-participants-list__hand-raised'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={classNames(
|
||||
'module-calling-participants-list__status-icon',
|
||||
participant.presenting &&
|
||||
'module-calling-participants-list__presenting',
|
||||
!participant.hasRemoteVideo &&
|
||||
'module-calling-participants-list__muted--video'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={classNames(
|
||||
'module-calling-participants-list__status-icon',
|
||||
!participant.hasRemoteAudio &&
|
||||
'module-calling-participants-list__muted--audio'
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
<div className="CallingAdhocCallInfo__Divider" />
|
||||
<div className="CallingAdhocCallInfo__CallLinkInfo">
|
||||
<button
|
||||
className="CallingAdhocCallInfo__MenuItem"
|
||||
onClick={onCopyCallLink}
|
||||
type="button"
|
||||
>
|
||||
<span className="CallingAdhocCallInfo__MenuItemIcon CallingAdhocCallInfo__MenuItemIcon--copy-link" />
|
||||
<span className="CallingAdhocCallInfo__MenuItemText">
|
||||
{i18n('icu:CallingAdhocCallInfo__CopyLink')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalHost>
|
||||
);
|
||||
}
|
|
@ -19,6 +19,7 @@ import {
|
|||
getDefaultConversationWithServiceId,
|
||||
} from '../test-both/helpers/getDefaultConversation';
|
||||
import { CallingToastProvider } from './CallingToast';
|
||||
import { CallMode } from '../types/Calling';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -33,24 +34,28 @@ const camera = {
|
|||
};
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
|
||||
const isGroupCall = overrideProps.isGroupCall ?? false;
|
||||
const conversation = isGroupCall
|
||||
? getDefaultConversation({
|
||||
title: 'Tahoe Trip',
|
||||
type: 'group',
|
||||
})
|
||||
: getDefaultConversation();
|
||||
const callMode = overrideProps.callMode ?? CallMode.Direct;
|
||||
const conversation =
|
||||
callMode === CallMode.Group
|
||||
? getDefaultConversation({
|
||||
title: 'Tahoe Trip',
|
||||
type: 'group',
|
||||
})
|
||||
: getDefaultConversation();
|
||||
|
||||
return {
|
||||
availableCameras: overrideProps.availableCameras || [camera],
|
||||
callMode,
|
||||
conversation,
|
||||
groupMembers:
|
||||
overrideProps.groupMembers ||
|
||||
(isGroupCall ? times(3, () => getDefaultConversation()) : undefined),
|
||||
(callMode === CallMode.Group
|
||||
? times(3, () => getDefaultConversation())
|
||||
: undefined),
|
||||
hasLocalAudio: overrideProps.hasLocalAudio ?? true,
|
||||
hasLocalVideo: overrideProps.hasLocalVideo ?? false,
|
||||
i18n,
|
||||
isGroupCall,
|
||||
isAdhocJoinRequestPending: false,
|
||||
isConversationTooBigToRing: false,
|
||||
isCallFull: overrideProps.isCallFull ?? false,
|
||||
me:
|
||||
|
@ -133,13 +138,16 @@ export function InitiallyMuted(): JSX.Element {
|
|||
}
|
||||
|
||||
export function GroupCallWithNoPeekedParticipants(): JSX.Element {
|
||||
const props = createProps({ isGroupCall: true, peekedParticipants: [] });
|
||||
const props = createProps({
|
||||
callMode: CallMode.Group,
|
||||
peekedParticipants: [],
|
||||
});
|
||||
return <CallingLobby {...props} />;
|
||||
}
|
||||
|
||||
export function GroupCallWith1PeekedParticipant(): JSX.Element {
|
||||
const props = createProps({
|
||||
isGroupCall: true,
|
||||
callMode: CallMode.Group,
|
||||
peekedParticipants: [{ title: 'Sam' }].map(fakePeekedParticipant),
|
||||
});
|
||||
return <CallingLobby {...props} />;
|
||||
|
@ -148,7 +156,7 @@ export function GroupCallWith1PeekedParticipant(): JSX.Element {
|
|||
export function GroupCallWith1PeekedParticipantSelf(): JSX.Element {
|
||||
const serviceId = generateAci();
|
||||
const props = createProps({
|
||||
isGroupCall: true,
|
||||
callMode: CallMode.Group,
|
||||
me: getDefaultConversation({
|
||||
id: generateUuid(),
|
||||
serviceId,
|
||||
|
@ -160,7 +168,7 @@ export function GroupCallWith1PeekedParticipantSelf(): JSX.Element {
|
|||
|
||||
export function GroupCallWith4PeekedParticipants(): JSX.Element {
|
||||
const props = createProps({
|
||||
isGroupCall: true,
|
||||
callMode: CallMode.Group,
|
||||
peekedParticipants: ['Sam', 'Cayce', 'April', 'Logan', 'Carl'].map(title =>
|
||||
fakePeekedParticipant({ title })
|
||||
),
|
||||
|
@ -170,7 +178,7 @@ export function GroupCallWith4PeekedParticipants(): JSX.Element {
|
|||
|
||||
export function GroupCallWith4PeekedParticipantsParticipantsList(): JSX.Element {
|
||||
const props = createProps({
|
||||
isGroupCall: true,
|
||||
callMode: CallMode.Group,
|
||||
peekedParticipants: ['Sam', 'Cayce', 'April', 'Logan', 'Carl'].map(title =>
|
||||
fakePeekedParticipant({ title })
|
||||
),
|
||||
|
@ -181,7 +189,7 @@ export function GroupCallWith4PeekedParticipantsParticipantsList(): JSX.Element
|
|||
|
||||
export function GroupCallWithCallFull(): JSX.Element {
|
||||
const props = createProps({
|
||||
isGroupCall: true,
|
||||
callMode: CallMode.Group,
|
||||
isCallFull: true,
|
||||
peekedParticipants: ['Sam', 'Cayce'].map(title =>
|
||||
fakePeekedParticipant({ title })
|
||||
|
@ -192,7 +200,7 @@ export function GroupCallWithCallFull(): JSX.Element {
|
|||
|
||||
export function GroupCallWith0PeekedParticipantsBigGroup(): JSX.Element {
|
||||
const props = createProps({
|
||||
isGroupCall: true,
|
||||
callMode: CallMode.Group,
|
||||
groupMembers: times(100, () => getDefaultConversation()),
|
||||
});
|
||||
return <CallingLobby {...props} />;
|
||||
|
|
|
@ -19,17 +19,22 @@ import {
|
|||
CallingLobbyJoinButton,
|
||||
CallingLobbyJoinButtonVariant,
|
||||
} from './CallingLobbyJoinButton';
|
||||
import { CallMode } from '../types/Calling';
|
||||
import type { CallingConversationType } from '../types/Calling';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { useIsOnline } from '../hooks/useIsOnline';
|
||||
import * as KeyboardLayout from '../services/keyboardLayout';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import { useCallingToasts } from './CallingToast';
|
||||
import { CallingButtonToastsContainer } from './CallingToastManager';
|
||||
import { isGroupOrAdhocCallMode } from '../util/isGroupOrAdhocCall';
|
||||
import { isSharingPhoneNumberWithEverybody } from '../util/phoneNumberSharingMode';
|
||||
|
||||
export type PropsType = {
|
||||
availableCameras: Array<MediaDeviceInfo>;
|
||||
callMode: CallMode;
|
||||
conversation: Pick<
|
||||
ConversationType,
|
||||
CallingConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'color'
|
||||
|
@ -54,8 +59,8 @@ export type PropsType = {
|
|||
hasLocalAudio: boolean;
|
||||
hasLocalVideo: boolean;
|
||||
i18n: LocalizerType;
|
||||
isAdhocJoinRequestPending: boolean;
|
||||
isConversationTooBigToRing: boolean;
|
||||
isGroupCall: boolean;
|
||||
isCallFull?: boolean;
|
||||
me: Readonly<
|
||||
Pick<ConversationType, 'avatarPath' | 'color' | 'id' | 'serviceId'>
|
||||
|
@ -75,12 +80,13 @@ export type PropsType = {
|
|||
|
||||
export function CallingLobby({
|
||||
availableCameras,
|
||||
callMode,
|
||||
conversation,
|
||||
groupMembers,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
i18n,
|
||||
isGroupCall = false,
|
||||
isAdhocJoinRequestPending,
|
||||
isCallFull = false,
|
||||
isConversationTooBigToRing,
|
||||
me,
|
||||
|
@ -99,6 +105,8 @@ export function CallingLobby({
|
|||
|
||||
const shouldShowLocalVideo = hasLocalVideo && availableCameras.length > 0;
|
||||
|
||||
const isGroupOrAdhocCall = isGroupOrAdhocCallMode(callMode);
|
||||
|
||||
const toggleAudio = React.useCallback((): void => {
|
||||
setLocalAudio({ enabled: !hasLocalAudio });
|
||||
}, [hasLocalAudio, setLocalAudio]);
|
||||
|
@ -161,12 +169,12 @@ export function CallingLobby({
|
|||
: CallingButtonType.AUDIO_OFF;
|
||||
|
||||
const isRingButtonVisible: boolean =
|
||||
isGroupCall &&
|
||||
isGroupOrAdhocCall &&
|
||||
peekedParticipants.length === 0 &&
|
||||
(groupMembers || []).length > 1;
|
||||
|
||||
let preCallInfoRingMode: RingMode;
|
||||
if (isGroupCall) {
|
||||
if (isGroupOrAdhocCall) {
|
||||
preCallInfoRingMode =
|
||||
outgoingRing && !isConversationTooBigToRing
|
||||
? RingMode.WillRing
|
||||
|
@ -205,10 +213,12 @@ export function CallingLobby({
|
|||
}
|
||||
|
||||
const callStatus = React.useMemo(() => {
|
||||
if (isGroupCall) {
|
||||
if (isGroupOrAdhocCall) {
|
||||
return (
|
||||
<CallParticipantCount
|
||||
callMode={callMode}
|
||||
i18n={i18n}
|
||||
isAdhocJoinRequestPending={isAdhocJoinRequestPending}
|
||||
groupMemberCount={groupMembers?.length ?? 0}
|
||||
participantCount={peekedParticipants.length}
|
||||
toggleParticipants={toggleParticipants}
|
||||
|
@ -223,7 +233,9 @@ export function CallingLobby({
|
|||
}
|
||||
return null;
|
||||
}, [
|
||||
isGroupCall,
|
||||
callMode,
|
||||
isAdhocJoinRequestPending,
|
||||
isGroupOrAdhocCall,
|
||||
peekedParticipants.length,
|
||||
i18n,
|
||||
hasLocalVideo,
|
||||
|
@ -252,7 +264,7 @@ export function CallingLobby({
|
|||
|
||||
<CallingHeader
|
||||
i18n={i18n}
|
||||
isGroupCall={isGroupCall}
|
||||
isGroupCall={isGroupOrAdhocCall}
|
||||
participantCount={peekedParticipants.length}
|
||||
toggleSettings={toggleSettings}
|
||||
onCancel={onCallCanceled}
|
||||
|
@ -280,6 +292,14 @@ export function CallingLobby({
|
|||
{i18n('icu:calling__your-video-is-off')}
|
||||
</div>
|
||||
|
||||
{callMode === CallMode.Adhoc && (
|
||||
<div className="CallingLobby__CallLinkNotice">
|
||||
{isSharingPhoneNumberWithEverybody()
|
||||
? i18n('icu:CallingLobby__CallLinkNotice--phone-sharing')
|
||||
: i18n('icu:CallingLobby__CallLinkNotice')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CallingButtonToastsContainer
|
||||
hasLocalAudio={hasLocalAudio}
|
||||
outgoingRing={outgoingRing}
|
||||
|
|
|
@ -23,6 +23,8 @@ import { usePageVisibility } from '../hooks/usePageVisibility';
|
|||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteParticipant';
|
||||
import { isReconnecting } from '../util/callingIsReconnecting';
|
||||
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
|
||||
import { assertDev } from '../util/assert';
|
||||
|
||||
// This value should be kept in sync with the hard-coded CSS height. It should also be
|
||||
// less than `MAX_FRAME_HEIGHT`.
|
||||
|
@ -97,17 +99,17 @@ export function CallingPipRemoteVideo({
|
|||
|
||||
const activeGroupCallSpeaker: undefined | GroupCallRemoteParticipantType =
|
||||
useMemo(() => {
|
||||
if (activeCall.callMode !== CallMode.Group) {
|
||||
if (!isGroupOrAdhocActiveCall(activeCall)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return maxBy(activeCall.remoteParticipants, participant =>
|
||||
participant.presenting ? Infinity : participant.speakerTime || -Infinity
|
||||
);
|
||||
}, [activeCall.callMode, activeCall.remoteParticipants]);
|
||||
}, [activeCall]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCall.callMode !== CallMode.Group) {
|
||||
if (!isGroupOrAdhocActiveCall(activeCall)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -136,8 +138,7 @@ export function CallingPipRemoteVideo({
|
|||
);
|
||||
}
|
||||
}, [
|
||||
activeCall.callMode,
|
||||
activeCall.remoteParticipants,
|
||||
activeCall,
|
||||
activeGroupCallSpeaker,
|
||||
isPageVisible,
|
||||
setGroupCallVideoRequest,
|
||||
|
@ -149,6 +150,10 @@ export function CallingPipRemoteVideo({
|
|||
if (!hasRemoteVideo) {
|
||||
return <NoVideo activeCall={activeCall} i18n={i18n} />;
|
||||
}
|
||||
assertDev(
|
||||
conversation.type === 'direct',
|
||||
'CallingPipRemoteVideo for direct call must be associated with direct conversation'
|
||||
);
|
||||
return (
|
||||
<div className="module-calling-pip__video--remote">
|
||||
<DirectCallRemoteParticipant
|
||||
|
@ -162,6 +167,7 @@ export function CallingPipRemoteVideo({
|
|||
);
|
||||
}
|
||||
case CallMode.Group:
|
||||
case CallMode.Adhoc:
|
||||
if (!activeGroupCallSpeaker) {
|
||||
return <NoVideo activeCall={activeCall} i18n={i18n} />;
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import React from 'react';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { CallingConversationType } from '../types/Calling';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { getParticipantName } from '../util/callingGetParticipantName';
|
||||
|
@ -17,7 +18,7 @@ export enum RingMode {
|
|||
|
||||
export type PropsType = {
|
||||
conversation: Pick<
|
||||
ConversationType,
|
||||
CallingConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'color'
|
||||
|
@ -114,6 +115,7 @@ export function CallingPreCallInfo({
|
|||
memberNames = [getParticipantName(conversation)];
|
||||
break;
|
||||
case 'group':
|
||||
case 'callLink':
|
||||
memberNames = groupMembers
|
||||
.filter(member => member.id !== me.id)
|
||||
.map(getParticipantName);
|
||||
|
|
|
@ -10,6 +10,7 @@ import { CallingToastProvider, useCallingToasts } from './CallingToast';
|
|||
import { usePrevious } from '../hooks/usePrevious';
|
||||
import { difference as setDifference } from '../util/setUtil';
|
||||
import { isMoreRecentThan } from '../util/timestamp';
|
||||
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
|
||||
|
||||
type PropsType = {
|
||||
activeCall: ActiveCallType;
|
||||
|
@ -24,13 +25,16 @@ function getCurrentPresenter(
|
|||
if (activeCall.presentingSource) {
|
||||
return { id: ME };
|
||||
}
|
||||
if (activeCall.callMode === CallMode.Direct) {
|
||||
if (
|
||||
activeCall.callMode === CallMode.Direct &&
|
||||
activeCall.conversation.type === 'direct'
|
||||
) {
|
||||
const isOtherPersonPresenting = activeCall.remoteParticipants.some(
|
||||
participant => participant.presenting
|
||||
);
|
||||
return isOtherPersonPresenting ? activeCall.conversation : undefined;
|
||||
}
|
||||
if (activeCall.callMode === CallMode.Group) {
|
||||
if (isGroupOrAdhocActiveCall(activeCall)) {
|
||||
return activeCall.remoteParticipants.find(
|
||||
participant => participant.presenting
|
||||
);
|
||||
|
|
|
@ -10,12 +10,14 @@ import { ErrorModal } from './ErrorModal';
|
|||
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { ButtonVariant } from './Button';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
title: overrideProps.title ?? '',
|
||||
buttonVariant: overrideProps.buttonVariant ?? undefined,
|
||||
description: overrideProps.description ?? '',
|
||||
title: overrideProps.title ?? '',
|
||||
i18n,
|
||||
onClose: action('onClick'),
|
||||
});
|
||||
|
@ -30,6 +32,12 @@ export function Normal(): JSX.Element {
|
|||
return <ErrorModal {...createProps()} />;
|
||||
}
|
||||
|
||||
export function PrimaryButton(): JSX.Element {
|
||||
return (
|
||||
<ErrorModal {...createProps({ buttonVariant: ButtonVariant.Primary })} />
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomStrings(): JSX.Element {
|
||||
return (
|
||||
<ErrorModal
|
||||
|
|
|
@ -8,6 +8,7 @@ import { Modal } from './Modal';
|
|||
import { Button, ButtonVariant } from './Button';
|
||||
|
||||
export type PropsType = {
|
||||
buttonVariant?: ButtonVariant;
|
||||
description?: string;
|
||||
title?: string;
|
||||
|
||||
|
@ -22,10 +23,14 @@ function focusRef(el: HTMLElement | null) {
|
|||
}
|
||||
|
||||
export function ErrorModal(props: PropsType): JSX.Element {
|
||||
const { description, i18n, onClose, title } = props;
|
||||
const { buttonVariant, description, i18n, onClose, title } = props;
|
||||
|
||||
const footer = (
|
||||
<Button onClick={onClose} ref={focusRef} variant={ButtonVariant.Secondary}>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
ref={focusRef}
|
||||
variant={buttonVariant || ButtonVariant.Secondary}
|
||||
>
|
||||
{i18n('icu:Confirmation--confirm')}
|
||||
</Button>
|
||||
);
|
||||
|
|
|
@ -136,6 +136,7 @@ export function LinkPreview(): JSX.Element {
|
|||
contentType: IMAGE_JPEG,
|
||||
}),
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
title: LONG_TITLE,
|
||||
},
|
||||
],
|
||||
|
|
|
@ -452,6 +452,7 @@ function ForwardMessageEditor({
|
|||
onClose={removeLinkPreview}
|
||||
title={linkPreview.title}
|
||||
url={linkPreview.url}
|
||||
isCallLink={linkPreview.isCallLink}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
@ -40,8 +40,11 @@ export type PropsType = {
|
|||
editHistoryMessages: EditHistoryMessagesType | undefined;
|
||||
renderEditHistoryMessagesModal: () => JSX.Element;
|
||||
// ErrorModal
|
||||
errorModalProps: { description?: string; title?: string } | undefined;
|
||||
errorModalProps:
|
||||
| { buttonVariant?: ButtonVariant; description?: string; title?: string }
|
||||
| undefined;
|
||||
renderErrorModal: (opts: {
|
||||
buttonVariant?: ButtonVariant;
|
||||
description?: string;
|
||||
title?: string;
|
||||
}) => JSX.Element;
|
||||
|
|
|
@ -71,6 +71,7 @@ LinkPreview.args = {
|
|||
}),
|
||||
title: 'Cats & Kittens LOL',
|
||||
url: 'https://www.catsandkittens.lolcats/kittens/page/1',
|
||||
isCallLink: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -184,6 +184,7 @@ export function LinkPreview(): JSX.Element {
|
|||
preview: {
|
||||
url: 'https://www.signal.org/workworkwork',
|
||||
title: 'Signal >> Careers',
|
||||
isCallLink: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
@ -200,6 +201,7 @@ export function LinkPreviewThumbnail(): JSX.Element {
|
|||
preview: {
|
||||
url: 'https://www.signal.org/workworkwork',
|
||||
title: 'Signal >> Careers',
|
||||
isCallLink: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
@ -216,6 +218,7 @@ export function LinkPreviewLongTitle(): JSX.Element {
|
|||
title:
|
||||
'2021 Etihad Airways Abu Dhabi Grand Prix Race Summary - F1 RaceCast Dec 10 to Dec 12 - ESPN',
|
||||
url: 'https://www.espn.com/f1/race/_/id/600001776',
|
||||
isCallLink: false,
|
||||
},
|
||||
text: 'Spoiler alert!',
|
||||
textForegroundColor: 4294704123,
|
||||
|
@ -232,6 +235,7 @@ export function LinkPreviewJustUrl(): JSX.Element {
|
|||
color: 4294951251,
|
||||
preview: {
|
||||
url: 'https://www.rolex.com/en-us/watches/day-date/m228236-0012.html',
|
||||
isCallLink: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
@ -246,6 +250,7 @@ export function LinkPreviewJustUrlText(): JSX.Element {
|
|||
color: 4294951251,
|
||||
preview: {
|
||||
url: 'https://www.rolex.com/en-us/watches/day-date/m228236-0012.html',
|
||||
isCallLink: false,
|
||||
},
|
||||
text: 'Check this out!',
|
||||
}}
|
||||
|
@ -261,6 +266,7 @@ export function LinkPreviewReallyLongDomain(): JSX.Element {
|
|||
color: 4294951251,
|
||||
preview: {
|
||||
url: 'https://llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch.international/',
|
||||
isCallLink: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
@ -284,6 +290,7 @@ export function LinkPreviewWRJ(): JSX.Element {
|
|||
preview: {
|
||||
title: 'Romeo and Juliet: Entire Play',
|
||||
url: 'http://shakespeare.mit.edu/romeo_juliet/full.html',
|
||||
isCallLink: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
@ -307,6 +314,7 @@ export function TextBackgroundAndLinkPreview(): JSX.Element {
|
|||
preview: {
|
||||
title: 'A really long title so that the we can test the margins',
|
||||
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
isCallLink: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -61,6 +61,8 @@ function getToast(toastType: ToastType): AnyToast {
|
|||
};
|
||||
case ToastType.ConversationUnarchived:
|
||||
return { toastType: ToastType.ConversationUnarchived };
|
||||
case ToastType.CopiedCallLink:
|
||||
return { toastType: ToastType.CopiedCallLink };
|
||||
case ToastType.CopiedUsername:
|
||||
return { toastType: ToastType.CopiedUsername };
|
||||
case ToastType.CopiedUsernameLink:
|
||||
|
|
|
@ -188,6 +188,14 @@ export function renderToast({
|
|||
);
|
||||
}
|
||||
|
||||
if (toastType === ToastType.CopiedCallLink) {
|
||||
return (
|
||||
<Toast onClose={hideToast} timeout={3 * SECOND}>
|
||||
{i18n('icu:calling__call-link-copied')}
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
if (toastType === ToastType.CopiedUsername) {
|
||||
return (
|
||||
<Toast onClose={hideToast} timeout={3 * SECOND}>
|
||||
|
|
|
@ -216,6 +216,9 @@ function renderCallingNotificationButton(
|
|||
}
|
||||
break;
|
||||
}
|
||||
case CallMode.Adhoc:
|
||||
log.warn('CallingNotification for adhoc call, should never happen');
|
||||
return null;
|
||||
default:
|
||||
log.error(missingCaseError(props.callHistory.mode));
|
||||
return null;
|
||||
|
|
|
@ -1229,6 +1229,9 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{first.isCallLink && (
|
||||
<div className="module-message__link-preview__call-link-icon" />
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
'module-message__link-preview__text',
|
||||
|
@ -1931,6 +1934,31 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
private renderAction(): JSX.Element | null {
|
||||
const { direction, i18n, previews } = this.props;
|
||||
if (previews?.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onlyPreview = previews[0];
|
||||
if (onlyPreview.isCallLink) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={classNames('module-message__action', {
|
||||
'module-message__action--incoming': direction === 'incoming',
|
||||
'module-message__action--outgoing': direction === 'outgoing',
|
||||
})}
|
||||
onClick={() => openLinkInWebBrowser(onlyPreview.url)}
|
||||
>
|
||||
{i18n('icu:calling__join')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private renderError(): ReactNode {
|
||||
const { status, direction } = this.props;
|
||||
|
||||
|
@ -2406,6 +2434,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
{this.renderPayment()}
|
||||
{this.renderEmbeddedContact()}
|
||||
{this.renderText()}
|
||||
{this.renderAction()}
|
||||
{this.renderMetadata()}
|
||||
{this.renderSendMessageButton()}
|
||||
</>
|
||||
|
|
|
@ -32,6 +32,7 @@ const getDefaultProps = (): Props => ({
|
|||
onClose: action('onClose'),
|
||||
title: 'This is a super-sweet site',
|
||||
url: 'https://www.signal.org',
|
||||
isCallLink: false,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
|
|
|
@ -426,6 +426,7 @@ export function EmojiMessages(): JSX.Element {
|
|||
width: 320,
|
||||
}),
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
title: 'Signal',
|
||||
description:
|
||||
'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.',
|
||||
|
@ -894,6 +895,7 @@ LinkPreviewInGroup.args = {
|
|||
width: 320,
|
||||
}),
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
title: 'Signal',
|
||||
description:
|
||||
'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.',
|
||||
|
@ -931,6 +933,7 @@ LinkPreviewWithQuote.args = {
|
|||
width: 320,
|
||||
}),
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
title: 'Signal',
|
||||
description:
|
||||
'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.',
|
||||
|
@ -956,6 +959,7 @@ LinkPreviewWithSmallImage.args = {
|
|||
width: 50,
|
||||
}),
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
title: 'Signal',
|
||||
description:
|
||||
'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.',
|
||||
|
@ -973,6 +977,7 @@ LinkPreviewWithoutImage.args = {
|
|||
{
|
||||
domain: 'signal.org',
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
title: 'Signal',
|
||||
description:
|
||||
'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.',
|
||||
|
@ -990,6 +995,7 @@ LinkPreviewWithNoDescription.args = {
|
|||
{
|
||||
domain: 'signal.org',
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
title: 'Signal',
|
||||
url: 'https://www.signal.org',
|
||||
date: Date.now(),
|
||||
|
@ -1005,6 +1011,7 @@ LinkPreviewWithLongDescription.args = {
|
|||
{
|
||||
domain: 'signal.org',
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
title: 'Signal',
|
||||
description: Array(10)
|
||||
.fill(
|
||||
|
@ -1032,6 +1039,7 @@ LinkPreviewWithSmallImageLongDescription.args = {
|
|||
width: 50,
|
||||
}),
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
title: 'Signal',
|
||||
description: Array(10)
|
||||
.fill(
|
||||
|
@ -1059,6 +1067,7 @@ LinkPreviewWithNoDate.args = {
|
|||
width: 320,
|
||||
}),
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
title: 'Signal',
|
||||
description:
|
||||
'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.',
|
||||
|
@ -1082,6 +1091,7 @@ LinkPreviewWithTooNewADate.args = {
|
|||
width: 320,
|
||||
}),
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
title: 'Signal',
|
||||
description:
|
||||
'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.',
|
||||
|
@ -1093,6 +1103,23 @@ LinkPreviewWithTooNewADate.args = {
|
|||
text: 'Be sure to look at https://www.signal.org',
|
||||
};
|
||||
|
||||
export const LinkPreviewWithCallLink = Template.bind({});
|
||||
LinkPreviewWithCallLink.args = {
|
||||
previews: [
|
||||
{
|
||||
url: 'https://signal.link/call/#key=hzcn-pcff-ctsc-bdbf-stcr-tzpc-bhqx-kghh',
|
||||
title: 'Camping Prep',
|
||||
description: 'Use this link to join a Signal call',
|
||||
image: undefined,
|
||||
date: undefined,
|
||||
isCallLink: true,
|
||||
isStickerPack: false,
|
||||
},
|
||||
],
|
||||
status: 'sent',
|
||||
text: 'Use this link to join a Signal call: https://signal.link/call/#key=hzcn-pcff-ctsc-bdbf-stcr-tzpc-bhqx-kghh',
|
||||
};
|
||||
|
||||
export function Image(): JSX.Element {
|
||||
const darkImageProps = createProps({
|
||||
attachments: [
|
||||
|
@ -1672,6 +1699,7 @@ NotApprovedWithLinkPreview.args = {
|
|||
width: 320,
|
||||
}),
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
title: 'Signal',
|
||||
description:
|
||||
'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.',
|
||||
|
|
|
@ -15,7 +15,7 @@ import { v4 as getGuid } from 'uuid';
|
|||
import LRU from 'lru-cache';
|
||||
import * as log from './logging/log';
|
||||
import {
|
||||
getCheckedCredentialsForToday,
|
||||
getCheckedGroupCredentialsForToday,
|
||||
maybeFetchNewCredentials,
|
||||
} from './services/groupCredentialFetcher';
|
||||
import { storageServiceUploadJob } from './services/storage';
|
||||
|
@ -1687,7 +1687,7 @@ async function makeRequestWithTemporalRetry<T>({
|
|||
secretParams: string;
|
||||
request: (sender: MessageSender, options: GroupCredentialsType) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const groupCredentials = getCheckedCredentialsForToday(
|
||||
const groupCredentials = getCheckedGroupCredentialsForToday(
|
||||
`makeRequestWithTemporalRetry/${logId}`
|
||||
);
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { debounce, omit } from 'lodash';
|
||||
|
||||
import { CallLinkRootKey } from '@signalapp/ringrtc';
|
||||
import type { LinkPreviewWithHydratedData } from '../types/message/LinkPreviews';
|
||||
import type {
|
||||
LinkPreviewImage,
|
||||
|
@ -28,6 +29,8 @@ import { imageToBlurHash } from '../util/imageToBlurHash';
|
|||
import { maybeParseUrl } from '../util/url';
|
||||
import { sniffImageMimeType } from '../util/sniffImageMimeType';
|
||||
import { drop } from '../util/drop';
|
||||
import { linkCallRoute } from '../util/signalRoutes';
|
||||
import { calling } from './calling';
|
||||
|
||||
const LINK_PREVIEW_TIMEOUT = 60 * SECOND;
|
||||
|
||||
|
@ -164,6 +167,7 @@ export async function addLinkPreview(
|
|||
window.reduxActions.linkPreviews.addLinkPreview(
|
||||
{
|
||||
url,
|
||||
isCallLink: false,
|
||||
},
|
||||
source,
|
||||
conversationId
|
||||
|
@ -220,6 +224,7 @@ export async function addLinkPreview(
|
|||
date: dropNull(result.date),
|
||||
domain: LinkPreview.getDomain(result.url),
|
||||
isStickerPack: LinkPreview.isStickerPack(result.url),
|
||||
isCallLink: LinkPreview.isCallLink(result.url),
|
||||
},
|
||||
source,
|
||||
conversationId
|
||||
|
@ -274,6 +279,7 @@ export function sanitizeLinkPreview(
|
|||
date: dropNull(item.date),
|
||||
domain: LinkPreview.getDomain(item.url),
|
||||
isStickerPack: LinkPreview.isStickerPack(item.url),
|
||||
isCallLink: LinkPreview.isCallLink(item.url),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -284,6 +290,7 @@ export function sanitizeLinkPreview(
|
|||
date: dropNull(item.date),
|
||||
domain: LinkPreview.getDomain(item.url),
|
||||
isStickerPack: LinkPreview.isStickerPack(item.url),
|
||||
isCallLink: LinkPreview.isCallLink(item.url),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -303,6 +310,9 @@ async function getPreview(
|
|||
if (LinkPreview.isGroupLink(url)) {
|
||||
return getGroupPreview(url, abortSignal);
|
||||
}
|
||||
if (LinkPreview.isCallLink(url)) {
|
||||
return getCallLinkPreview(url, abortSignal);
|
||||
}
|
||||
|
||||
// This is already checked elsewhere, but we want to be extra-careful.
|
||||
if (!LinkPreview.shouldPreviewHref(url)) {
|
||||
|
@ -563,3 +573,30 @@ async function getGroupPreview(
|
|||
url,
|
||||
};
|
||||
}
|
||||
|
||||
async function getCallLinkPreview(
|
||||
url: string,
|
||||
_abortSignal: Readonly<AbortSignal>
|
||||
): Promise<null | LinkPreviewResult> {
|
||||
const parsedUrl = linkCallRoute.fromUrl(url);
|
||||
if (parsedUrl == null) {
|
||||
throw new Error('Failed to parse call link URL');
|
||||
}
|
||||
|
||||
const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key);
|
||||
const callLinkState = await calling.readCallLink({ callLinkRootKey });
|
||||
if (!callLinkState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
title:
|
||||
callLinkState.name === ''
|
||||
? window.i18n('icu:calling__call-link-default-title')
|
||||
: callLinkState.name,
|
||||
description: window.i18n('icu:message--call-link-description'),
|
||||
image: undefined,
|
||||
date: null,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -6,7 +6,9 @@ import { ipcRenderer } from 'electron';
|
|||
import type {
|
||||
AudioDevice,
|
||||
CallId,
|
||||
CallLinkState,
|
||||
DeviceId,
|
||||
GroupCallObserver,
|
||||
PeekInfo,
|
||||
UserId,
|
||||
VideoFrameSource,
|
||||
|
@ -18,6 +20,7 @@ import {
|
|||
Call,
|
||||
CallingMessage,
|
||||
CallMessageUrgency,
|
||||
CallLinkRootKey,
|
||||
CallLogLevel,
|
||||
CallState,
|
||||
CanvasVideoRenderer,
|
||||
|
@ -40,8 +43,10 @@ import {
|
|||
import { uniqBy, noop } from 'lodash';
|
||||
|
||||
import Long from 'long';
|
||||
import type { CallLinkAuthCredentialPresentation } from '@signalapp/libsignal-client/zkgroup';
|
||||
import type {
|
||||
ActionsType as CallingReduxActionsType,
|
||||
CallLinkStateType,
|
||||
GroupCallParticipantInfoType,
|
||||
GroupCallPeekInfoType,
|
||||
} from '../state/ducks/calling';
|
||||
|
@ -123,6 +128,11 @@ import {
|
|||
import { isNormalNumber } from '../util/isNormalNumber';
|
||||
import { LocalCallEvent } from '../types/CallDisposition';
|
||||
import { isInSystemContacts } from '../util/isInSystemContacts';
|
||||
import {
|
||||
getRoomIdFromRootKey,
|
||||
getCallLinkAuthCredentialPresentation,
|
||||
} from '../util/callLinks';
|
||||
import { isAdhocCallingEnabled } from '../util/isAdhocCallingEnabled';
|
||||
|
||||
const {
|
||||
processGroupCallRingCancellation,
|
||||
|
@ -157,6 +167,7 @@ type CallingReduxInterface = Pick<
|
|||
| 'callStateChange'
|
||||
| 'cancelIncomingGroupCallRing'
|
||||
| 'groupCallAudioLevelsChange'
|
||||
| 'groupCallEnded'
|
||||
| 'groupCallRaisedHandsChange'
|
||||
| 'groupCallStateChange'
|
||||
| 'outgoingCall'
|
||||
|
@ -168,6 +179,7 @@ type CallingReduxInterface = Pick<
|
|||
| 'remoteVideoChange'
|
||||
| 'setPresenting'
|
||||
| 'startCallingLobby'
|
||||
| 'startCallLinkLobby'
|
||||
| 'peekNotConnectedGroupCall'
|
||||
> & {
|
||||
areAnyCallsActiveOrRinging(): boolean;
|
||||
|
@ -302,7 +314,7 @@ export class CallingClass {
|
|||
|
||||
private deviceReselectionTimer?: NodeJS.Timeout;
|
||||
|
||||
private callsByConversation: { [conversationId: string]: Call | GroupCall };
|
||||
private callsLookup: { [key: string]: Call | GroupCall };
|
||||
|
||||
private hadLocalVideoBeforePresenting?: boolean;
|
||||
|
||||
|
@ -314,7 +326,7 @@ export class CallingClass {
|
|||
});
|
||||
this.videoRenderer = new CanvasVideoRenderer();
|
||||
|
||||
this.callsByConversation = {};
|
||||
this.callsLookup = {};
|
||||
}
|
||||
|
||||
initialize(reduxInterface: CallingReduxInterface, sfuUrl: string): void {
|
||||
|
@ -412,6 +424,11 @@ export class CallingClass {
|
|||
}
|
||||
case CallMode.Group:
|
||||
break;
|
||||
case CallMode.Adhoc:
|
||||
log.error(
|
||||
'startCallingLobby() not implemented for adhoc calls. Did you mean: startCallLinkLobby()?'
|
||||
);
|
||||
return;
|
||||
default:
|
||||
throw missingCaseError(callMode);
|
||||
}
|
||||
|
@ -511,6 +528,80 @@ export class CallingClass {
|
|||
}
|
||||
}
|
||||
|
||||
async readCallLink({
|
||||
callLinkRootKey,
|
||||
}: Readonly<{
|
||||
callLinkRootKey: CallLinkRootKey;
|
||||
}>): Promise<CallLinkState | undefined> {
|
||||
if (!this._sfuUrl) {
|
||||
throw new Error('Missing SFU URL; not handling call link');
|
||||
}
|
||||
|
||||
const roomId = getRoomIdFromRootKey(callLinkRootKey);
|
||||
const authCredentialPresentation =
|
||||
await getCallLinkAuthCredentialPresentation(callLinkRootKey);
|
||||
|
||||
log.info(`readCallLink: roomId ${roomId}`);
|
||||
const result = await RingRTC.readCallLink(
|
||||
this._sfuUrl,
|
||||
authCredentialPresentation.serialize(),
|
||||
callLinkRootKey
|
||||
);
|
||||
if (!result.success) {
|
||||
log.warn(`readCallLink: failed ${roomId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('readCallLink: success', result);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
async startCallLinkLobby({
|
||||
callLinkRootKey,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo = true,
|
||||
}: Readonly<{
|
||||
callLinkRootKey: CallLinkRootKey;
|
||||
hasLocalAudio: boolean;
|
||||
hasLocalVideo?: boolean;
|
||||
}>): Promise<
|
||||
| undefined
|
||||
| {
|
||||
callMode: CallMode.Adhoc;
|
||||
connectionState: GroupCallConnectionState;
|
||||
hasLocalAudio: boolean;
|
||||
hasLocalVideo: boolean;
|
||||
joinState: GroupCallJoinState;
|
||||
peekInfo?: GroupCallPeekInfoType;
|
||||
remoteParticipants: Array<GroupCallParticipantInfoType>;
|
||||
}
|
||||
> {
|
||||
const roomId = getRoomIdFromRootKey(callLinkRootKey);
|
||||
log.info('startCallLinkLobby() for roomId', roomId);
|
||||
|
||||
await this.startDeviceReselectionTimer();
|
||||
|
||||
const authCredentialPresentation =
|
||||
await getCallLinkAuthCredentialPresentation(callLinkRootKey);
|
||||
|
||||
const groupCall = this.connectCallLinkCall({
|
||||
roomId,
|
||||
authCredentialPresentation,
|
||||
callLinkRootKey,
|
||||
adminPasskey: undefined,
|
||||
});
|
||||
|
||||
groupCall.setOutgoingAudioMuted(!hasLocalAudio);
|
||||
groupCall.setOutgoingVideoMuted(!hasLocalVideo);
|
||||
|
||||
this.enableLocalCamera();
|
||||
|
||||
return {
|
||||
callMode: CallMode.Adhoc,
|
||||
...this.formatGroupCallForRedux(groupCall),
|
||||
};
|
||||
}
|
||||
|
||||
async startOutgoingDirectCall(
|
||||
conversationId: string,
|
||||
hasLocalAudio: boolean,
|
||||
|
@ -575,12 +666,12 @@ export class CallingClass {
|
|||
}
|
||||
|
||||
private getDirectCall(conversationId: string): undefined | Call {
|
||||
const call = getOwn(this.callsByConversation, conversationId);
|
||||
const call = getOwn(this.callsLookup, conversationId);
|
||||
return call instanceof Call ? call : undefined;
|
||||
}
|
||||
|
||||
private getGroupCall(conversationId: string): undefined | GroupCall {
|
||||
const call = getOwn(this.callsByConversation, conversationId);
|
||||
const call = getOwn(this.callsLookup, conversationId);
|
||||
return call instanceof GroupCall ? call : undefined;
|
||||
}
|
||||
|
||||
|
@ -659,6 +750,45 @@ export class CallingClass {
|
|||
);
|
||||
}
|
||||
|
||||
public async peekCallLinkCall(
|
||||
roomId: string,
|
||||
rootKey: string | undefined
|
||||
): Promise<PeekInfo> {
|
||||
log.info(`peekCallLinkCall: For roomId ${roomId}`);
|
||||
const statefulPeekInfo = this.getGroupCall(roomId)?.getPeekInfo();
|
||||
if (statefulPeekInfo) {
|
||||
return statefulPeekInfo;
|
||||
}
|
||||
|
||||
if (!rootKey) {
|
||||
throw new Error(
|
||||
'Missing call link root key, cannot do stateless peeking'
|
||||
);
|
||||
}
|
||||
|
||||
if (!this._sfuUrl) {
|
||||
throw new Error('Missing SFU URL; not peeking call link call');
|
||||
}
|
||||
|
||||
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
|
||||
|
||||
const authCredentialPresentation =
|
||||
await getCallLinkAuthCredentialPresentation(callLinkRootKey);
|
||||
|
||||
const result = await RingRTC.peekCallLinkCall(
|
||||
this._sfuUrl,
|
||||
authCredentialPresentation.serialize(),
|
||||
callLinkRootKey
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Failed to peek call link, error ${result.errorStatusCode}, roomId ${roomId}.`
|
||||
);
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a conversation's group call and connect it to Redux.
|
||||
*
|
||||
|
@ -695,7 +825,6 @@ export class CallingClass {
|
|||
|
||||
const groupIdBuffer = Buffer.from(Bytes.fromBase64(groupId));
|
||||
|
||||
let updateMessageState = GroupCallUpdateMessageState.SentNothing;
|
||||
let isRequestingMembershipProof = false;
|
||||
|
||||
const outerGroupCall = RingRTC.getGroupCall(
|
||||
|
@ -704,174 +833,7 @@ export class CallingClass {
|
|||
Buffer.alloc(0),
|
||||
AUDIO_LEVEL_INTERVAL_MS,
|
||||
{
|
||||
onLocalDeviceStateChanged: groupCall => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const peekInfo = groupCall.getPeekInfo() ?? null;
|
||||
|
||||
log.info(
|
||||
'GroupCall#onLocalDeviceStateChanged',
|
||||
formatLocalDeviceState(localDeviceState),
|
||||
peekInfo != null ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
|
||||
const groupCallMeta = getGroupCallMeta(peekInfo);
|
||||
|
||||
if (groupCallMeta != null) {
|
||||
try {
|
||||
const localCallEvent = getLocalCallEventFromJoinState(
|
||||
convertJoinState(localDeviceState.joinState),
|
||||
groupCallMeta
|
||||
);
|
||||
|
||||
if (localCallEvent != null && peekInfo != null) {
|
||||
const conversation =
|
||||
window.ConversationController.get(conversationId);
|
||||
strictAssert(
|
||||
conversation != null,
|
||||
'GroupCall#onLocalDeviceStateChanged: Missing conversation'
|
||||
);
|
||||
const peerId = getPeerIdFromConversation(
|
||||
conversation.attributes
|
||||
);
|
||||
|
||||
const callDetails = getCallDetailsFromGroupCallMeta(
|
||||
peerId,
|
||||
groupCallMeta
|
||||
);
|
||||
const callEvent = getCallEventDetails(
|
||||
callDetails,
|
||||
localCallEvent,
|
||||
'RingRTC.onLocalDeviceStateChanged'
|
||||
);
|
||||
drop(updateCallHistoryFromLocalEvent(callEvent, null));
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'GroupCall#onLocalDeviceStateChanged: Error updating state',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
localDeviceState.connectionState === ConnectionState.NotConnected
|
||||
) {
|
||||
// NOTE: This assumes that only one call is active at a time. For example, if
|
||||
// there are two calls using the camera, this will disable both of them.
|
||||
// That's fine for now, but this will break if that assumption changes.
|
||||
this.disableLocalVideo();
|
||||
|
||||
delete this.callsByConversation[conversationId];
|
||||
|
||||
if (
|
||||
updateMessageState === GroupCallUpdateMessageState.SentJoin &&
|
||||
peekInfo?.eraId != null
|
||||
) {
|
||||
updateMessageState = GroupCallUpdateMessageState.SentLeft;
|
||||
void this.sendGroupCallUpdateMessage(
|
||||
conversationId,
|
||||
peekInfo?.eraId
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.callsByConversation[conversationId] = groupCall;
|
||||
|
||||
// NOTE: This assumes only one active call at a time. See comment above.
|
||||
if (localDeviceState.videoMuted) {
|
||||
this.disableLocalVideo();
|
||||
} else {
|
||||
this.videoCapturer.enableCaptureAndSend(groupCall);
|
||||
}
|
||||
|
||||
if (
|
||||
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
||||
localDeviceState.joinState === JoinState.Joined &&
|
||||
peekInfo?.eraId != null
|
||||
) {
|
||||
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
||||
void this.sendGroupCallUpdateMessage(
|
||||
conversationId,
|
||||
peekInfo?.eraId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.syncGroupCallToRedux(conversationId, groupCall);
|
||||
},
|
||||
onRemoteDeviceStatesChanged: groupCall => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const peekInfo = groupCall.getPeekInfo();
|
||||
|
||||
log.info(
|
||||
'GroupCall#onRemoteDeviceStatesChanged',
|
||||
formatLocalDeviceState(localDeviceState),
|
||||
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
|
||||
this.syncGroupCallToRedux(conversationId, groupCall);
|
||||
},
|
||||
onAudioLevels: groupCall => {
|
||||
const remoteDeviceStates = groupCall.getRemoteDeviceStates();
|
||||
if (!remoteDeviceStates) {
|
||||
return;
|
||||
}
|
||||
const localAudioLevel = groupCall.getLocalDeviceState().audioLevel;
|
||||
|
||||
this.reduxInterface?.groupCallAudioLevelsChange({
|
||||
conversationId,
|
||||
localAudioLevel,
|
||||
remoteDeviceStates,
|
||||
});
|
||||
},
|
||||
onLowBandwidthForVideo: (_groupCall, _recovered) => {
|
||||
// TODO: Implement handling of "low outgoing bandwidth for video" notification.
|
||||
},
|
||||
|
||||
/**
|
||||
* @param reactions A list of reactions received by the client ordered
|
||||
* from oldest to newest.
|
||||
*/
|
||||
onReactions: (_groupCall, reactions) => {
|
||||
this.reduxInterface?.receiveGroupCallReactions({
|
||||
conversationId,
|
||||
reactions,
|
||||
});
|
||||
},
|
||||
onRaisedHands: (_groupCall, raisedHands) => {
|
||||
this.reduxInterface?.groupCallRaisedHandsChange({
|
||||
conversationId,
|
||||
raisedHands,
|
||||
});
|
||||
},
|
||||
onPeekChanged: groupCall => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const peekInfo = groupCall.getPeekInfo() ?? null;
|
||||
|
||||
log.info(
|
||||
'GroupCall#onPeekChanged',
|
||||
formatLocalDeviceState(localDeviceState),
|
||||
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
|
||||
const { eraId } = peekInfo ?? {};
|
||||
|
||||
if (
|
||||
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
||||
localDeviceState.connectionState !== ConnectionState.NotConnected &&
|
||||
localDeviceState.joinState === JoinState.Joined &&
|
||||
eraId
|
||||
) {
|
||||
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
||||
void this.sendGroupCallUpdateMessage(conversationId, eraId);
|
||||
}
|
||||
|
||||
void this.updateCallHistoryForGroupCall(
|
||||
conversationId,
|
||||
convertJoinState(localDeviceState.joinState),
|
||||
peekInfo
|
||||
);
|
||||
this.syncGroupCallToRedux(conversationId, groupCall);
|
||||
},
|
||||
...this.getGroupCallObserver(conversationId, CallMode.Group),
|
||||
async requestMembershipProof(groupCall) {
|
||||
if (isRequestingMembershipProof) {
|
||||
return;
|
||||
|
@ -893,20 +855,6 @@ export class CallingClass {
|
|||
isRequestingMembershipProof = false;
|
||||
}
|
||||
},
|
||||
requestGroupMembers: groupCall => {
|
||||
groupCall.setGroupMembers(this.getGroupCallMembers(conversationId));
|
||||
},
|
||||
onEnded: (groupCall, endedReason) => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const peekInfo = groupCall.getPeekInfo();
|
||||
|
||||
log.info(
|
||||
'GroupCall#onEnded',
|
||||
endedReason,
|
||||
formatLocalDeviceState(localDeviceState),
|
||||
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -918,7 +866,62 @@ export class CallingClass {
|
|||
|
||||
outerGroupCall.connect();
|
||||
|
||||
this.syncGroupCallToRedux(conversationId, outerGroupCall);
|
||||
this.syncGroupCallToRedux(conversationId, outerGroupCall, CallMode.Group);
|
||||
|
||||
return outerGroupCall;
|
||||
}
|
||||
|
||||
connectCallLinkCall({
|
||||
roomId,
|
||||
authCredentialPresentation,
|
||||
callLinkRootKey,
|
||||
adminPasskey,
|
||||
}: {
|
||||
roomId: string;
|
||||
authCredentialPresentation: CallLinkAuthCredentialPresentation;
|
||||
callLinkRootKey: CallLinkRootKey;
|
||||
adminPasskey: Buffer | undefined;
|
||||
}): GroupCall {
|
||||
if (!isAdhocCallingEnabled()) {
|
||||
throw new Error(
|
||||
'Adhoc calling is not enabled; not connecting call link call'
|
||||
);
|
||||
}
|
||||
|
||||
const existing = this.getGroupCall(roomId);
|
||||
if (existing) {
|
||||
const isExistingCallNotConnected =
|
||||
existing.getLocalDeviceState().connectionState ===
|
||||
ConnectionState.NotConnected;
|
||||
if (isExistingCallNotConnected) {
|
||||
existing.connect();
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (!this._sfuUrl) {
|
||||
throw new Error('Missing SFU URL; not connecting group call link call');
|
||||
}
|
||||
|
||||
const outerGroupCall = RingRTC.getCallLinkCall(
|
||||
this._sfuUrl,
|
||||
authCredentialPresentation.serialize(),
|
||||
callLinkRootKey,
|
||||
adminPasskey,
|
||||
Buffer.alloc(0),
|
||||
AUDIO_LEVEL_INTERVAL_MS,
|
||||
this.getGroupCallObserver(roomId, CallMode.Adhoc)
|
||||
);
|
||||
|
||||
if (!outerGroupCall) {
|
||||
// This should be very rare, likely due to RingRTC not being able to get a lock
|
||||
// or memory or something like that.
|
||||
throw new Error('Failed to get a group call instance; cannot start call');
|
||||
}
|
||||
|
||||
outerGroupCall.connect();
|
||||
|
||||
this.syncGroupCallToRedux(roomId, outerGroupCall, CallMode.Adhoc);
|
||||
|
||||
return outerGroupCall;
|
||||
}
|
||||
|
@ -971,6 +974,250 @@ export class CallingClass {
|
|||
groupCall.join();
|
||||
}
|
||||
|
||||
private getGroupCallObserver(
|
||||
conversationId: string,
|
||||
callMode: CallMode.Group | CallMode.Adhoc
|
||||
): GroupCallObserver {
|
||||
let updateMessageState = GroupCallUpdateMessageState.SentNothing;
|
||||
|
||||
return {
|
||||
onLocalDeviceStateChanged: groupCall => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const peekInfo = groupCall.getPeekInfo() ?? null;
|
||||
|
||||
log.info(
|
||||
'GroupCall#onLocalDeviceStateChanged',
|
||||
formatLocalDeviceState(localDeviceState),
|
||||
peekInfo != null ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
|
||||
const groupCallMeta = getGroupCallMeta(peekInfo);
|
||||
|
||||
// TODO: Handle call history for adhoc calls
|
||||
if (groupCallMeta != null && callMode === CallMode.Group) {
|
||||
try {
|
||||
const localCallEvent = getLocalCallEventFromJoinState(
|
||||
convertJoinState(localDeviceState.joinState),
|
||||
groupCallMeta
|
||||
);
|
||||
|
||||
if (localCallEvent != null && peekInfo != null) {
|
||||
const conversation =
|
||||
window.ConversationController.get(conversationId);
|
||||
strictAssert(
|
||||
conversation != null,
|
||||
'GroupCall#onLocalDeviceStateChanged: Missing conversation'
|
||||
);
|
||||
const peerId = getPeerIdFromConversation(conversation.attributes);
|
||||
|
||||
const callDetails = getCallDetailsFromGroupCallMeta(
|
||||
peerId,
|
||||
groupCallMeta
|
||||
);
|
||||
const callEvent = getCallEventDetails(
|
||||
callDetails,
|
||||
localCallEvent,
|
||||
'RingRTC.onLocalDeviceStateChanged'
|
||||
);
|
||||
drop(updateCallHistoryFromLocalEvent(callEvent, null));
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'GroupCall#onLocalDeviceStateChanged: Error updating state',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (localDeviceState.connectionState === ConnectionState.NotConnected) {
|
||||
// NOTE: This assumes that only one call is active at a time. For example, if
|
||||
// there are two calls using the camera, this will disable both of them.
|
||||
// That's fine for now, but this will break if that assumption changes.
|
||||
this.disableLocalVideo();
|
||||
|
||||
delete this.callsLookup[conversationId];
|
||||
|
||||
if (
|
||||
updateMessageState === GroupCallUpdateMessageState.SentJoin &&
|
||||
peekInfo?.eraId != null
|
||||
) {
|
||||
updateMessageState = GroupCallUpdateMessageState.SentLeft;
|
||||
if (callMode === CallMode.Group) {
|
||||
void this.sendGroupCallUpdateMessage(
|
||||
conversationId,
|
||||
peekInfo?.eraId
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.callsLookup[conversationId] = groupCall;
|
||||
|
||||
// NOTE: This assumes only one active call at a time. See comment above.
|
||||
if (localDeviceState.videoMuted) {
|
||||
this.disableLocalVideo();
|
||||
} else {
|
||||
this.videoCapturer.enableCaptureAndSend(groupCall);
|
||||
}
|
||||
|
||||
if (
|
||||
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
||||
localDeviceState.joinState === JoinState.Joined &&
|
||||
peekInfo?.eraId != null
|
||||
) {
|
||||
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
||||
if (callMode === CallMode.Group) {
|
||||
void this.sendGroupCallUpdateMessage(
|
||||
conversationId,
|
||||
peekInfo?.eraId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.syncGroupCallToRedux(conversationId, groupCall, callMode);
|
||||
},
|
||||
onRemoteDeviceStatesChanged: groupCall => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const peekInfo = groupCall.getPeekInfo();
|
||||
|
||||
log.info(
|
||||
'GroupCall#onRemoteDeviceStatesChanged',
|
||||
formatLocalDeviceState(localDeviceState),
|
||||
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
|
||||
this.syncGroupCallToRedux(conversationId, groupCall, callMode);
|
||||
},
|
||||
onAudioLevels: groupCall => {
|
||||
const remoteDeviceStates = groupCall.getRemoteDeviceStates();
|
||||
if (!remoteDeviceStates) {
|
||||
return;
|
||||
}
|
||||
const localAudioLevel = groupCall.getLocalDeviceState().audioLevel;
|
||||
|
||||
this.reduxInterface?.groupCallAudioLevelsChange({
|
||||
callMode,
|
||||
conversationId,
|
||||
localAudioLevel,
|
||||
remoteDeviceStates,
|
||||
});
|
||||
},
|
||||
onLowBandwidthForVideo: (_groupCall, _recovered) => {
|
||||
// TODO: Implement handling of "low outgoing bandwidth for video" notification.
|
||||
},
|
||||
|
||||
/**
|
||||
* @param reactions A list of reactions received by the client ordered
|
||||
* from oldest to newest.
|
||||
*/
|
||||
onReactions: (_groupCall, reactions) => {
|
||||
this.reduxInterface?.receiveGroupCallReactions({
|
||||
callMode,
|
||||
conversationId,
|
||||
reactions,
|
||||
});
|
||||
},
|
||||
onRaisedHands: (_groupCall, raisedHands) => {
|
||||
this.reduxInterface?.groupCallRaisedHandsChange({
|
||||
callMode,
|
||||
conversationId,
|
||||
raisedHands,
|
||||
});
|
||||
},
|
||||
onPeekChanged: groupCall => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const peekInfo = groupCall.getPeekInfo() ?? null;
|
||||
|
||||
log.info(
|
||||
'GroupCall#onPeekChanged',
|
||||
formatLocalDeviceState(localDeviceState),
|
||||
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
|
||||
if (callMode === CallMode.Group) {
|
||||
const { eraId } = peekInfo ?? {};
|
||||
if (
|
||||
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
||||
localDeviceState.connectionState !== ConnectionState.NotConnected &&
|
||||
localDeviceState.joinState === JoinState.Joined &&
|
||||
eraId
|
||||
) {
|
||||
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
||||
void this.sendGroupCallUpdateMessage(conversationId, eraId);
|
||||
}
|
||||
|
||||
void this.updateCallHistoryForGroupCall(
|
||||
conversationId,
|
||||
convertJoinState(localDeviceState.joinState),
|
||||
peekInfo
|
||||
);
|
||||
}
|
||||
// TODO: Call history for adhoc calls
|
||||
|
||||
this.syncGroupCallToRedux(conversationId, groupCall, callMode);
|
||||
},
|
||||
async requestMembershipProof(_groupCall) {
|
||||
log.error('GroupCall#requestMembershipProof not implemented.');
|
||||
},
|
||||
requestGroupMembers: groupCall => {
|
||||
groupCall.setGroupMembers(this.getGroupCallMembers(conversationId));
|
||||
},
|
||||
onEnded: (groupCall, endedReason) => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const peekInfo = groupCall.getPeekInfo();
|
||||
|
||||
log.info(
|
||||
'GroupCall#onEnded',
|
||||
endedReason,
|
||||
formatLocalDeviceState(localDeviceState),
|
||||
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
|
||||
this.reduxInterface?.groupCallEnded({
|
||||
conversationId,
|
||||
endedReason,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async joinCallLinkCall({
|
||||
roomId,
|
||||
rootKey,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
}: {
|
||||
roomId: string;
|
||||
rootKey: string;
|
||||
hasLocalAudio: boolean;
|
||||
hasLocalVideo: boolean;
|
||||
}): Promise<void> {
|
||||
const haveMediaPermissions = await this.requestPermissions(hasLocalVideo);
|
||||
if (!haveMediaPermissions) {
|
||||
log.info('Permissions were denied, but allow joining call link call');
|
||||
}
|
||||
|
||||
await this.startDeviceReselectionTimer();
|
||||
|
||||
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
|
||||
const authCredentialPresentation =
|
||||
await getCallLinkAuthCredentialPresentation(callLinkRootKey);
|
||||
|
||||
// RingRTC reuses the same type GroupCall between Adhoc and Group calls.
|
||||
const groupCall = this.connectCallLinkCall({
|
||||
roomId,
|
||||
authCredentialPresentation,
|
||||
callLinkRootKey,
|
||||
adminPasskey: undefined,
|
||||
});
|
||||
|
||||
groupCall.setOutgoingAudioMuted(!hasLocalAudio);
|
||||
groupCall.setOutgoingVideoMuted(!hasLocalVideo);
|
||||
this.videoCapturer.enableCaptureAndSend(groupCall);
|
||||
|
||||
groupCall.join();
|
||||
}
|
||||
|
||||
private getCallIdForConversation(conversationId: string): undefined | CallId {
|
||||
return this.getDirectCall(conversationId)?.callId;
|
||||
}
|
||||
|
@ -1130,6 +1377,17 @@ export class CallingClass {
|
|||
};
|
||||
}
|
||||
|
||||
public formatCallLinkStateForRedux(
|
||||
callLinkState: CallLinkState
|
||||
): CallLinkStateType {
|
||||
const { name, restrictions, expiration } = callLinkState;
|
||||
return {
|
||||
name,
|
||||
restrictions,
|
||||
expiration: expiration.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
public getGroupCallVideoFrameSource(
|
||||
conversationId: string,
|
||||
demuxId: number
|
||||
|
@ -1167,10 +1425,12 @@ export class CallingClass {
|
|||
|
||||
private syncGroupCallToRedux(
|
||||
conversationId: string,
|
||||
groupCall: GroupCall
|
||||
groupCall: GroupCall,
|
||||
callMode: CallMode.Group | CallMode.Adhoc
|
||||
): void {
|
||||
this.reduxInterface?.groupCallStateChange({
|
||||
conversationId,
|
||||
callMode,
|
||||
...this.formatGroupCallForRedux(groupCall),
|
||||
});
|
||||
}
|
||||
|
@ -1287,7 +1547,7 @@ export class CallingClass {
|
|||
hangup(conversationId: string, reason: string): void {
|
||||
log.info(`CallingClass.hangup(${conversationId}): ${reason}`);
|
||||
|
||||
const specificCall = getOwn(this.callsByConversation, conversationId);
|
||||
const specificCall = getOwn(this.callsLookup, conversationId);
|
||||
if (!specificCall) {
|
||||
log.error(
|
||||
`hangup: Trying to hang up a non-existent call for conversation ${conversationId}`
|
||||
|
@ -1296,7 +1556,7 @@ export class CallingClass {
|
|||
|
||||
ipcRenderer.send('close-screen-share-controller');
|
||||
|
||||
const entries = Object.entries(this.callsByConversation);
|
||||
const entries = Object.entries(this.callsLookup);
|
||||
log.info(`hangup: ${entries.length} call(s) to hang up...`);
|
||||
|
||||
entries.forEach(([callConversationId, call]) => {
|
||||
|
@ -1317,13 +1577,14 @@ export class CallingClass {
|
|||
}
|
||||
|
||||
hangupAllCalls(reason: string): void {
|
||||
for (const conversationId of Object.keys(this.callsByConversation)) {
|
||||
const conversationIds = Object.keys(this.callsLookup);
|
||||
for (const conversationId of conversationIds) {
|
||||
this.hangup(conversationId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
setOutgoingAudio(conversationId: string, enabled: boolean): void {
|
||||
const call = getOwn(this.callsByConversation, conversationId);
|
||||
const call = getOwn(this.callsLookup, conversationId);
|
||||
if (!call) {
|
||||
log.warn('Trying to set outgoing audio for a non-existent call');
|
||||
return;
|
||||
|
@ -1339,7 +1600,7 @@ export class CallingClass {
|
|||
}
|
||||
|
||||
setOutgoingVideo(conversationId: string, enabled: boolean): void {
|
||||
const call = getOwn(this.callsByConversation, conversationId);
|
||||
const call = getOwn(this.callsLookup, conversationId);
|
||||
if (!call) {
|
||||
log.warn('Trying to set outgoing video for a non-existent call');
|
||||
return;
|
||||
|
@ -1413,7 +1674,7 @@ export class CallingClass {
|
|||
hasLocalVideo: boolean,
|
||||
source?: PresentedSource
|
||||
): Promise<void> {
|
||||
const call = getOwn(this.callsByConversation, conversationId);
|
||||
const call = getOwn(this.callsLookup, conversationId);
|
||||
if (!call) {
|
||||
log.warn('Trying to set presenting for a non-existent call');
|
||||
return;
|
||||
|
@ -2156,7 +2417,7 @@ export class CallingClass {
|
|||
}
|
||||
|
||||
private attachToCall(conversation: ConversationModel, call: Call): void {
|
||||
this.callsByConversation[conversation.id] = call;
|
||||
this.callsLookup[conversation.id] = call;
|
||||
|
||||
const { reduxInterface } = this;
|
||||
if (!reduxInterface) {
|
||||
|
@ -2174,7 +2435,7 @@ export class CallingClass {
|
|||
if (call.state === CallState.Ended) {
|
||||
this.stopDeviceReselectionTimer();
|
||||
this.lastMediaDeviceSettings = undefined;
|
||||
delete this.callsByConversation[conversation.id];
|
||||
delete this.callsLookup[conversation.id];
|
||||
}
|
||||
|
||||
const localCallEvent = getLocalCallEventFromDirectCall(call);
|
||||
|
|
|
@ -2,7 +2,11 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { first, last, sortBy } from 'lodash';
|
||||
import { AuthCredentialWithPniResponse } from '@signalapp/libsignal-client/zkgroup';
|
||||
import {
|
||||
AuthCredentialWithPniResponse,
|
||||
CallLinkAuthCredentialResponse,
|
||||
GenericServerPublicParams,
|
||||
} from '@signalapp/libsignal-client/zkgroup';
|
||||
|
||||
import { getClientZkAuthOperations } from '../util/zkgroup';
|
||||
|
||||
|
@ -23,14 +27,14 @@ type RequestDatesType = {
|
|||
startDayInMs: number;
|
||||
endDayInMs: number;
|
||||
};
|
||||
type NextCredentialsType = {
|
||||
export type NextCredentialsType = {
|
||||
today: GroupCredentialType;
|
||||
tomorrow: GroupCredentialType;
|
||||
};
|
||||
|
||||
let started = false;
|
||||
|
||||
function getCheckedCredentials(reason: string): CredentialsDataType {
|
||||
function getCheckedGroupCredentials(reason: string): CredentialsDataType {
|
||||
const result = window.storage.get('groupCredentials');
|
||||
strictAssert(
|
||||
result !== undefined,
|
||||
|
@ -39,6 +43,17 @@ function getCheckedCredentials(reason: string): CredentialsDataType {
|
|||
return result;
|
||||
}
|
||||
|
||||
function getCheckedCallLinkAuthCredentials(
|
||||
reason: string
|
||||
): CredentialsDataType {
|
||||
const result = window.storage.get('callLinkAuthCredentials');
|
||||
strictAssert(
|
||||
result !== undefined,
|
||||
`getCheckedCallLinkAuthCredentials: no credentials found, ${reason}`
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function initializeGroupCredentialFetcher(): Promise<void> {
|
||||
if (started) {
|
||||
return;
|
||||
|
@ -97,46 +112,57 @@ export async function runWithRetry(
|
|||
}
|
||||
}
|
||||
|
||||
// In cases where we are at a day boundary, we might need to use tomorrow in a retry
|
||||
export function getCheckedCredentialsForToday(
|
||||
reason: string
|
||||
function getCredentialsForToday(
|
||||
credentials: CredentialsDataType
|
||||
): NextCredentialsType {
|
||||
const data = getCheckedCredentials(reason);
|
||||
|
||||
const today = toDayMillis(Date.now());
|
||||
const todayIndex = data.findIndex(
|
||||
const todayIndex = credentials.findIndex(
|
||||
(item: GroupCredentialType) => item.redemptionTime === today
|
||||
);
|
||||
if (todayIndex < 0) {
|
||||
throw new Error(
|
||||
'getCredentialsForToday: Cannot find credentials for today. ' +
|
||||
`First: ${first(data)?.redemptionTime}, ` +
|
||||
`last: ${last(data)?.redemptionTime}`
|
||||
`First: ${first(credentials)?.redemptionTime}, ` +
|
||||
`last: ${last(credentials)?.redemptionTime}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
today: data[todayIndex],
|
||||
tomorrow: data[todayIndex + 1],
|
||||
today: credentials[todayIndex],
|
||||
tomorrow: credentials[todayIndex + 1],
|
||||
};
|
||||
}
|
||||
|
||||
// In cases where we are at a day boundary, we might need to use tomorrow in a retry
|
||||
export function getCheckedGroupCredentialsForToday(
|
||||
reason: string
|
||||
): NextCredentialsType {
|
||||
return getCredentialsForToday(getCheckedGroupCredentials(reason));
|
||||
}
|
||||
|
||||
export function getCheckedCallLinkAuthCredentialsForToday(
|
||||
reason: string
|
||||
): NextCredentialsType {
|
||||
return getCredentialsForToday(getCheckedCallLinkAuthCredentials(reason));
|
||||
}
|
||||
|
||||
export async function maybeFetchNewCredentials(): Promise<void> {
|
||||
const logId = 'maybeFetchNewCredentials';
|
||||
|
||||
const aci = window.textsecure.storage.user.getAci();
|
||||
if (!aci) {
|
||||
const maybeAci = window.textsecure.storage.user.getAci();
|
||||
if (!maybeAci) {
|
||||
log.info(`${logId}: no ACI, returning early`);
|
||||
return;
|
||||
}
|
||||
const aci = maybeAci;
|
||||
|
||||
const previous: CredentialsDataType =
|
||||
const prevGroupCredentials: CredentialsDataType =
|
||||
window.storage.get('groupCredentials') ?? [];
|
||||
const requestDates = getDatesForRequest(previous);
|
||||
if (!requestDates) {
|
||||
log.info(`${logId}: no new credentials needed`);
|
||||
return;
|
||||
}
|
||||
const prevCallLinkAuthCredentials: CredentialsDataType =
|
||||
window.storage.get('callLinkAuthCredentials') ?? [];
|
||||
|
||||
const requestDates = getDatesForRequest(prevGroupCredentials);
|
||||
const requestDatesCallLinks = getDatesForRequest(prevCallLinkAuthCredentials);
|
||||
|
||||
const { server } = window.textsecure;
|
||||
if (!server) {
|
||||
|
@ -144,7 +170,22 @@ export async function maybeFetchNewCredentials(): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const { startDayInMs, endDayInMs } = requestDates;
|
||||
let startDayInMs: number;
|
||||
let endDayInMs: number;
|
||||
if (requestDates) {
|
||||
startDayInMs = requestDates.startDayInMs;
|
||||
endDayInMs = requestDates.endDayInMs;
|
||||
if (requestDatesCallLinks) {
|
||||
startDayInMs = Math.min(startDayInMs, requestDatesCallLinks.startDayInMs);
|
||||
endDayInMs = Math.max(endDayInMs, requestDatesCallLinks.endDayInMs);
|
||||
}
|
||||
} else if (requestDatesCallLinks) {
|
||||
startDayInMs = requestDatesCallLinks.startDayInMs;
|
||||
endDayInMs = requestDatesCallLinks.endDayInMs;
|
||||
} else {
|
||||
log.info(`${logId}: no new credentials needed`);
|
||||
return;
|
||||
}
|
||||
log.info(
|
||||
`${logId}: fetching credentials for ${startDayInMs} through ${endDayInMs}`
|
||||
);
|
||||
|
@ -156,8 +197,11 @@ export async function maybeFetchNewCredentials(): Promise<void> {
|
|||
|
||||
// Received credentials depend on us knowing up-to-date PNI. Use the latest
|
||||
// value from the server and log error on mismatch.
|
||||
const { pni: untaggedPni, credentials: rawCredentials } =
|
||||
await server.getGroupCredentials({ startDayInMs, endDayInMs });
|
||||
const {
|
||||
pni: untaggedPni,
|
||||
credentials: rawCredentials,
|
||||
callLinkAuthCredentials,
|
||||
} = await server.getGroupCredentials({ startDayInMs, endDayInMs });
|
||||
strictAssert(
|
||||
untaggedPni,
|
||||
'Server must give pni along with group credentials'
|
||||
|
@ -169,42 +213,98 @@ export async function maybeFetchNewCredentials(): Promise<void> {
|
|||
log.error(`${logId}: local PNI ${localPni}, does not match remote ${pni}`);
|
||||
}
|
||||
|
||||
const newCredentials = sortCredentials(rawCredentials).map(
|
||||
(item: GroupCredentialType) => {
|
||||
const authCredential =
|
||||
clientZKAuthOperations.receiveAuthCredentialWithPniAsServiceId(
|
||||
toAciObject(aci),
|
||||
toPniObject(pni),
|
||||
item.redemptionTime,
|
||||
new AuthCredentialWithPniResponse(
|
||||
Buffer.from(item.credential, 'base64')
|
||||
)
|
||||
);
|
||||
const credential = authCredential.serialize().toString('base64');
|
||||
function formatCredential(item: GroupCredentialType): GroupCredentialType {
|
||||
const authCredential =
|
||||
clientZKAuthOperations.receiveAuthCredentialWithPniAsServiceId(
|
||||
toAciObject(aci),
|
||||
toPniObject(pni),
|
||||
item.redemptionTime,
|
||||
new AuthCredentialWithPniResponse(
|
||||
Buffer.from(item.credential, 'base64')
|
||||
)
|
||||
);
|
||||
const credential = authCredential.serialize().toString('base64');
|
||||
|
||||
return {
|
||||
redemptionTime: item.redemptionTime * durations.SECOND,
|
||||
credential,
|
||||
};
|
||||
}
|
||||
return {
|
||||
redemptionTime: item.redemptionTime * durations.SECOND,
|
||||
credential,
|
||||
};
|
||||
}
|
||||
|
||||
const newGroupCredentials =
|
||||
sortCredentials(rawCredentials).map(formatCredential);
|
||||
const genericServerPublicParamsBase64 = window.getGenericServerPublicParams();
|
||||
const genericServerPublicParams = new GenericServerPublicParams(
|
||||
Buffer.from(genericServerPublicParamsBase64, 'base64')
|
||||
);
|
||||
|
||||
function formatCallingCredential(
|
||||
item: GroupCredentialType
|
||||
): GroupCredentialType {
|
||||
const response = new CallLinkAuthCredentialResponse(
|
||||
Buffer.from(item.credential, 'base64')
|
||||
);
|
||||
const authCredential = response.receive(
|
||||
toAciObject(aci),
|
||||
item.redemptionTime,
|
||||
genericServerPublicParams
|
||||
);
|
||||
const credential = authCredential.serialize().toString('base64');
|
||||
|
||||
return {
|
||||
redemptionTime: item.redemptionTime * durations.SECOND,
|
||||
credential,
|
||||
};
|
||||
}
|
||||
|
||||
const newCallLinkAuthCredentialsRaw = sortCredentials(
|
||||
callLinkAuthCredentials
|
||||
);
|
||||
const newCallLinkAuthCredentials = newCallLinkAuthCredentialsRaw.map(
|
||||
formatCallingCredential
|
||||
);
|
||||
|
||||
const today = toDayMillis(Date.now());
|
||||
const previousCleaned = previous
|
||||
? previous.filter(
|
||||
(item: GroupCredentialType) => item.redemptionTime >= today
|
||||
)
|
||||
: [];
|
||||
const finalCredentials = [...previousCleaned, ...newCredentials];
|
||||
const prevGroupCredentialsCleaned =
|
||||
prevGroupCredentials?.filter(
|
||||
(item: GroupCredentialType) => item.redemptionTime >= today
|
||||
) ?? [];
|
||||
const prevCallLinkAuthCredentialsCleaned =
|
||||
prevCallLinkAuthCredentials?.filter(
|
||||
(item: GroupCredentialType) => item.redemptionTime >= today
|
||||
) ?? [];
|
||||
const finalGroupCredentials = [
|
||||
...prevGroupCredentialsCleaned,
|
||||
...newGroupCredentials,
|
||||
];
|
||||
const finalCallLinkAuthCredentials = [
|
||||
...prevCallLinkAuthCredentialsCleaned,
|
||||
...newCallLinkAuthCredentials,
|
||||
];
|
||||
|
||||
log.info(
|
||||
`${logId}: saving ${newCredentials.length} new credentials, ` +
|
||||
`cleaning up ${previous.length - previousCleaned.length} old ` +
|
||||
`credentials, haveToday=${haveToday(finalCredentials)}`
|
||||
`${logId}: saving ${
|
||||
finalGroupCredentials.length
|
||||
} new group credentials, cleaning up ${
|
||||
prevGroupCredentials.length - prevGroupCredentialsCleaned.length
|
||||
} old group credentials, haveToday=${haveToday(finalGroupCredentials)}`
|
||||
);
|
||||
log.info(
|
||||
`${logId}: saving ${
|
||||
finalCallLinkAuthCredentials.length
|
||||
} new call link auth credentials, cleaning up ${
|
||||
prevCallLinkAuthCredentials.length -
|
||||
prevCallLinkAuthCredentialsCleaned.length
|
||||
} old call link auth credentials, haveToday=${haveToday(
|
||||
finalCallLinkAuthCredentials
|
||||
)}`
|
||||
);
|
||||
|
||||
// Note: we don't wait for this to finish
|
||||
await window.storage.put('groupCredentials', finalCredentials);
|
||||
await window.storage.put('groupCredentials', finalGroupCredentials);
|
||||
await window.storage.put(
|
||||
'callLinkAuthCredentials',
|
||||
finalCallLinkAuthCredentials
|
||||
);
|
||||
log.info(`${logId}: Save complete.`);
|
||||
}
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -34,6 +34,9 @@ export const getIncomingCall = (
|
|||
call.connectionState === GroupCallConnectionState.NotConnected &&
|
||||
isAnybodyElseInGroupCall(call.peekInfo, ourAci)
|
||||
);
|
||||
case CallMode.Adhoc:
|
||||
// Adhoc calls cannot be incoming.
|
||||
return;
|
||||
default:
|
||||
throw missingCaseError(call);
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@ import {
|
|||
import { SHOW_TOAST } from './toast';
|
||||
import type { ShowToastActionType } from './toast';
|
||||
import { isDownloaded } from '../../types/Attachment';
|
||||
import type { ButtonVariant } from '../../components/Button';
|
||||
|
||||
// State
|
||||
|
||||
|
@ -86,6 +87,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
|
|||
deleteMessagesProps?: DeleteMessagesPropsType;
|
||||
editHistoryMessages?: EditHistoryMessagesType;
|
||||
errorModalProps?: {
|
||||
buttonVariant?: ButtonVariant;
|
||||
description?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
@ -308,6 +310,7 @@ type CloseErrorModalActionType = ReadonlyDeep<{
|
|||
export type ShowErrorModalActionType = ReadonlyDeep<{
|
||||
type: typeof SHOW_ERROR_MODAL;
|
||||
payload: {
|
||||
buttonVariant?: ButtonVariant;
|
||||
description?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
@ -729,15 +732,18 @@ function closeErrorModal(): CloseErrorModalActionType {
|
|||
}
|
||||
|
||||
function showErrorModal({
|
||||
buttonVariant,
|
||||
description,
|
||||
title,
|
||||
}: {
|
||||
title?: string;
|
||||
buttonVariant?: ButtonVariant;
|
||||
description?: string;
|
||||
title?: string;
|
||||
}): ShowErrorModalActionType {
|
||||
return {
|
||||
type: SHOW_ERROR_MODAL,
|
||||
payload: {
|
||||
buttonVariant,
|
||||
description,
|
||||
title,
|
||||
},
|
||||
|
|
|
@ -7,10 +7,14 @@ import type { StateType } from '../reducer';
|
|||
import type {
|
||||
CallingStateType,
|
||||
CallsByConversationType,
|
||||
AdhocCallsType,
|
||||
CallLinksByRoomIdType,
|
||||
DirectCallStateType,
|
||||
GroupCallStateType,
|
||||
} from '../ducks/calling';
|
||||
import { getIncomingCall as getIncomingCallHelper } from '../ducks/callingHelpers';
|
||||
import { CallMode } from '../../types/Calling';
|
||||
import type { CallLinkType } from '../../types/CallLink';
|
||||
import { getUserACI } from './user';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
import type { AciString } from '../../types/ServiceId';
|
||||
|
@ -30,6 +34,38 @@ export const getCallsByConversation = createSelector(
|
|||
state.callsByConversation
|
||||
);
|
||||
|
||||
export const getAdhocCalls = createSelector(
|
||||
getCalling,
|
||||
(state: CallingStateType): AdhocCallsType => state.adhocCalls
|
||||
);
|
||||
|
||||
export const getCallLinksByRoomId = createSelector(
|
||||
getCalling,
|
||||
(state: CallingStateType): CallLinksByRoomIdType => state.callLinks
|
||||
);
|
||||
|
||||
export type CallLinkSelectorType = (roomId: string) => CallLinkType | undefined;
|
||||
|
||||
export const getCallLinkSelector = createSelector(
|
||||
getCallLinksByRoomId,
|
||||
(callLinksByRoomId: CallLinksByRoomIdType): CallLinkSelectorType =>
|
||||
(roomId: string): CallLinkType | undefined => {
|
||||
const callLinkState = getOwn(callLinksByRoomId, roomId);
|
||||
if (!callLinkState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, restrictions, rootKey, expiration } = callLinkState;
|
||||
return {
|
||||
roomId,
|
||||
name,
|
||||
restrictions,
|
||||
rootKey,
|
||||
expiration,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export type CallSelectorType = (
|
||||
conversationId: string
|
||||
) => CallStateType | undefined;
|
||||
|
@ -40,15 +76,33 @@ export const getCallSelector = createSelector(
|
|||
getOwn(callsByConversation, conversationId)
|
||||
);
|
||||
|
||||
export type AdhocCallSelectorType = (
|
||||
conversationId: string
|
||||
) => GroupCallStateType | undefined;
|
||||
export const getAdhocCallSelector = createSelector(
|
||||
getAdhocCalls,
|
||||
(adhocCalls: AdhocCallsType): AdhocCallSelectorType =>
|
||||
(roomId: string) =>
|
||||
getOwn(adhocCalls, roomId)
|
||||
);
|
||||
|
||||
export const getActiveCall = createSelector(
|
||||
getActiveCallState,
|
||||
getCallSelector,
|
||||
(activeCallState, callSelector): undefined | CallStateType => {
|
||||
if (activeCallState && activeCallState.conversationId) {
|
||||
return callSelector(activeCallState.conversationId);
|
||||
getAdhocCallSelector,
|
||||
(
|
||||
activeCallState,
|
||||
callSelector,
|
||||
adhocCallSelector
|
||||
): undefined | CallStateType => {
|
||||
const { callMode, conversationId } = activeCallState || {};
|
||||
if (!conversationId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return callMode === CallMode.Adhoc
|
||||
? adhocCallSelector(conversationId)
|
||||
: callSelector(conversationId);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -42,7 +42,7 @@ import type {
|
|||
import type { PropsType as ProfileChangeNotificationPropsType } from '../../components/conversation/ProfileChangeNotification';
|
||||
import type { QuotedAttachmentType } from '../../components/conversation/Quote';
|
||||
|
||||
import { getDomain, isStickerPack } from '../../types/LinkPreview';
|
||||
import { getDomain, isCallLink, isStickerPack } from '../../types/LinkPreview';
|
||||
import type {
|
||||
AciString,
|
||||
PniString,
|
||||
|
@ -383,6 +383,7 @@ const getPreviewsForMessage = ({
|
|||
return previews.map(preview => ({
|
||||
...preview,
|
||||
isStickerPack: isStickerPack(preview.url),
|
||||
isCallLink: isCallLink(preview.url),
|
||||
domain: getDomain(preview.url),
|
||||
image: preview.image ? getPropsForAttachment(preview.image) : undefined,
|
||||
}));
|
||||
|
|
|
@ -15,7 +15,7 @@ import { getIntl, getTheme } from '../selectors/user';
|
|||
import { getMe, getConversationSelector } from '../selectors/conversations';
|
||||
import { getActiveCall } from '../ducks/calling';
|
||||
import type { ConversationType } from '../ducks/conversations';
|
||||
import { getIncomingCall } from '../selectors/calling';
|
||||
import { getCallLinkSelector, getIncomingCall } from '../selectors/calling';
|
||||
import { isGroupCallRaiseHandEnabled } from '../../util/isGroupCallRaiseHandEnabled';
|
||||
import { isGroupCallReactionsEnabled } from '../../util/isGroupCallReactionsEnabled';
|
||||
import type {
|
||||
|
@ -23,12 +23,14 @@ import type {
|
|||
ActiveCallType,
|
||||
ActiveDirectCallType,
|
||||
ActiveGroupCallType,
|
||||
CallingConversationType,
|
||||
ConversationsByDemuxIdType,
|
||||
GroupCallRemoteParticipantType,
|
||||
} from '../../types/Calling';
|
||||
import { isAciString } from '../../util/isAciString';
|
||||
import type { AciString } from '../../types/ServiceId';
|
||||
import { CallMode, CallState } from '../../types/Calling';
|
||||
import type { CallLinkType } from '../../types/CallLink';
|
||||
import type { StateType } from '../reducer';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { SmartCallingDeviceSelection } from './CallingDeviceSelection';
|
||||
|
@ -51,6 +53,7 @@ import { isConversationTooBigToRing } from '../../conversations/isConversationTo
|
|||
import { strictAssert } from '../../util/assert';
|
||||
import { renderEmojiPicker } from './renderEmojiPicker';
|
||||
import { renderReactionPicker } from './renderReactionPicker';
|
||||
import { callLinkToConversation } from '../../util/callLinks';
|
||||
|
||||
function renderDeviceSelection(): JSX.Element {
|
||||
return <SmartCallingDeviceSelection />;
|
||||
|
@ -133,7 +136,19 @@ const mapStateToActiveCallProp = (
|
|||
}
|
||||
|
||||
const conversationSelector = getConversationSelector(state);
|
||||
const conversation = conversationSelector(activeCallState.conversationId);
|
||||
let conversation: CallingConversationType;
|
||||
if (call.callMode === CallMode.Adhoc) {
|
||||
const callLinkSelector = getCallLinkSelector(state);
|
||||
const callLink = callLinkSelector(activeCallState.conversationId);
|
||||
if (!callLink) {
|
||||
// An error is logged in mapStateToCallLinkProp
|
||||
return undefined;
|
||||
}
|
||||
|
||||
conversation = callLinkToConversation(callLink, window.i18n);
|
||||
} else {
|
||||
conversation = conversationSelector(activeCallState.conversationId);
|
||||
}
|
||||
if (!conversation) {
|
||||
log.error('The active call has no corresponding conversation');
|
||||
return undefined;
|
||||
|
@ -199,7 +214,8 @@ const mapStateToActiveCallProp = (
|
|||
},
|
||||
],
|
||||
} satisfies ActiveDirectCallType;
|
||||
case CallMode.Group: {
|
||||
case CallMode.Group:
|
||||
case CallMode.Adhoc: {
|
||||
const conversationsWithSafetyNumberChanges: Array<ConversationType> = [];
|
||||
const groupMembers: Array<ConversationType> = [];
|
||||
const remoteParticipants: Array<GroupCallRemoteParticipantType> = [];
|
||||
|
@ -305,7 +321,7 @@ const mapStateToActiveCallProp = (
|
|||
|
||||
return {
|
||||
...baseResult,
|
||||
callMode: CallMode.Group,
|
||||
callMode: call.callMode,
|
||||
connectionState: call.connectionState,
|
||||
conversationsWithSafetyNumberChanges,
|
||||
conversationsByDemuxId,
|
||||
|
@ -326,6 +342,31 @@ const mapStateToActiveCallProp = (
|
|||
}
|
||||
};
|
||||
|
||||
const mapStateToCallLinkProp = (state: StateType): CallLinkType | undefined => {
|
||||
const { calling } = state;
|
||||
const { activeCallState } = calling;
|
||||
|
||||
if (!activeCallState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const call = getActiveCall(calling);
|
||||
if (call?.callMode !== CallMode.Adhoc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callLinkSelector = getCallLinkSelector(state);
|
||||
const callLink = callLinkSelector(activeCallState.conversationId);
|
||||
if (!callLink) {
|
||||
log.error(
|
||||
'Active call referred to a call link but no corresponding call link in state.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return callLink;
|
||||
};
|
||||
|
||||
const mapStateToIncomingCallProp = (
|
||||
state: StateType
|
||||
): DirectIncomingCall | GroupIncomingCall | null => {
|
||||
|
@ -371,6 +412,9 @@ const mapStateToIncomingCallProp = (
|
|||
remoteParticipants: call.remoteParticipants,
|
||||
};
|
||||
}
|
||||
case CallMode.Adhoc:
|
||||
log.error('Cannot handle an incoming adhoc call');
|
||||
return null;
|
||||
default:
|
||||
throw missingCaseError(call);
|
||||
}
|
||||
|
@ -381,6 +425,7 @@ const mapStateToProps = (state: StateType) => {
|
|||
|
||||
return {
|
||||
activeCall: mapStateToActiveCallProp(state),
|
||||
callLink: mapStateToCallLinkProp(state),
|
||||
bounceAppIconStart,
|
||||
bounceAppIconStop,
|
||||
availableCameras: state.calling.availableCameras,
|
||||
|
|
|
@ -36,6 +36,7 @@ import { useSearchActions } from '../ducks/search';
|
|||
import { useStoriesActions } from '../ducks/stories';
|
||||
import { getCannotLeaveBecauseYouAreLastAdmin } from '../../components/conversation/conversation-details/ConversationDetails';
|
||||
import { getGroupMemberships } from '../../util/getGroupMemberships';
|
||||
import { isGroupOrAdhocCallState } from '../../util/isGroupOrAdhocCall';
|
||||
|
||||
export type OwnProps = {
|
||||
id: string;
|
||||
|
@ -59,10 +60,11 @@ const getOutgoingCallButtonStyle = (
|
|||
return OutgoingCallButtonStyle.None;
|
||||
case CallMode.Direct:
|
||||
return OutgoingCallButtonStyle.Both;
|
||||
case CallMode.Group: {
|
||||
case CallMode.Group:
|
||||
case CallMode.Adhoc: {
|
||||
const call = getOwn(calling.callsByConversation, conversation.id);
|
||||
if (
|
||||
call?.callMode === CallMode.Group &&
|
||||
isGroupOrAdhocCallState(call) &&
|
||||
isAnybodyElseInGroupCall(call.peekInfo, ourAci)
|
||||
) {
|
||||
return OutgoingCallButtonStyle.Join;
|
||||
|
|
|
@ -6,6 +6,7 @@ import { useSelector } from 'react-redux';
|
|||
|
||||
import type { GlobalModalsStateType } from '../ducks/globalModals';
|
||||
import type { StateType } from '../reducer';
|
||||
import type { ButtonVariant } from '../../components/Button';
|
||||
import { ErrorModal } from '../../components/ErrorModal';
|
||||
import { GlobalModalContainer } from '../../components/GlobalModalContainer';
|
||||
import { SmartAboutContactModal } from './AboutContactModal';
|
||||
|
@ -133,10 +134,19 @@ export function SmartGlobalModalContainer(): JSX.Element {
|
|||
);
|
||||
|
||||
const renderErrorModal = useCallback(
|
||||
({ description, title }: { description?: string; title?: string }) => (
|
||||
({
|
||||
buttonVariant,
|
||||
description,
|
||||
title,
|
||||
}: {
|
||||
buttonVariant?: ButtonVariant;
|
||||
description?: string;
|
||||
title?: string;
|
||||
}) => (
|
||||
<ErrorModal
|
||||
title={title}
|
||||
buttonVariant={buttonVariant}
|
||||
description={description}
|
||||
title={title}
|
||||
i18n={i18n}
|
||||
onClose={closeErrorModal}
|
||||
/>
|
||||
|
|
|
@ -14,6 +14,7 @@ describe('shouldUseFullSizeLinkPreviewImage', () => {
|
|||
domain: 'example.com',
|
||||
url: 'https://example.com/foo.html',
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
};
|
||||
|
||||
it('returns false if there is no image', () => {
|
||||
|
|
|
@ -17,6 +17,7 @@ describe('both/state/ducks/linkPreviews', () => {
|
|||
domain: 'signal.org',
|
||||
url: 'https://www.signal.org',
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -75,6 +75,34 @@ describe('Privacy', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('redactCallLinkRoomIds', () => {
|
||||
it('should redact call link room IDs', () => {
|
||||
const text =
|
||||
'Log line with call link room ID 7f3d431d4512b30754915a262db43cd789f799d710525a83429d48aee8c2cd4b\n' +
|
||||
'and another IN ALL UPPERCASE 7F3D431D4512B30754915A262DB43CD789F799D710525A83429D48AEE8C2CD4B';
|
||||
|
||||
const actual = Privacy.redactCallLinkRoomIds(text);
|
||||
const expected =
|
||||
'Log line with call link room ID [REDACTED]d4b\n' +
|
||||
'and another IN ALL UPPERCASE [REDACTED]D4B';
|
||||
assert.equal(actual, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactCallLinkRootKeys', () => {
|
||||
it('should redact call link root keys', () => {
|
||||
const text =
|
||||
'Log line with call link https://signal.link/call/#key=hktt-kskq-dhcn-bgkm-hbbg-qqkq-sfbp-czmc\n' +
|
||||
'and another IN ALL UPPERCASE HKTT-KSKQ-DHCN-BGKM-HBBG-QQKQ-SFBP-CZMC';
|
||||
|
||||
const actual = Privacy.redactCallLinkRootKeys(text);
|
||||
const expected =
|
||||
'Log line with call link https://signal.link/call/#key=[REDACTED]hktt\n' +
|
||||
'and another IN ALL UPPERCASE [REDACTED]HKTT';
|
||||
assert.equal(actual, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactAll', () => {
|
||||
it('should redact all sensitive information', () => {
|
||||
const encodedAppRootPath = APP_ROOT_PATH.replace(/ /g, '%20');
|
||||
|
|
|
@ -138,6 +138,7 @@ describe('Conversations', () => {
|
|||
[
|
||||
{
|
||||
url: 'https://sometest.signal.org/',
|
||||
isCallLink: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
@ -154,6 +155,7 @@ describe('Conversations', () => {
|
|||
size: 100,
|
||||
data: new Uint8Array(),
|
||||
},
|
||||
isCallLink: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
|
|
@ -63,6 +63,7 @@ describe('calling duck', () => {
|
|||
const stateWithActiveDirectCall: CallingStateTypeWithActiveCall = {
|
||||
...stateWithDirectCall,
|
||||
activeCallState: {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: directCallState.conversationId,
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
|
@ -145,6 +146,7 @@ describe('calling duck', () => {
|
|||
const stateWithActiveGroupCall: CallingStateTypeWithActiveCall = {
|
||||
...stateWithGroupCall,
|
||||
activeCallState: {
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
|
@ -473,6 +475,7 @@ describe('calling duck', () => {
|
|||
const result = reducer(stateWithIncomingDirectCall, action);
|
||||
|
||||
assert.deepEqual(result.activeCallState, {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: true,
|
||||
|
@ -567,6 +570,7 @@ describe('calling duck', () => {
|
|||
const result = reducer(stateWithIncomingGroupCall, action);
|
||||
|
||||
assert.deepEqual(result.activeCallState, {
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: true,
|
||||
|
@ -816,6 +820,7 @@ describe('calling duck', () => {
|
|||
|
||||
it("does nothing if there's no relevant call", () => {
|
||||
const action = groupCallAudioLevelsChange({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'garbage',
|
||||
localAudioLevel: 1,
|
||||
remoteDeviceStates,
|
||||
|
@ -839,6 +844,7 @@ describe('calling duck', () => {
|
|||
},
|
||||
};
|
||||
const action = groupCallAudioLevelsChange({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
localAudioLevel: 0.001,
|
||||
remoteDeviceStates,
|
||||
|
@ -851,6 +857,7 @@ describe('calling duck', () => {
|
|||
|
||||
it('updates the set of speaking participants, including yourself', () => {
|
||||
const action = groupCallAudioLevelsChange({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
localAudioLevel: 0.8,
|
||||
remoteDeviceStates,
|
||||
|
@ -888,6 +895,7 @@ describe('calling duck', () => {
|
|||
const result = reducer(
|
||||
getEmptyState(),
|
||||
getAction({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joining,
|
||||
|
@ -952,6 +960,7 @@ describe('calling duck', () => {
|
|||
const result = reducer(
|
||||
stateWithGroupCall,
|
||||
getAction({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
|
@ -1025,6 +1034,7 @@ describe('calling duck', () => {
|
|||
const result = reducer(
|
||||
state,
|
||||
getAction({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.NotJoined,
|
||||
|
@ -1078,6 +1088,7 @@ describe('calling duck', () => {
|
|||
const result = reducer(
|
||||
state,
|
||||
getAction({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
localDemuxId: 1,
|
||||
|
@ -1118,6 +1129,7 @@ describe('calling duck', () => {
|
|||
const result = reducer(
|
||||
stateWithGroupCall,
|
||||
getAction({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
|
@ -1151,6 +1163,7 @@ describe('calling duck', () => {
|
|||
const result = reducer(
|
||||
stateWithActiveGroupCall,
|
||||
getAction({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'another-fake-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
|
@ -1178,6 +1191,7 @@ describe('calling duck', () => {
|
|||
);
|
||||
|
||||
assert.deepEqual(result.activeCallState, {
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
|
@ -1196,6 +1210,7 @@ describe('calling duck', () => {
|
|||
const result = reducer(
|
||||
stateWithActiveGroupCall,
|
||||
getAction({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
|
@ -1241,6 +1256,7 @@ describe('calling duck', () => {
|
|||
const result = reducer(
|
||||
state,
|
||||
getAction({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
|
@ -1270,6 +1286,7 @@ describe('calling duck', () => {
|
|||
const result = reducer(
|
||||
state,
|
||||
getAction({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
|
@ -1484,6 +1501,7 @@ describe('calling duck', () => {
|
|||
|
||||
it('adds reactions by timestamp', function (this: Mocha.Context) {
|
||||
const firstAction = getAction({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
reactions: [
|
||||
{
|
||||
|
@ -1504,6 +1522,7 @@ describe('calling duck', () => {
|
|||
const secondDate = new Date(NOW.getTime() + 1234);
|
||||
this.sandbox.useFakeTimers({ now: secondDate });
|
||||
const secondAction = getAction({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
reactions: [
|
||||
{
|
||||
|
@ -1529,6 +1548,7 @@ describe('calling duck', () => {
|
|||
|
||||
it('sets multiple reactions with the same timestamp', () => {
|
||||
const action = getAction({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
reactions: [
|
||||
{
|
||||
|
@ -1588,6 +1608,7 @@ describe('calling duck', () => {
|
|||
|
||||
it('adds a local copy', () => {
|
||||
const action = getAction({
|
||||
callMode: CallMode.Group,
|
||||
conversationId: 'fake-group-call-conversation-id',
|
||||
value: '❤️',
|
||||
});
|
||||
|
@ -1858,6 +1879,7 @@ describe('calling duck', () => {
|
|||
isVideoCall: true,
|
||||
});
|
||||
assert.deepEqual(result.activeCallState, {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: true,
|
||||
|
@ -2151,6 +2173,7 @@ describe('calling duck', () => {
|
|||
isVideoCall: false,
|
||||
});
|
||||
assert.deepEqual(result.activeCallState, {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
|
|
|
@ -983,6 +983,7 @@ describe('both/state/ducks/stories', () => {
|
|||
digest: 'digest-1',
|
||||
size: 0,
|
||||
},
|
||||
isCallLink: false,
|
||||
};
|
||||
const messageAttributes = {
|
||||
...getStoryMessage(storyId),
|
||||
|
|
|
@ -62,6 +62,7 @@ describe('state/selectors/calling', () => {
|
|||
const stateWithActiveDirectCall: CallingStateType = {
|
||||
...stateWithDirectCall,
|
||||
activeCallState: {
|
||||
callMode: CallMode.Direct,
|
||||
conversationId: 'fake-direct-call-conversation-id',
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: false,
|
||||
|
|
|
@ -649,6 +649,10 @@ export type GroupCredentialsType = {
|
|||
groupPublicParamsHex: string;
|
||||
authCredentialPresentationHex: string;
|
||||
};
|
||||
export type CallLinkAuthCredentialsType = {
|
||||
callLinkPublicParamsHex: string;
|
||||
authCredentialPresentationHex: string;
|
||||
};
|
||||
export type GetGroupLogOptionsType = Readonly<{
|
||||
startVersion: number | undefined;
|
||||
includeFirstState: boolean;
|
||||
|
@ -798,6 +802,7 @@ export type GetGroupCredentialsOptionsType = Readonly<{
|
|||
export type GetGroupCredentialsResultType = Readonly<{
|
||||
pni?: UntaggedPniString | null;
|
||||
credentials: ReadonlyArray<GroupCredentialType>;
|
||||
callLinkAuthCredentials: ReadonlyArray<GroupCredentialType>;
|
||||
}>;
|
||||
|
||||
const verifyServiceIdResponse = z.object({
|
||||
|
@ -3114,10 +3119,6 @@ export function initialize({
|
|||
);
|
||||
}
|
||||
|
||||
type CredentialResponseType = {
|
||||
credentials: Array<GroupCredentialType>;
|
||||
};
|
||||
|
||||
async function getGroupCredentials({
|
||||
startDayInMs,
|
||||
endDayInMs,
|
||||
|
@ -3132,7 +3133,7 @@ export function initialize({
|
|||
'pniAsServiceId=true',
|
||||
httpType: 'GET',
|
||||
responseType: 'json',
|
||||
})) as CredentialResponseType;
|
||||
})) as GetGroupCredentialsResultType;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
|
33
ts/types/CallLink.ts
Normal file
33
ts/types/CallLink.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { ReadonlyDeep } from 'type-fest';
|
||||
import { z } from 'zod';
|
||||
import type { CallLinkRestrictions as RingRTCCallLinkRestrictions } from '@signalapp/ringrtc';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
|
||||
export type CallLinkConversationType = ReadonlyDeep<
|
||||
Omit<ConversationType, 'type'> & {
|
||||
type: 'callLink';
|
||||
storySendMode?: undefined;
|
||||
acknowledgedGroupNameCollisions?: undefined;
|
||||
}
|
||||
>;
|
||||
|
||||
// Must match `CallLinkRestrictions` in @signalapp/ringrtc
|
||||
export enum CallLinkRestrictions {
|
||||
None = 0,
|
||||
AdminApproval = 1,
|
||||
Unknown = 2,
|
||||
}
|
||||
|
||||
export const callLinkRestrictionsSchema = z.nativeEnum(
|
||||
CallLinkRestrictions
|
||||
) satisfies z.ZodType<RingRTCCallLinkRestrictions>;
|
||||
|
||||
export type CallLinkType = Readonly<{
|
||||
roomId: string;
|
||||
rootKey: string;
|
||||
name: string;
|
||||
restrictions: CallLinkRestrictions;
|
||||
expiration: number;
|
||||
}>;
|
|
@ -4,6 +4,7 @@
|
|||
import type { AudioDevice, Reaction as CallReaction } from '@signalapp/ringrtc';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { AciString, ServiceIdString } from './ServiceId';
|
||||
import type { CallLinkConversationType } from './CallLink';
|
||||
|
||||
export const MAX_CALLING_REACTIONS = 5;
|
||||
export const CALLING_REACTIONS_LIFETIME = 4000;
|
||||
|
@ -12,6 +13,7 @@ export const CALLING_REACTIONS_LIFETIME = 4000;
|
|||
export enum CallMode {
|
||||
Direct = 'Direct',
|
||||
Group = 'Group',
|
||||
Adhoc = 'Adhoc',
|
||||
}
|
||||
|
||||
// Speaker and Presentation mode have the same UI, but Presentation is only set
|
||||
|
@ -48,7 +50,7 @@ export type ActiveCallReaction = {
|
|||
export type ActiveCallReactionsType = ReadonlyArray<ActiveCallReaction>;
|
||||
|
||||
export type ActiveCallBaseType = {
|
||||
conversation: ConversationType;
|
||||
conversation: CallingConversationType;
|
||||
hasLocalAudio: boolean;
|
||||
hasLocalVideo: boolean;
|
||||
localAudioLevel: number;
|
||||
|
@ -85,7 +87,7 @@ export type ActiveDirectCallType = ActiveCallBaseType & {
|
|||
};
|
||||
|
||||
export type ActiveGroupCallType = ActiveCallBaseType & {
|
||||
callMode: CallMode.Group;
|
||||
callMode: CallMode.Group | CallMode.Adhoc;
|
||||
connectionState: GroupCallConnectionState;
|
||||
conversationsByDemuxId: ConversationsByDemuxIdType;
|
||||
conversationsWithSafetyNumberChanges: Array<ConversationType>;
|
||||
|
@ -199,3 +201,7 @@ export type ChangeIODevicePayloadType =
|
|||
| { type: CallingDeviceType.CAMERA; selectedDevice: string }
|
||||
| { type: CallingDeviceType.MICROPHONE; selectedDevice: AudioDevice }
|
||||
| { type: CallingDeviceType.SPEAKER; selectedDevice: AudioDevice };
|
||||
|
||||
export type CallingConversationType =
|
||||
| ConversationType
|
||||
| CallLinkConversationType;
|
||||
|
|
|
@ -9,7 +9,11 @@ import { maybeParseUrl } from '../util/url';
|
|||
import { replaceEmojiWithSpaces } from '../util/emoji';
|
||||
|
||||
import type { AttachmentWithHydratedData } from './Attachment';
|
||||
import { artAddStickersRoute, groupInvitesRoute } from '../util/signalRoutes';
|
||||
import {
|
||||
artAddStickersRoute,
|
||||
groupInvitesRoute,
|
||||
linkCallRoute,
|
||||
} from '../util/signalRoutes';
|
||||
|
||||
export type LinkPreviewImage = AttachmentWithHydratedData;
|
||||
|
||||
|
@ -95,6 +99,11 @@ export function shouldLinkifyMessage(
|
|||
return true;
|
||||
}
|
||||
|
||||
export function isCallLink(link = ''): boolean {
|
||||
const url = maybeParseUrl(link);
|
||||
return url?.protocol === 'https:' && linkCallRoute.isMatch(url);
|
||||
}
|
||||
|
||||
export function isStickerPack(link = ''): boolean {
|
||||
const url = maybeParseUrl(link);
|
||||
return url?.protocol === 'https:' && artAddStickersRoute.isMatch(url);
|
||||
|
|
|
@ -59,6 +59,7 @@ export const rendererConfigSchema = z.object({
|
|||
registrationChallengeUrl: configRequiredStringSchema,
|
||||
serverPublicParams: configRequiredStringSchema,
|
||||
serverTrustRoot: configRequiredStringSchema,
|
||||
genericServerPublicParams: configRequiredStringSchema,
|
||||
serverUrl: configRequiredStringSchema,
|
||||
sfuUrl: configRequiredStringSchema,
|
||||
storageUrl: configRequiredStringSchema,
|
||||
|
|
1
ts/types/Storage.d.ts
vendored
1
ts/types/Storage.d.ts
vendored
|
@ -141,6 +141,7 @@ export type StorageAccessType = {
|
|||
serverTimeSkew: number;
|
||||
unidentifiedDeliveryIndicators: boolean;
|
||||
groupCredentials: ReadonlyArray<GroupCredentialType>;
|
||||
callLinkAuthCredentials: ReadonlyArray<GroupCredentialType>;
|
||||
lastReceivedAtCounter: number;
|
||||
preferredReactionEmoji: ReadonlyArray<string>;
|
||||
skinTone: number;
|
||||
|
|
|
@ -20,6 +20,7 @@ export enum ToastType {
|
|||
ConversationMarkedUnread = 'ConversationMarkedUnread',
|
||||
ConversationRemoved = 'ConversationRemoved',
|
||||
ConversationUnarchived = 'ConversationUnarchived',
|
||||
CopiedCallLink = 'CopiedCallLink',
|
||||
CopiedUsername = 'CopiedUsername',
|
||||
CopiedUsernameLink = 'CopiedUsernameLink',
|
||||
DangerousFileType = 'DangerousFileType',
|
||||
|
@ -86,6 +87,7 @@ export type AnyToast =
|
|||
| { toastType: ToastType.ConversationMarkedUnread }
|
||||
| { toastType: ToastType.ConversationRemoved; parameters: { title: string } }
|
||||
| { toastType: ToastType.ConversationUnarchived }
|
||||
| { toastType: ToastType.CopiedCallLink }
|
||||
| { toastType: ToastType.CopiedUsername }
|
||||
| { toastType: ToastType.CopiedUsernameLink }
|
||||
| { toastType: ToastType.DangerousFileType }
|
||||
|
|
|
@ -9,6 +9,7 @@ type GenericLinkPreviewType<Image> = {
|
|||
domain?: string;
|
||||
url: string;
|
||||
isStickerPack?: boolean;
|
||||
isCallLink: boolean;
|
||||
image?: Readonly<Image>;
|
||||
date?: number;
|
||||
};
|
||||
|
|
|
@ -514,6 +514,9 @@ export function transitionCallHistory(
|
|||
event,
|
||||
direction
|
||||
);
|
||||
} else if (mode === CallMode.Adhoc) {
|
||||
// TODO: DESKTOP-6653
|
||||
strictAssert(false, 'cannot transitionCallHistory for adhoc calls yet');
|
||||
} else {
|
||||
throw missingCaseError(mode);
|
||||
}
|
||||
|
|
10
ts/util/callLinkRootKeyToUrl.ts
Normal file
10
ts/util/callLinkRootKeyToUrl.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export function callLinkRootKeyToUrl(rootKey: string): string | undefined {
|
||||
if (!rootKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
return `https://signal.link/call/#key=${rootKey}`;
|
||||
}
|
73
ts/util/callLinks.ts
Normal file
73
ts/util/callLinks.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { CallLinkRootKey } from '@signalapp/ringrtc';
|
||||
import { Aci } from '@signalapp/libsignal-client';
|
||||
import type { CallLinkAuthCredentialPresentation } from './zkgroup';
|
||||
import {
|
||||
CallLinkAuthCredential,
|
||||
CallLinkSecretParams,
|
||||
GenericServerPublicParams,
|
||||
} from './zkgroup';
|
||||
import { getCheckedCallLinkAuthCredentialsForToday } from '../services/groupCredentialFetcher';
|
||||
import * as durations from './durations';
|
||||
import type { CallLinkConversationType, CallLinkType } from '../types/CallLink';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
|
||||
export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string {
|
||||
return rootKey.deriveRoomId().toString('hex');
|
||||
}
|
||||
|
||||
export function getCallLinkRootKeyFromUrlKey(key: string): Uint8Array {
|
||||
// Returns `Buffer` which inherits from `Uint8Array`
|
||||
return CallLinkRootKey.parse(key).bytes;
|
||||
}
|
||||
|
||||
export async function getCallLinkAuthCredentialPresentation(
|
||||
callLinkRootKey: CallLinkRootKey
|
||||
): Promise<CallLinkAuthCredentialPresentation> {
|
||||
const credentials = getCheckedCallLinkAuthCredentialsForToday(
|
||||
'getCallLinkAuthCredentialPresentation'
|
||||
);
|
||||
const todaysCredentials = credentials.today.credential;
|
||||
const credential = new CallLinkAuthCredential(
|
||||
Buffer.from(todaysCredentials, 'base64')
|
||||
);
|
||||
|
||||
const genericServerPublicParamsBase64 = window.getGenericServerPublicParams();
|
||||
const genericServerPublicParams = new GenericServerPublicParams(
|
||||
Buffer.from(genericServerPublicParamsBase64, 'base64')
|
||||
);
|
||||
|
||||
const ourAci = window.textsecure.storage.user.getAci();
|
||||
if (ourAci == null) {
|
||||
throw new Error('Failed to get our ACI');
|
||||
}
|
||||
const userId = Aci.fromUuid(ourAci);
|
||||
|
||||
const callLinkSecretParams = CallLinkSecretParams.deriveFromRootKey(
|
||||
callLinkRootKey.bytes
|
||||
);
|
||||
const presentation = credential.present(
|
||||
userId,
|
||||
credentials.today.redemptionTime / durations.SECOND,
|
||||
genericServerPublicParams,
|
||||
callLinkSecretParams
|
||||
);
|
||||
return presentation;
|
||||
}
|
||||
|
||||
export function callLinkToConversation(
|
||||
callLink: CallLinkType,
|
||||
i18n: LocalizerType
|
||||
): CallLinkConversationType {
|
||||
const { roomId, name } = callLink;
|
||||
return {
|
||||
id: roomId,
|
||||
type: 'callLink',
|
||||
isMe: false,
|
||||
title: name || i18n('icu:calling__call-link-default-title'),
|
||||
sharedGroupNames: [],
|
||||
acceptedMessageRequest: true,
|
||||
badges: [],
|
||||
};
|
||||
}
|
|
@ -128,6 +128,10 @@ export function getCallingNotificationText(
|
|||
);
|
||||
return getGroupCallNotificationText(groupCallEnded, callCreator, i18n);
|
||||
}
|
||||
if (callHistory.mode === CallMode.Adhoc) {
|
||||
// TODO: DESKTOP-6653
|
||||
return null;
|
||||
}
|
||||
throw missingCaseError(callHistory.mode);
|
||||
}
|
||||
|
||||
|
|
8
ts/util/isAdhocCallingEnabled.ts
Normal file
8
ts/util/isAdhocCallingEnabled.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as RemoteConfig from '../RemoteConfig';
|
||||
|
||||
export function isAdhocCallingEnabled(): boolean {
|
||||
return Boolean(RemoteConfig.isEnabled('desktop.calling.adhoc'));
|
||||
}
|
31
ts/util/isGroupOrAdhocCall.ts
Normal file
31
ts/util/isGroupOrAdhocCall.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { CallMode } from '../types/Calling';
|
||||
import type { ActiveCallType, ActiveGroupCallType } from '../types/Calling';
|
||||
import type {
|
||||
DirectCallStateType,
|
||||
GroupCallStateType,
|
||||
} from '../state/ducks/calling';
|
||||
|
||||
export function isGroupOrAdhocActiveCall(
|
||||
activeCall: ActiveCallType | undefined
|
||||
): activeCall is ActiveGroupCallType {
|
||||
return Boolean(activeCall && isGroupOrAdhocCallMode(activeCall.callMode));
|
||||
}
|
||||
|
||||
export function isGroupOrAdhocCallMode(
|
||||
callMode: CallMode | undefined | null
|
||||
): callMode is CallMode.Group | CallMode.Adhoc {
|
||||
return callMode === CallMode.Group || callMode === CallMode.Adhoc;
|
||||
}
|
||||
|
||||
export function isGroupOrAdhocCallState(
|
||||
callState: DirectCallStateType | GroupCallStateType | undefined
|
||||
): callState is GroupCallStateType {
|
||||
return Boolean(
|
||||
callState &&
|
||||
(callState.callMode === CallMode.Group ||
|
||||
callState.callMode === CallMode.Adhoc)
|
||||
);
|
||||
}
|
|
@ -16,6 +16,9 @@ const UUID_OR_STORY_ID_PATTERN =
|
|||
/[0-9A-F]{8}-[0-9A-F]{4}-[04][0-9A-F]{3}-[089AB][0-9A-F]{3}-[0-9A-F]{9}([0-9A-F]{3})/gi;
|
||||
const GROUP_ID_PATTERN = /(group\()([^)]+)(\))/g;
|
||||
const GROUP_V2_ID_PATTERN = /(groupv2\()([^=)]+)(=?=?\))/g;
|
||||
const CALL_LINK_ROOM_ID_PATTERN = /[0-9A-F]{61}([0-9A-F]{3})/gi;
|
||||
const CALL_LINK_ROOT_KEY_PATTERN =
|
||||
/([A-Z]{4})-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}/gi;
|
||||
const REDACTION_PLACEHOLDER = '[REDACTED]';
|
||||
|
||||
export type RedactFunction = (value: string) => string;
|
||||
|
@ -125,6 +128,22 @@ export const redactGroupIds = (text: string): string => {
|
|||
);
|
||||
};
|
||||
|
||||
export const redactCallLinkRoomIds = (text: string): string => {
|
||||
if (!isString(text)) {
|
||||
throw new TypeError("'text' must be a string");
|
||||
}
|
||||
|
||||
return text.replace(CALL_LINK_ROOM_ID_PATTERN, `${REDACTION_PLACEHOLDER}$1`);
|
||||
};
|
||||
|
||||
export const redactCallLinkRootKeys = (text: string): string => {
|
||||
if (!isString(text)) {
|
||||
throw new TypeError("'text' must be a string");
|
||||
}
|
||||
|
||||
return text.replace(CALL_LINK_ROOT_KEY_PATTERN, `${REDACTION_PLACEHOLDER}$1`);
|
||||
};
|
||||
|
||||
const createRedactSensitivePaths = (
|
||||
paths: ReadonlyArray<string>
|
||||
): RedactFunction => {
|
||||
|
@ -146,7 +165,9 @@ export const redactAll: RedactFunction = compose(
|
|||
(text: string) => redactSensitivePaths(text),
|
||||
redactGroupIds,
|
||||
redactPhoneNumbers,
|
||||
redactUuids
|
||||
redactUuids,
|
||||
redactCallLinkRoomIds,
|
||||
redactCallLinkRootKeys
|
||||
);
|
||||
|
||||
const removeNewlines: RedactFunction = text => text.replace(/\r?\n|\r/g, '');
|
||||
|
|
1
ts/window.d.ts
vendored
1
ts/window.d.ts
vendored
|
@ -193,6 +193,7 @@ declare global {
|
|||
getHostName: () => string;
|
||||
getInteractionMode: () => 'mouse' | 'keyboard';
|
||||
getServerPublicParams: () => string;
|
||||
getGenericServerPublicParams: () => string;
|
||||
getSfuUrl: () => string;
|
||||
getSocketStatus: () => SocketStatus;
|
||||
getSyncRequest: (timeoutMillis?: number) => SyncRequest;
|
||||
|
|
|
@ -21,6 +21,7 @@ import type {
|
|||
NotificationClickData,
|
||||
WindowsNotificationData,
|
||||
} from '../../services/notifications';
|
||||
import { isAdhocCallingEnabled } from '../../util/isAdhocCallingEnabled';
|
||||
|
||||
// It is important to call this as early as possible
|
||||
window.i18n = SignalContext.i18n;
|
||||
|
@ -52,6 +53,7 @@ window.getBuildExpiration = () => config.buildExpiration;
|
|||
window.getHostName = () => config.hostname;
|
||||
window.getServerTrustRoot = () => config.serverTrustRoot;
|
||||
window.getServerPublicParams = () => config.serverPublicParams;
|
||||
window.getGenericServerPublicParams = () => config.genericServerPublicParams;
|
||||
window.getSfuUrl = () => config.sfuUrl;
|
||||
window.isBehindProxy = () => Boolean(config.proxyUrl);
|
||||
|
||||
|
@ -329,6 +331,19 @@ ipc.on('start-call-lobby', (_event, { conversationId }) => {
|
|||
});
|
||||
});
|
||||
|
||||
ipc.on('start-call-link', (_event, { key }) => {
|
||||
if (isAdhocCallingEnabled()) {
|
||||
window.reduxActions?.calling?.startCallLinkLobby({
|
||||
rootKey: key,
|
||||
});
|
||||
} else {
|
||||
const { unknownSignalLink } = window.Events;
|
||||
if (unknownSignalLink) {
|
||||
unknownSignalLink();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipc.on('show-window', () => {
|
||||
window.IPC.showWindow();
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue