Update call lobby UI to match new designs

This commit is contained in:
Evan Hahn 2021-08-17 16:45:18 -05:00 committed by GitHub
parent 50c4fa06cc
commit 763c35e546
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 857 additions and 435 deletions

View file

@ -1300,7 +1300,11 @@
}, },
"calling__call-is-full": { "calling__call-is-full": {
"message": "Call is full", "message": "Call is full",
"description": "Button label in the call lobby when you can't join because the call is full" "description": "Text in the call lobby when you can't join because the call is full"
},
"calling__button--video__label": {
"message": "Camera",
"description": "Label under the video button"
}, },
"calling__button--video-disabled": { "calling__button--video-disabled": {
"message": "Camera disabled", "message": "Camera disabled",
@ -1314,6 +1318,10 @@
"message": "Turn on camera", "message": "Turn on camera",
"description": "Button tooltip label for turning on the camera" "description": "Button tooltip label for turning on the camera"
}, },
"calling__button--audio__label": {
"message": "Mute",
"description": "Label under the audio button"
},
"calling__button--audio-disabled": { "calling__button--audio-disabled": {
"message": "Microphone disabled", "message": "Microphone disabled",
"description": "Button tooltip label when the microphone is disabled" "description": "Button tooltip label when the microphone is disabled"
@ -1326,6 +1334,10 @@
"message": "Unmute mic", "message": "Unmute mic",
"description": "Button tooltip label for turning on the microphone" "description": "Button tooltip label for turning on the microphone"
}, },
"calling__button--presenting__label": {
"message": "Share",
"description": "Label under the share screen button"
},
"calling__button--presenting-disabled": { "calling__button--presenting-disabled": {
"message": "Presenting disabled", "message": "Presenting disabled",
"description": "Button tooltip label for when screen sharing is disabled" "description": "Button tooltip label for when screen sharing is disabled"
@ -1342,11 +1354,11 @@
"message": "Your camera is off", "message": "Your camera is off",
"description": "Label in the calling lobby indicating that your camera is off" "description": "Label in the calling lobby indicating that your camera is off"
}, },
"calling__lobby-summary--zero": { "calling__pre-call-info--empty-group": {
"message": "No one else is here", "message": "No one else is here",
"description": "Shown in the calling lobby to describe who is in the call" "description": "Shown in the calling lobby to describe who is in the call"
}, },
"calling__lobby-summary--single": { "calling__pre-call-info--1-person-in-call": {
"message": "$first$ is in this call", "message": "$first$ is in this call",
"description": "Shown in the calling lobby to describe who is in the call", "description": "Shown in the calling lobby to describe who is in the call",
"placeholders": { "placeholders": {
@ -1356,11 +1368,11 @@
} }
} }
}, },
"calling__lobby-summary--self": { "calling__pre-call-info--another-device-in-call": {
"message": "One of your other devices is in this call", "message": "One of your other devices is in this call",
"description": "Shown in the calling lobby to describe when it is just you" "description": "Shown in the calling lobby to describe when it is just you"
}, },
"calling__lobby-summary--double": { "calling__pre-call-info--2-people-in-call": {
"message": "$first$ and $second$ are in this call", "message": "$first$ and $second$ are in this call",
"description": "Shown in the calling lobby to describe who is in the call", "description": "Shown in the calling lobby to describe who is in the call",
"placeholders": { "placeholders": {
@ -1374,7 +1386,7 @@
} }
} }
}, },
"calling__lobby-summary--triple": { "calling__pre-call-info--3-people-in-call": {
"message": "$first$, $second$, and $third$ are in this call", "message": "$first$, $second$, and $third$ are in this call",
"description": "Shown in the calling lobby to describe who is in the call", "description": "Shown in the calling lobby to describe who is in the call",
"placeholders": { "placeholders": {
@ -1392,7 +1404,7 @@
} }
} }
}, },
"calling__lobby-summary--many": { "calling__pre-call-info--many-people-in-call": {
"message": "$first$, $second$, and $others$ others are in this call", "message": "$first$, $second$, and $others$ others are in this call",
"description": "Shown in the calling lobby to describe who is in the call", "description": "Shown in the calling lobby to describe who is in the call",
"placeholders": { "placeholders": {
@ -1410,6 +1422,76 @@
} }
} }
}, },
"calling__pre-call-info--will-ring-1": {
"message": "Signal will ring $person$",
"description": "Shown in the calling lobby to describe who will be rung",
"placeholders": {
"person": {
"content": "$1",
"example": "Sam"
}
}
},
"calling__pre-call-info--will-notify-1": {
"message": "$person$ will be notified",
"description": "Shown in the calling lobby to describe who will be notified",
"placeholders": {
"person": {
"content": "$1",
"example": "Sam"
}
}
},
"calling__pre-call-info--will-notify-2": {
"message": "$first$ and $second$ will be notified",
"description": "Shown in the calling lobby to describe who will be notified",
"placeholders": {
"first": {
"content": "$1",
"example": "Sam"
},
"second": {
"content": "$2",
"example": "Cayce"
}
}
},
"calling__pre-call-info--will-notify-3": {
"message": "$first$, $second$, and $third$ will be notified",
"description": "Shown in the calling lobby to describe who will be notified",
"placeholders": {
"first": {
"content": "$1",
"example": "Sam"
},
"second": {
"content": "$2",
"example": "Cayce"
},
"third": {
"content": "$3",
"example": "April"
}
}
},
"calling__pre-call-info--will-notify-many": {
"message": "$first$, $second$, and $others$ others will be notified",
"description": "Shown in the calling lobby to describe who will be notified",
"placeholders": {
"person": {
"content": "$1",
"example": "Sam"
},
"second": {
"content": "$2",
"example": "Cayce"
},
"others": {
"content": "$3",
"example": "5"
}
}
},
"calling__in-this-call--zero": { "calling__in-this-call--zero": {
"message": "No one else is here", "message": "No one else is here",
"description": "Shown in the participants list to describe how many people are in the call" "description": "Shown in the participants list to describe how many people are in the call"

View file

@ -222,6 +222,10 @@
text-align: inherit; text-align: inherit;
} }
@mixin calling-text-shadow {
text-shadow: 0 0 4px $color-black-alpha-40;
}
// --- Buttons // --- Buttons
// Individual traits // Individual traits

View file

