Raise Hand in Group Calls

This commit is contained in:
ayumi-signal 2023-12-06 13:52:29 -08:00 committed by GitHub
parent 45aeaeefd4
commit d6db3f7943
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1050 additions and 51 deletions

View file

@ -1772,6 +1772,10 @@
"messageformat": "More options",
"description": "Tooltip label for button in the calling screen that opens a menu with other call actions such as React or Raise Hand."
},
"icu:CallingRaisedHandsList__Title": {
"messageformat": "Raised hands · {count, plural, one {# person} other {# people}}",
"description": "Shown in the call raised hands list to describe how many people have active raised hands"
},
"icu:CallingReactions--me": {
"messageformat": "You",
"description": "Label next to in-call reactions to indicate that the current user sent that reaction."
@ -3591,6 +3595,38 @@
"messageformat": "Ringing off",
"description": "Shown in a group call lobby when call ringing is enabled, then the user disables ringing using the Ringing toggle button."
},
"icu:CallControls__RaiseHandsToast--you": {
"messageformat": "Your hand is raised.",
"description": "Shown in a call when the user raises their hand."
},
"icu:CallControls__RaiseHandsToast--one": {
"messageformat": "{name} raised a hand.",
"description": "Shown in a call when someone else raises their hand."
},
"icu:CallControls__RaiseHandsToast--two": {
"messageformat": "{name} and {otherName} raised a hand.",
"description": "Shown in a call when 2 persons raise their hands."
},
"icu:CallControls__RaiseHandsToast--more": {
"messageformat": "{name}, {otherName}, and {overflowCount, number} more raised a hand.",
"description": "Shown in a call when 3 or more persons raise their hands."
},
"icu:CallControls__RaiseHands--open-queue": {
"messageformat": "Open queue",
"description": "Link in call raised hands list and in toast shown when someone else raises their hand. Link opens the list of all raised hands."
},
"icu:CallControls__RaiseHands--lower": {
"messageformat": "Lower",
"description": "Link in call raised hands list and in toast shown when user raises their hand. Link allows user to lower their hand."
},
"icu:CallControls__MenuItemRaiseHand": {
"messageformat": "Raise Hand",
"description": "Menu item to raise your hand during a call."
},
"icu:CallControls__MenuItemRaiseHand--lower": {
"messageformat": "Lower Hand",
"description": "Menu item to lower your previously raised hand during a call."
},
"icu:callingDeviceSelection__settings": {
"messageformat": "Settings",
"description": "Title for device selection settings"

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M17.018 13.436a6.668 6.668 0 0 1-12.678 1.81l-2.75-5.173a1.667 1.667 0 0 1 .569-2.249c.046-.03.1-.057.142-.08l.033-.018c.094-.05.174-.092.25-.126a2.5 2.5 0 0 1 2.833.551V4.167a2.083 2.083 0 0 1 2.532-2.035 2.084 2.084 0 0 1 3.992-.39 2.085 2.085 0 0 1 2.642 2.008v.042a2.083 2.083 0 0 1 2.5 2.042v7.083c0 .18-.022.353-.065.52Zm-9.101-9.27a.417.417 0 0 0-.834 0v7.5H5.504L4.411 9.612a3.95 3.95 0 0 0-.092-.17.833.833 0 0 0-1.051-.321 3.941 3.941 0 0 0-.23.12l-.003.002.001.002.031.059 1.922 3.614h.003l.834 1.568a5.001 5.001 0 0 0 9.574-1.568h.017V5.833a.417.417 0 0 0-.834 0v3.75h-1.666V3.75a.417.417 0 0 0-.834 0v5.833h-1.666V2.5a.417.417 0 0 0-.834 0v7.083H7.917V4.166Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 776 B

View file

@ -0,0 +1 @@
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.75 10h.002c0 .046 0 .09-.002.135v.032c0 .061-.003.122-.01.182a5.418 5.418 0 0 1-10.345 1.88L1.198 8.097c-.018-.034-.042-.078-.062-.122a1.417 1.417 0 0 1 .547-1.786c.04-.025.085-.05.119-.067l.027-.015c.074-.039.14-.074.204-.103a2.084 2.084 0 0 1 2.217.323V3.333a1.75 1.75 0 0 1 2.044-1.725 1.75 1.75 0 0 1 3.309-.313 1.75 1.75 0 0 1 2.146 1.64 1.75 1.75 0 0 1 2.001 1.732V10Zm-7.5-6.667a.25.25 0 1 0-.5 0v6H4.31l-.855-1.605a3.227 3.227 0 0 0-.07-.131.583.583 0 0 0-.736-.225 1.912 1.912 0 0 0-.108.056l1.71 3.215v.002l.502.945a3.918 3.918 0 0 0 7.497-1.475V4.667a.25.25 0 0 0-.5 0v3h-1.5V3a.25.25 0 1 0-.5 0v4.667h-1.5V2a.25.25 0 0 0-.5 0v5.667h-1.5V3.333Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 764 B

View file

@ -0,0 +1 @@
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.5 10.725a5.218 5.218 0 0 1-9.923 1.422L1.374 8.004a1.217 1.217 0 0 1 .413-1.645c.035-.022.074-.042.109-.061l.025-.013a1.883 1.883 0 0 1 2.53.567v-3.52a1.55 1.55 0 0 1 2.006-1.481 1.55 1.55 0 0 1 3.023-.313A1.55 1.55 0 0 1 11.55 3v.184a1.55 1.55 0 0 1 2 1.484v5.666c0 .136-.017.267-.05.392ZM6.45 3.332a.45.45 0 0 0-.9.001v6H4.535l-.903-1.7a3.124 3.124 0 0 0-.078-.142.783.783 0 0 0-.988-.302 3.127 3.127 0 0 0-.192.1l-.009.005a.117.117 0 0 0-.044.146l.004.009.026.048L4.4 11.35v.003l.178.335a4.117 4.117 0 0 0 7.859-1.356h.013V4.667a.45.45 0 1 0-.9 0v3h-1.1V3a.45.45 0 1 0-.9 0v4.667h-1.1V2a.45.45 0 0 0-.9 0v5.667h-1.1V3.332Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 734 B

View file

@ -0,0 +1 @@
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.65 10h.002c0 .046 0 .09-.002.134v.033a5.318 5.318 0 0 1-10.166 2.018L1.287 8.051c-.018-.035-.04-.077-.059-.117A1.317 1.317 0 0 1 1.85 6.21l.026-.014a1.984 1.984 0 0 1 2.475.367v-3.23a1.65 1.65 0 0 1 2.023-1.607 1.65 1.65 0 0 1 3.17-.312A1.651 1.651 0 0 1 11.65 3v.054a1.65 1.65 0 0 1 2 1.613V10Zm-7.3-6.667a.35.35 0 0 0-.7 0v6H4.422l-.878-1.652a3.16 3.16 0 0 0-.075-.137.683.683 0 0 0-.862-.263 3.166 3.166 0 0 0-.185.097l-.004.002a.017.017 0 0 0-.006.019l.002.004.025.047L3.795 10h.002l.85 1.597a4.019 4.019 0 0 0 7.703-1.48v-5.45a.35.35 0 1 0-.7 0v3h-1.3V3a.35.35 0 1 0-.7 0v4.667h-1.3V2a.35.35 0 1 0-.7 0v5.667h-1.3V3.333Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 734 B

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16.814 13.395a6.46 6.46 0 0 1-12.286 1.76l-2.754-5.18A1.458 1.458 0 0 1 2.4 7.928l.03-.015c.096-.052.17-.091.239-.122a2.292 2.292 0 0 1 2.956.999V4.167a1.875 1.875 0 0 1 2.503-1.768 1.875 1.875 0 0 1 3.681-.393 1.875 1.875 0 0 1 2.566 1.744v.315a1.875 1.875 0 0 1 2.5 1.768v7.084c0 .165-.021.325-.061.478Zm-8.689-9.23a.625.625 0 0 0-1.25.002v7.5H5.74L4.595 9.513a3.907 3.907 0 0 0-.1-.182 1.042 1.042 0 0 0-1.313-.401 3.902 3.902 0 0 0-.244.127l-.013.008a.208.208 0 0 0-.074.275l.033.06 1.87 3.517h.01l.842 1.582a5.21 5.21 0 0 0 10.003-1.582h.016V5.833a.625.625 0 0 0-1.25 0v3.75h-1.25V3.75a.625.625 0 0 0-1.25 0v5.833h-1.25V2.5a.625.625 0 0 0-1.25 0v7.083h-1.25V4.166Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 775 B

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16.916 13.416A6.564 6.564 0 0 1 4.434 15.2l-2.753-5.177c-.023-.044-.05-.093-.07-.14a1.562 1.562 0 0 1 .602-1.971c.044-.028.094-.054.137-.077l.032-.017c.095-.05.172-.092.244-.124a2.396 2.396 0 0 1 2.895.739V4.167A1.98 1.98 0 0 1 8.035 2.26a1.98 1.98 0 0 1 3.842-.39 1.98 1.98 0 0 1 2.602 1.88v.172a1.982 1.982 0 0 1 2.5 1.91v7.084a2 2 0 0 1-.063.499ZM8.02 4.166a.52.52 0 0 0-1.042 0v7.5H5.625v.006l-1.122-2.11a3.922 3.922 0 0 0-.096-.176.937.937 0 0 0-1.182-.361 3.911 3.911 0 0 0-.237.124l-.01.005a.104.104 0 0 0-.039.129v-.001.002-.001l.005.009.031.06 2.65 4.983v.006l.142.268a5.105 5.105 0 0 0 9.737-1.692h.017V5.833a.52.52 0 0 0-1.042 0v3.75h-1.458V3.75a.52.52 0 0 0-1.042 0v5.833h-1.458V2.5a.52.52 0 0 0-1.042 0v7.083H8.021V4.166Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 840 B

View file

@ -3806,6 +3806,12 @@ button.module-image__border-overlay:focus {
&__grid {
flex-grow: 1;
position: relative;
.module-ongoing-call__group-call-remote-participant--hand-raised
.module-ongoing-call__group-call-remote-participant__info__contact-name {
display: block;
visibility: visible;
}
}
&__overflow {
@ -4005,24 +4011,30 @@ button.module-image__border-overlay:focus {
}
}
&__info {
align-items: flex-end;
bottom: 0;
&__footer {
display: flex;
position: absolute;
bottom: 0;
height: 60px;
justify-content: space-between;
padding-block: 0 16px;
padding-inline: 16px;
user-select: none;
width: 100%;
z-index: $z-index-above-base;
}
&__info {
display: flex;
align-items: center;
align-self: flex-end;
justify-content: space-between;
max-width: 100%;
&__contact-name {
flex-grow: 1;
font-size: 13px;
line-height: 18px;
color: $color-white;
margin-inline-end: 20px;
overflow: hidden;
text-overflow: ellipsis;
visibility: hidden;
@ -4030,17 +4042,39 @@ button.module-image__border-overlay:focus {
}
}
&--hand-raised &__footer {
background: transparent;
padding-block: 0 8px;
padding-inline: 8px;
}
&--hand-raised &__info {
background: $color-white;
border-radius: 40px;
&__contact-name {
display: none;
color: $color-black;
margin-inline-end: 12px;
}
}
&:hover {
.module-ongoing-call__group-call-remote-participant__info {
.module-ongoing-call__group-call-remote-participant__info__contact-name {
display: block;
visibility: visible;
}
}
&:hover:not(
.module-ongoing-call__group-call-remote-participant--hand-raised
) {
.module-ongoing-call__group-call-remote-participant__footer {
background: linear-gradient(
180deg,
transparent,
$color-black-alpha-60 100%
);
&__contact-name {
visibility: visible;
}
}
}
}
@ -4332,6 +4366,7 @@ button.module-image__border-overlay:focus {
height: 18px;
width: 18px;
margin-inline-end: 4px;
z-index: $z-index-above-base;
@include keyboard-mode {
@ -4344,6 +4379,9 @@ button.module-image__border-overlay:focus {
&__status {
display: flex;
flex-basis: 64px;
flex-shrink: 0;
align-items: center;
justify-content: end;
}
&__muted {

View file

@ -68,6 +68,7 @@ $color-ultramarine-dark: #1851b4;
$color-ultramarine-icon: #3a76f0;
$color-ultramarine-light: #6191f3;
$color-ultramarine-dawn: #406ec9;
$color-ultramarine-pastel: #abc4f8;
$color-ultramarine: #2c6bed;
// Flat colors

View file

@ -110,6 +110,10 @@
margin-block: -5px;
}
.CallControls__MoreOptionsButtonContainer--menu-shown .module-tooltip {
opacity: 0;
}
.CallControls__OuterSpacer {
// Defined in _modules but duplicated here for ease of refactor
$local-preview-width: 108px;
@ -120,13 +124,14 @@
position: absolute;
inset-inline-start: min(48%, 40vw);
inset-block-end: 70px;
z-index: $z-index-calling;
z-index: $z-index-toast;
}
.CallControls__MoreOptionsMenu {
display: flex;
flex-direction: column;
max-height: calc(var(--window-height) - 155px);
font-size: 13px;
filter: drop-shadow(0px 4px 3px $color-black-alpha-20);
pointer-events: auto;
}
@ -136,6 +141,43 @@
max-width: calc(var(--window-width) / 2 + 20px);
}
.CallControls__MoreOptionsButtonContainer--menu-shown .module-tooltip {
opacity: 0;
.CallControls__MoreOptionsMenu
.module-emoji-picker
+ .CallControls__MenuItemRaiseHand {
display: none;
}
.CallControls__MoreOptionsMenu .module-ReactionPickerPicker {
@media (prefers-reduced-motion: no-preference) {
animation-duration: 200ms;
}
}
.CallControls__MenuItemRaiseHand {
@include button-reset;
display: flex;
min-width: 290px;
padding-block: 12px;
padding-inline: 12px;
margin-block-start: 8px;
border-radius: 10px;
align-items: center;
text-align: start;
background-color: $color-gray-75;
color: $color-white;
filter: drop-shadow(0px 4px 3px $color-black-alpha-20);
}
.CallControls__MenuItemRaiseHand:hover {
background-color: $color-gray-65;
}
.CallControls__MenuItemRaiseHandIcon {
@include color-svg(
'../images/icons/v3/raise_hand/raise_hand-light.svg',
$color-gray-15
);
height: 16px;
width: 16px;
margin-inline: 2px 12px;
}

View file

@ -0,0 +1,96 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.CallingRaisedHandsList {
width: 100%;
height: auto;
margin-block-end: auto;
}
.CallingRaisedHandsList__width-container {
width: 320px;
height: auto;
margin-block-end: 72px;
margin-inline-start: 8px;
}
.CallingRaisedHandsList__overlay {
background: transparent;
}
.CallingRaisedHandsList__overlay-container {
flex-direction: column;
padding: 0;
justify-content: flex-end;
align-items: start;
}
.CallingRaisedHandsList__Overlay {
align-items: start;
}
.CallingRaisedHandsList__Button {
@include button-reset;
position: absolute;
inset-inline-start: 16px;
inset-block-end: 16px;
display: flex;
padding-block: 14px;
padding-inline: 12px;
background: $color-gray-78;
border-radius: 24px;
color: $color-white;
font-size: 14px;
z-index: $z-index-above-above-base;
@include keyboard-mode {
&:focus {
outline: 2px solid $color-ultramarine;
}
}
}
.CallingRaisedHandsList__ButtonIcon {
display: inline-block;
$icon-size: 20px;
width: $icon-size;
height: $icon-size;
margin-inline-end: 4px;
content: '';
@include color-svg(
'../images/icons/v3/raise_hand/raise_hand-light.svg',
$color-gray-15
);
}
.CallingRaisedHandsList__AvatarAndName {
max-width: 205px;
}
.CallingRaisedHandsList__NameHandIcon {
display: inline-block;
$icon-size: 16px;
width: $icon-size;
height: $icon-size;
content: '';
@include color-svg(
'../images/icons/v3/raise_hand/raise_hand-light.svg',
$color-gray-15
);
}
.CallingRaisedHandsList__LowerMyHandLink {
@include button-reset;
margin-inline-end: 24px;
font-size: 13px;
font-weight: 500;
color: $color-ultramarine-pastel;
@include keyboard-mode {
&:focus {
outline: 2px solid $color-ultramarine;
}
}
}

View file

@ -0,0 +1,40 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.CallingReactionsToast__Content {
display: flex;
margin-block: 4px;
margin-inline: 8px;
font-weight: 500;
}
.CallingReactionsToast__HandIcon {
display: inline-block;
$icon-size: 16px;
width: $icon-size;
height: $icon-size;
margin-inline-end: 8px;
content: '';
@include color-svg(
'../images/icons/v3/raise_hand/raise_hand-light.svg',
$color-white
);
}
.CallingRaisedHandsToasts__Link {
@include button-reset;
color: $color-ultramarine-pastel;
font-weight: 600;
margin-inline-start: 16px;
@include keyboard-mode {
&:focus {
outline: 2px solid $color-ultramarine;
}
}
}
.module-calling-participants-list__status {
flex-basis: auto;
}

View file

@ -22,6 +22,17 @@
}
}
.CallingStatusIndicator--HandRaised {
background: $color-white;
}
.CallingStatusIndicator--HandRaised::after {
@include color-svg(
'../images/icons/v3/raise_hand/raise_hand-light.svg',
$color-black
);
}
.CallingStatusIndicator--Video::after {
@include color-svg(
'../images/icons/v3/video/video-slash-fill-light.svg',
@ -29,9 +40,25 @@
);
}
.module-ongoing-call__footer__local-preview .CallingStatusIndicator--Video {
.module-ongoing-call__footer__local-preview .CallingStatusIndicator {
position: absolute;
top: 6px;
inset-inline-start: 6px;
z-index: $z-index-base;
}
.module-ongoing-call__footer__local-preview .CallingStatusIndicator--Video {
top: 6px;
inset-inline-start: 6px;
}
.module-ongoing-call__footer__local-preview
.CallingStatusIndicator--HandRaised {
bottom: 6px;
inset-inline-start: 6px;
}
.module-ongoing-call__participants__grid
.module-ongoing-call__group-call-remote-participant--hand-raised
.CallingStatusIndicator--HandRaised {
margin-block: 1px;
margin-inline-start: 5px;
}

View file

@ -46,6 +46,8 @@
@import './components/CallingScreenSharingController.scss';
@import './components/CallingSelectPresentingSourcesModal.scss';
@import './components/CallingToast.scss';
@import './components/CallingRaisedHandsList.scss';
@import './components/CallingRaisedHandsToasts.scss';
@import './components/CallingReactionsToasts.scss';
@import './components/ChatColorPicker.scss';
@import './components/Checkbox.scss';

View file

@ -74,6 +74,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
hangUpActiveCall: action('hang-up-active-call'),
i18n,
isGroupCallOutboundRingEnabled: true,
isGroupCallRaiseHandEnabled: true,
isGroupCallReactionsEnabled: true,
keyChangeOk: action('key-change-ok'),
me: {
@ -90,6 +91,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
renderEmojiPicker: () => <>EmojiPicker</>,
renderReactionPicker: () => <div />,
renderSafetyNumberViewer: (_: SafetyNumberProps) => <div />,
sendGroupCallRaiseHand: action('send-group-call-raise-hand'),
sendGroupCallReaction: action('send-group-call-reaction'),
setGroupCallVideoRequest: action('set-group-call-video-request'),
setIsCallActive: action('set-is-call-active'),
@ -159,6 +161,7 @@ export function OngoingGroupCall(): JSX.Element {
groupMembers: [],
isConversationTooBigToRing: false,
peekedParticipants: [],
raisedHands: new Set<number>(),
remoteParticipants: [],
remoteAudioLevels: new Map<number, number>(),
},
@ -247,6 +250,7 @@ export function GroupCallSafetyNumberChanged(): JSX.Element {
groupMembers: [],
isConversationTooBigToRing: false,
peekedParticipants: [],
raisedHands: new Set<number>(),
remoteParticipants: [],
remoteAudioLevels: new Map<number, number>(),
},

View file

@ -33,6 +33,7 @@ import type {
CancelCallType,
DeclineCallType,
KeyChangeOkType,
SendGroupCallRaiseHandType,
SendGroupCallReactionType,
SetGroupCallVideoRequestType,
SetLocalAudioType,
@ -87,6 +88,7 @@ export type PropsType = {
declineCall: (_: DeclineCallType) => void;
i18n: LocalizerType;
isGroupCallOutboundRingEnabled: boolean;
isGroupCallRaiseHandEnabled: boolean;
isGroupCallReactionsEnabled: boolean;
me: ConversationType;
notifyForCall: (
@ -96,6 +98,7 @@ export type PropsType = {
) => unknown;
openSystemPreferencesAction: () => unknown;
playRingtone: () => unknown;
sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void;
sendGroupCallReaction: (payload: SendGroupCallReactionType) => void;
setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void;
setIsCallActive: (_: boolean) => void;
@ -130,6 +133,7 @@ function ActiveCallManager({
hangUpActiveCall,
i18n,
isGroupCallOutboundRingEnabled,
isGroupCallRaiseHandEnabled,
isGroupCallReactionsEnabled,
keyChangeOk,
getGroupCallVideoFrameSource,
@ -141,6 +145,7 @@ function ActiveCallManager({
renderEmojiPicker,
renderReactionPicker,
renderSafetyNumberViewer,
sendGroupCallRaiseHand,
sendGroupCallReaction,
setGroupCallVideoRequest,
setLocalAudio,
@ -341,11 +346,13 @@ function ActiveCallManager({
groupMembers={groupMembers}
hangUpActiveCall={hangUpActiveCall}
i18n={i18n}
isGroupCallRaiseHandEnabled={isGroupCallRaiseHandEnabled}
isGroupCallReactionsEnabled={isGroupCallReactionsEnabled}
me={me}
openSystemPreferencesAction={openSystemPreferencesAction}
renderEmojiPicker={renderEmojiPicker}
renderReactionPicker={renderReactionPicker}
sendGroupCallRaiseHand={sendGroupCallRaiseHand}
sendGroupCallReaction={sendGroupCallReaction}
setGroupCallVideoRequest={setGroupCallVideoRequestForConversation}
setLocalPreview={setLocalPreview}

View file

@ -35,6 +35,7 @@ import enMessages from '../../_locales/en/messages.json';
import { CallingToastProvider, useCallingToasts } from './CallingToast';
const MAX_PARTICIPANTS = 75;
const LOCAL_DEMUX_ID = 1;
const i18n = setupI18n('en', enMessages);
@ -66,6 +67,7 @@ type GroupCallOverrideProps = OverridePropsBase & {
callMode: CallMode.Group;
connectionState?: GroupCallConnectionState;
peekedParticipants?: Array<ConversationType>;
raisedHands?: Set<number>;
remoteParticipants?: Array<GroupCallRemoteParticipantType>;
remoteAudioLevel?: number;
};
@ -92,12 +94,8 @@ const createActiveDirectCallProp = (
],
});
const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
callMode: CallMode.Group as CallMode.Group,
connectionState:
overrideProps.connectionState || GroupCallConnectionState.Connected,
conversationsWithSafetyNumberChanges: [],
conversationsByDemuxId: new Map<number, ConversationType>(
const getConversationsByDemuxId = (overrideProps: GroupCallOverrideProps) => {
const conversationsByDemuxId = new Map<number, ConversationType>(
overrideProps.remoteParticipants?.map((participant, index) => [
participant.demuxId,
getDefaultConversationWithServiceId({
@ -105,9 +103,19 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
title: `Participant ${index + 1}`,
}),
])
),
);
conversationsByDemuxId.set(LOCAL_DEMUX_ID, conversation);
return conversationsByDemuxId;
};
const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
callMode: CallMode.Group as CallMode.Group,
connectionState:
overrideProps.connectionState || GroupCallConnectionState.Connected,
conversationsWithSafetyNumberChanges: [],
conversationsByDemuxId: getConversationsByDemuxId(overrideProps),
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
localDemuxId: LOCAL_DEMUX_ID,
maxDevices: 5,
deviceCount: (overrideProps.remoteParticipants || []).length,
groupMembers: overrideProps.remoteParticipants || [],
@ -116,6 +124,7 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
isConversationTooBigToRing: false,
peekedParticipants:
overrideProps.peekedParticipants || overrideProps.remoteParticipants || [],
raisedHands: overrideProps.raisedHands || new Set<number>(),
remoteParticipants: overrideProps.remoteParticipants || [],
remoteAudioLevels: new Map<number, number>(
overrideProps.remoteParticipants?.map((_participant, index) => [
@ -163,6 +172,7 @@ const createProps = (
getPresentingSources: action('get-presenting-sources'),
hangUpActiveCall: action('hang-up'),
i18n,
isGroupCallRaiseHandEnabled: true,
isGroupCallReactionsEnabled: true,
me: getDefaultConversation({
color: AvatarColors[1],
@ -175,6 +185,7 @@ const createProps = (
openSystemPreferencesAction: action('open-system-preferences-action'),
renderEmojiPicker: () => <>EmojiPicker</>,
renderReactionPicker: () => <div />,
sendGroupCallRaiseHand: action('send-group-call-raise-hand'),
sendGroupCallReaction: action('send-group-call-reaction'),
setGroupCallVideoRequest: action('set-group-call-video-request'),
setLocalAudio: action('set-local-audio'),
@ -299,6 +310,7 @@ export function GroupCall1(): JSX.Element {
demuxId: 0,
hasRemoteAudio: true,
hasRemoteVideo: true,
isHandRaised: false,
presenting: false,
sharingScreen: false,
videoAspectRatio: 1.3,
@ -314,12 +326,41 @@ export function GroupCall1(): JSX.Element {
);
}
export function GroupCallYourHandRaised(): JSX.Element {
return (
<CallScreen
{...createProps({
callMode: CallMode.Group,
remoteParticipants: [
{
aci: generateAci(),
demuxId: 0,
hasRemoteAudio: true,
hasRemoteVideo: true,
isHandRaised: false,
presenting: false,
sharingScreen: false,
videoAspectRatio: 1.3,
...getDefaultConversation({
isBlocked: false,
serviceId: generateAci(),
title: 'Tyler',
}),
},
],
raisedHands: new Set([LOCAL_DEMUX_ID]),
})}
/>
);
}
// We generate these upfront so that the list is stable when you move the slider.
const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({
aci: generateAci(),
demuxId: index,
hasRemoteAudio: index % 3 !== 0,
hasRemoteVideo: index % 4 !== 0,
isHandRaised: (index - 3) % 10 === 0,
presenting: false,
sharingScreen: false,
videoAspectRatio: Math.random() < 0.7 ? 1.3 : Math.random() * 0.4 + 0.6,
@ -406,6 +447,7 @@ export function GroupCallReconnecting(): JSX.Element {
demuxId: 0,
hasRemoteAudio: true,
hasRemoteVideo: true,
isHandRaised: false,
presenting: false,
sharingScreen: false,
videoAspectRatio: 1.3,

View file

@ -8,6 +8,7 @@ import classNames from 'classnames';
import type { VideoFrameSource } from '@signalapp/ringrtc';
import type {
ActiveCallStateType,
SendGroupCallRaiseHandType,
SendGroupCallReactionType,
SetLocalAudioType,
SetLocalPreviewType,
@ -74,6 +75,7 @@ import { handleOutsideClick } from '../util/handleOutsideClick';
import type { Props as ReactionPickerProps } from './conversation/ReactionPicker';
import type { SmartReactionPicker } from '../state/smart/ReactionPicker';
import { Emoji } from './emoji/Emoji';
import { CallingRaisedHandsList } from './CallingRaisedHandsList';
export type PropsType = {
activeCall: ActiveCallType;
@ -82,12 +84,14 @@ export type PropsType = {
groupMembers?: Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
hangUpActiveCall: (reason: string) => void;
i18n: LocalizerType;
isGroupCallRaiseHandEnabled: boolean;
isGroupCallReactionsEnabled: boolean;
me: ConversationType;
openSystemPreferencesAction: () => unknown;
renderReactionPicker: (
props: React.ComponentProps<typeof SmartReactionPicker>
) => JSX.Element;
sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void;
sendGroupCallReaction: (payload: SendGroupCallReactionType) => void;
setGroupCallVideoRequest: (
_: Array<GroupCallVideoRequest>,
@ -155,12 +159,14 @@ export function CallScreen({
groupMembers,
hangUpActiveCall,
i18n,
isGroupCallRaiseHandEnabled,
isGroupCallReactionsEnabled,
me,
openSystemPreferencesAction,
renderEmojiPicker,
renderReactionPicker,
setGroupCallVideoRequest,
sendGroupCallRaiseHand,
sendGroupCallReaction,
setLocalAudio,
setLocalVideo,
@ -232,6 +238,11 @@ export function CallScreen({
setShowMoreOptions(prevValue => !prevValue);
}, []);
const [showRaisedHandsList, setShowRaisedHandsList] = useState(false);
const toggleRaisedHandsList = useCallback(() => {
setShowRaisedHandsList(prevValue => !prevValue);
}, []);
const [controlsHover, setControlsHover] = useState(false);
const onControlsMouseEnter = useCallback(() => {
@ -460,7 +471,8 @@ export function CallScreen({
});
const isGroupCall = activeCall.callMode === CallMode.Group;
const isMoreOptionsButtonEnabled = isGroupCall && isGroupCallReactionsEnabled;
const isMoreOptionsButtonEnabled =
isGroupCall && (isGroupCallRaiseHandEnabled || isGroupCallReactionsEnabled);
let presentingButtonType: CallingButtonType;
if (presentingSource) {
@ -471,6 +483,110 @@ export function CallScreen({
presentingButtonType = CallingButtonType.PRESENTING_OFF;
}
const raisedHands =
activeCall.callMode === CallMode.Group ? 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.
const syncedLocalHandRaised = isHandRaised(raisedHands, localDemuxId);
// Don't call setLocalHandRaised because it only sets local state. Instead call
// toggleRaiseHand() which will set ringrtc state and call setLocalHandRaised.
const [localHandRaised, setLocalHandRaised] = useState<boolean>(
syncedLocalHandRaised
);
const previousLocalHandRaised = usePrevious(localHandRaised, localHandRaised);
const toggleRaiseHand = useCallback(
(raise?: boolean) => {
const nextValue = raise ?? !localHandRaised;
if (nextValue === previousLocalHandRaised) {
return;
}
setLocalHandRaised(nextValue);
// It's possible that the ringrtc call can fail due to flaky network connection.
// In that case, local and remote state (localHandRaised and raisedHands) can
// get out of sync. The user might need to manually toggle raise hand to get to
// a coherent state. It would be nice if this returned a Promise (but it doesn't)
sendGroupCallRaiseHand({
conversationId: conversation.id,
raise: nextValue,
});
},
[
localHandRaised,
previousLocalHandRaised,
conversation.id,
sendGroupCallRaiseHand,
]
);
const renderRaisedHandsToast = React.useCallback(
(hands: Array<number>) => {
const names = hands.map(demuxId =>
demuxId === localDemuxId
? i18n('icu:you')
: conversationsByDemuxId.get(demuxId)?.title
);
let message: string;
let buttonOverride: JSX.Element | undefined;
const count = names.length;
switch (count) {
case 0:
return undefined;
case 1:
if (names[0] === i18n('icu:you')) {
message = i18n('icu:CallControls__RaiseHandsToast--you');
buttonOverride = (
<button
className="CallingRaisedHandsToasts__Link"
onClick={() => toggleRaiseHand(false)}
type="button"
>
{i18n('icu:CallControls__RaiseHands--lower')}
</button>
);
} else {
message = i18n('icu:CallControls__RaiseHandsToast--one', {
name: names[0],
});
}
break;
case 2:
message = i18n('icu:CallControls__RaiseHandsToast--two', {
name: names[0],
otherName: names[1],
});
break;
default:
message = i18n('icu:CallControls__RaiseHandsToast--more', {
name: names[0],
otherName: names[1],
overflowCount: names.length - 2,
});
}
return (
<div className="CallingReactionsToast__Content">
<span className="CallingReactionsToast__HandIcon" />
{message}
{buttonOverride || (
<button
className="link CallingRaisedHandsToasts__Link"
onClick={() => setShowRaisedHandsList(true)}
type="button"
>
{i18n('icu:CallControls__RaiseHands--open-queue')}
</button>
)}
</div>
);
},
[i18n, localDemuxId, conversationsByDemuxId, toggleRaiseHand]
);
const raisedHandsCount: number = raisedHands?.size ?? 0;
const callStatus: ReactNode | string = React.useMemo(() => {
if (isRinging) {
return i18n('icu:outgoingCallRinging');
@ -599,6 +715,39 @@ export function CallScreen({
localDemuxId={localDemuxId}
i18n={i18n}
/>
{raisedHands && raisedHandsCount > 0 && (
<>
<button
className="CallingRaisedHandsList__Button"
onClick={toggleRaisedHandsList}
type="button"
>
<span className="CallingRaisedHandsList__ButtonIcon" />
{syncedLocalHandRaised ? (
<>
{i18n('icu:you')}
{raisedHandsCount > 1 && ` + ${String(raisedHandsCount - 1)}`}
</>
) : (
raisedHandsCount
)}
</button>
{showRaisedHandsList && (
<CallingRaisedHandsList
i18n={i18n}
onClose={() => setShowRaisedHandsList(false)}
onLowerMyHand={() => {
toggleRaiseHand(false);
setShowRaisedHandsList(false);
}}
localDemuxId={localDemuxId}
conversationsByDemuxId={conversationsByDemuxId}
raisedHands={raisedHands}
localHandRaised={syncedLocalHandRaised}
/>
)}
</>
)}
<div className="module-ongoing-call__footer">
<div className="module-calling__spacer CallControls__OuterSpacer" />
<div
@ -616,6 +765,8 @@ export function CallScreen({
<CallingButtonToastsContainer
hasLocalAudio={hasLocalAudio}
outgoingRing={undefined}
raisedHands={raisedHands}
renderRaisedHandsToast={renderRaisedHandsToast}
i18n={i18n}
/>
@ -625,18 +776,34 @@ export function CallScreen({
className="CallControls__MoreOptionsMenu"
ref={moreOptionsMenuRef}
>
{renderReactionPicker({
ref: reactionPickerRef,
onClose: () => setShowMoreOptions(false),
onPick: emoji => {
setShowMoreOptions(false);
sendGroupCallReaction({
conversationId: conversation.id,
value: emoji,
});
},
renderEmojiPicker,
})}
{isGroupCallReactionsEnabled &&
renderReactionPicker({
ref: reactionPickerRef,
onClose: () => setShowMoreOptions(false),
onPick: emoji => {
setShowMoreOptions(false);
sendGroupCallReaction({
conversationId: conversation.id,
value: emoji,
});
},
renderEmojiPicker,
})}
{isGroupCallRaiseHandEnabled && (
<button
className="CallControls__MenuItemRaiseHand"
onClick={() => {
setShowMoreOptions(false);
toggleRaiseHand();
}}
type="button"
>
<span className="CallControls__MenuItemRaiseHandIcon" />
{localHandRaised
? i18n('icu:CallControls__MenuItemRaiseHand--lower')
: i18n('icu:CallControls__MenuItemRaiseHand')}
</button>
)}
</div>
</div>
)}
@ -715,6 +882,9 @@ export function CallScreen({
audioLevel={localAudioLevel}
shouldShowSpeaking={isSpeaking}
/>
{syncedLocalHandRaised && (
<div className="CallingStatusIndicator CallingStatusIndicator--HandRaised" />
)}
</div>
) : (
<div className="module-ongoing-call__footer__local-preview" />
@ -875,3 +1045,14 @@ function CallingReactionsToasts(props: CallingReactionsToastsType) {
useReactionsToast(props);
return null;
}
function isHandRaised(
raisedHands: Set<number> | undefined,
demuxId: number | undefined
): boolean {
if (raisedHands === undefined || demuxId === undefined) {
return false;
}
return raisedHands.has(demuxId);
}

View file

@ -25,6 +25,7 @@ function createParticipant(
demuxId: 2,
hasRemoteAudio: Boolean(participantProps.hasRemoteAudio),
hasRemoteVideo: Boolean(participantProps.hasRemoteVideo),
isHandRaised: Boolean(participantProps.isHandRaised),
presenting: Boolean(participantProps.presenting),
sharingScreen: Boolean(participantProps.sharingScreen),
videoAspectRatio: 1.3,

View file

@ -140,6 +140,7 @@ export function GroupCall(args: PropsType): JSX.Element {
maxDevices: 5,
deviceCount: 0,
peekedParticipants: [],
raisedHands: new Set<number>(),
remoteParticipants: [],
remoteAudioLevels: new Map<number, number>(),
}}

View file

@ -0,0 +1,97 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { times } from 'lodash';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './CallingRaisedHandsList';
import { CallingRaisedHandsList } from './CallingRaisedHandsList';
import type { ConversationType } from '../state/ducks/conversations';
import { AvatarColors } from '../types/Colors';
import { getDefaultConversationWithServiceId } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const MAX_HANDS = 20;
const LOCAL_DEMUX_ID = 1;
const NAMES = [
'Tom Ato',
'Ann Chovy',
'Longanisa Lisa Duchess of Summer Pumpkin',
'Rick Astley',
'Ash Ketchup',
'Kiki',
];
const i18n = setupI18n('en', enMessages);
const conversation = getDefaultConversationWithServiceId({
id: '3051234567',
avatarPath: undefined,
color: AvatarColors[0],
title: 'Rick Sanchez',
name: 'Rick Sanchez',
phoneNumber: '3051234567',
profileName: 'Rick Sanchez',
});
const conversationsByDemuxId = new Map<number, ConversationType>(
times(MAX_HANDS).map(index => [
LOCAL_DEMUX_ID + index + 1,
getDefaultConversationWithServiceId({
title: NAMES[index] || `Participant ${index + 1}`,
}),
])
);
conversationsByDemuxId.set(LOCAL_DEMUX_ID, conversation);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
i18n,
onClose: action('on-close'),
onLowerMyHand: action('on-lower-my-hand'),
localDemuxId: LOCAL_DEMUX_ID,
conversationsByDemuxId,
localHandRaised: overrideProps.localHandRaised || false,
raisedHands: overrideProps.raisedHands || new Set<number>(),
});
export default {
title: 'Components/CallingRaisedHandsList',
} satisfies Meta<PropsType>;
export function Me(): JSX.Element {
const props = createProps({
localHandRaised: true,
raisedHands: new Set([LOCAL_DEMUX_ID]),
});
return <CallingRaisedHandsList {...props} />;
}
export function MeOnAnotherDevice(): JSX.Element {
const props = createProps({
raisedHands: new Set([LOCAL_DEMUX_ID]),
});
return <CallingRaisedHandsList {...props} />;
}
export function MeAndOne(): JSX.Element {
const props = createProps({
localHandRaised: true,
raisedHands: new Set([LOCAL_DEMUX_ID, LOCAL_DEMUX_ID + 1]),
});
return <CallingRaisedHandsList {...props} />;
}
export function One(): JSX.Element {
const props = createProps({ raisedHands: new Set([LOCAL_DEMUX_ID + 1]) });
return <CallingRaisedHandsList {...props} />;
}
export function Many(): JSX.Element {
const props = createProps({
raisedHands: new Set([...conversationsByDemuxId.keys()]),
});
return <CallingRaisedHandsList {...props} />;
}

View file

@ -0,0 +1,137 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Avatar, AvatarSize } from './Avatar';
import { ContactName } from './conversation/ContactName';
import type { ConversationsByDemuxIdType } from '../types/Calling';
import type { ServiceIdString } from '../types/ServiceId';
import type { LocalizerType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
import { ModalHost } from './ModalHost';
import * as log from '../logging/log';
export type PropsType = {
readonly i18n: LocalizerType;
readonly onClose: () => void;
readonly onLowerMyHand: () => void;
readonly localDemuxId: number | undefined;
readonly conversationsByDemuxId: ConversationsByDemuxIdType;
readonly raisedHands: Set<number>;
readonly localHandRaised: boolean;
};
export function CallingRaisedHandsList({
i18n,
onClose,
onLowerMyHand,
localDemuxId,
conversationsByDemuxId,
raisedHands,
localHandRaised,
}: PropsType): JSX.Element | null {
const ourServiceId: ServiceIdString | undefined = localDemuxId
? conversationsByDemuxId.get(localDemuxId)?.serviceId
: undefined;
const participants = React.useMemo<Array<ConversationType>>(() => {
const serviceIds: Set<ServiceIdString> = new Set();
const conversations: Array<ConversationType> = [];
raisedHands.forEach(demuxId => {
const conversation = conversationsByDemuxId.get(demuxId);
if (!conversation) {
log.warn(
'CallingRaisedHandsList: Failed to get conversationsByDemuxId for demuxId',
{ demuxId }
);
return;
}
const { serviceId } = conversation;
if (serviceId) {
if (serviceIds.has(serviceId)) {
return;
}
serviceIds.add(serviceId);
}
conversations.push(conversation);
});
return conversations;
}, [raisedHands, conversationsByDemuxId]);
return (
<ModalHost
modalName="CallingRaisedHandsList"
moduleClassName="CallingRaisedHandsList"
onClose={onClose}
>
<div className="CallingRaisedHandsList module-calling-participants-list">
<div className="module-calling-participants-list__header">
<div className="module-calling-participants-list__title">
{i18n('icu:CallingRaisedHandsList__Title', {
count: 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">
{participants.map((participant: ConversationType, index: number) => (
<li
className="module-calling-participants-list__contact"
key={participant.serviceId ?? index}
>
<div className="CallingRaisedHandsList__AvatarAndName 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}
/>
)}
</div>
<div className="module-calling-participants-list__status">
{localHandRaised &&
ourServiceId &&
participant.serviceId === ourServiceId && (
<button
className="CallingRaisedHandsList__LowerMyHandLink"
type="button"
onClick={onLowerMyHand}
>
{i18n('icu:CallControls__RaiseHands--lower')}
</button>
)}
<div className="CallingRaisedHandsList__NameHandIcon" />
</div>
</li>
))}
</ul>
</div>
</ModalHost>
);
}

View file

@ -8,6 +8,11 @@ import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util';
import { CallingToastProvider, useCallingToasts } from './CallingToast';
import { usePrevious } from '../hooks/usePrevious';
import {
difference as setDifference,
isEqual as setIsEqual,
} from '../util/setUtil';
import * as log from '../logging/log';
type PropsType = {
activeCall: ActiveCallType;
@ -136,9 +141,101 @@ function useOutgoingRingToast({
}, [outgoingRing, previousOutgoingRing, hideToast, showToast, i18n]);
}
function useRaisedHandsToast({
raisedHands,
renderRaisedHandsToast,
}: {
raisedHands?: Set<number>;
renderRaisedHandsToast?: (
hands: Array<number>
) => JSX.Element | string | undefined;
}): void {
const RAISED_HANDS_TOAST_KEY = 'raised-hands';
const LOAD_DELAY = 2000;
const { showToast, hideToast } = useCallingToasts();
// Hand state is updated after a delay upon joining a call, so it can appear that
// hands were raised immediately when you join a call. To avoid spurious toasts, add
// an initial delay before showing toasts.
const [isLoaded, setIsLoaded] = React.useState<boolean>(false);
React.useEffect(() => {
const timeout = setTimeout(() => {
setIsLoaded(true);
}, LOAD_DELAY);
return () => clearTimeout(timeout);
}, []);
const previousRaisedHands = usePrevious(raisedHands, raisedHands);
const [newHands, loweredHands]: [Set<number>, Set<number>] = isLoaded
? [
setDifference(
raisedHands ?? new Set(),
previousRaisedHands ?? new Set()
),
setDifference(
previousRaisedHands ?? new Set(),
raisedHands ?? new Set()
),
]
: [new Set(), new Set()];
const raisedHandsInLastShownToastRef = useRef<Set<number>>(new Set());
const raisedHandsInLastShownToast = raisedHandsInLastShownToastRef.current;
React.useEffect(() => {
// 1. If no hands are raised, then hide any raise hand toast.
// 2. Check if someone lowered their hand which they had recently raised. The
// previous toast saying they raised their hand would now be out of date, so we
// should hide it.
if (
raisedHands?.size === 0 ||
(raisedHandsInLastShownToast.size > 0 &&
loweredHands.size > 0 &&
setIsEqual(raisedHandsInLastShownToast, loweredHands))
) {
hideToast(RAISED_HANDS_TOAST_KEY);
}
if (newHands.size === 0 || !renderRaisedHandsToast) {
return;
}
const content = renderRaisedHandsToast([...newHands].reverse());
if (!content) {
log.warn(
'CallingToastManager useRaisedHandsToast: Failed to call renderRaisedHandsToast()'
);
return;
}
hideToast(RAISED_HANDS_TOAST_KEY);
// Note: Don't set { dismissable: true } or else the links (Lower or View Queue)
// will cause nested buttons (dismissable toasts are <button>s)
showToast({
key: RAISED_HANDS_TOAST_KEY,
content,
autoClose: true,
});
raisedHandsInLastShownToastRef.current = newHands;
}, [
raisedHands,
previousRaisedHands,
newHands,
raisedHandsInLastShownToast,
loweredHands,
renderRaisedHandsToast,
hideToast,
showToast,
]);
}
type CallingButtonToastsType = {
hasLocalAudio: boolean;
outgoingRing: boolean | undefined;
raisedHands?: Set<number>;
renderRaisedHandsToast?: (
hands: Array<number>
) => JSX.Element | string | undefined;
i18n: LocalizerType;
};
@ -161,10 +258,13 @@ export function CallingButtonToastsContainer(
function CallingButtonToasts({
hasLocalAudio,
outgoingRing,
raisedHands,
renderRaisedHandsToast,
i18n,
}: CallingButtonToastsType) {
useMutedToast({ hasLocalAudio, i18n });
useOutgoingRingToast({ outgoingRing, i18n });
useRaisedHandsToast({ raisedHands, renderRaisedHandsToast });
return null;
}

View file

@ -23,6 +23,7 @@ const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({
demuxId: index,
hasRemoteAudio: index % 3 !== 0,
hasRemoteVideo: index % 4 !== 0,
isHandRaised: (index - 2) % 8 === 0,
presenting: false,
sharingScreen: false,
videoAspectRatio: 1.3,

View file

@ -38,10 +38,12 @@ const createProps = (
isBlocked = false,
hasRemoteAudio = false,
presenting = false,
isHandRaised = false,
}: {
isBlocked?: boolean;
hasRemoteAudio?: boolean;
presenting?: boolean;
isHandRaised?: boolean;
} = {}
): PropsType => ({
getFrameBuffer,
@ -55,6 +57,7 @@ const createProps = (
demuxId: 123,
hasRemoteAudio,
hasRemoteVideo: true,
isHandRaised,
presenting,
sharingScreen: false,
videoAspectRatio: 1.3,
@ -119,6 +122,23 @@ export function Speaking(): JSX.Element {
);
}
export function HandRaised(): JSX.Element {
return (
<GroupCallRemoteParticipant
{...createProps(
{
isInPip: false,
height: 120,
left: 0,
top: 0,
width: 120,
},
{ isHandRaised: true }
)}
/>
);
}
export function IsInPip(): JSX.Element {
return (
<GroupCallRemoteParticipant

View file

@ -80,6 +80,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
demuxId,
hasRemoteAudio,
hasRemoteVideo,
isHandRaised,
isBlocked,
isMe,
profileName,
@ -295,7 +296,9 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
isSpeaking &&
!isActiveSpeakerInSpeakerView &&
remoteParticipantsCount > 1 &&
'module-ongoing-call__group-call-remote-participant--speaking'
'module-ongoing-call__group-call-remote-participant--speaking',
isHandRaised &&
'module-ongoing-call__group-call-remote-participant--hand-raised'
)}
ref={intersectionRef}
style={containerStyles}
@ -307,15 +310,16 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
audioLevel={props.audioLevel}
shouldShowSpeaking={isSpeaking}
/>
<div
className={classNames(
'module-ongoing-call__group-call-remote-participant__info'
)}
>
<ContactName
module="module-ongoing-call__group-call-remote-participant__info__contact-name"
title={title}
/>
<div className="module-ongoing-call__group-call-remote-participant__footer">
<div className="module-ongoing-call__group-call-remote-participant__info">
{isHandRaised && (
<div className="CallingStatusIndicator CallingStatusIndicator--HandRaised" />
)}
<ContactName
module="module-ongoing-call__group-call-remote-participant__info__contact-name"
title={title}
/>
</div>
</div>
</>
)}

View file

@ -163,6 +163,7 @@ type CallingReduxInterface = Pick<
| 'callStateChange'
| 'cancelIncomingGroupCallRing'
| 'groupCallAudioLevelsChange'
| 'groupCallRaisedHandsChange'
| 'groupCallStateChange'
| 'outgoingCall'
| 'receiveGroupCallReactions'
@ -847,8 +848,11 @@ export class CallingClass {
reactions,
});
},
onRaisedHands: (_groupCall, _raisedHands) => {
// TODO: Implement handling of raised hands.
onRaisedHands: (_groupCall, raisedHands) => {
this.reduxInterface?.groupCallRaisedHandsChange({
conversationId,
raisedHands,
});
},
onPeekChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState();
@ -1153,6 +1157,14 @@ export class CallingClass {
groupCall.resendMediaKeys();
}
public sendGroupCallRaiseHand(conversationId: string, raise: boolean): void {
const groupCall = this.getGroupCall(conversationId);
if (!groupCall) {
throw new Error('Could not find matching call');
}
groupCall.raiseHand(raise);
}
public sendGroupCallReaction(conversationId: string, value: string): void {
const groupCall = this.getGroupCall(conversationId);
if (!groupCall) {

View file

@ -116,6 +116,7 @@ export type GroupCallStateType = {
localDemuxId: number | undefined;
joinState: GroupCallJoinState;
peekInfo?: GroupCallPeekInfoType;
raisedHands?: Array<number>;
remoteParticipants: Array<GroupCallParticipantInfoType>;
remoteAudioLevels?: Map<number, number>;
} & GroupCallRingStateType;
@ -222,11 +223,15 @@ type IncomingGroupCallType = ReadonlyDeep<{
ringerAci: AciString;
}>;
export type SendGroupCallRaiseHandType = ReadonlyDeep<{
conversationId: string;
raise: boolean;
}>;
export type SendGroupCallReactionType = ReadonlyDeep<{
conversationId: string;
value: string;
}>;
type SendGroupCallReactionLocalCopyType = ReadonlyDeep<{
conversationId: string;
value: string;
@ -445,6 +450,7 @@ const CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED';
const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN';
const DECLINE_DIRECT_CALL = 'calling/DECLINE_DIRECT_CALL';
const GROUP_CALL_AUDIO_LEVELS_CHANGE = 'calling/GROUP_CALL_AUDIO_LEVELS_CHANGE';
const GROUP_CALL_RAISED_HANDS_CHANGE = 'calling/GROUP_CALL_RAISED_HANDS_CHANGE';
const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE';
const GROUP_CALL_REACTIONS_RECEIVED = 'calling/GROUP_CALL_REACTIONS_RECEIVED';
const GROUP_CALL_REACTIONS_EXPIRED = 'calling/GROUP_CALL_REACTIONS_EXPIRED';
@ -455,6 +461,7 @@ const MARK_CALL_TRUSTED = 'calling/MARK_CALL_TRUSTED';
const MARK_CALL_UNTRUSTED = 'calling/MARK_CALL_UNTRUSTED';
const OUTGOING_CALL = 'calling/OUTGOING_CALL';
const PEEK_GROUP_CALL_FULFILLED = 'calling/PEEK_GROUP_CALL_FULFILLED';
const RAISE_HAND_GROUP_CALL = 'calling/RAISE_HAND_GROUP_CALL';
const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES';
const REMOTE_SHARING_SCREEN_CHANGE = 'calling/REMOTE_SHARING_SCREEN_CHANGE';
const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE';
@ -525,6 +532,16 @@ type GroupCallAudioLevelsChangeActionType = ReadonlyDeep<{
payload: GroupCallAudioLevelsChangeActionPayloadType;
}>;
type GroupCallRaisedHandsChangeActionPayloadType = ReadonlyDeep<{
conversationId: string;
raisedHands: ReadonlyArray<number>;
}>;
type GroupCallRaisedHandsChangeActionType = ReadonlyDeep<{
type: 'calling/GROUP_CALL_RAISED_HANDS_CHANGE';
payload: GroupCallRaisedHandsChangeActionPayloadType;
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type GroupCallStateChangeActionType = {
type: 'calling/GROUP_CALL_STATE_CHANGE';
@ -580,6 +597,11 @@ type KeyChangeOkActionType = ReadonlyDeep<{
payload: null;
}>;
type SendGroupCallRaiseHandActionType = ReadonlyDeep<{
type: 'calling/RAISE_HAND_GROUP_CALL';
payload: SendGroupCallRaiseHandType;
}>;
export type SendGroupCallReactionActionType = ReadonlyDeep<{
type: 'calling/SEND_GROUP_CALL_REACTION';
payload: SendGroupCallReactionLocalCopyType;
@ -692,6 +714,7 @@ export type CallingActionType =
| ConversationRemovedActionType
| DeclineCallActionType
| GroupCallAudioLevelsChangeActionType
| GroupCallRaisedHandsChangeActionType
| GroupCallStateChangeActionType
| GroupCallReactionsReceivedActionType
| GroupCallReactionsExpiredActionType
@ -950,6 +973,12 @@ function receiveGroupCallReactions(
};
}
function groupCallRaisedHandsChange(
payload: GroupCallRaisedHandsChangeActionPayloadType
): GroupCallRaisedHandsChangeActionType {
return { type: GROUP_CALL_RAISED_HANDS_CHANGE, payload };
}
function groupCallStateChange(
payload: GroupCallStateChangeArgumentType
): ThunkAction<void, RootStateType, unknown, GroupCallStateChangeActionType> {
@ -1075,6 +1104,19 @@ function keyChangeOk(
};
}
function sendGroupCallRaiseHand(
payload: SendGroupCallRaiseHandType
): ThunkAction<void, RootStateType, unknown, SendGroupCallRaiseHandActionType> {
return dispatch => {
calling.sendGroupCallRaiseHand(payload.conversationId, payload.raise);
dispatch({
type: RAISE_HAND_GROUP_CALL,
payload,
});
};
}
function sendGroupCallReaction(
payload: SendGroupCallReactionType
): ThunkAction<
@ -1612,6 +1654,7 @@ export const actions = {
declineCall,
getPresentingSources,
groupCallAudioLevelsChange,
groupCallRaisedHandsChange,
groupCallStateChange,
hangUpActiveCall,
keyChangeOk,
@ -1630,6 +1673,7 @@ export const actions = {
remoteSharingScreenChange,
remoteVideoChange,
returnToActiveCall,
sendGroupCallRaiseHand,
sendGroupCallReaction,
setGroupCallVideoRequest,
setIsCallActive,
@ -2137,6 +2181,7 @@ export function reducer(
localDemuxId,
peekInfo: newPeekInfo,
remoteParticipants,
raisedHands: existingCall?.raisedHands ?? [],
...newRingState,
},
},
@ -2267,6 +2312,29 @@ export function reducer(
};
}
if (action.type === GROUP_CALL_RAISED_HANDS_CHANGE) {
const { conversationId, raisedHands } = action.payload;
const { activeCallState } = state;
const existingCall = getGroupCall(conversationId, state);
if (
state.activeCallState?.conversationId !== conversationId ||
!activeCallState ||
!existingCall
) {
return state;
}
return {
...state,
callsByConversation: {
...callsByConversation,
[conversationId]: { ...existingCall, raisedHands: [...raisedHands] },
},
};
}
if (action.type === REMOTE_SHARING_SCREEN_CHANGE) {
const { conversationId, isSharingScreen } = action.payload;
const call = getOwn(state.callsByConversation, conversationId);

View file

@ -13,6 +13,7 @@ import { getActiveCall } from '../ducks/calling';
import type { ConversationType } from '../ducks/conversations';
import { getIncomingCall } from '../selectors/calling';
import { isGroupCallOutboundRingEnabled } from '../../util/isGroupCallOutboundRingEnabled';
import { isGroupCallRaiseHandEnabled } from '../../util/isGroupCallRaiseHandEnabled';
import { isGroupCallReactionsEnabled } from '../../util/isGroupCallReactionsEnabled';
import type {
ActiveCallBaseType,
@ -201,6 +202,8 @@ const mapStateToActiveCallProp = (
const remoteParticipants: Array<GroupCallRemoteParticipantType> = [];
const peekedParticipants: Array<ConversationType> = [];
const conversationsByDemuxId: ConversationsByDemuxIdType = new Map();
const { localDemuxId } = call;
const raisedHands: Set<number> = new Set(call.raisedHands ?? []);
const { memberships = [] } = conversation;
@ -243,6 +246,7 @@ const mapStateToActiveCallProp = (
demuxId: remoteParticipant.demuxId,
hasRemoteAudio: remoteParticipant.hasRemoteAudio,
hasRemoteVideo: remoteParticipant.hasRemoteVideo,
isHandRaised: raisedHands.has(remoteParticipant.demuxId),
presenting: remoteParticipant.presenting,
sharingScreen: remoteParticipant.sharingScreen,
speakerTime: remoteParticipant.speakerTime,
@ -254,6 +258,17 @@ const mapStateToActiveCallProp = (
);
}
if (localDemuxId !== undefined) {
conversationsByDemuxId.set(localDemuxId, getMe(state));
}
// Filter raisedHands to ensure valid demuxIds.
raisedHands.forEach(demuxId => {
if (!conversationsByDemuxId.has(demuxId)) {
raisedHands.delete(demuxId);
}
});
for (
let i = 0;
i < activeCallState.safetyNumberChangedAcis.length;
@ -293,9 +308,10 @@ const mapStateToActiveCallProp = (
groupMembers,
isConversationTooBigToRing: isConversationTooBigToRing(conversation),
joinState: call.joinState,
localDemuxId: call.localDemuxId,
localDemuxId,
maxDevices: peekInfo.maxDevices,
peekedParticipants,
raisedHands,
remoteParticipants,
remoteAudioLevels: call.remoteAudioLevels || new Map<number, number>(),
} satisfies ActiveGroupCallType;
@ -360,6 +376,7 @@ const mapStateToProps = (state: StateType) => {
getPreferredBadge: getPreferredBadgeSelector(state),
i18n: getIntl(state),
isGroupCallOutboundRingEnabled: isGroupCallOutboundRingEnabled(),
isGroupCallRaiseHandEnabled: isGroupCallRaiseHandEnabled(),
isGroupCallReactionsEnabled: isGroupCallReactionsEnabled(),
incomingCall,
me: getMe(state),

View file

@ -940,6 +940,7 @@ describe('calling duck', () => {
videoAspectRatio: 4 / 3,
},
],
raisedHands: [],
}
);
});
@ -997,6 +998,7 @@ describe('calling duck', () => {
videoAspectRatio: 16 / 9,
},
],
raisedHands: [],
}
);
});

View file

@ -96,6 +96,7 @@ export type ActiveGroupCallType = ActiveCallBaseType & {
groupMembers: Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
isConversationTooBigToRing: boolean;
peekedParticipants: Array<ConversationType>;
raisedHands: Set<number>;
remoteParticipants: Array<GroupCallRemoteParticipantType>;
remoteAudioLevels: Map<number, number>;
};
@ -158,6 +159,7 @@ export type GroupCallRemoteParticipantType = ConversationType & {
demuxId: number;
hasRemoteAudio: boolean;
hasRemoteVideo: boolean;
isHandRaised: boolean;
presenting: boolean;
sharingScreen: boolean;
speakerTime?: number;

View file

@ -0,0 +1,8 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as RemoteConfig from '../RemoteConfig';
export function isGroupCallRaiseHandEnabled(): boolean {
return Boolean(RemoteConfig.isEnabled('desktop.internalUser'));
}

View file

@ -3058,6 +3058,13 @@
"reasonCategory": "usageTrusted",
"updated": "2023-10-26T13:57:41.860Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CallingToastManager.tsx",
"line": " const raisedHandsInLastShownToastRef = useRef<Set<number>>(new Set());",
"reasonCategory": "usageTrusted",
"updated": "2023-12-05T22:11:41.559Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CallsList.tsx",