Basic call link join support

This commit is contained in:
ayumi-signal 2024-02-22 13:19:50 -08:00 committed by GitHub
parent 2bfb6e7481
commit 96b3413feb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 2438 additions and 509 deletions

View file

@ -57,7 +57,7 @@ export type Props = {
loading?: boolean;
acceptedMessageRequest: boolean;
conversationType: 'group' | 'direct';
conversationType: 'group' | 'direct' | 'callLink';
isMe: boolean;
noteToSelf?: boolean;
phoneNumber?: string;

View file

@ -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'),

View file

@ -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;

View file

@ -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', {

View file

@ -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);

View 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} />;
}

View 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>
);
}

View file

@ -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} />;

View file

@ -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}

View file

@ -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} />;
}

View file

@ -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);

View file

@ -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
);

View file

@ -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

View file

@ -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>
);

View file

@ -136,6 +136,7 @@ export function LinkPreview(): JSX.Element {
contentType: IMAGE_JPEG,
}),
isStickerPack: false,
isCallLink: false,
title: LONG_TITLE,
},
],

View file

@ -452,6 +452,7 @@ function ForwardMessageEditor({
onClose={removeLinkPreview}
title={linkPreview.title}
url={linkPreview.url}
isCallLink={linkPreview.isCallLink}
/>
</div>
) : null}

View file

@ -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;

View file

@ -71,6 +71,7 @@ LinkPreview.args = {
}),
title: 'Cats & Kittens LOL',
url: 'https://www.catsandkittens.lolcats/kittens/page/1',
isCallLink: false,
},
};

View file

@ -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,
},
}}
/>

View file

@ -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:

View file

@ -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}>

View file

@ -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;

View file

@ -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()}
</>

View file

@ -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

View file

@ -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.',