@ -5201,7 +5201,7 @@ button.module-image__border-overlay:focus {
padding-bottom: 24px; padding-bottom: 24px;
padding-top: calc(24px + var(--title-bar-drag-area-height)); padding-top: calc(24px + var(--title-bar-drag-area-height));
text-align: center; text-align: center;
text-shadow: 0px 0px 4px rgba(0, 0, 0, 0.25); @include calling-text-shadow;
width: 100%; width: 100%;
&--header-name { &--header-name {
@ -5224,6 +5224,10 @@ button.module-image__border-overlay:focus {
position: absolute; position: absolute;
text-align: center; text-align: center;
width: 100%; width: 100%;
&--inline {
position: static;
}
} }
&__background { &__background {
@ -5237,7 +5241,6 @@ button.module-image__border-overlay:focus {
width: 100%; width: 100%;
&--blur { &--blur {
position: absolute;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
@ -5248,31 +5251,24 @@ button.module-image__border-overlay:focus {
} }
} }
&__video-off { &__camera-is-off {
&--icon { @include calling-text-shadow;
@include font-body-1;
color: $color-white;
display: flex;
z-index: 1;
&::before {
content: '';
display: block;
@include color-svg( @include color-svg(
'../images/icons/v2/video-off-solid-24.svg', '../images/icons/v2/video-off-solid-24.svg',
$color-white $color-white
); );
height: 24px; height: 24px;
margin-bottom: 8px; margin-right: 10px;
width: 24px; width: 24px;
} }
&--text {
color: $color-white;
z-index: 1;
}
&--container {
display: flex;
flex-direction: row;
margin-top: 12px;
.module-calling__video-off--text {
margin-left: 10px;
}
}
} }
} }
@ -5352,20 +5348,31 @@ button.module-image__border-overlay:focus {
height: $size; height: $size;
width: $size; width: $size;
} }
&__cancel {
@include color-svg('../images/icons/v2/x-24.svg', $color-white);
height: $size;
width: $size;
}
}
.module-calling-button__container {
display: inline-flex;
flex-direction: column;
} }
.module-calling-button__icon { .module-calling-button__icon {
border-radius: 56px; border-radius: 52px;
height: 56px; height: 52px;
width: 56px; width: 52px;
@mixin calling-button-icon($icon, $background-color, $icon-color) { @mixin calling-button-icon($icon, $background-color, $icon-color) {
background-color: $background-color; background-color: $background-color;
div { div {
@include color-svg($icon, $icon-color); @include color-svg($icon, $icon-color);
height: 28px; height: 24px;
width: 28px; width: 24px;
} }
} }
@ -5433,6 +5440,16 @@ button.module-image__border-overlay:focus {
} }
} }
.module-calling-button__label {
@include font-subtitle;
margin-top: 8px;
text-align: center;
text-transform: lowercase;
color: $color-white;
@include calling-text-shadow;
user-select: none;
}
@keyframes module-ongoing-call__controls--fade-in { @keyframes module-ongoing-call__controls--fade-in {
from { from {
opacity: 0; opacity: 0;
@ -5804,84 +5821,6 @@ button.module-image__border-overlay:focus {
} }
} }
.module-calling-lobby {
&__actions {
align-items: flex-start;
display: flex;
flex-direction: row;
flex: 0 0 100px;
}
&__button {
margin-left: 8px;
margin-right: 8px;
width: 160px;
&[disabled] {
opacity: 0.5;
}
}
// The dimensions of this element are set by JavaScript.
&__local-preview {
$transition: 200ms ease-out;
@include font-body-2;
border-radius: 8px;
color: $color-white;
display: flex;
flex-direction: column;
max-height: 100%;
max-width: 100%;
overflow: hidden;
position: relative;
transition: width $transition, height $transition;
&-container {
align-items: center;
display: flex;
flex-direction: column;
flex: 1 1 auto;
justify-content: center;
margin: 24px;
overflow: hidden;
width: 90%;
}
&__video-on {
background-color: $color-gray-80;
display: block;
flex-grow: 1;
object-fit: contain;
transform: rotateY(180deg);
width: 100%;
height: 100%;
}
&__video-off {
&__icon {
@include color-svg(
'../images/icons/v2/video-off-solid-24.svg',
$color-white
);
height: 24px;
margin-bottom: 8px;
width: 24px;
}
&__text {
z-index: 1;
}
}
}
&__info {
color: $color-white;
margin-bottom: 36px;
margin-top: 12px;
}
}
.module-calling-pip { .module-calling-pip {
backface-visibility: hidden; backface-visibility: hidden;
background-color: $color-gray-95; background-color: $color-gray-95;
@ -9547,40 +9486,6 @@ button.module-image__border-overlay:focus {
outline: none; outline: none;
padding: 7px 12px; padding: 7px 12px;
} }
&__gray {
@include font-body-1-bold;
background-color: $color-gray-45;
border-radius: 4px;
border: none;
color: $color-white;
line-height: 24px;
outline: none;
padding: 7px 14px;
@include keyboard-mode {
&:focus {
box-shadow: 0px 0px 0px 2px $color-ultramarine;
}
}
}
&__green {
@include font-body-1-bold;
background-color: $color-accent-green;
border-radius: 4px;
border: none;
color: $color-white;
line-height: 24px;
outline: none;
padding: 7px 14px;
@include keyboard-mode {
&:focus {
box-shadow: 0px 0px 0px 2px $color-ultramarine;
}
}
}
} }
// Module: Group Contact Details // Module: Group Contact Details

View file

@ -134,4 +134,26 @@
@include hover-and-active-states($background-color, $color-white); @include hover-and-active-states($background-color, $color-white);
} }
} }
&--calling {
$color: $color-white;
$background-color: $color-accent-green;
@include rounded-corners;
color: $color;
background: $background-color;
&:disabled {
color: fade-out($color, 0.4);
background: fade-out($background-color, 0.6);
}
@include light-theme {
@include hover-and-active-states($background-color, $color-black);
}
@include dark-theme {
@include hover-and-active-states($background-color, $color-white);
}
}
} }

View file

@ -0,0 +1,48 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-CallingLobby {
&__local-preview {
height: 100%;
object-fit: cover;
opacity: 0.6;
position: absolute;
transform: rotateY(180deg);
width: 100%;
z-index: -1;
}
&__camera-is-off {
@include calling-text-shadow;
@include font-subtitle;
align-items: center;
color: $color-white;
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
text-align: center;
transition: opacity 100ms ease-out;
user-select: none;
&--visible {
opacity: 1;
}
&--invisible {
opacity: 0;
}
&::before {
content: '';
display: block;
@include color-svg(
'../images/icons/v2/video-off-solid-24.svg',
$color-white
);
height: 24px;
margin-bottom: 8px;
width: 24px;
}
}
}

View file

@ -0,0 +1,6 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-CallingLobbyJoinButton {
margin-bottom: 32px;
}

View file

@ -0,0 +1,29 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-CallingPreCallInfo {
text-align: center;
user-select: none;
&__title,
&__subtitle {
-webkit-box-orient: vertical;
color: $color-white;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
@include calling-text-shadow;
}
&__title {
-webkit-line-clamp: 1;
@include font-title-2;
margin-top: 16px;
}
&__subtitle {
-webkit-line-clamp: 2;
@include font-body-1;
margin-top: 8px;
}
}

View file

@ -37,6 +37,9 @@
@import './components/AvatarTextEditor.scss'; @import './components/AvatarTextEditor.scss';
@import './components/BetterAvatarBubble.scss'; @import './components/BetterAvatarBubble.scss';
@import './components/Button.scss'; @import './components/Button.scss';
@import './components/CallingLobby.scss';
@import './components/CallingLobbyJoinButton.scss';
@import './components/CallingPreCallInfo.scss';
@import './components/CallingScreenSharingController.scss'; @import './components/CallingScreenSharingController.scss';
@import './components/CallingSelectPresentingSourcesModal.scss'; @import './components/CallingSelectPresentingSourcesModal.scss';
@import './components/ChatColorPicker.scss'; @import './components/ChatColorPicker.scss';

View file

