Properly style call buttons across app, when already in a call

This commit is contained in:
Scott Nonnenberg 2024-08-27 06:48:41 +10:00 committed by GitHub
parent 3c25092f50
commit c251867699
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 610 additions and 189 deletions

View file

@ -37,6 +37,16 @@ export function KitchenSink(): JSX.Element {
{variant}
</Button>
</p>
<p>
<Button
discouraged
onClick={action('onClick')}
size={size}
variant={variant}
>
{variant}
</Button>
</p>
</React.Fragment>
))}
</React.Fragment>

View file

@ -43,6 +43,7 @@ export enum ButtonIconType {
export type PropsType = {
className?: string;
disabled?: boolean;
discouraged?: boolean;
icon?: ButtonIconType;
size?: ButtonSize;
style?: CSSProperties;
@ -105,6 +106,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
children,
className,
disabled = false,
discouraged = false,
icon,
style,
tabIndex,
@ -143,8 +145,10 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
'module-Button',
sizeClassName,
variantClassName,
discouraged ? `${variantClassName}--discouraged` : undefined,
icon && `module-Button--icon--${icon}`,
className
className,
className && discouraged ? `${className}--discouraged` : undefined
)}
disabled={disabled}
onClick={onClick}

View file

@ -23,6 +23,7 @@ export default {
i18n,
callHistoryGroup: getFakeCallLinkHistoryGroup(),
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
hasActiveCall: false,
onDeleteCallLink: action('onDeleteCallLink'),
onOpenCallLinkAddNameModal: action('onOpenCallLinkAddNameModal'),
onStartCallLinkLobby: action('onStartCallLinkLobby'),
@ -38,3 +39,7 @@ export function Admin(args: CallLinkDetailsProps): JSX.Element {
export function NonAdmin(args: CallLinkDetailsProps): JSX.Element {
return <CallLinkDetails {...args} callLink={FAKE_CALL_LINK} />;
}
export function InAnotherCall(args: CallLinkDetailsProps): JSX.Element {
return <CallLinkDetails {...args} callLink={FAKE_CALL_LINK} hasActiveCall />;
}

View file

@ -20,6 +20,7 @@ import { getColorForCallLink } from '../util/getColorForCallLink';
import { isCallLinkAdmin } from '../types/CallLink';
import { CallLinkRestrictionsSelect } from './CallLinkRestrictionsSelect';
import { ConfirmationDialog } from './ConfirmationDialog';
import { InAnotherCallTooltip } from './conversation/InAnotherCallTooltip';
function toUrlWithoutProtocol(url: URL): string {
return `${url.hostname}${url.pathname}${url.search}${url.hash}`;
@ -28,6 +29,7 @@ function toUrlWithoutProtocol(url: URL): string {
export type CallLinkDetailsProps = Readonly<{
callHistoryGroup: CallHistoryGroup;
callLink: CallLinkType;
hasActiveCall: boolean;
i18n: LocalizerType;
onDeleteCallLink: () => void;
onOpenCallLinkAddNameModal: () => void;
@ -40,6 +42,7 @@ export function CallLinkDetails({
callHistoryGroup,
callLink,
i18n,
hasActiveCall,
onDeleteCallLink,
onOpenCallLinkAddNameModal,
onStartCallLinkLobby,
@ -51,6 +54,18 @@ export function CallLinkDetails({
const webUrl = linkCallRoute.toWebUrl({
key: callLink.rootKey,
});
const joinButton = (
<Button
className="CallLinkDetails__HeaderButton"
variant={ButtonVariant.SecondaryAffirmative}
discouraged={hasActiveCall}
size={ButtonSize.Small}
onClick={onStartCallLinkLobby}
>
{i18n('icu:CallLinkDetails__Join')}
</Button>
);
return (
<div className="CallLinkDetails__Container">
<header className="CallLinkDetails__Header">
@ -77,14 +92,13 @@ export function CallLinkDetails({
</p>
</div>
<div className="CallLinkDetails__HeaderActions">
<Button
className="CallLinkDetails__HeaderButton"
variant={ButtonVariant.SecondaryAffirmative}
size={ButtonSize.Small}
onClick={onStartCallLinkLobby}
>
{i18n('icu:CallLinkDetails__Join')}
</Button>
{hasActiveCall ? (
<InAnotherCallTooltip i18n={i18n}>
{joinButton}
</InAnotherCallTooltip>
) : (
joinButton
)}
</div>
</header>
<CallHistoryGroupPanelSection

View file

@ -18,6 +18,7 @@ export default {
args: {
i18n,
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
hasActiveCall: false,
onClose: action('onClose'),
onCopyCallLink: action('onCopyCallLink'),
onOpenCallLinkAddNameModal: action('onOpenCallLinkAddNameModal'),
@ -30,3 +31,7 @@ export default {
export function Basic(args: CallLinkEditModalProps): JSX.Element {
return <CallLinkEditModal {...args} />;
}
export function InAnotherCall(args: CallLinkEditModalProps): JSX.Element {
return <CallLinkEditModal {...args} hasActiveCall />;
}

View file

@ -13,6 +13,7 @@ import { Button, ButtonSize, ButtonVariant } from './Button';
import { Avatar, AvatarSize } from './Avatar';
import { getColorForCallLink } from '../util/getColorForCallLink';
import { CallLinkRestrictionsSelect } from './CallLinkRestrictionsSelect';
import { InAnotherCallTooltip } from './conversation/InAnotherCallTooltip';
const CallLinkEditModalRowIconClasses = {
Edit: 'CallLinkEditModal__RowIcon--Edit',
@ -67,6 +68,7 @@ function Hr() {
export type CallLinkEditModalProps = {
i18n: LocalizerType;
callLink: CallLinkType;
hasActiveCall: boolean;
onClose: () => void;
onCopyCallLink: () => void;
onOpenCallLinkAddNameModal: () => void;
@ -78,6 +80,7 @@ export type CallLinkEditModalProps = {
export function CallLinkEditModal({
i18n,
callLink,
hasActiveCall,
onClose,
onCopyCallLink,
onOpenCallLinkAddNameModal,
@ -91,6 +94,18 @@ export function CallLinkEditModal({
return linkCallRoute.toWebUrl({ key: callLink.rootKey }).toString();
}, [callLink.rootKey]);
const joinButton = (
<Button
onClick={onStartCallLinkLobby}
size={ButtonSize.Small}
variant={ButtonVariant.SecondaryAffirmative}
discouraged={hasActiveCall}
className="CallLinkEditModal__JoinButton"
>
{i18n('icu:CallLinkEditModal__JoinButtonLabel')}
</Button>
);
return (
<Modal
i18n={i18n}
@ -141,14 +156,13 @@ export function CallLinkEditModal({
</button>
</div>
<div className="CallLinkEditModal__Header__Actions">
<Button
onClick={onStartCallLinkLobby}
size={ButtonSize.Small}
variant={ButtonVariant.SecondaryAffirmative}
className="CallLinkEditModal__JoinButton"
>
{i18n('icu:CallLinkEditModal__JoinButtonLabel')}
</Button>
{hasActiveCall ? (
<InAnotherCallTooltip i18n={i18n}>
{joinButton}
</InAnotherCallTooltip>
) : (
joinButton
)}
</div>
</div>

View file

@ -44,7 +44,7 @@ import {
formatCallHistoryGroup,
getCallIdFromEra,
} from '../util/callDisposition';
import { CallsNewCallButton } from './CallsNewCall';
import { CallsNewCallButton } from './CallsNewCallButton';
import { Tooltip, TooltipPlacement } from './Tooltip';
import { Theme } from '../util/theme';
import type { CallingConversationType } from '../types/Calling';

View file

@ -20,8 +20,10 @@ import { I18n } from './I18n';
import { SizeObserver } from '../hooks/useSizeObserver';
import { CallType } from '../types/CallDisposition';
import type { CallsTabSelectedView } from './CallsTab';
import { Tooltip, TooltipPlacement } from './Tooltip';
import { offsetDistanceModifier } from '../util/popperUtil';
import {
InAnotherCallTooltip,
getTooltipContent,
} from './conversation/InAnotherCallTooltip';
type CallsNewCallProps = Readonly<{
hasActiveCall: boolean;
@ -53,9 +55,9 @@ export function CallsNewCallButton({
onClick: () => void;
}): JSX.Element {
let innerContent: React.ReactNode | string;
let tooltipContent = '';
let inAnotherCallTooltipContent = '';
if (!isEnabled) {
tooltipContent = i18n('icu:ContactModal--already-in-call');
inAnotherCallTooltipContent = getTooltipContent(i18n);
}
// Note: isActive is only set for groups and adhoc calls
if (isActive) {
@ -82,7 +84,7 @@ export function CallsNewCallButton({
? undefined
: 'CallsNewCall__ItemActionButton--join-call-disabled'
)}
aria-label={tooltipContent}
aria-label={inAnotherCallTooltipContent}
onClick={event => {
event.stopPropagation();
onClick();
@ -92,17 +94,10 @@ export function CallsNewCallButton({
</button>
);
return tooltipContent === '' ? (
return inAnotherCallTooltipContent === '' ? (
buttonContent
) : (
<Tooltip
className="CallsNewCall__ItemActionButtonTooltip"
content={tooltipContent}
direction={TooltipPlacement.Top}
popperModifiers={[offsetDistanceModifier(15)]}
>
{buttonContent}
</Tooltip>
<InAnotherCallTooltip i18n={i18n}>{buttonContent}</InAnotherCallTooltip>
);
}

View file

@ -11,7 +11,7 @@ import type {
CallHistoryGroup,
CallHistoryPagination,
} from '../types/CallDisposition';
import { CallsNewCall } from './CallsNewCall';
import { CallsNewCall } from './CallsNewCallButton';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
import type {
ActiveCallStateType,

View file

@ -29,6 +29,7 @@ export default {
} satisfies Meta<PropsType>;
const getCommonProps = (options: {
activeConversationId?: string;
mode: CallMode;
type?: CallType;
direction?: CallDirection;
@ -81,7 +82,7 @@ const getCommonProps = (options: {
status,
},
callCreator,
activeConversationId: null,
activeConversationId: options.activeConversationId ?? null,
groupCallEnded,
maxDevices,
deviceCount,
@ -118,6 +119,42 @@ export function AcceptedIncomingAudioCall(): JSX.Element {
);
}
export function AcceptedIncomingAudioCallWithActiveCall(): JSX.Element {
return (
<CallingNotification
{...getCommonProps({
mode: CallMode.Direct,
type: CallType.Audio,
direction: CallDirection.Incoming,
status: DirectCallStatus.Accepted,
groupCallEnded: null,
deviceCount: 0,
maxDevices: Infinity,
activeConversationId: 'someOtherConversation',
})}
/>
);
}
export function AcceptedIncomingAudioCallInCurrentCall(): JSX.Element {
const props = getCommonProps({
mode: CallMode.Direct,
type: CallType.Audio,
direction: CallDirection.Incoming,
status: DirectCallStatus.Accepted,
groupCallEnded: null,
deviceCount: 0,
maxDevices: Infinity,
});
return (
<CallingNotification
{...props}
activeConversationId={props.conversationId}
/>
);
}
export function AcceptedIncomingVideoCall(): JSX.Element {
return (
<CallingNotification
@ -374,6 +411,42 @@ export function GroupCallActiveCallFull(): JSX.Element {
);
}
export function GroupCallActiveInAnotherCall(): JSX.Element {
return (
<CallingNotification
{...getCommonProps({
mode: CallMode.Group,
type: CallType.Group,
direction: CallDirection.Incoming,
status: GroupCallStatus.GenericGroupCall,
groupCallEnded: false,
deviceCount: 8,
maxDevices: 10,
activeConversationId: 'someOtherId',
})}
/>
);
}
export function GroupCallActiveInCurrentCall(): JSX.Element {
const props = getCommonProps({
mode: CallMode.Group,
type: CallType.Group,
direction: CallDirection.Incoming,
status: GroupCallStatus.GenericGroupCall,
groupCallEnded: false,
deviceCount: 8,
maxDevices: 10,
});
return (
<CallingNotification
{...props}
activeConversationId={props.conversationId}
/>
);
}
export function GroupCallEnded(): JSX.Element {
return (
<CallingNotification

View file

@ -37,6 +37,7 @@ import {
} from '../../hooks/useKeyboardShortcuts';
import { MINUTE } from '../../util/durations';
import { isMoreRecentThan } from '../../util/timestamp';
import { InAnotherCallTooltip } from './InAnotherCallTooltip';
export type PropsActionsType = {
onOutgoingAudioCallInConversation: (conversationId: string) => void;
@ -162,6 +163,11 @@ function renderCallingNotificationButton(
let disabledTooltipText: undefined | string;
let onClick: () => void;
const inThisCall = Boolean(
props.activeConversationId &&
props.activeConversationId === props.conversationId
);
if (props.callHistory == null) {
return null;
}
@ -169,25 +175,20 @@ function renderCallingNotificationButton(
switch (props.callHistory.mode) {
case CallMode.Direct: {
const { direction, type } = props.callHistory;
if (props.callHistory.status === DirectCallStatus.Pending) {
if (props.callHistory.status === DirectCallStatus.Pending || inThisCall) {
return null;
}
buttonText =
direction === CallDirection.Incoming
? i18n('icu:calling__call-back')
: i18n('icu:calling__call-again');
if (props.activeConversationId != null) {
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
onClick = noop;
} else {
onClick = () => {
if (type === CallType.Video) {
onOutgoingVideoCallInConversation(conversationId);
} else {
onOutgoingAudioCallInConversation(conversationId);
}
};
}
onClick = () => {
if (type === CallType.Video) {
onOutgoingVideoCallInConversation(conversationId);
} else {
onOutgoingAudioCallInConversation(conversationId);
}
};
break;
}
case CallMode.Group: {
@ -207,15 +208,16 @@ function renderCallingNotificationButton(
return null;
}
} else if (props.activeConversationId != null) {
if (props.activeConversationId === conversationId) {
if (inThisCall) {
buttonText = i18n('icu:calling__return');
onClick = returnToActiveCall;
} else {
buttonText = i18n('icu:calling__join');
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
onClick = noop;
onClick = () => {
onOutgoingVideoCallInConversation(conversationId);
};
}
} else if (props.deviceCount > props.maxDevices) {
} else if (props.deviceCount >= props.maxDevices) {
buttonText = i18n('icu:calling__call-is-full');
disabledTooltipText = i18n(
'icu:calling__call-notification__button__call-full-tooltip',
@ -240,9 +242,16 @@ function renderCallingNotificationButton(
return null;
}
const disabled = Boolean(disabledTooltipText);
const inAnotherCall = Boolean(
!disabled &&
props.activeConversationId &&
props.activeConversationId !== props.conversationId
);
const button = (
<Button
disabled={Boolean(disabledTooltipText)}
disabled={disabled}
discouraged={inAnotherCall}
onClick={onClick}
size={ButtonSize.Small}
variant={ButtonVariant.SystemMessage}
@ -258,5 +267,9 @@ function renderCallingNotificationButton(
</Tooltip>
);
}
if (inAnotherCall) {
return <InAnotherCallTooltip i18n={i18n}>{button}</InAnotherCallTooltip>;
}
return button;
}

View file

@ -127,3 +127,8 @@ InSystemContacts.args = {
systemGivenName: defaultContact.title,
},
};
export const InAnotherCall = Template.bind({});
InAnotherCall.args = {
hasActiveCall: true,
};

View file

@ -26,9 +26,11 @@ import { Button, ButtonIconType, ButtonVariant } from '../Button';
import { isInSystemContacts } from '../../util/isInSystemContacts';
import { InContactsIcon } from '../InContactsIcon';
import { canHaveNicknameAndNote } from '../../util/nicknames';
import { Tooltip, TooltipPlacement } from '../Tooltip';
import { offsetDistanceModifier } from '../../util/popperUtil';
import { getThemeByThemeType } from '../../util/theme';
import {
InAnotherCallTooltip,
getTooltipContent,
} from './InAnotherCallTooltip';
export type PropsDataType = {
areWeASubscriber: boolean;
@ -124,11 +126,17 @@ export function ContactModal({
const renderQuickActions = React.useCallback(
(conversationId: string) => {
const inAnotherCallTooltipContent = hasActiveCall
? getTooltipContent(i18n)
: undefined;
const discouraged = hasActiveCall;
const videoCallButton = (
<Button
icon={ButtonIconType.video}
variant={ButtonVariant.Details}
disabled={hasActiveCall}
discouraged={discouraged}
aria-label={inAnotherCallTooltipContent}
onClick={() => {
hideContactModal();
onOutgoingVideoCallInConversation(conversationId);
@ -141,7 +149,8 @@ export function ContactModal({
<Button
icon={ButtonIconType.audio}
variant={ButtonVariant.Details}
disabled={hasActiveCall}
discouraged={discouraged}
aria-label={inAnotherCallTooltipContent}
onClick={() => {
hideContactModal();
onOutgoingAudioCallInConversation(conversationId);
@ -170,28 +179,16 @@ export function ContactModal({
{i18n('icu:ConversationDetails__HeaderButton--Message')}
</Button>
{hasActiveCall ? (
<Tooltip
className="ContactModal__tooltip"
wrapperClassName="ContactModal__tooltip-wrapper"
content={i18n('icu:ContactModal--already-in-call')}
direction={TooltipPlacement.Top}
popperModifiers={[offsetDistanceModifier(5)]}
>
<InAnotherCallTooltip i18n={i18n}>
{videoCallButton}
</Tooltip>
</InAnotherCallTooltip>
) : (
videoCallButton
)}
{hasActiveCall ? (
<Tooltip
className="ContactModal__tooltip"
wrapperClassName="ContactModal__tooltip-wrapper"
content={i18n('icu:ContactModal--already-in-call')}
direction={TooltipPlacement.Top}
popperModifiers={[offsetDistanceModifier(5)]}
>
<InAnotherCallTooltip i18n={i18n}>
{audioCallButton}
</Tooltip>
</InAnotherCallTooltip>
) : (
audioCallButton
)}

View file

@ -5,7 +5,10 @@ import type { ComponentProps } from 'react';
import React, { useContext } from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import {
getDefaultConversation,
getDefaultGroup,
} from '../../test-both/helpers/getDefaultConversation';
import { getRandomColor } from '../../test-both/helpers/getRandomColor';
import { setupI18n } from '../../util/setupI18n';
import { DurationInSeconds } from '../../util/durations';
@ -17,6 +20,7 @@ import {
OutgoingCallButtonStyle,
} from './ConversationHeader';
import { gifUrl } from '../../storybook/Fixtures';
import { ThemeType } from '../../types/Util';
export default {
title: 'Components/Conversation/ConversationHeader',
@ -30,17 +34,14 @@ type ItemsType = Array<{
}>;
const commonConversation = getDefaultConversation();
const commonProps = {
const commonProps: PropsType = {
...commonConversation,
conversationId: commonConversation.id,
conversationType: commonConversation.type,
conversation: getDefaultConversation(),
conversationName: commonConversation,
addedByName: null,
isBlocked: commonConversation.isBlocked ?? false,
isReported: commonConversation.isReported ?? false,
theme: ThemeType.light,
cannotLeaveBecauseYouAreLastAdmin: false,
showBackButton: false,
outgoingCallButtonStyle: OutgoingCallButtonStyle.Both,
isSelectMode: false,
@ -159,21 +160,6 @@ export function PrivateConvo(): JSX.Element {
}),
},
},
{
title: 'With back button',
props: {
...commonProps,
showBackButton: true,
conversation: getDefaultConversation({
color: getRandomColor(),
phoneNumber: '(202) 555-0004',
title: '(202) 555-0004',
type: 'direct',
id: '6',
acceptedMessageRequest: true,
}),
},
},
{
title: 'Disappearing messages set',
props: {
@ -422,7 +408,6 @@ export function NeedsDeleteConfirmation(): JSX.Element {
React.useState(false);
const props = {
...commonProps,
conversation: getDefaultConversation(),
localDeleteWarningShown,
setLocalDeleteWarningShown: () => setLocalDeleteWarningShown(true),
};
@ -436,7 +421,6 @@ export function NeedsDeleteConfirmationButNotEnabled(): JSX.Element {
React.useState(false);
const props = {
...commonProps,
conversation: getDefaultConversation(),
localDeleteWarningShown,
isDeleteSyncSendEnabled: false,
setLocalDeleteWarningShown: () => setLocalDeleteWarningShown(true),
@ -445,3 +429,48 @@ export function NeedsDeleteConfirmationButNotEnabled(): JSX.Element {
return <ConversationHeader {...props} theme={theme} />;
}
export function DirectConversationInAnotherCall(): JSX.Element {
const props = {
...commonProps,
hasActiveCall: true,
};
const theme = useContext(StorybookThemeContext);
return <ConversationHeader {...props} theme={theme} />;
}
export function DirectConversationInCurrentCall(): JSX.Element {
const props = {
...commonProps,
hasActiveCall: true,
outgoingCallButtonStyle: OutgoingCallButtonStyle.None,
};
const theme = useContext(StorybookThemeContext);
return <ConversationHeader {...props} theme={theme} />;
}
export function GroupConversationInAnotherCall(): JSX.Element {
const props = {
...commonProps,
conversation: getDefaultGroup(),
hasActiveCall: true,
outgoingCallButtonStyle: OutgoingCallButtonStyle.Join,
};
const theme = useContext(StorybookThemeContext);
return <ConversationHeader {...props} theme={theme} />;
}
export function GroupConversationInCurrentCall(): JSX.Element {
const props = {
...commonProps,
conversation: getDefaultGroup(),
hasActiveCall: true,
outgoingCallButtonStyle: OutgoingCallButtonStyle.None,
};
const theme = useContext(StorybookThemeContext);
return <ConversationHeader {...props} theme={theme} />;
}

View file

@ -39,6 +39,7 @@ import {
} from './MessageRequestActionsConfirmation';
import type { MinimalConversation } from '../../hooks/useMinimalConversation';
import { LocalDeleteWarningModal } from '../LocalDeleteWarningModal';
import { InAnotherCallTooltip } from './InAnotherCallTooltip';
function HeaderInfoTitle({
name,
@ -93,6 +94,7 @@ export type PropsDataType = {
conversationName: ContactNameData;
hasPanelShowing?: boolean;
hasStories?: HasStories;
hasActiveCall?: boolean;
localDeleteWarningShown: boolean;
isDeleteSyncSendEnabled: boolean;
isMissingMandatoryProfileSharing?: boolean;
@ -149,6 +151,7 @@ export const ConversationHeader = memo(function ConversationHeader({
cannotLeaveBecauseYouAreLastAdmin,
conversation,
conversationName,
hasActiveCall,
hasPanelShowing,
hasStories,
i18n,
@ -295,6 +298,7 @@ export const ConversationHeader = memo(function ConversationHeader({
{!isSMSOnly && !isSignalConversation && (
<OutgoingCallButtons
conversation={conversation}
hasActiveCall={hasActiveCall}
i18n={i18n}
isNarrow={isNarrow}
onOutgoingAudioCall={onOutgoingAudioCall}
@ -806,6 +810,7 @@ function HeaderMenu({
function OutgoingCallButtons({
conversation,
hasActiveCall,
i18n,
isNarrow,
onOutgoingAudioCall,
@ -815,24 +820,39 @@ function OutgoingCallButtons({
PropsType,
| 'i18n'
| 'conversation'
| 'hasActiveCall'
| 'onOutgoingAudioCall'
| 'onOutgoingVideoCall'
| 'outgoingCallButtonStyle'
>): JSX.Element | null {
const disabled =
conversation.type === 'group' &&
conversation.announcementsOnly &&
!conversation.areWeAdmin;
const inAnotherCall = !disabled && hasActiveCall;
const videoButton = (
<button
aria-label={i18n('icu:makeOutgoingVideoCall')}
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--video',
conversation.announcementsOnly && !conversation.areWeAdmin
disabled
? 'module-ConversationHeader__button--show-disabled'
: undefined,
inAnotherCall
? 'module-ConversationHeader__button--in-another-call'
: undefined
)}
onClick={onOutgoingVideoCall}
type="button"
/>
);
const videoElement = inAnotherCall ? (
<InAnotherCallTooltip i18n={i18n}>{videoButton}</InAnotherCallTooltip>
) : (
videoButton
);
const startCallShortcuts = useStartCallShortcuts(
onOutgoingAudioCall,
@ -844,31 +864,49 @@ function OutgoingCallButtons({
case OutgoingCallButtonStyle.None:
return null;
case OutgoingCallButtonStyle.JustVideo:
return videoButton;
return videoElement;
case OutgoingCallButtonStyle.Both:
// eslint-disable-next-line no-case-declarations
const audioButton = (
<button
type="button"
onClick={onOutgoingAudioCall}
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--audio',
inAnotherCall
? 'module-ConversationHeader__button--in-another-call'
: undefined
)}
aria-label={i18n('icu:makeOutgoingCall')}
/>
);
return (
<>
{videoButton}
<button
type="button"
onClick={onOutgoingAudioCall}
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--audio'
)}
aria-label={i18n('icu:makeOutgoingCall')}
/>
{videoElement}
{inAnotherCall ? (
<InAnotherCallTooltip i18n={i18n}>
{audioButton}
</InAnotherCallTooltip>
) : (
audioButton
)}
</>
);
case OutgoingCallButtonStyle.Join:
return (
// eslint-disable-next-line no-case-declarations
const joinButton = (
<button
aria-label={i18n('icu:joinOngoingCall')}
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--join-call',
conversation.announcementsOnly && !conversation.areWeAdmin
disabled
? 'module-ConversationHeader__button--show-disabled'
: undefined,
inAnotherCall
? 'module-ConversationHeader__button--in-another-call'
: undefined
)}
onClick={onOutgoingVideoCall}
@ -877,6 +915,11 @@ function OutgoingCallButtons({
{isNarrow ? null : i18n('icu:joinOngoingCall')}
</button>
);
return inAnotherCall ? (
<InAnotherCallTooltip i18n={i18n}>{joinButton}</InAnotherCallTooltip>
) : (
joinButton
);
default:
throw missingCaseError(outgoingCallButtonStyle);
}

View file

@ -0,0 +1,31 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { offsetDistanceModifier } from '../../util/popperUtil';
import { Tooltip, TooltipPlacement } from '../Tooltip';
import type { LocalizerType } from '../../types/I18N';
type Props = {
i18n: LocalizerType;
children: React.ReactNode;
};
export function getTooltipContent(i18n: LocalizerType): string {
return i18n('icu:calling__in-another-call-tooltip');
}
export function InAnotherCallTooltip({ i18n, children }: Props): JSX.Element {
return (
<Tooltip
className="InAnotherCallTooltip"
content={getTooltipContent(i18n)}
direction={TooltipPlacement.Top}
popperModifiers={[offsetDistanceModifier(5)]}
>
{children}
</Tooltip>
);
}

View file

@ -97,10 +97,9 @@ import { PanelType } from '../../types/Panels';
import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser';
import { RenderLocation } from './MessageTextRenderer';
import { UserText } from '../UserText';
import {
getColorForCallLink,
getKeyFromCallLink,
} from '../../util/getColorForCallLink';
import { getColorForCallLink } from '../../util/getColorForCallLink';
import { getKeyFromCallLink } from '../../util/callLinks';
import { InAnotherCallTooltip } from './InAnotherCallTooltip';
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
@ -215,6 +214,7 @@ export type PropsData = {
customColor?: CustomColorType;
conversationId: string;
displayLimit?: number;
activeCallConversationId?: string;
text?: string;
textDirection: TextDirection;
textAttachment?: AttachmentType;
@ -1980,23 +1980,38 @@ export class Message extends React.PureComponent<Props, State> {
}
private renderAction(): JSX.Element | null {
const { direction, i18n, previews } = this.props;
const { direction, activeCallConversationId, i18n, previews } = this.props;
if (this.shouldShowJoinButton()) {
const firstPreview = previews[0];
const inAnotherCall = Boolean(
activeCallConversationId &&
(!firstPreview.callLinkRoomId ||
activeCallConversationId !== firstPreview.callLinkRoomId)
);
return (
const joinButton = (
<button
type="button"
className={classNames('module-message__action', {
'module-message__action--incoming': direction === 'incoming',
'module-message__action--outgoing': direction === 'outgoing',
'module-message__action--incoming--in-another-call':
direction === 'incoming' && inAnotherCall,
'module-message__action--outgoing--in-another-call':
direction === 'outgoing' && inAnotherCall,
})}
onClick={() => openLinkInWebBrowser(firstPreview?.url)}
>
{i18n('icu:calling__join')}
</button>
);
return inAnotherCall ? (
<InAnotherCallTooltip i18n={i18n}>{joinButton}</InAnotherCallTooltip>
) : (
joinButton
);
}
return null;

View file

@ -1122,8 +1122,26 @@ LinkPreviewWithCallLink.args = {
text: 'Use this link to join a Signal call: https://signal.link/call/#key=hzcn-pcff-ctsc-bdbf-stcr-tzpc-bhqx-kghh',
};
export const LinkPreviewWithCallLinkInGroup = Template.bind({});
LinkPreviewWithCallLinkInGroup.args = {
export const LinkPreviewWithCallLinkInAnotherCall = Template.bind({});
LinkPreviewWithCallLinkInAnotherCall.args = {
previews: [
{
url: 'https://signal.link/call/#key=hzcn-pcff-ctsc-bdbf-stcr-tzpc-bhqx-kghh',
title: 'Camping Prep',
description: 'Use this link to join a Signal call',
image: undefined,
date: undefined,
isCallLink: true,
isStickerPack: false,
},
],
status: 'sent',
activeCallConversationId: 'some-other-conversation',
text: 'Use this link to join a Signal call: https://signal.link/call/#key=hzcn-pcff-ctsc-bdbf-stcr-tzpc-bhqx-kghh',
};
export const LinkPreviewWithCallLinkInCurrentCall = Template.bind({});
LinkPreviewWithCallLinkInCurrentCall.args = {
previews: [
{
url: 'https://signal.link/call/#key=hzcn-pcff-ctsc-bdbf-stcr-tzpc-bhqx-kghh',
@ -1133,11 +1151,13 @@ LinkPreviewWithCallLinkInGroup.args = {
image: undefined,
date: undefined,
isCallLink: true,
callLinkRoomId: 'room-id',
isStickerPack: false,
},
],
conversationType: 'group',
status: 'sent',
activeCallConversationId: 'room-id',
text: 'Use this link to join a Signal call: https://signal.link/call/#key=hzcn-pcff-ctsc-bdbf-stcr-tzpc-bhqx-kghh',
};

View file

@ -232,3 +232,15 @@ export function WithCallHistoryGroup(): JSX.Element {
/>
);
}
export function InAnotherCallGroup(): JSX.Element {
const props = createProps();
return <ConversationDetails {...props} hasActiveCall />;
}
export function InAnotherCallIndividual(): JSX.Element {
const props = createProps();
return <ConversationDetails {...props} hasActiveCall isGroup={false} />;
}

View file

@ -5,7 +5,6 @@ import type { ReactNode } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { Button, ButtonIconType, ButtonVariant } from '../../Button';
import { Tooltip } from '../../Tooltip';
import type {
ConversationType,
PushPanelForConversationActionType,
@ -57,6 +56,10 @@ import { NavTab } from '../../../state/ducks/nav';
import { ContextMenu } from '../../ContextMenu';
import { canHaveNicknameAndNote } from '../../../util/nicknames';
import { CallHistoryGroupPanelSection } from './CallHistoryGroupPanelSection';
import {
InAnotherCallTooltip,
getTooltipContent,
} from '../InAnotherCallTooltip';
enum ModalState {
AddingGroupMembers,
@ -743,22 +746,21 @@ function ConversationDetailsCallButton({
onClick: () => unknown;
type: 'audio' | 'video';
}>) {
const tooltipContent = hasActiveCall ? getTooltipContent(i18n) : undefined;
const button = (
<Button
icon={ButtonIconType[type]}
onClick={onClick}
variant={ButtonVariant.Details}
discouraged={hasActiveCall}
aria-label={tooltipContent}
>
{type === 'audio' ? i18n('icu:audio') : i18n('icu:video')}
</Button>
);
if (hasActiveCall) {
return (
<Tooltip content={i18n('icu:calling__in-another-call-tooltip')}>
{button}
</Tooltip>
);
return <InAnotherCallTooltip i18n={i18n}>{button}</InAnotherCallTooltip>;
}
return button;