From 582bdcee8593b550b04bb129b65bac70ed26c9e2 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:42:16 -0500 Subject: [PATCH] Add group call participant menu to participant grid tiles Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> --- stylesheets/_modules.scss | 5 + ts/components/CallManager.dom.tsx | 1 + ts/components/CallScreen.dom.stories.tsx | 2 + ts/components/CallScreen.dom.tsx | 7 + .../CallingParticipantMenu.dom.stories.tsx | 4 +- ts/components/CallingParticipantMenu.dom.tsx | 60 +++++- .../GroupCallOverflowArea.dom.stories.tsx | 2 + ts/components/GroupCallOverflowArea.dom.tsx | 9 + .../GroupCallRemoteParticipant.dom.tsx | 188 ++++++++++++------ .../GroupCallRemoteParticipants.dom.tsx | 11 + .../smart/CallingParticipantMenu.preload.tsx | 13 +- 11 files changed, 231 insertions(+), 71 deletions(-) diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 2e08497aa6..bee22fc6b3 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3991,6 +3991,11 @@ button.module-image__border-overlay:focus { } } + &__dropdown button { + // Matches CallingAudioIndicator + background: rgba(variables.$color-gray-80, 0.7); + } + &--hand-raised &__footer { background: transparent; } diff --git a/ts/components/CallManager.dom.tsx b/ts/components/CallManager.dom.tsx index c5b72dc6cb..738a6f44f4 100644 --- a/ts/components/CallManager.dom.tsx +++ b/ts/components/CallManager.dom.tsx @@ -479,6 +479,7 @@ function ActiveCallManager({ isCallLinkAdmin={isCallLinkAdmin} me={me} openSystemPreferencesAction={openSystemPreferencesAction} + renderCallingParticipantMenu={renderCallingParticipantMenu} renderReactionPicker={renderReactionPicker} sendGroupCallRaiseHand={sendGroupCallRaiseHand} sendGroupCallReaction={sendGroupCallReaction} diff --git a/ts/components/CallScreen.dom.stories.tsx b/ts/components/CallScreen.dom.stories.tsx index d28246800d..fc6f9fac9d 100644 --- a/ts/components/CallScreen.dom.stories.tsx +++ b/ts/components/CallScreen.dom.stories.tsx @@ -37,6 +37,7 @@ import type { CallingImageDataCache } from './CallManager.dom.tsx'; import { MINUTE } from '../util/durations/index.std.ts'; import { strictAssert } from '../util/assert.std.ts'; import { generateAci } from '../test-helpers/serviceIdUtils.std.ts'; +import { renderCallingParticipantMenu } from './CallingParticipantMenu.dom.stories.tsx'; const { sample, shuffle, times } = lodash; @@ -248,6 +249,7 @@ const createProps = ( openSystemPreferencesAction: action('open-system-preferences-action'), renderReactionPicker: () =>
, cancelPresenting: action('cancel-presenting'), + renderCallingParticipantMenu, sendGroupCallRaiseHand: action('send-group-call-raise-hand'), sendGroupCallReaction: action('send-group-call-reaction'), setGroupCallVideoRequest: action('set-group-call-video-request'), diff --git a/ts/components/CallScreen.dom.tsx b/ts/components/CallScreen.dom.tsx index fefae068f1..8c115de214 100644 --- a/ts/components/CallScreen.dom.tsx +++ b/ts/components/CallScreen.dom.tsx @@ -110,6 +110,7 @@ import { PIP_MAXIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER, PIP_MINIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER, } from './CallingPip.dom.tsx'; +import type { PropsType as SmartCallingParticipantMenuProps } from '../state/smart/CallingParticipantMenu.preload.tsx'; const { isEqual, noop } = lodash; @@ -130,6 +131,9 @@ export type PropsType = { isCallLinkAdmin: boolean; me: ConversationType; openSystemPreferencesAction: () => unknown; + readonly renderCallingParticipantMenu: ( + props: SmartCallingParticipantMenuProps + ) => React.JSX.Element; renderReactionPicker: ( props: React.ComponentProps ) => React.JSX.Element; @@ -220,6 +224,7 @@ export function CallScreen({ isCallLinkAdmin, me, openSystemPreferencesAction, + renderCallingParticipantMenu, renderReactionPicker, setGroupCallVideoRequest, sendGroupCallRaiseHand, @@ -972,6 +977,7 @@ export function CallScreen({ case CallMode.Adhoc: remoteParticipantsElement = ( 0 diff --git a/ts/components/CallingParticipantMenu.dom.stories.tsx b/ts/components/CallingParticipantMenu.dom.stories.tsx index 79d855df93..ca34afb838 100644 --- a/ts/components/CallingParticipantMenu.dom.stories.tsx +++ b/ts/components/CallingParticipantMenu.dom.stories.tsx @@ -13,7 +13,7 @@ import { AxoButton } from '../axo/AxoButton.dom.tsx'; const { i18n } = window.SignalContext; export default { - title: 'CallingParticipantMenu', + title: 'Components/CallingParticipantMenu', excludeStories: ['renderCallingParticipantMenu'], } satisfies Meta; @@ -23,11 +23,13 @@ const defaultProps: CallingParticipantMenuProps = { i18n, renderer: 'AxoContextMenu', isMuteAudioDisabled: false, + onBlockFromCall: action('on-block-from-call'), onMuteAudio: action('on-mute-audio'), onUnmuteAudio: null, onViewProfile: action('on-view-profile'), onGoToChat: action('on-go-to-chat'), onRemoveFromCall: action('on-remove-from-call'), + participantTitle: 'Participant Name', children:
Menu
, }; diff --git a/ts/components/CallingParticipantMenu.dom.tsx b/ts/components/CallingParticipantMenu.dom.tsx index 6d10964c93..0bf81c45b0 100644 --- a/ts/components/CallingParticipantMenu.dom.tsx +++ b/ts/components/CallingParticipantMenu.dom.tsx @@ -1,17 +1,21 @@ // Copyright 2026 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { type ReactNode } from 'react'; +import React, { useState, type ReactNode } from 'react'; import type { LocalizerType } from '../types/I18N.std.ts'; import { AxoMenuBuilder } from '../axo/AxoMenuBuilder.dom.tsx'; import { AxoTheme } from '../axo/AxoTheme.dom.tsx'; +import { ConfirmationDialog } from './ConfirmationDialog.dom.tsx'; +import { strictAssert } from '../util/assert.std.ts'; export type CallingParticipantMenuProps = Readonly<{ - align: AxoMenuBuilder.Align; - side: AxoMenuBuilder.Side; + align?: AxoMenuBuilder.Align; + side?: AxoMenuBuilder.Side; i18n: LocalizerType; renderer: AxoMenuBuilder.Renderer; isMuteAudioDisabled: boolean; + participantTitle: string | undefined; + onBlockFromCall: (() => void) | null; onMuteAudio: (() => void) | null; onUnmuteAudio: (() => void) | null; onViewProfile: (() => void) | null; @@ -26,13 +30,18 @@ export function CallingParticipantMenu({ i18n, renderer, isMuteAudioDisabled, + onBlockFromCall, onMuteAudio, onUnmuteAudio, onViewProfile, onGoToChat, onRemoveFromCall, + participantTitle, children, }: CallingParticipantMenuProps): React.JSX.Element { + const [removeFromCallModalVisible, setRemoveFromCallModalVisible] = + useState(false); + return ( )} - {onRemoveFromCall && ( + {onBlockFromCall && onRemoveFromCall && ( setRemoveFromCallModalVisible(true)} > {i18n('icu:CallingParticipantMenu__RemoveFromCall')} )} + {removeFromCallModalVisible && ( + { + strictAssert( + onBlockFromCall, + 'onBlockFromCall prop is required' + ); + onBlockFromCall(); + }, + style: 'negative', + text: i18n( + 'icu:CallingAdhocCallInfo__RemoveClientDialogButton--block' + ), + }, + { + action: () => { + strictAssert( + onRemoveFromCall, + 'onRemoveFromCall prop is required' + ); + onRemoveFromCall(); + }, + style: 'negative', + text: i18n( + 'icu:CallingAdhocCallInfo__RemoveClientDialogButton--remove' + ), + }, + ]} + cancelText={i18n('icu:cancel')} + i18n={i18n} + onClose={() => setRemoveFromCallModalVisible(false)} + > + {i18n('icu:CallingAdhocCallInfo__RemoveClientDialogBody', { + name: participantTitle ?? '', + })} + + )} ); } diff --git a/ts/components/GroupCallOverflowArea.dom.stories.tsx b/ts/components/GroupCallOverflowArea.dom.stories.tsx index 09c196018a..9e527292c3 100644 --- a/ts/components/GroupCallOverflowArea.dom.stories.tsx +++ b/ts/components/GroupCallOverflowArea.dom.stories.tsx @@ -13,6 +13,7 @@ import { FRAME_BUFFER_SIZE } from '../calling/constants.std.ts'; import type { CallingImageDataCache } from './CallManager.dom.tsx'; import { MINUTE } from '../util/durations/index.std.ts'; import { generateAci } from '../test-helpers/serviceIdUtils.std.ts'; +import { renderCallingParticipantMenu } from './CallingParticipantMenu.dom.stories.tsx'; const { memoize, times } = lodash; @@ -53,6 +54,7 @@ const defaultProps = { onParticipantVisibilityChanged: action('onParticipantVisibilityChanged'), remoteAudioLevels: new Map(), remoteParticipantsCount: 1, + renderCallingParticipantMenu, }; // This component is usually rendered on a call screen. diff --git a/ts/components/GroupCallOverflowArea.dom.tsx b/ts/components/GroupCallOverflowArea.dom.tsx index 786ccb2cb8..af2db2793e 100644 --- a/ts/components/GroupCallOverflowArea.dom.tsx +++ b/ts/components/GroupCallOverflowArea.dom.tsx @@ -9,6 +9,7 @@ import type { LocalizerType } from '../types/Util.std.ts'; import type { GroupCallRemoteParticipantType } from '../types/Calling.std.ts'; import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant.dom.tsx'; import type { CallingImageDataCache } from './CallManager.dom.tsx'; +import type { PropsType as SmartCallingParticipantMenuProps } from '../state/smart/CallingParticipantMenu.preload.tsx'; const OVERFLOW_SCROLLED_TO_EDGE_THRESHOLD = 20; const OVERFLOW_SCROLL_BUTTON_RATIO = 0.75; @@ -17,6 +18,7 @@ const OVERFLOW_SCROLL_BUTTON_RATIO = 0.75; export const OVERFLOW_PARTICIPANT_WIDTH = 107; export type PropsType = { + callConversationId?: string; getFrameBuffer: () => Uint8Array; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; i18n: LocalizerType; @@ -31,9 +33,13 @@ export type PropsType = { overflowedParticipants: ReadonlyArray; remoteAudioLevels: Map; remoteParticipantsCount: number; + renderCallingParticipantMenu: ( + props: SmartCallingParticipantMenuProps + ) => React.JSX.Element; }; export function GroupCallOverflowArea({ + callConversationId, getFrameBuffer, getGroupCallVideoFrameSource, imageDataCache, @@ -45,6 +51,7 @@ export function GroupCallOverflowArea({ overflowedParticipants, remoteAudioLevels, remoteParticipantsCount, + renderCallingParticipantMenu, }: PropsType): React.JSX.Element | null { const overflowRef = useRef(null); const [overflowScrollTop, setOverflowScrollTop] = useState(0); @@ -123,6 +130,7 @@ export function GroupCallOverflowArea({ > {overflowedParticipants.map(remoteParticipant => ( Uint8Array; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; i18n: LocalizerType; @@ -54,6 +59,9 @@ type BasePropsType = { onVisibilityChanged?: (demuxId: number, isVisible: boolean) => unknown; remoteParticipant: GroupCallRemoteParticipantType; remoteParticipantsCount: number; + renderCallingParticipantMenu?: ( + props: SmartCallingParticipantMenuProps + ) => React.JSX.Element; }; type InPipPropsType = { @@ -78,6 +86,7 @@ export type PropsType = BasePropsType & export const GroupCallRemoteParticipant: React.FC = React.memo( function GroupCallRemoteParticipantInner(props) { const { + callConversationId, getFrameBuffer, getGroupCallVideoFrameSource, imageDataCache, @@ -85,6 +94,7 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( onClickRaisedHand, onVisibilityChanged, remoteParticipantsCount, + renderCallingParticipantMenu, isActiveSpeakerInSpeakerView, isCallReconnecting, isInOverflow, @@ -97,6 +107,7 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( avatarUrl, color, demuxId, + id: participantConversationId, hasAvatar, hasRemoteAudio, hasRemoteVideo, @@ -534,6 +545,31 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( titleNoDefault, ]); + const maybeWrapWithParticipantMenu = useCallback( + (renderer: AxoMenuBuilder.Renderer, children: ReactNode): ReactNode => { + if (renderCallingParticipantMenu) { + return renderCallingParticipantMenu({ + callConversationId, + participantConversationId, + demuxId, + hasAudio: hasRemoteAudio, + renderer, + children, + align: isInOverflow ? 'end' : 'start', + }); + } + return children; + }, + [ + callConversationId, + demuxId, + hasRemoteAudio, + isInOverflow, + participantConversationId, + renderCallingParticipantMenu, + ] + ); + return ( <> {showErrorDialog && ( @@ -548,69 +584,97 @@ export const GroupCallRemoteParticipant: React.FC = React.memo( {errorDialogBody} )} - -
1 && - 'module-ongoing-call__group-call-remote-participant--speaking', - isHandRaised && - 'module-ongoing-call__group-call-remote-participant--hand-raised', - isOnTop && - 'module-ongoing-call__group-call-remote-participant--is-on-top' - )} - ref={intersectionRef} - style={containerStyles} - > - {!props.isInPip && ( - <> - 1 && + 'module-ongoing-call__group-call-remote-participant--speaking', + isHandRaised && + 'module-ongoing-call__group-call-remote-participant--hand-raised', + isOnTop && + 'module-ongoing-call__group-call-remote-participant--is-on-top' + )} + ref={intersectionRef} + style={containerStyles} + > + {!props.isInPip && ( + <> + {renderCallingParticipantMenu && ( +
+ {maybeWrapWithParticipantMenu( + 'AxoDropdownMenu', + + )} +
+ )} + +
+ {footerInfoElement} +
+ + )} + {wantsToShowVideo && ( + { + remoteVideoRef.current = canvasEl; + if (canvasEl) { + canvasContextRef.current = canvasEl.getContext('2d', { + alpha: false, + }); + } else { + canvasContextRef.current = null; + } + }} /> -
- {footerInfoElement} -
- - )} - {wantsToShowVideo && ( - { - remoteVideoRef.current = canvasEl; - if (canvasEl) { - canvasContextRef.current = canvasEl.getContext('2d', { - alpha: false, - }); - } else { - canvasContextRef.current = null; - } - }} - /> - )} - {noVideoNode && ( - - {noVideoNode} - - )} -
+ )} + {noVideoNode && ( + + {noVideoNode} + + )} +
+ )} ); } diff --git a/ts/components/GroupCallRemoteParticipants.dom.tsx b/ts/components/GroupCallRemoteParticipants.dom.tsx index 514b64d28e..4920a66b34 100644 --- a/ts/components/GroupCallRemoteParticipants.dom.tsx +++ b/ts/components/GroupCallRemoteParticipants.dom.tsx @@ -28,6 +28,7 @@ import { MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH } from '../calling/constants.std.ts'; import { SizeObserver } from '../hooks/useSizeObserver.dom.tsx'; import { strictAssert } from '../util/assert.std.ts'; import type { CallingImageDataCache } from './CallManager.dom.tsx'; +import type { PropsType as SmartCallingParticipantMenuProps } from '../state/smart/CallingParticipantMenu.preload.tsx'; const { clamp, chunk, maxBy, flatten, noop } = lodash; @@ -62,6 +63,7 @@ type ParticipantTileType = | PaginationButtonType; type PropsType = { + callConversationId?: string; callViewMode: CallViewMode; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; i18n: LocalizerType; @@ -74,6 +76,9 @@ type PropsType = { speakerHeight: number ) => void; remoteAudioLevels: Map; + renderCallingParticipantMenu: ( + props: SmartCallingParticipantMenuProps + ) => React.JSX.Element; onClickRaisedHand?: () => void; }; @@ -115,6 +120,7 @@ enum VideoRequestMode { // 5. Lay out this arrangement on the screen. export function GroupCallRemoteParticipants({ + callConversationId, callViewMode, getGroupCallVideoFrameSource, imageDataCache, @@ -124,6 +130,7 @@ export function GroupCallRemoteParticipants({ remoteParticipants, setGroupCallVideoRequest, remoteAudioLevels, + renderCallingParticipantMenu, onClickRaisedHand, }: PropsType): React.JSX.Element { const [gridDimensions, setGridDimensions] = useState({ @@ -357,6 +364,7 @@ export function GroupCallRemoteParticipants({ return ( 0 ? ( ) : null} diff --git a/ts/state/smart/CallingParticipantMenu.preload.tsx b/ts/state/smart/CallingParticipantMenu.preload.tsx index a1ea68ea09..1e16e47e02 100644 --- a/ts/state/smart/CallingParticipantMenu.preload.tsx +++ b/ts/state/smart/CallingParticipantMenu.preload.tsx @@ -27,8 +27,8 @@ export type PropsType = { participantConversationId?: string; demuxId?: number; hasAudio: boolean; - align: AxoMenuBuilder.Align; - side: AxoMenuBuilder.Side; + align?: AxoMenuBuilder.Align; + side?: AxoMenuBuilder.Side; renderer: AxoMenuBuilder.Renderer; children: ReactNode; }; @@ -74,8 +74,10 @@ export const SmartCallingParticipantMenu = memo( const { showContactModal } = useGlobalModalActions(); const { showConversation } = useConversationsActions(); - const { setLocalAudio, removeClient, sendRemoteMute } = useCallingActions(); + const { blockClient, setLocalAudio, removeClient, sendRemoteMute } = + useCallingActions(); + let participantTitle: string | undefined; let onMuteAudio: (() => void) | null; let onUnmuteAudio: (() => void) | null; let onViewProfile: (() => void) | null; @@ -113,12 +115,14 @@ export const SmartCallingParticipantMenu = memo( contactId: participantConversation.id, conversationId: callConversationId, }); + participantTitle = participantConversation.title; } else { onGoToChat = null; onViewProfile = null; } } + let onBlockFromCall: (() => void) | null = null; let onRemoveFromCall: (() => void) | null = null; if (activeCallState?.callMode === CallMode.Adhoc) { const callLink = callLinkSelector(activeCallState.conversationId); @@ -128,6 +132,7 @@ export const SmartCallingParticipantMenu = memo( demuxId !== undefined && demuxId !== localDemuxId ) { + onBlockFromCall = () => blockClient({ demuxId }); onRemoveFromCall = () => removeClient({ demuxId }); } } @@ -139,6 +144,8 @@ export const SmartCallingParticipantMenu = memo( renderer={renderer} i18n={i18n} isMuteAudioDisabled={!hasAudio} + participantTitle={participantTitle} + onBlockFromCall={onBlockFromCall} onMuteAudio={onMuteAudio} onUnmuteAudio={onUnmuteAudio} onGoToChat={onGoToChat}