@ -19,6 +19,7 @@ story.add('Kitchen sink', () => (
ButtonVariant.SecondaryAffirmative, ButtonVariant.SecondaryAffirmative,
ButtonVariant.SecondaryDestructive, ButtonVariant.SecondaryDestructive,
ButtonVariant.Destructive, ButtonVariant.Destructive,
ButtonVariant.Calling,
].map(variant => ( ].map(variant => (
<React.Fragment key={variant}> <React.Fragment key={variant}>
<p> <p>
@ -50,3 +51,9 @@ story.add('aria-label', () => (
onClick={action('onClick')} onClick={action('onClick')}
/> />
)); ));
story.add('Custom styles', () => (
<Button onClick={action('onClick')} style={{ transform: 'rotate(5deg)' }}>
Hello world
</Button>
));

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { MouseEventHandler, ReactNode } from 'react'; import React, { CSSProperties, MouseEventHandler, ReactNode } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { assert } from '../util/assert'; import { assert } from '../util/assert';
@ -17,12 +17,15 @@ export enum ButtonVariant {
SecondaryAffirmative, SecondaryAffirmative,
SecondaryDestructive, SecondaryDestructive,
Destructive, Destructive,
Calling,
} }
type PropsType = { type PropsType = {
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
size?: ButtonSize; size?: ButtonSize;
style?: CSSProperties;
tabIndex?: number;
variant?: ButtonVariant; variant?: ButtonVariant;
} & ( } & (
| { | {
@ -64,6 +67,7 @@ const VARIANT_CLASS_NAMES = new Map<ButtonVariant, string>([
'module-Button--secondary module-Button--secondary--destructive', 'module-Button--secondary module-Button--secondary--destructive',
], ],
[ButtonVariant.Destructive, 'module-Button--destructive'], [ButtonVariant.Destructive, 'module-Button--destructive'],
[ButtonVariant.Calling, 'module-Button--calling'],
]); ]);
export const Button = React.forwardRef<HTMLButtonElement, PropsType>( export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
@ -73,6 +77,8 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
className, className,
disabled = false, disabled = false,
size = ButtonSize.Medium, size = ButtonSize.Medium,
style,
tabIndex,
variant = ButtonVariant.Primary, variant = ButtonVariant.Primary,
} = props; } = props;
const ariaLabel = props['aria-label']; const ariaLabel = props['aria-label'];
@ -105,6 +111,8 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
ref={ref} ref={ref}
style={style}
tabIndex={tabIndex}
// The `type` should either be "button" or "submit", which is effectively static. // The `type` should either be "button" or "submit", which is effectively static.
// eslint-disable-next-line react/button-has-type // eslint-disable-next-line react/button-has-type
type={type} type={type}

View file

