diff --git a/_locales/en/messages.json b/_locales/en/messages.json index afb5737bea..dd40513bf9 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/images/icons/v3/raise_hand/raise_hand-bold.svg b/images/icons/v3/raise_hand/raise_hand-bold.svg new file mode 100644 index 0000000000..07159b7af1 --- /dev/null +++ b/images/icons/v3/raise_hand/raise_hand-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/raise_hand/raise_hand-compact-bold.svg b/images/icons/v3/raise_hand/raise_hand-compact-bold.svg new file mode 100644 index 0000000000..85adcafc8d --- /dev/null +++ b/images/icons/v3/raise_hand/raise_hand-compact-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/raise_hand/raise_hand-compact-light.svg b/images/icons/v3/raise_hand/raise_hand-compact-light.svg new file mode 100644 index 0000000000..356158139b --- /dev/null +++ b/images/icons/v3/raise_hand/raise_hand-compact-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/raise_hand/raise_hand-compact.svg b/images/icons/v3/raise_hand/raise_hand-compact.svg new file mode 100644 index 0000000000..d7dba09d48 --- /dev/null +++ b/images/icons/v3/raise_hand/raise_hand-compact.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/raise_hand/raise_hand-light.svg b/images/icons/v3/raise_hand/raise_hand-light.svg new file mode 100644 index 0000000000..7305080ab0 --- /dev/null +++ b/images/icons/v3/raise_hand/raise_hand-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/raise_hand/raise_hand.svg b/images/icons/v3/raise_hand/raise_hand.svg new file mode 100644 index 0000000000..f4dd5b9200 --- /dev/null +++ b/images/icons/v3/raise_hand/raise_hand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 50b0c05f77..77f2f2932e 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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 { diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index d7a89a915d..d03aa10fe5 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -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 diff --git a/stylesheets/components/CallControls.scss b/stylesheets/components/CallControls.scss index d689f64ac9..8eed7b12ad 100644 --- a/stylesheets/components/CallControls.scss +++ b/stylesheets/components/CallControls.scss @@ -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; } diff --git a/stylesheets/components/CallingRaisedHandsList.scss b/stylesheets/components/CallingRaisedHandsList.scss new file mode 100644 index 0000000000..d978286f04 --- /dev/null +++ b/stylesheets/components/CallingRaisedHandsList.scss @@ -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; + } + } +} diff --git a/stylesheets/components/CallingRaisedHandsToasts.scss b/stylesheets/components/CallingRaisedHandsToasts.scss new file mode 100644 index 0000000000..586b30c0ce --- /dev/null +++ b/stylesheets/components/CallingRaisedHandsToasts.scss @@ -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; +} diff --git a/stylesheets/components/CallingStatusIndicator.scss b/stylesheets/components/CallingStatusIndicator.scss index 8ad67ec760..81e45eb7d3 100644 --- a/stylesheets/components/CallingStatusIndicator.scss +++ b/stylesheets/components/CallingStatusIndicator.scss @@ -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; +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index ee21965275..727209c7df 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -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'; diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 0ec9919fe3..d1bd130e82 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -74,6 +74,7 @@ const createProps = (storyProps: Partial = {}): 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 => ({ renderEmojiPicker: () => <>EmojiPicker, renderReactionPicker: () =>
, renderSafetyNumberViewer: (_: SafetyNumberProps) =>
, + 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(), remoteParticipants: [], remoteAudioLevels: new Map(), }, @@ -247,6 +250,7 @@ export function GroupCallSafetyNumberChanged(): JSX.Element { groupMembers: [], isConversationTooBigToRing: false, peekedParticipants: [], + raisedHands: new Set(), remoteParticipants: [], remoteAudioLevels: new Map(), }, diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 244ccf3f7e..050642c062 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -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} diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 8d9b018cad..384faa4d0c 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -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; + raisedHands?: Set; remoteParticipants?: Array; 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( +const getConversationsByDemuxId = (overrideProps: GroupCallOverrideProps) => { + const conversationsByDemuxId = new Map( 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(), remoteParticipants: overrideProps.remoteParticipants || [], remoteAudioLevels: new Map( 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: () =>
, + 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 ( + + ); +} + // 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, diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index c70d1ff4cc..48ec471cee 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -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>; hangUpActiveCall: (reason: string) => void; i18n: LocalizerType; + isGroupCallRaiseHandEnabled: boolean; isGroupCallReactionsEnabled: boolean; me: ConversationType; openSystemPreferencesAction: () => unknown; renderReactionPicker: ( props: React.ComponentProps ) => JSX.Element; + sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void; sendGroupCallReaction: (payload: SendGroupCallReactionType) => void; setGroupCallVideoRequest: ( _: Array, @@ -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( + 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) => { + 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 = ( + + ); + } 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 ( +
+ + {message} + {buttonOverride || ( + + )} +
+ ); + }, + [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 && ( + <> + + {showRaisedHandsList && ( + setShowRaisedHandsList(false)} + onLowerMyHand={() => { + toggleRaiseHand(false); + setShowRaisedHandsList(false); + }} + localDemuxId={localDemuxId} + conversationsByDemuxId={conversationsByDemuxId} + raisedHands={raisedHands} + localHandRaised={syncedLocalHandRaised} + /> + )} + + )}
@@ -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 && ( + + )}
)} @@ -715,6 +882,9 @@ export function CallScreen({ audioLevel={localAudioLevel} shouldShowSpeaking={isSpeaking} /> + {syncedLocalHandRaised && ( +
+ )}
) : (
@@ -875,3 +1045,14 @@ function CallingReactionsToasts(props: CallingReactionsToastsType) { useReactionsToast(props); return null; } + +function isHandRaised( + raisedHands: Set | undefined, + demuxId: number | undefined +): boolean { + if (raisedHands === undefined || demuxId === undefined) { + return false; + } + + return raisedHands.has(demuxId); +} diff --git a/ts/components/CallingParticipantsList.stories.tsx b/ts/components/CallingParticipantsList.stories.tsx index e048994c87..a6893937a9 100644 --- a/ts/components/CallingParticipantsList.stories.tsx +++ b/ts/components/CallingParticipantsList.stories.tsx @@ -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, diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index dca7e31ebe..4e412f9301 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -140,6 +140,7 @@ export function GroupCall(args: PropsType): JSX.Element { maxDevices: 5, deviceCount: 0, peekedParticipants: [], + raisedHands: new Set(), remoteParticipants: [], remoteAudioLevels: new Map(), }} diff --git a/ts/components/CallingRaisedHandsList.stories.tsx b/ts/components/CallingRaisedHandsList.stories.tsx new file mode 100644 index 0000000000..06807147a4 --- /dev/null +++ b/ts/components/CallingRaisedHandsList.stories.tsx @@ -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( + 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 => ({ + 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(), +}); + +export default { + title: 'Components/CallingRaisedHandsList', +} satisfies Meta; + +export function Me(): JSX.Element { + const props = createProps({ + localHandRaised: true, + raisedHands: new Set([LOCAL_DEMUX_ID]), + }); + return ; +} + +export function MeOnAnotherDevice(): JSX.Element { + const props = createProps({ + raisedHands: new Set([LOCAL_DEMUX_ID]), + }); + return ; +} + +export function MeAndOne(): JSX.Element { + const props = createProps({ + localHandRaised: true, + raisedHands: new Set([LOCAL_DEMUX_ID, LOCAL_DEMUX_ID + 1]), + }); + return ; +} + +export function One(): JSX.Element { + const props = createProps({ raisedHands: new Set([LOCAL_DEMUX_ID + 1]) }); + return ; +} + +export function Many(): JSX.Element { + const props = createProps({ + raisedHands: new Set([...conversationsByDemuxId.keys()]), + }); + return ; +} diff --git a/ts/components/CallingRaisedHandsList.tsx b/ts/components/CallingRaisedHandsList.tsx new file mode 100644 index 0000000000..d5eb7b0ed5 --- /dev/null +++ b/ts/components/CallingRaisedHandsList.tsx @@ -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; + 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>(() => { + const serviceIds: Set = new Set(); + const conversations: Array = []; + 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 ( + +
+
+
+ {i18n('icu:CallingRaisedHandsList__Title', { + count: participants.length, + })} +
+
+
    + {participants.map((participant: ConversationType, index: number) => ( +
  • +
    + + {ourServiceId && participant.serviceId === ourServiceId ? ( + + {i18n('icu:you')} + + ) : ( + + )} +
    +
    + {localHandRaised && + ourServiceId && + participant.serviceId === ourServiceId && ( + + )} +
    +
    +
  • + ))} +
+
+
+ ); +} diff --git a/ts/components/CallingToastManager.tsx b/ts/components/CallingToastManager.tsx index a8b5f04a12..a1d0e3529e 100644 --- a/ts/components/CallingToastManager.tsx +++ b/ts/components/CallingToastManager.tsx @@ -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; + renderRaisedHandsToast?: ( + hands: Array + ) => 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(false); + React.useEffect(() => { + const timeout = setTimeout(() => { + setIsLoaded(true); + }, LOAD_DELAY); + return () => clearTimeout(timeout); + }, []); + + const previousRaisedHands = usePrevious(raisedHands, raisedHands); + const [newHands, loweredHands]: [Set, Set] = isLoaded + ? [ + setDifference( + raisedHands ?? new Set(), + previousRaisedHands ?? new Set() + ), + setDifference( + previousRaisedHands ?? new Set(), + raisedHands ?? new Set() + ), + ] + : [new Set(), new Set()]; + + const raisedHandsInLastShownToastRef = useRef>(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