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

@ -7587,11 +7587,15 @@ button.module-calling-participants-list__contact {
@include font-body-1-bold(); @include font-body-1-bold();
} }
.module-message__action--outgoing { .module-message__action--outgoing {
color: $color-white;
background-color: rgba($color-white, 0.22); background-color: rgba($color-white, 0.22);
&:hover { &:hover {
background-color: rgba($color-white, 0.36); background-color: rgba($color-white, 0.36);
} }
} }
.module-message__action--outgoing--in-another-call {
color: rgba($color-white, 0.5);
}
.module-message__action--incoming { .module-message__action--incoming {
@include light-theme { @include light-theme {
color: $color-link; color: $color-link;
@ -7608,6 +7612,14 @@ button.module-calling-participants-list__contact {
} }
} }
} }
.module-message__action--incoming--in-another-call {
@include light-theme {
color: rgba($color-link, 0.5);
}
@include dark-theme {
color: rgba($color-white, 0.5);
}
}
.module-message__link-preview__call-link-icon { .module-message__link-preview__call-link-icon {
display: flex; display: flex;

View file

@ -71,6 +71,15 @@
background: fade-out($background-color, 0.6); background: fade-out($background-color, 0.6);
} }
&--discouraged {
@include light-theme {
opacity: 0.4;
}
@include dark-theme {
opacity: 0.5;
}
}
@include light-theme { @include light-theme {
@include hover-and-active-states($background-color, $color-black); @include hover-and-active-states($background-color, $color-black);
} }
@ -96,10 +105,16 @@
&--affirmative { &--affirmative {
color: $color-ultramarine; color: $color-ultramarine;
} }
&--affirmative--discouraged {
color: fade-out($color-ultramarine, 0.5);
}
&--destructive { &--destructive {
color: $color-accent-red; color: $color-accent-red;
} }
&--destructive--discouraged {
color: fade-out($color-ultramarine, 0.5);
}
@include hover-and-active-states($background-color, $color-black); @include hover-and-active-states($background-color, $color-black);
} }
@ -119,10 +134,16 @@
&--affirmative { &--affirmative {
color: $color-ultramarine-light; color: $color-ultramarine-light;
} }
&--affirmative--discouraged {
color: fade-out($color-ultramarine-light, 0.5);
}
&--destructive { &--destructive {
color: $color-accent-red; color: $color-accent-red;
} }
&--destructive--discouraged {
color: fade-out($color-accent-red, 0.5);
}
@include hover-and-active-states($background-color, $color-white); @include hover-and-active-states($background-color, $color-white);
} }
@ -162,6 +183,15 @@
background: fade-out($background-color, 0.6); background: fade-out($background-color, 0.6);
} }
&--discouraged {
@include light-theme {
opacity: 0.4;
}
@include dark-theme {
opacity: 0.5;
}
}
@include light-theme { @include light-theme {
@include hover-and-active-states($background-color, $color-black); @include hover-and-active-states($background-color, $color-black);
} }
@ -191,6 +221,10 @@
color: fade-out($color, 0.4); color: fade-out($color, 0.4);
background: fade-out($background-color, 0.6); background: fade-out($background-color, 0.6);
} }
&--discouraged {
color: fade-out($color, 0.5);
}
@include hover-and-active-states($background-color, $color-black); @include hover-and-active-states($background-color, $color-black);
} }
@ -205,6 +239,10 @@
color: fade-out($color, 0.4); color: fade-out($color, 0.4);
background: fade-out($background-color, 0.6); background: fade-out($background-color, 0.6);
} }
&--discouraged {
color: fade-out($color, 0.5);
}
@include hover-and-active-states($background-color, $color-white); @include hover-and-active-states($background-color, $color-white);
} }
} }
@ -221,6 +259,15 @@
min-width: 68px; min-width: 68px;
padding: 8px; padding: 8px;
&--discouraged {
@include light-theme {
opacity: 0.4;
}
@include dark-theme {
opacity: 0.5;
}
}
@include light-theme { @include light-theme {
background-color: $color-gray-05; background-color: $color-gray-05;
color: $color-black; color: $color-black;

View file

@ -406,7 +406,12 @@
} }
.CallsNewCall__ItemActionButton--join-call-disabled { .CallsNewCall__ItemActionButton--join-call-disabled {
opacity: 0.5; @include light-theme {
opacity: 0.4;
}
@include dark-theme {
opacity: 0.5;
}
} }
.CallsNewCall__ItemActionButtonTooltip { .CallsNewCall__ItemActionButtonTooltip {

View file

@ -309,8 +309,4 @@
margin-block: 8px 5px; margin-block: 8px 5px;
} }
&__tooltip {
@include tooltip;
}
} }

View file

@ -171,21 +171,38 @@
opacity: 0.5; opacity: 0.5;
} }
&--in-another-call {
@include light-theme {
opacity: 0.5;
}
@include dark-theme {
opacity: 0.4;
}
}
&:not(:disabled) { &:not(:disabled) {
@include light-theme { @include light-theme {
&:hover, &:hover {
&:focus {
background: $color-gray-02; background: $color-gray-02;
} }
&:focus {
@include keyboard-mode {
background: $color-gray-02;
}
}
&:active { &:active {
background: $color-gray-05; background: $color-gray-05;
} }
} }
@include dark-theme { @include dark-theme {
&:hover, &:hover {
&:focus {
background: $color-gray-80; background: $color-gray-80;
} }
&:focus {
@include keyboard-mode {
background: $color-gray-02;
}
}
&:active { &:active {
background: $color-gray-75; background: $color-gray-75;
} }
@ -281,13 +298,13 @@
} }
&:not(:disabled) { &:not(:disabled) {
// Override hover state coming from __button above. // Override hover/focus/active state coming from __button above.
&:hover { &:hover,
&:active {
@include any-theme { @include any-theme {
background-color: darken($background, 16%); background-color: darken($background, 16%);
} }
} }
&:focus { &:focus {
@include keyboard-mode { @include keyboard-mode {
background-color: darken($background, 16%); background-color: darken($background, 16%);

View file

@ -0,0 +1,6 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.InAnotherCallTooltip {
@include tooltip;
}

View file

@ -100,6 +100,7 @@
@import './components/GroupDialog.scss'; @import './components/GroupDialog.scss';
@import './components/GroupInput.scss'; @import './components/GroupInput.scss';
@import './components/HueSlider.scss'; @import './components/HueSlider.scss';
@import './components/InAnotherCallTooltip.scss';
@import './components/Inbox.scss'; @import './components/Inbox.scss';
@import './components/IncomingCallBar.scss'; @import './components/IncomingCallBar.scss';
@import './components/Input.scss'; @import './components/Input.scss';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,6 +29,7 @@ export default {
} satisfies Meta<PropsType>; } satisfies Meta<PropsType>;
const getCommonProps = (options: { const getCommonProps = (options: {
activeConversationId?: string;
mode: CallMode; mode: CallMode;
type?: CallType; type?: CallType;
direction?: CallDirection; direction?: CallDirection;
@ -81,7 +82,7 @@ const getCommonProps = (options: {
status, status,
}, },
callCreator, callCreator,
activeConversationId: null, activeConversationId: options.activeConversationId ?? null,
groupCallEnded, groupCallEnded,
maxDevices, maxDevices,
deviceCount, 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 { export function AcceptedIncomingVideoCall(): JSX.Element {
return ( return (
<CallingNotification <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 { export function GroupCallEnded(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification

View file

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

View file

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

View file

@ -5,7 +5,10 @@ import type { ComponentProps } from 'react';
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react'; 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 { getRandomColor } from '../../test-both/helpers/getRandomColor';
import { setupI18n } from '../../util/setupI18n'; import { setupI18n } from '../../util/setupI18n';
import { DurationInSeconds } from '../../util/durations'; import { DurationInSeconds } from '../../util/durations';
@ -17,6 +20,7 @@ import {
OutgoingCallButtonStyle, OutgoingCallButtonStyle,
} from './ConversationHeader'; } from './ConversationHeader';
import { gifUrl } from '../../storybook/Fixtures'; import { gifUrl } from '../../storybook/Fixtures';
import { ThemeType } from '../../types/Util';
export default { export default {
title: 'Components/Conversation/ConversationHeader', title: 'Components/Conversation/ConversationHeader',
@ -30,17 +34,14 @@ type ItemsType = Array<{
}>; }>;
const commonConversation = getDefaultConversation(); const commonConversation = getDefaultConversation();
const commonProps = { const commonProps: PropsType = {
...commonConversation, ...commonConversation,
conversationId: commonConversation.id, conversation: getDefaultConversation(),
conversationType: commonConversation.type,
conversationName: commonConversation, conversationName: commonConversation,
addedByName: null, addedByName: null,
isBlocked: commonConversation.isBlocked ?? false, theme: ThemeType.light,
isReported: commonConversation.isReported ?? false,
cannotLeaveBecauseYouAreLastAdmin: false, cannotLeaveBecauseYouAreLastAdmin: false,
showBackButton: false,
outgoingCallButtonStyle: OutgoingCallButtonStyle.Both, outgoingCallButtonStyle: OutgoingCallButtonStyle.Both,
isSelectMode: false, 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', title: 'Disappearing messages set',
props: { props: {
@ -422,7 +408,6 @@ export function NeedsDeleteConfirmation(): JSX.Element {
React.useState(false); React.useState(false);
const props = { const props = {
...commonProps, ...commonProps,
conversation: getDefaultConversation(),
localDeleteWarningShown, localDeleteWarningShown,
setLocalDeleteWarningShown: () => setLocalDeleteWarningShown(true), setLocalDeleteWarningShown: () => setLocalDeleteWarningShown(true),
}; };
@ -436,7 +421,6 @@ export function NeedsDeleteConfirmationButNotEnabled(): JSX.Element {
React.useState(false); React.useState(false);
const props = { const props = {
...commonProps, ...commonProps,
conversation: getDefaultConversation(),
localDeleteWarningShown, localDeleteWarningShown,
isDeleteSyncSendEnabled: false, isDeleteSyncSendEnabled: false,
setLocalDeleteWarningShown: () => setLocalDeleteWarningShown(true), setLocalDeleteWarningShown: () => setLocalDeleteWarningShown(true),
@ -445,3 +429,48 @@ export function NeedsDeleteConfirmationButNotEnabled(): JSX.Element {
return <ConversationHeader {...props} theme={theme} />; 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'; } from './MessageRequestActionsConfirmation';
import type { MinimalConversation } from '../../hooks/useMinimalConversation'; import type { MinimalConversation } from '../../hooks/useMinimalConversation';
import { LocalDeleteWarningModal } from '../LocalDeleteWarningModal'; import { LocalDeleteWarningModal } from '../LocalDeleteWarningModal';
import { InAnotherCallTooltip } from './InAnotherCallTooltip';
function HeaderInfoTitle({ function HeaderInfoTitle({
name, name,
@ -93,6 +94,7 @@ export type PropsDataType = {
conversationName: ContactNameData; conversationName: ContactNameData;
hasPanelShowing?: boolean; hasPanelShowing?: boolean;
hasStories?: HasStories; hasStories?: HasStories;
hasActiveCall?: boolean;
localDeleteWarningShown: boolean; localDeleteWarningShown: boolean;
isDeleteSyncSendEnabled: boolean; isDeleteSyncSendEnabled: boolean;
isMissingMandatoryProfileSharing?: boolean; isMissingMandatoryProfileSharing?: boolean;
@ -149,6 +151,7 @@ export const ConversationHeader = memo(function ConversationHeader({
cannotLeaveBecauseYouAreLastAdmin, cannotLeaveBecauseYouAreLastAdmin,
conversation, conversation,
conversationName, conversationName,
hasActiveCall,
hasPanelShowing, hasPanelShowing,
hasStories, hasStories,
i18n, i18n,
@ -295,6 +298,7 @@ export const ConversationHeader = memo(function ConversationHeader({
{!isSMSOnly && !isSignalConversation && ( {!isSMSOnly && !isSignalConversation && (
<OutgoingCallButtons <OutgoingCallButtons
conversation={conversation} conversation={conversation}
hasActiveCall={hasActiveCall}
i18n={i18n} i18n={i18n}
isNarrow={isNarrow} isNarrow={isNarrow}
onOutgoingAudioCall={onOutgoingAudioCall} onOutgoingAudioCall={onOutgoingAudioCall}
@ -806,6 +810,7 @@ function HeaderMenu({
function OutgoingCallButtons({ function OutgoingCallButtons({
conversation, conversation,
hasActiveCall,
i18n, i18n,
isNarrow, isNarrow,
onOutgoingAudioCall, onOutgoingAudioCall,
@ -815,24 +820,39 @@ function OutgoingCallButtons({
PropsType, PropsType,
| 'i18n' | 'i18n'
| 'conversation' | 'conversation'
| 'hasActiveCall'
| 'onOutgoingAudioCall' | 'onOutgoingAudioCall'
| 'onOutgoingVideoCall' | 'onOutgoingVideoCall'
| 'outgoingCallButtonStyle' | 'outgoingCallButtonStyle'
>): JSX.Element | null { >): JSX.Element | null {
const disabled =
conversation.type === 'group' &&
conversation.announcementsOnly &&
!conversation.areWeAdmin;
const inAnotherCall = !disabled && hasActiveCall;
const videoButton = ( const videoButton = (
<button <button
aria-label={i18n('icu:makeOutgoingVideoCall')} aria-label={i18n('icu:makeOutgoingVideoCall')}
className={classNames( className={classNames(
'module-ConversationHeader__button', 'module-ConversationHeader__button',
'module-ConversationHeader__button--video', 'module-ConversationHeader__button--video',
conversation.announcementsOnly && !conversation.areWeAdmin disabled
? 'module-ConversationHeader__button--show-disabled' ? 'module-ConversationHeader__button--show-disabled'
: undefined,
inAnotherCall
? 'module-ConversationHeader__button--in-another-call'
: undefined : undefined
)} )}
onClick={onOutgoingVideoCall} onClick={onOutgoingVideoCall}
type="button" type="button"
/> />
); );
const videoElement = inAnotherCall ? (
<InAnotherCallTooltip i18n={i18n}>{videoButton}</InAnotherCallTooltip>
) : (
videoButton
);
const startCallShortcuts = useStartCallShortcuts( const startCallShortcuts = useStartCallShortcuts(
onOutgoingAudioCall, onOutgoingAudioCall,
@ -844,31 +864,49 @@ function OutgoingCallButtons({
case OutgoingCallButtonStyle.None: case OutgoingCallButtonStyle.None:
return null; return null;
case OutgoingCallButtonStyle.JustVideo: case OutgoingCallButtonStyle.JustVideo:
return videoButton; return videoElement;
case OutgoingCallButtonStyle.Both: 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 ( return (
<> <>
{videoButton} {videoElement}
<button {inAnotherCall ? (
type="button" <InAnotherCallTooltip i18n={i18n}>
onClick={onOutgoingAudioCall} {audioButton}
className={classNames( </InAnotherCallTooltip>
'module-ConversationHeader__button', ) : (
'module-ConversationHeader__button--audio' audioButton
)} )}
aria-label={i18n('icu:makeOutgoingCall')}
/>
</> </>
); );
case OutgoingCallButtonStyle.Join: case OutgoingCallButtonStyle.Join:
return ( // eslint-disable-next-line no-case-declarations
const joinButton = (
<button <button
aria-label={i18n('icu:joinOngoingCall')} aria-label={i18n('icu:joinOngoingCall')}
className={classNames( className={classNames(
'module-ConversationHeader__button', 'module-ConversationHeader__button',
'module-ConversationHeader__button--join-call', 'module-ConversationHeader__button--join-call',
conversation.announcementsOnly && !conversation.areWeAdmin disabled
? 'module-ConversationHeader__button--show-disabled' ? 'module-ConversationHeader__button--show-disabled'
: undefined,
inAnotherCall
? 'module-ConversationHeader__button--in-another-call'
: undefined : undefined
)} )}
onClick={onOutgoingVideoCall} onClick={onOutgoingVideoCall}
@ -877,6 +915,11 @@ function OutgoingCallButtons({
{isNarrow ? null : i18n('icu:joinOngoingCall')} {isNarrow ? null : i18n('icu:joinOngoingCall')}
</button> </button>
); );
return inAnotherCall ? (
<InAnotherCallTooltip i18n={i18n}>{joinButton}</InAnotherCallTooltip>
) : (
joinButton
);
default: default:
throw missingCaseError(outgoingCallButtonStyle); 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 { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser';
import { RenderLocation } from './MessageTextRenderer'; import { RenderLocation } from './MessageTextRenderer';
import { UserText } from '../UserText'; import { UserText } from '../UserText';
import { import { getColorForCallLink } from '../../util/getColorForCallLink';
getColorForCallLink, import { getKeyFromCallLink } from '../../util/callLinks';
getKeyFromCallLink, import { InAnotherCallTooltip } from './InAnotherCallTooltip';
} from '../../util/getColorForCallLink';
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16; const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18; const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
@ -215,6 +214,7 @@ export type PropsData = {
customColor?: CustomColorType; customColor?: CustomColorType;
conversationId: string; conversationId: string;
displayLimit?: number; displayLimit?: number;
activeCallConversationId?: string;
text?: string; text?: string;
textDirection: TextDirection; textDirection: TextDirection;
textAttachment?: AttachmentType; textAttachment?: AttachmentType;
@ -1980,23 +1980,38 @@ export class Message extends React.PureComponent<Props, State> {
} }
private renderAction(): JSX.Element | null { private renderAction(): JSX.Element | null {
const { direction, i18n, previews } = this.props; const { direction, activeCallConversationId, i18n, previews } = this.props;
if (this.shouldShowJoinButton()) { if (this.shouldShowJoinButton()) {
const firstPreview = previews[0]; const firstPreview = previews[0];
const inAnotherCall = Boolean(
activeCallConversationId &&
(!firstPreview.callLinkRoomId ||
activeCallConversationId !== firstPreview.callLinkRoomId)
);
return ( const joinButton = (
<button <button
type="button" type="button"
className={classNames('module-message__action', { className={classNames('module-message__action', {
'module-message__action--incoming': direction === 'incoming', 'module-message__action--incoming': direction === 'incoming',
'module-message__action--outgoing': direction === 'outgoing', '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)} onClick={() => openLinkInWebBrowser(firstPreview?.url)}
> >
{i18n('icu:calling__join')} {i18n('icu:calling__join')}
</button> </button>
); );
return inAnotherCall ? (
<InAnotherCallTooltip i18n={i18n}>{joinButton}</InAnotherCallTooltip>
) : (
joinButton
);
} }
return null; 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', 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({}); export const LinkPreviewWithCallLinkInAnotherCall = Template.bind({});
LinkPreviewWithCallLinkInGroup.args = { 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: [ previews: [
{ {
url: 'https://signal.link/call/#key=hzcn-pcff-ctsc-bdbf-stcr-tzpc-bhqx-kghh', url: 'https://signal.link/call/#key=hzcn-pcff-ctsc-bdbf-stcr-tzpc-bhqx-kghh',
@ -1133,11 +1151,13 @@ LinkPreviewWithCallLinkInGroup.args = {
image: undefined, image: undefined,
date: undefined, date: undefined,
isCallLink: true, isCallLink: true,
callLinkRoomId: 'room-id',
isStickerPack: false, isStickerPack: false,
}, },
], ],
conversationType: 'group', conversationType: 'group',
status: 'sent', 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', 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 React, { useEffect, useState, useCallback } from 'react';
import { Button, ButtonIconType, ButtonVariant } from '../../Button'; import { Button, ButtonIconType, ButtonVariant } from '../../Button';
import { Tooltip } from '../../Tooltip';
import type { import type {
ConversationType, ConversationType,
PushPanelForConversationActionType, PushPanelForConversationActionType,
@ -57,6 +56,10 @@ import { NavTab } from '../../../state/ducks/nav';
import { ContextMenu } from '../../ContextMenu'; import { ContextMenu } from '../../ContextMenu';
import { canHaveNicknameAndNote } from '../../../util/nicknames'; import { canHaveNicknameAndNote } from '../../../util/nicknames';
import { CallHistoryGroupPanelSection } from './CallHistoryGroupPanelSection'; import { CallHistoryGroupPanelSection } from './CallHistoryGroupPanelSection';
import {
InAnotherCallTooltip,
getTooltipContent,
} from '../InAnotherCallTooltip';
enum ModalState { enum ModalState {
AddingGroupMembers, AddingGroupMembers,
@ -743,22 +746,21 @@ function ConversationDetailsCallButton({
onClick: () => unknown; onClick: () => unknown;
type: 'audio' | 'video'; type: 'audio' | 'video';
}>) { }>) {
const tooltipContent = hasActiveCall ? getTooltipContent(i18n) : undefined;
const button = ( const button = (
<Button <Button
icon={ButtonIconType[type]} icon={ButtonIconType[type]}
onClick={onClick} onClick={onClick}
variant={ButtonVariant.Details} variant={ButtonVariant.Details}
discouraged={hasActiveCall}
aria-label={tooltipContent}
> >
{type === 'audio' ? i18n('icu:audio') : i18n('icu:video')} {type === 'audio' ? i18n('icu:audio') : i18n('icu:video')}
</Button> </Button>
); );
if (hasActiveCall) { if (hasActiveCall) {
return ( return <InAnotherCallTooltip i18n={i18n}>{button}</InAnotherCallTooltip>;
<Tooltip content={i18n('icu:calling__in-another-call-tooltip')}>
{button}
</Tooltip>
);
} }
return button; return button;

View file

@ -156,6 +156,7 @@ import {
copyFromQuotedMessage, copyFromQuotedMessage,
copyQuoteContentFromOriginal, copyQuoteContentFromOriginal,
} from '../messages/copyQuote'; } from '../messages/copyQuote';
import { getRoomIdFromCallLink } from '../util/callLinksRingrtc';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
@ -1823,19 +1824,34 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const urls = LinkPreview.findLinks(dataMessage.body || ''); const urls = LinkPreview.findLinks(dataMessage.body || '');
const incomingPreview = dataMessage.preview || []; const incomingPreview = dataMessage.preview || [];
const preview = incomingPreview.filter((item: LinkPreviewType) => { const preview = incomingPreview
if (!item.image && !item.title) { .map((item: LinkPreviewType) => {
return false; if (!item.image && !item.title) {
} return null;
// Story link previews don't have to correspond to links in the }
// message body. // Story link previews don't have to correspond to links in the
if (isStory(message.attributes)) { // message body.
return true; if (isStory(message.attributes)) {
} return item;
return ( }
urls.includes(item.url) && LinkPreview.shouldPreviewHref(item.url) if (
); !urls.includes(item.url) ||
}); !LinkPreview.shouldPreviewHref(item.url)
) {
return undefined;
}
if (LinkPreview.isCallLink(item.url)) {
return {
...item,
isCallLink: true,
callLinkRoomId: getRoomIdFromCallLink(item.url),
};
}
return item;
})
.filter(isNotNil);
if (preview.length < incomingPreview.length) { if (preview.length < incomingPreview.length) {
log.info( log.info(
`${message.idForLogging()}: Eliminated ${ `${message.idForLogging()}: Eliminated ${

View file

@ -30,8 +30,9 @@ import { imageToBlurHash } from '../util/imageToBlurHash';
import { maybeParseUrl } from '../util/url'; import { maybeParseUrl } from '../util/url';
import { sniffImageMimeType } from '../util/sniffImageMimeType'; import { sniffImageMimeType } from '../util/sniffImageMimeType';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import { linkCallRoute } from '../util/signalRoutes';
import { calling } from './calling'; import { calling } from './calling';
import { getKeyFromCallLink } from '../util/callLinks';
import { getRoomIdFromCallLink } from '../util/callLinksRingrtc';
const LINK_PREVIEW_TIMEOUT = 60 * SECOND; const LINK_PREVIEW_TIMEOUT = 60 * SECOND;
@ -266,29 +267,27 @@ export function getLinkPreviewForSend(
export function sanitizeLinkPreview( export function sanitizeLinkPreview(
item: LinkPreviewResult | LinkPreviewWithHydratedData item: LinkPreviewResult | LinkPreviewWithHydratedData
): LinkPreviewWithHydratedData { ): LinkPreviewWithHydratedData {
if (item.image) { const isCallLink = LinkPreview.isCallLink(item.url);
// We eliminate the ObjectURL here, unneeded for send or save const base: LinkPreviewWithHydratedData = {
return {
...item,
image: omit(item.image, 'url'),
title: dropNull(item.title),
description: dropNull(item.description),
date: dropNull(item.date),
domain: LinkPreview.getDomain(item.url),
isStickerPack: LinkPreview.isStickerPack(item.url),
isCallLink: LinkPreview.isCallLink(item.url),
};
}
return {
...item, ...item,
title: dropNull(item.title), title: dropNull(item.title),
description: dropNull(item.description), description: dropNull(item.description),
date: dropNull(item.date), date: dropNull(item.date),
domain: LinkPreview.getDomain(item.url), domain: LinkPreview.getDomain(item.url),
isStickerPack: LinkPreview.isStickerPack(item.url), isStickerPack: LinkPreview.isStickerPack(item.url),
isCallLink: LinkPreview.isCallLink(item.url), isCallLink,
callLinkRoomId: isCallLink ? getRoomIdFromCallLink(item.url) : undefined,
}; };
if (item.image) {
// We eliminate the ObjectURL here, unneeded for send or save
return {
...base,
image: omit(item.image, 'url'),
};
}
return base;
} }
async function getPreview( async function getPreview(
@ -577,12 +576,8 @@ async function getCallLinkPreview(
url: string, url: string,
_abortSignal: Readonly<AbortSignal> _abortSignal: Readonly<AbortSignal>
): Promise<null | LinkPreviewResult> { ): Promise<null | LinkPreviewResult> {
const parsedUrl = linkCallRoute.fromUrl(url); const keyString = getKeyFromCallLink(url);
if (parsedUrl == null) { const callLinkRootKey = CallLinkRootKey.parse(keyString);
throw new Error('Failed to parse call link URL');
}
const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key);
const callLinkState = await calling.readCallLink(callLinkRootKey); const callLinkState = await calling.readCallLink(callLinkRootKey);
if (callLinkState == null || callLinkState.revoked) { if (callLinkState == null || callLinkState.revoked) {
return null; return null;

View file

@ -2104,9 +2104,7 @@ const _startCallLinkLobby = async ({
const { activeCallState } = state.calling; const { activeCallState } = state.calling;
if (activeCallState && activeCallState.conversationId === roomId) { if (activeCallState && activeCallState.conversationId === roomId) {
dispatch({ dispatch(togglePip());
type: TOGGLE_PIP,
});
return; return;
} }
if (activeCallState) { if (activeCallState) {
@ -2263,7 +2261,13 @@ function startCallingLobby({
"startCallingLobby: can't start lobby without a conversation" "startCallingLobby: can't start lobby without a conversation"
); );
if (state.calling.activeCallState) { const { activeCallState } = state.calling;
if (activeCallState && activeCallState.conversationId === conversationId) {
dispatch(togglePip());
return;
}
if (activeCallState) {
dispatch( dispatch(
toggleConfirmLeaveCallModal({ toggleConfirmLeaveCallModal({
type: 'conversation', type: 'conversation',

View file

@ -577,6 +577,7 @@ export const getPropsForQuote = (
export type GetPropsForMessageOptions = Pick< export type GetPropsForMessageOptions = Pick<
GetPropsForBubbleOptions, GetPropsForBubbleOptions,
| 'activeCall'
| 'conversationSelector' | 'conversationSelector'
| 'ourConversationId' | 'ourConversationId'
| 'ourAci' | 'ourAci'
@ -676,6 +677,7 @@ export const getPropsForMessage = (
const payment = getPayment(message); const payment = getPayment(message);
const { const {
activeCall,
accountSelector, accountSelector,
conversationSelector, conversationSelector,
ourConversationId, ourConversationId,
@ -699,6 +701,7 @@ export const getPropsForMessage = (
const { sticker } = message; const { sticker } = message;
const isMessageTapToView = isTapToView(message); const isMessageTapToView = isTapToView(message);
const activeCallConversationId = activeCall?.conversationId;
const isTargeted = message.id === targetedMessageId; const isTargeted = message.id === targetedMessageId;
const isSelected = selectedMessageIds?.includes(message.id) ?? false; const isSelected = selectedMessageIds?.includes(message.id) ?? false;
@ -726,6 +729,7 @@ export const getPropsForMessage = (
attachmentDroppedDueToSize, attachmentDroppedDueToSize,
author, author,
bodyRanges, bodyRanges,
activeCallConversationId,
previews, previews,
quote, quote,
reactions, reactions,

View file

@ -5,7 +5,7 @@ import { useSelector } from 'react-redux';
import type { CallHistoryGroup } from '../../types/CallDisposition'; import type { CallHistoryGroup } from '../../types/CallDisposition';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { CallLinkDetails } from '../../components/CallLinkDetails'; import { CallLinkDetails } from '../../components/CallLinkDetails';
import { getCallLinkSelector } from '../selectors/calling'; import { getActiveCallState, getCallLinkSelector } from '../selectors/calling';
import { useGlobalModalActions } from '../ducks/globalModals'; import { useGlobalModalActions } from '../ducks/globalModals';
import { useCallingActions } from '../ducks/calling'; import { useCallingActions } from '../ducks/calling';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
@ -60,6 +60,11 @@ export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({
[roomId, updateCallLinkRestrictions] [roomId, updateCallLinkRestrictions]
); );
const activeCall = useSelector(getActiveCallState);
const hasActiveCall = Boolean(
activeCall && callLink && activeCall?.conversationId !== callLink?.roomId
);
if (callLink == null) { if (callLink == null) {
log.error(`SmartCallLinkDetails: callLink not found for room ${roomId}`); log.error(`SmartCallLinkDetails: callLink not found for room ${roomId}`);
return null; return null;
@ -69,6 +74,7 @@ export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({
<CallLinkDetails <CallLinkDetails
callHistoryGroup={callHistoryGroup} callHistoryGroup={callHistoryGroup}
callLink={callLink} callLink={callLink}
hasActiveCall={hasActiveCall}
i18n={i18n} i18n={i18n}
onDeleteCallLink={handleDeleteCallLink} onDeleteCallLink={handleDeleteCallLink}
onOpenCallLinkAddNameModal={handleOpenCallLinkAddNameModal} onOpenCallLinkAddNameModal={handleOpenCallLinkAddNameModal}

View file

@ -4,7 +4,7 @@ import React, { memo, useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { CallLinkEditModal } from '../../components/CallLinkEditModal'; import { CallLinkEditModal } from '../../components/CallLinkEditModal';
import { useCallingActions } from '../ducks/calling'; import { useCallingActions } from '../ducks/calling';
import { getCallLinkSelector } from '../selectors/calling'; import { getActiveCallState, getCallLinkSelector } from '../selectors/calling';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { useGlobalModalActions } from '../ducks/globalModals'; import { useGlobalModalActions } from '../ducks/globalModals';
@ -74,6 +74,11 @@ export const SmartCallLinkEditModal = memo(
toggleCallLinkEditModal(null); toggleCallLinkEditModal(null);
}, [callLink, startCallLinkLobby, toggleCallLinkEditModal]); }, [callLink, startCallLinkLobby, toggleCallLinkEditModal]);
const activeCall = useSelector(getActiveCallState);
const hasActiveCall = Boolean(
activeCall && callLink && activeCall?.conversationId !== callLink?.roomId
);
if (!callLink) { if (!callLink) {
log.error( log.error(
'SmartCallLinkEditModal: No call link found for roomId', 'SmartCallLinkEditModal: No call link found for roomId',
@ -86,6 +91,7 @@ export const SmartCallLinkEditModal = memo(
<CallLinkEditModal <CallLinkEditModal
i18n={i18n} i18n={i18n}
callLink={callLink} callLink={callLink}
hasActiveCall={hasActiveCall}
onClose={handleClose} onClose={handleClose}
onCopyCallLink={handleCopyCallLink} onCopyCallLink={handleCopyCallLink}
onOpenCallLinkAddNameModal={handleOpenCallLinkAddNameModal} onOpenCallLinkAddNameModal={handleOpenCallLinkAddNameModal}

View file

@ -154,7 +154,8 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
conversation, conversation,
allComposableConversations allComposableConversations
); );
const hasActiveCall = activeCall != null; const hasActiveCall =
activeCall != null && activeCall.conversationId !== conversationId;
const hasGroupLink = const hasGroupLink =
conversation.groupLink != null && conversation.groupLink != null &&
conversation.accessControlAddFromInviteLink !== ACCESS_ENUM.UNSATISFIABLE; conversation.accessControlAddFromInviteLink !== ACCESS_ENUM.UNSATISFIABLE;

View file

@ -56,7 +56,7 @@ const useOutgoingCallButtonStyle = (
const callSelector = useSelector(getCallSelector); const callSelector = useSelector(getCallSelector);
strictAssert(ourAci, 'useOutgoingCallButtonStyle missing our uuid'); strictAssert(ourAci, 'useOutgoingCallButtonStyle missing our uuid');
if (activeCall != null) { if (activeCall?.conversationId === conversation.id) {
return OutgoingCallButtonStyle.None; return OutgoingCallButtonStyle.None;
} }
@ -100,6 +100,8 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
const hasPanelShowing = useSelector(getHasPanelOpen); const hasPanelShowing = useSelector(getHasPanelOpen);
const outgoingCallButtonStyle = useOutgoingCallButtonStyle(conversation); const outgoingCallButtonStyle = useOutgoingCallButtonStyle(conversation);
const theme = useSelector(getTheme); const theme = useSelector(getTheme);
const activeCall = useSelector(getActiveCallState);
const hasActiveCall = Boolean(activeCall);
const { const {
destroyMessages, destroyMessages,
@ -264,6 +266,7 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
cannotLeaveBecauseYouAreLastAdmin={cannotLeaveBecauseYouAreLastAdmin} cannotLeaveBecauseYouAreLastAdmin={cannotLeaveBecauseYouAreLastAdmin}
conversation={minimalConversation} conversation={minimalConversation}
conversationName={conversationName} conversationName={conversationName}
hasActiveCall={hasActiveCall}
hasPanelShowing={hasPanelShowing} hasPanelShowing={hasPanelShowing}
hasStories={hasStories} hasStories={hasStories}
i18n={i18n} i18n={i18n}

View file

@ -10,6 +10,7 @@ type GenericLinkPreviewType<Image> = {
url: string; url: string;
isStickerPack?: boolean; isStickerPack?: boolean;
isCallLink?: boolean; isCallLink?: boolean;
callLinkRoomId?: string;
image?: Readonly<Image>; image?: Readonly<Image>;
date?: number; date?: number;
}; };

View file

@ -23,6 +23,18 @@ export const CALL_LINK_DEFAULT_STATE = {
expiration: null, expiration: null,
}; };
export function getKeyFromCallLink(callLink: string): string {
const url = new URL(callLink);
if (url == null) {
throw new Error('Failed to parse call link URL');
}
const hash = url.hash.slice(1);
const hashParams = new URLSearchParams(hash);
return hashParams.get('key') || '';
}
export function isCallLinksCreateEnabled(): boolean { export function isCallLinksCreateEnabled(): boolean {
if (isTestOrMockEnvironment()) { if (isTestOrMockEnvironment()) {
return true; return true;

View file

@ -28,7 +28,11 @@ import {
} from './zkgroup'; } from './zkgroup';
import { getCheckedCallLinkAuthCredentialsForToday } from '../services/groupCredentialFetcher'; import { getCheckedCallLinkAuthCredentialsForToday } from '../services/groupCredentialFetcher';
import * as durations from './durations'; import * as durations from './durations';
import { fromAdminKeyBytes, toAdminKeyBytes } from './callLinks'; import {
fromAdminKeyBytes,
getKeyFromCallLink,
toAdminKeyBytes,
} from './callLinks';
/** /**
* RingRTC conversions * RingRTC conversions
@ -64,6 +68,12 @@ export function getCallLinkRootKeyFromUrlKey(key: string): Uint8Array {
return CallLinkRootKey.parse(key).bytes; return CallLinkRootKey.parse(key).bytes;
} }
export function getRoomIdFromCallLink(url: string): string {
const keyString = getKeyFromCallLink(url);
const key = CallLinkRootKey.parse(keyString);
return getRoomIdFromRootKey(key);
}
export async function getCallLinkAuthCredentialPresentation( export async function getCallLinkAuthCredentialPresentation(
callLinkRootKey: CallLinkRootKey callLinkRootKey: CallLinkRootKey
): Promise<CallLinkAuthCredentialPresentation> { ): Promise<CallLinkAuthCredentialPresentation> {

View file

@ -18,11 +18,3 @@ export function getColorForCallLink(rootKey: string): string {
return AvatarColors[index]; return AvatarColors[index];
} }
export function getKeyFromCallLink(callLink: string): string {
const url = new URL(callLink);
const hash = url.hash.slice(1);
const hashParams = new URLSearchParams(hash);
return hashParams.get('key') || '';
}