@ -7,20 +7,26 @@ import { AvatarColorType } from '../types/Colors';
export type PropsType = { export type PropsType = {
avatarPath?: string; avatarPath?: string;
children: React.ReactNode; children?: React.ReactNode;
className?: string;
color?: AvatarColorType; color?: AvatarColorType;
}; };
export const CallBackgroundBlur = ({ export const CallBackgroundBlur = ({
avatarPath, avatarPath,
children, children,
className,
color, color,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
return ( return (
<div <div
className={classNames('module-calling__background', { className={classNames(
'module-calling__background',
{
[`module-background-color__${color || 'default'}`]: !avatarPath, [`module-background-color__${color || 'default'}`]: !avatarPath,
})} },
className
)}
> >
{avatarPath && ( {avatarPath && (
<div <div

View file

@ -137,6 +137,7 @@ story.add('Ongoing Group Call', () => (
deviceCount: 0, deviceCount: 0,
joinState: GroupCallJoinState.Joined, joinState: GroupCallJoinState.Joined,
maxDevices: 5, maxDevices: 5,
groupMembers: [],
peekedParticipants: [], peekedParticipants: [],
remoteParticipants: [], remoteParticipants: [],
}, },
@ -189,6 +190,7 @@ story.add('Group call - Safety Number Changed', () => (
deviceCount: 0, deviceCount: 0,
joinState: GroupCallJoinState.Joined, joinState: GroupCallJoinState.Joined,
maxDevices: 5, maxDevices: 5,
groupMembers: [],
peekedParticipants: [], peekedParticipants: [],
remoteParticipants: [], remoteParticipants: [],
}, },

View file

@ -163,6 +163,9 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
let isCallFull: boolean; let isCallFull: boolean;
let showCallLobby: boolean; let showCallLobby: boolean;
let groupMembers:
| undefined
| Array<Pick<ConversationType, 'firstName' | 'title' | 'uuid'>>;
switch (activeCall.callMode) { switch (activeCall.callMode) {
case CallMode.Direct: { case CallMode.Direct: {
@ -182,11 +185,13 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
} }
showCallLobby = !callState; showCallLobby = !callState;
isCallFull = false; isCallFull = false;
groupMembers = undefined;
break; break;
} }
case CallMode.Group: { case CallMode.Group: {
showCallLobby = activeCall.joinState === GroupCallJoinState.NotJoined; showCallLobby = activeCall.joinState === GroupCallJoinState.NotJoined;
isCallFull = activeCall.deviceCount >= activeCall.maxDevices; isCallFull = activeCall.deviceCount >= activeCall.maxDevices;
({ groupMembers } = activeCall);
break; break;
} }
default: default:
@ -199,6 +204,7 @@ const ActiveCallManager: React.FC<ActiveCallManagerPropsType> = ({
<CallingLobby <CallingLobby
availableCameras={availableCameras} availableCameras={availableCameras}
conversation={conversation} conversation={conversation}
groupMembers={groupMembers}
hasLocalAudio={hasLocalAudio} hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo} hasLocalVideo={hasLocalVideo}
i18n={i18n} i18n={i18n}

View file

@ -94,6 +94,7 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
joinState: GroupCallJoinState.Joined, joinState: GroupCallJoinState.Joined,
maxDevices: 5, maxDevices: 5,
deviceCount: (overrideProps.remoteParticipants || []).length, deviceCount: (overrideProps.remoteParticipants || []).length,
groupMembers: overrideProps.remoteParticipants || [],
// Because remote participants are a superset, we can use them in place of peeked // Because remote participants are a superset, we can use them in place of peeked
// participants. // participants.
peekedParticipants: peekedParticipants:

View file

@ -313,7 +313,6 @@ export const CallScreen: React.FC<PropsType> = ({
className={classNames('module-ongoing-call__header', controlsFadeClass)} className={classNames('module-ongoing-call__header', controlsFadeClass)}
> >
<CallingHeader <CallingHeader
canPip
i18n={i18n} i18n={i18n}
isInSpeakerView={isInSpeakerView} isInSpeakerView={isInSpeakerView}
isGroupCall={isGroupCall} isGroupCall={isGroupCall}
@ -357,11 +356,8 @@ export const CallScreen: React.FC<PropsType> = ({
sharedGroupNames={[]} sharedGroupNames={[]}
size={80} size={80}
/> />
<div className="module-calling__video-off--container"> <div className="module-calling__camera-is-off">
<div className="module-calling__video-off--icon" />
<span className="module-calling__video-off--text">
{i18n('calling__your-video-is-off')} {i18n('calling__your-video-is-off')}
</span>
</div> </div>
</CallBackgroundBlur> </CallBackgroundBlur>
</div> </div>

View file

@ -1,8 +1,9 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { useMemo } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { v4 as uuid } from 'uuid';
import { Tooltip, TooltipPlacement } from './Tooltip'; import { Tooltip, TooltipPlacement } from './Tooltip';
import { Theme } from '../util/theme'; import { Theme } from '../util/theme';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
@ -33,42 +34,55 @@ export const CallingButton = ({
onClick, onClick,
tooltipDirection, tooltipDirection,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
const uniqueButtonId = useMemo(() => uuid(), []);
let classNameSuffix = ''; let classNameSuffix = '';
let tooltipContent = ''; let tooltipContent = '';
let label = '';
let disabled = false; let disabled = false;
if (buttonType === CallingButtonType.AUDIO_DISABLED) { if (buttonType === CallingButtonType.AUDIO_DISABLED) {
classNameSuffix = 'audio--disabled'; classNameSuffix = 'audio--disabled';
tooltipContent = i18n('calling__button--audio-disabled'); tooltipContent = i18n('calling__button--audio-disabled');
label = i18n('calling__button--audio__label');
disabled = true; disabled = true;
} else if (buttonType === CallingButtonType.AUDIO_OFF) { } else if (buttonType === CallingButtonType.AUDIO_OFF) {
classNameSuffix = 'audio--off'; classNameSuffix = 'audio--off';
tooltipContent = i18n('calling__button--audio-on'); tooltipContent = i18n('calling__button--audio-on');
label = i18n('calling__button--audio__label');
} else if (buttonType === CallingButtonType.AUDIO_ON) { } else if (buttonType === CallingButtonType.AUDIO_ON) {
classNameSuffix = 'audio--on'; classNameSuffix = 'audio--on';
tooltipContent = i18n('calling__button--audio-off'); tooltipContent = i18n('calling__button--audio-off');
label = i18n('calling__button--audio__label');
} else if (buttonType === CallingButtonType.VIDEO_DISABLED) { } else if (buttonType === CallingButtonType.VIDEO_DISABLED) {
classNameSuffix = 'video--disabled'; classNameSuffix = 'video--disabled';
tooltipContent = i18n('calling__button--video-disabled'); tooltipContent = i18n('calling__button--video-disabled');
disabled = true; disabled = true;
label = i18n('calling__button--video__label');
} else if (buttonType === CallingButtonType.VIDEO_OFF) { } else if (buttonType === CallingButtonType.VIDEO_OFF) {
classNameSuffix = 'video--off'; classNameSuffix = 'video--off';
tooltipContent = i18n('calling__button--video-on'); tooltipContent = i18n('calling__button--video-on');
label = i18n('calling__button--video__label');
} else if (buttonType === CallingButtonType.VIDEO_ON) { } else if (buttonType === CallingButtonType.VIDEO_ON) {
classNameSuffix = 'video--on'; classNameSuffix = 'video--on';
tooltipContent = i18n('calling__button--video-off'); tooltipContent = i18n('calling__button--video-off');
label = i18n('calling__button--video__label');
} else if (buttonType === CallingButtonType.HANG_UP) { } else if (buttonType === CallingButtonType.HANG_UP) {
classNameSuffix = 'hangup'; classNameSuffix = 'hangup';
tooltipContent = i18n('calling__hangup'); tooltipContent = i18n('calling__hangup');
label = i18n('calling__hangup');
} else if (buttonType === CallingButtonType.PRESENTING_DISABLED) { } else if (buttonType === CallingButtonType.PRESENTING_DISABLED) {
classNameSuffix = 'presenting--disabled'; classNameSuffix = 'presenting--disabled';
tooltipContent = i18n('calling__button--presenting-disabled'); tooltipContent = i18n('calling__button--presenting-disabled');
disabled = true; disabled = true;
label = i18n('calling__button--presenting__label');
} else if (buttonType === CallingButtonType.PRESENTING_ON) { } else if (buttonType === CallingButtonType.PRESENTING_ON) {
classNameSuffix = 'presenting--on'; classNameSuffix = 'presenting--on';
tooltipContent = i18n('calling__button--presenting-off'); tooltipContent = i18n('calling__button--presenting-off');
label = i18n('calling__button--presenting__label');
} else if (buttonType === CallingButtonType.PRESENTING_OFF) { } else if (buttonType === CallingButtonType.PRESENTING_OFF) {
classNameSuffix = 'presenting--off'; classNameSuffix = 'presenting--off';
tooltipContent = i18n('calling__button--presenting-on'); tooltipContent = i18n('calling__button--presenting-on');
label = i18n('calling__button--presenting__label');
} }
const className = classNames( const className = classNames(
@ -82,15 +96,24 @@ export const CallingButton = ({
direction={tooltipDirection} direction={tooltipDirection}
theme={Theme.Dark} theme={Theme.Dark}
> >
<div className="module-calling-button__container">
<button <button
aria-label={tooltipContent} aria-label={tooltipContent}
className={className} className={className}
disabled={disabled} disabled={disabled}
id={uniqueButtonId}
onClick={onClick} onClick={onClick}
type="button" type="button"
> >
<div /> <div />
</button> </button>
<label
className="module-calling-button__label"
htmlFor={uniqueButtonId}
>
{label}
</label>
</div>
</Tooltip> </Tooltip>
); );
}; };

View file

@ -1,4 +1,4 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
@ -13,7 +13,6 @@ import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
canPip: boolean('canPip', Boolean(overrideProps.canPip)),
i18n, i18n,
isGroupCall: boolean('isGroupCall', Boolean(overrideProps.isGroupCall)), isGroupCall: boolean('isGroupCall', Boolean(overrideProps.isGroupCall)),
message: overrideProps.message, message: overrideProps.message,
@ -35,14 +34,18 @@ const story = storiesOf('Components/CallingHeader', module);
story.add('Default', () => <CallingHeader {...createProps()} />); story.add('Default', () => <CallingHeader {...createProps()} />);
story.add('Has Pip', () => ( story.add('Lobby style', () => (
<CallingHeader {...createProps({ canPip: true })} /> <CallingHeader
{...createProps()}
title={undefined}
togglePip={undefined}
onCancel={action('onClose')}
/>
)); ));
story.add('With Participants', () => ( story.add('With Participants', () => (
<CallingHeader <CallingHeader
{...createProps({ {...createProps({
canPip: true,
isGroupCall: true, isGroupCall: true,
participantCount: 10, participantCount: 10,
})} })}
@ -52,7 +55,6 @@ story.add('With Participants', () => (
story.add('With Participants (shown)', () => ( story.add('With Participants (shown)', () => (
<CallingHeader <CallingHeader
{...createProps({ {...createProps({
canPip: true,
isGroupCall: true, isGroupCall: true,
participantCount: 10, participantCount: 10,
showParticipantsList: true, showParticipantsList: true,

View file

@ -8,11 +8,11 @@ import { Tooltip } from './Tooltip';
import { Theme } from '../util/theme'; import { Theme } from '../util/theme';
export type PropsType = { export type PropsType = {
canPip?: boolean;
i18n: LocalizerType; i18n: LocalizerType;
isInSpeakerView?: boolean; isInSpeakerView?: boolean;
isGroupCall?: boolean; isGroupCall?: boolean;
message?: string; message?: string;
onCancel?: () => void;
participantCount: number; participantCount: number;
showParticipantsList: boolean; showParticipantsList: boolean;
title?: string; title?: string;
@ -23,11 +23,11 @@ export type PropsType = {
}; };
export const CallingHeader = ({ export const CallingHeader = ({
canPip = false,
i18n, i18n,
isInSpeakerView, isInSpeakerView,
isGroupCall = false, isGroupCall = false,
message, message,
onCancel,
participantCount, participantCount,
showParticipantsList, showParticipantsList,
title, title,
@ -44,7 +44,7 @@ export const CallingHeader = ({
<div className="module-ongoing-call__header-message">{message}</div> <div className="module-ongoing-call__header-message">{message}</div>
) : null} ) : null}
<div className="module-calling-tools"> <div className="module-calling-tools">
{isGroupCall ? ( {participantCount ? (
<div className="module-calling-tools__button"> <div className="module-calling-tools__button">
<Tooltip <Tooltip
content={i18n('calling__participants', [String(participantCount)])} content={i18n('calling__participants', [String(participantCount)])}
@ -111,7 +111,7 @@ export const CallingHeader = ({
</Tooltip> </Tooltip>
</div> </div>
)} )}
{canPip && ( {togglePip && (
<div className="module-calling-tools__button"> <div className="module-calling-tools__button">
<Tooltip content={i18n('calling__pip--on')} theme={Theme.Dark}> <Tooltip content={i18n('calling__pip--on')} theme={Theme.Dark}>
<button <button
@ -123,6 +123,18 @@ export const CallingHeader = ({
</Tooltip> </Tooltip>
</div> </div>
)} )}
{onCancel && (
<div className="module-calling-tools__button">
<Tooltip content={i18n('cancel')} theme={Theme.Dark}>
<button
aria-label={i18n('cancel')}
className="module-calling-button__cancel"
onClick={onCancel}
type="button"
/>
</Tooltip>
</div>
)}
</div> </div>
</div> </div>
); );

View file

@ -1,7 +1,8 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { times } from 'lodash';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { boolean } from '@storybook/addon-knobs'; import { boolean } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
@ -26,15 +27,34 @@ const camera = {
}, },
}; };
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
const isGroupCall = boolean(
'isGroupCall',
overrideProps.isGroupCall || false
);
const conversation = isGroupCall
? getDefaultConversation({
title: 'Tahoe Trip',
type: 'group',
})
: getDefaultConversation();
return {
availableCameras: overrideProps.availableCameras || [camera], availableCameras: overrideProps.availableCameras || [camera],
conversation: { conversation,
title: 'Rick Sanchez', groupMembers: isGroupCall
}, ? times(3, () => getDefaultConversation())
hasLocalAudio: boolean('hasLocalAudio', overrideProps.hasLocalAudio || false), : undefined,
hasLocalVideo: boolean('hasLocalVideo', overrideProps.hasLocalVideo || false), hasLocalAudio: boolean(
'hasLocalAudio',
overrideProps.hasLocalAudio || false
),
hasLocalVideo: boolean(
'hasLocalVideo',
overrideProps.hasLocalVideo || false
),
i18n, i18n,
isGroupCall: boolean('isGroupCall', overrideProps.isGroupCall || false), isGroupCall,
isCallFull: boolean('isCallFull', overrideProps.isCallFull || false), isCallFull: boolean('isCallFull', overrideProps.isCallFull || false),
me: overrideProps.me || { me: overrideProps.me || {
color: AvatarColors[0], color: AvatarColors[0],
@ -52,7 +72,8 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
), ),
toggleParticipants: action('toggle-participants'), toggleParticipants: action('toggle-participants'),
toggleSettings: action('toggle-settings'), toggleSettings: action('toggle-settings'),
}); };
};
const fakePeekedParticipant = (conversationProps: Partial<ConversationType>) => const fakePeekedParticipant = (conversationProps: Partial<ConversationType>) =>
getDefaultConversation({ getDefaultConversation({
@ -123,26 +144,6 @@ story.add('Group Call - 1 peeked participant (self)', () => {
return <CallingLobby {...props} />; return <CallingLobby {...props} />;
}); });
story.add('Group Call - 2 peeked participants', () => {
const props = createProps({
isGroupCall: true,
peekedParticipants: ['Sam', 'Cayce'].map(title =>
fakePeekedParticipant({ title })
),
});
return <CallingLobby {...props} />;
});
story.add('Group Call - 3 peeked participants', () => {
const props = createProps({
isGroupCall: true,
peekedParticipants: ['Sam', 'Cayce', 'April'].map(title =>
fakePeekedParticipant({ title })
),
});
return <CallingLobby {...props} />;
});
story.add('Group Call - 4 peeked participants', () => { story.add('Group Call - 4 peeked participants', () => {
const props = createProps({ const props = createProps({
isGroupCall: true, isGroupCall: true,

View file

@ -1,9 +1,8 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { ReactNode } from 'react'; import React from 'react';
import Measure from 'react-measure'; import classNames from 'classnames';
import { debounce } from 'lodash';
import { import {
SetLocalAudioType, SetLocalAudioType,
SetLocalPreviewType, SetLocalPreviewType,
@ -13,25 +12,32 @@ import { CallingButton, CallingButtonType } from './CallingButton';
import { TooltipPlacement } from './Tooltip'; import { TooltipPlacement } from './Tooltip';
import { CallBackgroundBlur } from './CallBackgroundBlur'; import { CallBackgroundBlur } from './CallBackgroundBlur';
import { CallingHeader } from './CallingHeader'; import { CallingHeader } from './CallingHeader';
import { Spinner } from './Spinner'; import { CallingPreCallInfo } from './CallingPreCallInfo';
import {
CallingLobbyJoinButton,
CallingLobbyJoinButtonVariant,
} from './CallingLobbyJoinButton';
import { AvatarColorType } from '../types/Colors'; import { AvatarColorType } from '../types/Colors';
import { LocalizerType } from '../types/Util'; import { LocalizerType } from '../types/Util';
import { ConversationType } from '../state/ducks/conversations'; import { ConversationType } from '../state/ducks/conversations';
import {
REQUESTED_VIDEO_WIDTH,
REQUESTED_VIDEO_HEIGHT,
} from '../calling/constants';
// We request dimensions but may not get them depending on the user's webcam. This is our
// fallback while we don't know.
const VIDEO_ASPECT_RATIO_FALLBACK =
REQUESTED_VIDEO_WIDTH / REQUESTED_VIDEO_HEIGHT;
export type PropsType = { export type PropsType = {
availableCameras: Array<MediaDeviceInfo>; availableCameras: Array<MediaDeviceInfo>;
conversation: { conversation: Pick<
title: string; ConversationType,
}; | 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'type'
| 'unblurredAvatarPath'
>;
groupMembers?: Array<Pick<ConversationType, 'title'>>;
hasLocalAudio: boolean; hasLocalAudio: boolean;
hasLocalVideo: boolean; hasLocalVideo: boolean;
i18n: LocalizerType; i18n: LocalizerType;
@ -56,6 +62,7 @@ export type PropsType = {
export const CallingLobby = ({ export const CallingLobby = ({
availableCameras, availableCameras,
conversation, conversation,
groupMembers,
hasLocalAudio, hasLocalAudio,
hasLocalVideo, hasLocalVideo,
i18n, i18n,
@ -72,19 +79,10 @@ export const CallingLobby = ({
toggleParticipants, toggleParticipants,
toggleSettings, toggleSettings,
}: PropsType): JSX.Element => { }: PropsType): JSX.Element => {
const [
localPreviewContainerWidth,
setLocalPreviewContainerWidth,
] = React.useState<null | number>(null);
const [
localPreviewContainerHeight,
setLocalPreviewContainerHeight,
] = React.useState<null | number>(null);
const [localVideoAspectRatio, setLocalVideoAspectRatio] = React.useState(
VIDEO_ASPECT_RATIO_FALLBACK
);
const localVideoRef = React.useRef<null | HTMLVideoElement>(null); const localVideoRef = React.useRef<null | HTMLVideoElement>(null);
const shouldShowLocalVideo = hasLocalVideo && availableCameras.length > 0;
const toggleAudio = React.useCallback((): void => { const toggleAudio = React.useCallback((): void => {
setLocalAudio({ enabled: !hasLocalAudio }); setLocalAudio({ enabled: !hasLocalAudio });
}, [hasLocalAudio, setLocalAudio]); }, [hasLocalAudio, setLocalAudio]);
@ -93,24 +91,6 @@ export const CallingLobby = ({
setLocalVideo({ enabled: !hasLocalVideo }); setLocalVideo({ enabled: !hasLocalVideo });
}, [hasLocalVideo, setLocalVideo]); }, [hasLocalVideo, setLocalVideo]);
const hasEverMeasured =
localPreviewContainerWidth !== null && localPreviewContainerHeight !== null;
const setLocalPreviewContainerDimensions = React.useMemo(() => {
const set = (bounds: Readonly<{ width: number; height: number }>) => {
setLocalPreviewContainerWidth(bounds.width);
setLocalPreviewContainerHeight(bounds.height);
};
if (hasEverMeasured) {
return debounce(set, 100, { maxWait: 3000 });
}
return set;
}, [
hasEverMeasured,
setLocalPreviewContainerWidth,
setLocalPreviewContainerHeight,
]);
React.useEffect(() => { React.useEffect(() => {
setLocalPreview({ element: localVideoRef }); setLocalPreview({ element: localVideoRef });
@ -119,21 +99,6 @@ export const CallingLobby = ({
}; };
}, [setLocalPreview]); }, [setLocalPreview]);
// This isn't perfect because it doesn't react to changes in the webcam's aspect ratio.
// For example, if you changed from Webcam A to Webcam B and Webcam B had a different
// aspect ratio, we wouldn't update.
//
// Unfortunately, RingRTC (1) doesn't update these dimensions with the "real" camera
// dimensions (2) doesn't give us any hooks or callbacks. For now, this works okay.
// We have `object-fit: contain` in the CSS in case we're wrong; not ideal, but
// usable.
React.useEffect(() => {
const videoEl = localVideoRef.current;
if (hasLocalVideo && videoEl && videoEl.width && videoEl.height) {
setLocalVideoAspectRatio(videoEl.width / videoEl.height);
}
}, [hasLocalVideo, setLocalVideoAspectRatio]);
React.useEffect(() => { React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent): void { function handleKeyDown(event: KeyboardEvent): void {
let eventHandled = false; let eventHandled = false;
@ -171,105 +136,66 @@ export const CallingLobby = ({
? CallingButtonType.AUDIO_ON ? CallingButtonType.AUDIO_ON
: CallingButtonType.AUDIO_OFF; : CallingButtonType.AUDIO_OFF;
// It should be rare to see yourself in this list, but it's possible if (1) you rejoin
// quickly, causing the server to return stale state (2) you have joined on another
// device.
const participantNames = peekedParticipants.map(participant =>
participant.uuid === me.uuid
? i18n('you')
: participant.firstName || participant.title
);
const hasYou = peekedParticipants.some(
participant => participant.uuid === me.uuid
);
const canJoin = !isCallFull && !isCallConnecting; const canJoin = !isCallFull && !isCallConnecting;
let joinButtonChildren: ReactNode; let callingLobbyJoinButtonVariant: CallingLobbyJoinButtonVariant;
if (isCallFull) { if (isCallFull) {
joinButtonChildren = i18n('calling__call-is-full'); callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.CallIsFull;
} else if (isCallConnecting) { } else if (isCallConnecting) {
joinButtonChildren = <Spinner svgSize="small" />; callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Loading;
} else if (peekedParticipants.length) { } else if (peekedParticipants.length) {
joinButtonChildren = i18n('calling__join'); callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Join;
} else { } else {
joinButtonChildren = i18n('calling__start'); callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Start;
}
let localPreviewStyles: React.CSSProperties;
// It'd be nice to use `hasEverMeasured` here, too, but TypeScript isn't smart enough
// to understand the logic here.
if (
localPreviewContainerWidth !== null &&
localPreviewContainerHeight !== null
) {
const containerAspectRatio =
localPreviewContainerWidth / localPreviewContainerHeight;
localPreviewStyles =
containerAspectRatio < localVideoAspectRatio
? {
width: '100%',
height: Math.floor(
localPreviewContainerWidth / localVideoAspectRatio
),
}
: {
width: Math.floor(
localPreviewContainerHeight * localVideoAspectRatio
),
height: '100%',
};
} else {
localPreviewStyles = { display: 'none' };
} }
return ( return (
<div className="module-calling__container"> <div className="module-calling__container">
{shouldShowLocalVideo ? (
<video
className="module-CallingLobby__local-preview"
ref={localVideoRef}
autoPlay
/>
) : (
<CallBackgroundBlur
className="module-CallingLobby__local-preview"
avatarPath={me.avatarPath}
color={me.color}
/>
)}
<CallingHeader <CallingHeader
title={conversation.title}
i18n={i18n} i18n={i18n}
isGroupCall={isGroupCall} isGroupCall={isGroupCall}
participantCount={peekedParticipants.length} participantCount={peekedParticipants.length}
showParticipantsList={showParticipantsList} showParticipantsList={showParticipantsList}
toggleParticipants={toggleParticipants} toggleParticipants={toggleParticipants}
toggleSettings={toggleSettings} toggleSettings={toggleSettings}
onCancel={onCallCanceled}
/> />
<Measure <CallingPreCallInfo
bounds conversation={conversation}
onResize={({ bounds }) => { groupMembers={groupMembers}
if (!bounds) { i18n={i18n}
window.log.error('We should be measuring bounds'); isCallFull={isCallFull}
return; me={me}
} peekedParticipants={peekedParticipants}
setLocalPreviewContainerDimensions(bounds);
}}
>
{({ measureRef }) => (
<div
ref={measureRef}
className="module-calling-lobby__local-preview-container"
>
<div
className="module-calling-lobby__local-preview"
style={localPreviewStyles}
>
{hasLocalVideo && availableCameras.length > 0 ? (
<video
className="module-calling-lobby__local-preview__video-on"
ref={localVideoRef}
autoPlay
/> />
) : (
<CallBackgroundBlur avatarPath={me.avatarPath} color={me.color}> <div
<div className="module-calling-lobby__local-preview__video-off__icon" /> className={classNames(
<span className="module-calling-lobby__local-preview__video-off__text"> 'module-CallingLobby__camera-is-off',
{i18n('calling__your-video-is-off')} `module-CallingLobby__camera-is-off--${
</span> shouldShowLocalVideo ? 'invisible' : 'visible'
</CallBackgroundBlur> }`
)} )}
>
{i18n('calling__your-video-is-off')}
</div>
<div className="module-calling__buttons"> <div className="module-calling__buttons module-calling__buttons--inline">
<CallingButton <CallingButton
buttonType={videoButtonType} buttonType={videoButtonType}
i18n={i18n} i18n={i18n}
@ -283,67 +209,16 @@ export const CallingLobby = ({
tooltipDirection={TooltipPlacement.Top} tooltipDirection={TooltipPlacement.Top}
/> />
</div> </div>
</div>
</div>
)}
</Measure>
{isGroupCall ? ( <CallingLobbyJoinButton
<div className="module-calling-lobby__info">
{participantNames.length === 0 &&
i18n('calling__lobby-summary--zero')}
{participantNames.length === 1 &&
hasYou &&
i18n('calling__lobby-summary--self')}
{participantNames.length === 1 &&
!hasYou &&
i18n('calling__lobby-summary--single', participantNames)}
{participantNames.length === 2 &&
i18n('calling__lobby-summary--double', {
first: participantNames[0],
second: participantNames[1],
})}
{participantNames.length === 3 &&
i18n('calling__lobby-summary--triple', {
first: participantNames[0],
second: participantNames[1],
third: participantNames[2],
})}
{participantNames.length > 3 &&
i18n('calling__lobby-summary--many', {
first: participantNames[0],
second: participantNames[1],
others: String(participantNames.length - 2),
})}
</div>
) : null}
<div className="module-calling-lobby__actions">
<button
className="module-button__gray module-calling-lobby__button"
onClick={onCallCanceled}
tabIndex={0}
type="button"
>
{i18n('cancel')}
</button>
<button
className="module-button__green module-calling-lobby__button"
disabled={!canJoin} disabled={!canJoin}
onClick={ i18n={i18n}
canJoin onClick={() => {
? () => {
setIsCallConnecting(true); setIsCallConnecting(true);
onJoinCall(); onJoinCall();
} }}
: undefined variant={callingLobbyJoinButtonVariant}
} />
tabIndex={0}
type="button"
>
{joinButtonChildren}
</button>
</div>
</div> </div>
); );
}; };

View file

@ -0,0 +1,107 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent, ReactChild, useState } from 'react';
import { noop } from 'lodash';
import type { LocalizerType } from '../types/Util';
import { Button, ButtonVariant } from './Button';
import { Spinner } from './Spinner';
const PADDING_HORIZONTAL = 48;
const PADDING_VERTICAL = 12;
export enum CallingLobbyJoinButtonVariant {
CallIsFull = 'CallIsFull',
Join = 'Join',
Loading = 'Loading',
Start = 'Start',
}
/**
* This component is a little weird. Why not just render a button with some children?
*
* The contents of this component can change but we don't want its size to change, so we
* render all the variants invisibly, compute the maximum size, and then render the
* "final" button with those dimensions.
*
* For example, we might initially render "Join call" and then render a spinner when you
* click the button. The button shouldn't resize in that situation.
*/
export const CallingLobbyJoinButton: FunctionComponent<{
disabled?: boolean;
i18n: LocalizerType;
onClick: () => void;
variant: CallingLobbyJoinButtonVariant;
}> = ({ disabled, i18n, onClick, variant }) => {
const [width, setWidth] = useState<undefined | number>();
const [height, setHeight] = useState<undefined | number>();
const childrenByVariant: Record<CallingLobbyJoinButtonVariant, ReactChild> = {
[CallingLobbyJoinButtonVariant.CallIsFull]: i18n('calling__call-is-full'),
[CallingLobbyJoinButtonVariant.Loading]: <Spinner svgSize="small" />,
[CallingLobbyJoinButtonVariant.Join]: i18n('calling__join'),
[CallingLobbyJoinButtonVariant.Start]: i18n('calling__start'),
};
return (
<>
{Boolean(width && height) && (
<Button
className="module-CallingLobbyJoinButton"
disabled={disabled}
onClick={onClick}
style={{ width, height }}
tabIndex={0}
variant={ButtonVariant.Calling}
>
{childrenByVariant[variant]}
</Button>
)}
<div
style={{
visibility: 'hidden',
position: 'fixed',
left: -9999,
top: -9999,
}}
>
{Object.values(CallingLobbyJoinButtonVariant).map(candidateVariant => (
<Button
key={candidateVariant}
className="module-CallingLobbyJoinButton"
variant={ButtonVariant.Calling}
onClick={noop}
ref={(button: HTMLButtonElement | null) => {
if (!button) {
return;
}
const {
width: variantWidth,
height: variantHeight,
} = button.getBoundingClientRect();
// We could set the padding in CSS, but we don't do that in case some other
// styling causes a re-render of the button but not of the component. This
// is easiest to reproduce in Storybook, where the font hasn't loaded yet;
// we compute the size, then the font makes the text a bit larger, and
// there's a layout issue.
setWidth((previousWidth = 0) =>
Math.ceil(
Math.max(previousWidth, variantWidth + PADDING_HORIZONTAL)
)
);
setHeight((previousHeight = 0) =>
Math.ceil(
Math.max(previousHeight, variantHeight + PADDING_VERTICAL)
)
);
}}
>
{childrenByVariant[candidateVariant]}
</Button>
))}
</div>
</>
);
};

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { times } from 'lodash';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { boolean } from '@storybook/addon-knobs'; import { boolean } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
@ -109,6 +110,7 @@ story.add('Group Call', () => {
callMode: CallMode.Group as CallMode.Group, callMode: CallMode.Group as CallMode.Group,
connectionState: GroupCallConnectionState.Connected, connectionState: GroupCallConnectionState.Connected,
conversationsWithSafetyNumberChanges: [], conversationsWithSafetyNumberChanges: [],
groupMembers: times(3, () => getDefaultConversation()),
joinState: GroupCallJoinState.Joined, joinState: GroupCallJoinState.Joined,
maxDevices: 5, maxDevices: 5,
deviceCount: 0, deviceCount: 0,

View file

@ -0,0 +1,100 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { times, range } from 'lodash';
import { storiesOf } from '@storybook/react';
import { setup as setupI18n } from '../../js/modules/i18n';
import enMessages from '../../_locales/en/messages.json';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { CallingPreCallInfo } from './CallingPreCallInfo';
const i18n = setupI18n('en', enMessages);
const getDefaultGroupConversation = () =>
getDefaultConversation({
name: 'Tahoe Trip',
phoneNumber: undefined,
profileName: undefined,
title: 'Tahoe Trip',
type: 'group',
});
const otherMembers = times(6, () => getDefaultConversation());
const story = storiesOf('Components/CallingPreCallInfo', module);
story.add('Direct conversation', () => (
<CallingPreCallInfo
conversation={getDefaultConversation()}
i18n={i18n}
me={getDefaultConversation()}
/>
));
story.add('Group conversation, empty group', () => (
<CallingPreCallInfo
conversation={getDefaultGroupConversation()}
groupMembers={[]}
i18n={i18n}
me={getDefaultConversation()}
peekedParticipants={[]}
/>
));
times(5, numberOfOtherPeople => {
story.add(
`Group conversation, group has ${numberOfOtherPeople} other member${
numberOfOtherPeople === 1 ? '' : 's'
}`,
() => (
<CallingPreCallInfo
conversation={getDefaultGroupConversation()}
groupMembers={otherMembers.slice(0, numberOfOtherPeople)}
i18n={i18n}
me={getDefaultConversation()}
peekedParticipants={[]}
/>
)
);
});
range(1, 5).forEach(numberOfOtherPeople => {
story.add(
`Group conversation, ${numberOfOtherPeople} peeked participant${
numberOfOtherPeople === 1 ? '' : 's'
}`,
() => (
<CallingPreCallInfo
conversation={getDefaultGroupConversation()}
groupMembers={otherMembers}
i18n={i18n}
me={getDefaultConversation()}
peekedParticipants={otherMembers.slice(0, numberOfOtherPeople)}
/>
)
);
});
story.add('Group conversation, you on an other device', () => {
const me = getDefaultConversation();
return (
<CallingPreCallInfo
conversation={getDefaultGroupConversation()}
groupMembers={otherMembers}
i18n={i18n}
me={me}
peekedParticipants={[me]}
/>
);
});
story.add('Group conversation, call is full', () => (
<CallingPreCallInfo
conversation={getDefaultGroupConversation()}
groupMembers={otherMembers}
i18n={i18n}
isCallFull
me={getDefaultConversation()}
peekedParticipants={otherMembers}
/>
));

View file

@ -0,0 +1,159 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent } from 'react';
import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util';
import { Avatar, AvatarSize } from './Avatar';
import { Emojify } from './conversation/Emojify';
import { missingCaseError } from '../util/missingCaseError';
type PropsType = {
conversation: Pick<
ConversationType,
| 'acceptedMessageRequest'
| 'avatarPath'
| 'color'
| 'isMe'
| 'name'
| 'phoneNumber'
| 'profileName'
| 'sharedGroupNames'
| 'title'
| 'type'
| 'unblurredAvatarPath'
>;
i18n: LocalizerType;
me: Pick<ConversationType, 'uuid'>;
// The following should only be set for group conversations.
groupMembers?: Array<Pick<ConversationType, 'firstName' | 'title' | 'uuid'>>;
isCallFull?: boolean;
peekedParticipants?: Array<
Pick<ConversationType, 'firstName' | 'title' | 'uuid'>
>;
};
export const CallingPreCallInfo: FunctionComponent<PropsType> = ({
conversation,
groupMembers = [],
i18n,
isCallFull = false,
me,
peekedParticipants = [],
}) => {
let subtitle: string;
if (isCallFull) {
subtitle = i18n('calling__call-is-full');
} else if (peekedParticipants.length) {
// It should be rare to see yourself in this list, but it's possible if (1) you rejoin
// quickly, causing the server to return stale state (2) you have joined on another
// device.
let hasYou = false;
const participantNames = peekedParticipants.map(participant => {
if (participant.uuid === me.uuid) {
hasYou = true;
return i18n('you');
}
return getParticipantName(participant);
});
switch (participantNames.length) {
case 1:
subtitle = hasYou
? i18n('calling__pre-call-info--another-device-in-call')
: i18n('calling__pre-call-info--1-person-in-call', participantNames);
break;
case 2:
subtitle = i18n('calling__pre-call-info--2-people-in-call', {
first: participantNames[0],
second: participantNames[1],
});
break;
case 3:
subtitle = i18n('calling__pre-call-info--3-people-in-call', {
first: participantNames[0],
second: participantNames[1],
third: participantNames[2],
});
break;
default:
subtitle = i18n('calling__pre-call-info--many-people-in-call', {
first: participantNames[0],
second: participantNames[1],
others: String(participantNames.length - 2),
});
break;
}
} else if (conversation.type === 'direct') {
subtitle = i18n('calling__pre-call-info--will-ring-1', [
getParticipantName(conversation),
]);
} else if (conversation.type === 'group') {
const memberNames = groupMembers.map(getParticipantName);
switch (memberNames.length) {
case 0:
subtitle = i18n('calling__pre-call-info--empty-group');
break;
case 1:
subtitle = i18n('calling__pre-call-info--will-notify-1', [
memberNames[0],
]);
break;
case 2:
subtitle = i18n('calling__pre-call-info--will-notify-2', {
first: memberNames[0],
second: memberNames[1],
});
break;
case 3:
subtitle = i18n('calling__pre-call-info--will-notify-3', {
first: memberNames[0],
second: memberNames[1],
third: memberNames[2],
});
break;
default:
subtitle = i18n('calling__pre-call-info--will-notify-many', {
first: memberNames[0],
second: memberNames[1],
others: String(memberNames.length - 2),
});
break;
}
} else {
throw missingCaseError(conversation.type);
}
return (
<div className="module-CallingPreCallInfo">
<Avatar
avatarPath={conversation.avatarPath}
color={conversation.color}
acceptedMessageRequest={conversation.acceptedMessageRequest}
conversationType={conversation.type}
isMe={conversation.isMe}
name={conversation.name}
noteToSelf={false}
phoneNumber={conversation.phoneNumber}
profileName={conversation.profileName}
sharedGroupNames={conversation.sharedGroupNames}
size={AvatarSize.ONE_HUNDRED_TWELVE}
title={conversation.title}
unblurredAvatarPath={conversation.unblurredAvatarPath}
i18n={i18n}
/>
<div className="module-CallingPreCallInfo__title">
<Emojify text={conversation.title} />
</div>
<div className="module-CallingPreCallInfo__subtitle">{subtitle}</div>
</div>
);
};
function getParticipantName(
participant: Readonly<Pick<ConversationType, 'firstName' | 'title'>>
): string {
return participant.firstName || participant.title;
}

View file

@ -115,9 +115,23 @@ const mapStateToActiveCallProp = (
}; };
case CallMode.Group: { case CallMode.Group: {
const conversationsWithSafetyNumberChanges: Array<ConversationType> = []; const conversationsWithSafetyNumberChanges: Array<ConversationType> = [];
const groupMembers: Array<ConversationType> = [];
const remoteParticipants: Array<GroupCallRemoteParticipantType> = []; const remoteParticipants: Array<GroupCallRemoteParticipantType> = [];
const peekedParticipants: Array<ConversationType> = []; const peekedParticipants: Array<ConversationType> = [];
const { memberships = [] } = conversation;
for (let i = 0; i < memberships.length; i += 1) {
const { conversationId } = memberships[i];
const member = conversationSelectorByUuid(conversationId);
if (!member) {
window.log.error('Group member has no corresponding conversation');
continue;
}
groupMembers.push(member);
}
for (let i = 0; i < call.remoteParticipants.length; i += 1) { for (let i = 0; i < call.remoteParticipants.length; i += 1) {
const remoteParticipant = call.remoteParticipants[i]; const remoteParticipant = call.remoteParticipants[i];
@ -183,6 +197,7 @@ const mapStateToActiveCallProp = (
connectionState: call.connectionState, connectionState: call.connectionState,
conversationsWithSafetyNumberChanges, conversationsWithSafetyNumberChanges,
deviceCount: call.peekInfo.deviceCount, deviceCount: call.peekInfo.deviceCount,
groupMembers,
joinState: call.joinState, joinState: call.joinState,
maxDevices: call.peekInfo.maxDevices, maxDevices: call.peekInfo.maxDevices,
peekedParticipants, peekedParticipants,

View file

@ -60,6 +60,7 @@ type ActiveGroupCallType = ActiveCallBaseType & {
joinState: GroupCallJoinState; joinState: GroupCallJoinState;
maxDevices: number; maxDevices: number;
deviceCount: number; deviceCount: number;
groupMembers: Array<Pick<ConversationType, 'firstName' | 'title' | 'uuid'>>;
peekedParticipants: Array<ConversationType>; peekedParticipants: Array<ConversationType>;
remoteParticipants: Array<GroupCallRemoteParticipantType>; remoteParticipants: Array<GroupCallRemoteParticipantType>;
}; };