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 && (
+
+ )}
+ {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}