Add group call participant menu to participant grid tiles

Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2026-04-30 13:42:16 -05:00
committed by GitHub
parent 574c1aa817
commit 582bdcee85
11 changed files with 231 additions and 71 deletions
+5
View File
@@ -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;
}
+1
View File
@@ -479,6 +479,7 @@ function ActiveCallManager({
isCallLinkAdmin={isCallLinkAdmin}
me={me}
openSystemPreferencesAction={openSystemPreferencesAction}
renderCallingParticipantMenu={renderCallingParticipantMenu}
renderReactionPicker={renderReactionPicker}
sendGroupCallRaiseHand={sendGroupCallRaiseHand}
sendGroupCallReaction={sendGroupCallReaction}
+2
View File
@@ -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: () => <div />,
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'),
+7
View File
@@ -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<typeof SmartReactionPicker>
) => 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 = (
<GroupCallRemoteParticipants
callConversationId={conversation.id}
callViewMode={activeCall.viewMode}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
imageDataCache={imageDataCache}
@@ -980,6 +986,7 @@ export function CallScreen({
remoteParticipants={activeCall.remoteParticipants}
setGroupCallVideoRequest={setGroupCallVideoRequest}
remoteAudioLevels={activeCall.remoteAudioLevels}
renderCallingParticipantMenu={renderCallingParticipantMenu}
isCallReconnecting={isReconnecting}
onClickRaisedHand={
raisedHandsCount > 0
@@ -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: <div>Menu</div>,
};
+55 -5
View File
@@ -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 (
<AxoTheme.Override theme="force-dark">
<AxoMenuBuilder.Root
@@ -74,16 +83,57 @@ export function CallingParticipantMenu({
{i18n('icu:CallingParticipantMenu__GoToChat')}
</AxoMenuBuilder.Item>
)}
{onRemoveFromCall && (
{onBlockFromCall && onRemoveFromCall && (
<AxoMenuBuilder.Item
symbol="minus-circle"
onSelect={onRemoveFromCall}
onSelect={() => setRemoveFromCallModalVisible(true)}
>
{i18n('icu:CallingParticipantMenu__RemoveFromCall')}
</AxoMenuBuilder.Item>
)}
</AxoMenuBuilder.Content>
</AxoMenuBuilder.Root>
{removeFromCallModalVisible && (
<ConfirmationDialog
dialogName="CallingAdhocCallInfo.removeClientDialog"
moduleClassName="CallingAdhocCallInfo__RemoveClientDialog"
actions={[
{
action: () => {
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 ?? '',
})}
</ConfirmationDialog>
)}
</AxoTheme.Override>
);
}
@@ -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<number, number>(),
remoteParticipantsCount: 1,
renderCallingParticipantMenu,
};
// This component is usually rendered on a call screen.
@@ -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<ArrayBuffer>;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
i18n: LocalizerType;
@@ -31,9 +33,13 @@ export type PropsType = {
overflowedParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
remoteAudioLevels: Map<number, number>;
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<HTMLDivElement | null>(null);
const [overflowScrollTop, setOverflowScrollTop] = useState(0);
@@ -123,6 +130,7 @@ export function GroupCallOverflowArea({
>
{overflowedParticipants.map(remoteParticipant => (
<GroupCallRemoteParticipant
callConversationId={callConversationId}
key={remoteParticipant.demuxId}
getFrameBuffer={getFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
@@ -137,6 +145,7 @@ export function GroupCallOverflowArea({
)}
remoteParticipant={remoteParticipant}
remoteParticipantsCount={remoteParticipantsCount}
renderCallingParticipantMenu={renderCallingParticipantMenu}
isActiveSpeakerInSpeakerView={false}
isCallReconnecting={isCallReconnecting}
isInOverflow
+126 -62
View File
@@ -31,6 +31,10 @@ import { Theme } from '../util/theme.std.ts';
import { isOlderThan } from '../util/timestamp.std.ts';
import type { CallingImageDataCache } from './CallManager.dom.tsx';
import { usePrevious } from '../hooks/usePrevious.std.ts';
import type { PropsType as SmartCallingParticipantMenuProps } from '../state/smart/CallingParticipantMenu.preload.tsx';
import type { AxoMenuBuilder } from '../axo/AxoMenuBuilder.dom.tsx';
import { AxoIconButton } from '../axo/AxoIconButton.dom.tsx';
import { tw } from '../axo/tw.dom.tsx';
const { debounce, noop } = lodash;
@@ -42,6 +46,7 @@ const DELAY_TO_SHOW_MISSING_MEDIA_KEYS = 5000;
const CONTAINER_TRANSITION_TIME = 200;
type BasePropsType = {
callConversationId?: string;
getFrameBuffer: () => Uint8Array<ArrayBuffer>;
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<PropsType> = React.memo(
function GroupCallRemoteParticipantInner(props) {
const {
callConversationId,
getFrameBuffer,
getGroupCallVideoFrameSource,
imageDataCache,
@@ -85,6 +94,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
onClickRaisedHand,
onVisibilityChanged,
remoteParticipantsCount,
renderCallingParticipantMenu,
isActiveSpeakerInSpeakerView,
isCallReconnecting,
isInOverflow,
@@ -97,6 +107,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
avatarUrl,
color,
demuxId,
id: participantConversationId,
hasAvatar,
hasRemoteAudio,
hasRemoteVideo,
@@ -534,6 +545,31 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = 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<PropsType> = React.memo(
{errorDialogBody}
</ConfirmationDialog>
)}
<div
className={classNames(
'module-ongoing-call__group-call-remote-participant',
isSpeaking &&
!isActiveSpeakerInSpeakerView &&
remoteParticipantsCount > 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 && (
<>
<CallingAudioIndicator
hasAudio={hasRemoteAudio}
audioLevel={props.audioLevel}
shouldShowSpeaking={isSpeaking}
{maybeWrapWithParticipantMenu(
'AxoContextMenu',
<div
className={classNames(
'module-ongoing-call__group-call-remote-participant',
tw('group'),
isSpeaking &&
!isActiveSpeakerInSpeakerView &&
remoteParticipantsCount > 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 && (
<div
className={classNames(
'module-ongoing-call__group-call-remote-participant__dropdown',
tw(
'absolute inset-s-2 top-2 legacy-z-index-base',
'opacity-0 group-hover:opacity-100 group-data-focused:opacity-100',
'has-data-[axo-dropdownmenu-state="open"]:opacity-100'
)
)}
>
{maybeWrapWithParticipantMenu(
'AxoDropdownMenu',
<AxoIconButton.Root
variant="floating-secondary"
size="sm"
symbol="chevron-down"
label={i18n(
'icu:CallingParticipantListItem__ContextMenuButton'
)}
tooltip={false}
/>
)}
</div>
)}
<CallingAudioIndicator
hasAudio={hasRemoteAudio}
audioLevel={props.audioLevel}
shouldShowSpeaking={isSpeaking}
/>
<div className="module-ongoing-call__group-call-remote-participant__footer">
{footerInfoElement}
</div>
</>
)}
{wantsToShowVideo && (
<canvas
className={classNames(
'module-ongoing-call__group-call-remote-participant__remote-video',
isCallReconnecting &&
'module-ongoing-call__group-call-remote-participant__remote-video--reconnecting'
)}
style={{
...canvasStyles,
// If we want to show video but don't have any yet, we still render the
// canvas invisibly. This lets us render frame data immediately without
// having to juggle anything.
...(hasVideoToShow ? {} : { display: 'none' }),
}}
ref={canvasEl => {
remoteVideoRef.current = canvasEl;
if (canvasEl) {
canvasContextRef.current = canvasEl.getContext('2d', {
alpha: false,
});
} else {
canvasContextRef.current = null;
}
}}
/>
<div className="module-ongoing-call__group-call-remote-participant__footer">
{footerInfoElement}
</div>
</>
)}
{wantsToShowVideo && (
<canvas
className={classNames(
'module-ongoing-call__group-call-remote-participant__remote-video',
isCallReconnecting &&
'module-ongoing-call__group-call-remote-participant__remote-video--reconnecting'
)}
style={{
...canvasStyles,
// If we want to show video but don't have any yet, we still render the
// canvas invisibly. This lets us render frame data immediately without
// having to juggle anything.
...(hasVideoToShow ? {} : { display: 'none' }),
}}
ref={canvasEl => {
remoteVideoRef.current = canvasEl;
if (canvasEl) {
canvasContextRef.current = canvasEl.getContext('2d', {
alpha: false,
});
} else {
canvasContextRef.current = null;
}
}}
/>
)}
{noVideoNode && (
<CallBackgroundBlur
avatarUrl={isBlocked ? undefined : avatarUrl}
className="module-ongoing-call__group-call-remote-participant-background"
>
{noVideoNode}
</CallBackgroundBlur>
)}
</div>
)}
{noVideoNode && (
<CallBackgroundBlur
avatarUrl={isBlocked ? undefined : avatarUrl}
className="module-ongoing-call__group-call-remote-participant-background"
>
{noVideoNode}
</CallBackgroundBlur>
)}
</div>
)}
</>
);
}
@@ -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<number, number>;
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<Dimensions>({
@@ -357,6 +364,7 @@ export function GroupCallRemoteParticipants({
return (
<GroupCallRemoteParticipant
callConversationId={callConversationId}
key={tile.demuxId}
getFrameBuffer={getFrameBuffer}
imageDataCache={imageDataCache}
@@ -370,6 +378,7 @@ export function GroupCallRemoteParticipants({
top={top}
width={renderedWidth}
remoteParticipantsCount={remoteParticipants.length}
renderCallingParticipantMenu={renderCallingParticipantMenu}
isActiveSpeakerInSpeakerView={isInSpeakerView}
isCallReconnecting={isCallReconnecting}
joinedAt={joinedAt}
@@ -531,6 +540,7 @@ export function GroupCallRemoteParticipants({
{shouldShowOverflow && overflowedParticipants.length > 0 ? (
<GroupCallOverflowArea
callConversationId={callConversationId}
getFrameBuffer={getFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
imageDataCache={imageDataCache}
@@ -542,6 +552,7 @@ export function GroupCallRemoteParticipants({
overflowedParticipants={overflowedParticipants}
remoteAudioLevels={remoteAudioLevels}
remoteParticipantsCount={remoteParticipants.length}
renderCallingParticipantMenu={renderCallingParticipantMenu}
/>
) : null}
</div>
@@ -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}