Basic call link join support
This commit is contained in:
parent
2bfb6e7481
commit
96b3413feb
75 changed files with 2438 additions and 509 deletions
|
@ -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.',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue