diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 580c437d06..926fdc0f0c 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -7587,11 +7587,15 @@ button.module-calling-participants-list__contact { @include font-body-1-bold(); } .module-message__action--outgoing { + color: $color-white; background-color: rgba($color-white, 0.22); &:hover { background-color: rgba($color-white, 0.36); } } +.module-message__action--outgoing--in-another-call { + color: rgba($color-white, 0.5); +} .module-message__action--incoming { @include light-theme { 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 { display: flex; diff --git a/stylesheets/components/Button.scss b/stylesheets/components/Button.scss index 54bf6845a7..27e38f7997 100644 --- a/stylesheets/components/Button.scss +++ b/stylesheets/components/Button.scss @@ -71,6 +71,15 @@ background: fade-out($background-color, 0.6); } + &--discouraged { + @include light-theme { + opacity: 0.4; + } + @include dark-theme { + opacity: 0.5; + } + } + @include light-theme { @include hover-and-active-states($background-color, $color-black); } @@ -96,10 +105,16 @@ &--affirmative { color: $color-ultramarine; } + &--affirmative--discouraged { + color: fade-out($color-ultramarine, 0.5); + } &--destructive { color: $color-accent-red; } + &--destructive--discouraged { + color: fade-out($color-ultramarine, 0.5); + } @include hover-and-active-states($background-color, $color-black); } @@ -119,10 +134,16 @@ &--affirmative { color: $color-ultramarine-light; } + &--affirmative--discouraged { + color: fade-out($color-ultramarine-light, 0.5); + } &--destructive { color: $color-accent-red; } + &--destructive--discouraged { + color: fade-out($color-accent-red, 0.5); + } @include hover-and-active-states($background-color, $color-white); } @@ -162,6 +183,15 @@ background: fade-out($background-color, 0.6); } + &--discouraged { + @include light-theme { + opacity: 0.4; + } + @include dark-theme { + opacity: 0.5; + } + } + @include light-theme { @include hover-and-active-states($background-color, $color-black); } @@ -191,6 +221,10 @@ color: fade-out($color, 0.4); background: fade-out($background-color, 0.6); } + &--discouraged { + color: fade-out($color, 0.5); + } + @include hover-and-active-states($background-color, $color-black); } @@ -205,6 +239,10 @@ color: fade-out($color, 0.4); background: fade-out($background-color, 0.6); } + &--discouraged { + color: fade-out($color, 0.5); + } + @include hover-and-active-states($background-color, $color-white); } } @@ -221,6 +259,15 @@ min-width: 68px; padding: 8px; + &--discouraged { + @include light-theme { + opacity: 0.4; + } + @include dark-theme { + opacity: 0.5; + } + } + @include light-theme { background-color: $color-gray-05; color: $color-black; diff --git a/stylesheets/components/CallsTab.scss b/stylesheets/components/CallsTab.scss index dd933cb6b4..06fcee9ab4 100644 --- a/stylesheets/components/CallsTab.scss +++ b/stylesheets/components/CallsTab.scss @@ -406,7 +406,12 @@ } .CallsNewCall__ItemActionButton--join-call-disabled { - opacity: 0.5; + @include light-theme { + opacity: 0.4; + } + @include dark-theme { + opacity: 0.5; + } } .CallsNewCall__ItemActionButtonTooltip { diff --git a/stylesheets/components/ContactModal.scss b/stylesheets/components/ContactModal.scss index cc3ac37704..d6a1fb0061 100644 --- a/stylesheets/components/ContactModal.scss +++ b/stylesheets/components/ContactModal.scss @@ -309,8 +309,4 @@ margin-block: 8px 5px; } - - &__tooltip { - @include tooltip; - } } diff --git a/stylesheets/components/ConversationHeader.scss b/stylesheets/components/ConversationHeader.scss index 0b655bbd20..f5b3f120c5 100644 --- a/stylesheets/components/ConversationHeader.scss +++ b/stylesheets/components/ConversationHeader.scss @@ -171,21 +171,38 @@ opacity: 0.5; } + &--in-another-call { + @include light-theme { + opacity: 0.5; + } + @include dark-theme { + opacity: 0.4; + } + } + &:not(:disabled) { @include light-theme { - &:hover, - &:focus { + &:hover { background: $color-gray-02; } + &:focus { + @include keyboard-mode { + background: $color-gray-02; + } + } &:active { background: $color-gray-05; } } @include dark-theme { - &:hover, - &:focus { + &:hover { background: $color-gray-80; } + &:focus { + @include keyboard-mode { + background: $color-gray-02; + } + } &:active { background: $color-gray-75; } @@ -281,13 +298,13 @@ } &:not(:disabled) { - // Override hover state coming from __button above. - &:hover { + // Override hover/focus/active state coming from __button above. + &:hover, + &:active { @include any-theme { background-color: darken($background, 16%); } } - &:focus { @include keyboard-mode { background-color: darken($background, 16%); diff --git a/stylesheets/components/InAnotherCallTooltip.scss b/stylesheets/components/InAnotherCallTooltip.scss new file mode 100644 index 0000000000..a666cd4e4c --- /dev/null +++ b/stylesheets/components/InAnotherCallTooltip.scss @@ -0,0 +1,6 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.InAnotherCallTooltip { + @include tooltip; +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index d51eff8990..733af684d6 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -100,6 +100,7 @@ @import './components/GroupDialog.scss'; @import './components/GroupInput.scss'; @import './components/HueSlider.scss'; +@import './components/InAnotherCallTooltip.scss'; @import './components/Inbox.scss'; @import './components/IncomingCallBar.scss'; @import './components/Input.scss'; diff --git a/ts/components/Button.stories.tsx b/ts/components/Button.stories.tsx index c745533933..42296fee40 100644 --- a/ts/components/Button.stories.tsx +++ b/ts/components/Button.stories.tsx @@ -37,6 +37,16 @@ export function KitchenSink(): JSX.Element { {variant}

+

+ +

))} diff --git a/ts/components/Button.tsx b/ts/components/Button.tsx index 36dee96f50..8c511c1188 100644 --- a/ts/components/Button.tsx +++ b/ts/components/Button.tsx @@ -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( children, className, disabled = false, + discouraged = false, icon, style, tabIndex, @@ -143,8 +145,10 @@ export const Button = React.forwardRef( 'module-Button', sizeClassName, variantClassName, + discouraged ? `${variantClassName}--discouraged` : undefined, icon && `module-Button--icon--${icon}`, - className + className, + className && discouraged ? `${className}--discouraged` : undefined )} disabled={disabled} onClick={onClick} diff --git a/ts/components/CallLinkDetails.stories.tsx b/ts/components/CallLinkDetails.stories.tsx index e91f827d85..0662457c2b 100644 --- a/ts/components/CallLinkDetails.stories.tsx +++ b/ts/components/CallLinkDetails.stories.tsx @@ -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 ; } + +export function InAnotherCall(args: CallLinkDetailsProps): JSX.Element { + return ; +} diff --git a/ts/components/CallLinkDetails.tsx b/ts/components/CallLinkDetails.tsx index 40a8b57fda..e42718b443 100644 --- a/ts/components/CallLinkDetails.tsx +++ b/ts/components/CallLinkDetails.tsx @@ -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 = ( + + ); + return (
@@ -77,14 +92,13 @@ export function CallLinkDetails({

- + {hasActiveCall ? ( + + {joinButton} + + ) : ( + joinButton + )}
; } + +export function InAnotherCall(args: CallLinkEditModalProps): JSX.Element { + return ; +} diff --git a/ts/components/CallLinkEditModal.tsx b/ts/components/CallLinkEditModal.tsx index 5d6b6f186e..f4f78e8193 100644 --- a/ts/components/CallLinkEditModal.tsx +++ b/ts/components/CallLinkEditModal.tsx @@ -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 = ( + + ); + return (
- + {hasActiveCall ? ( + + {joinButton} + + ) : ( + joinButton + )}
diff --git a/ts/components/CallsList.tsx b/ts/components/CallsList.tsx index aa031f6c17..16005cbf99 100644 --- a/ts/components/CallsList.tsx +++ b/ts/components/CallsList.tsx @@ -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'; diff --git a/ts/components/CallsNewCall.tsx b/ts/components/CallsNewCallButton.tsx similarity index 94% rename from ts/components/CallsNewCall.tsx rename to ts/components/CallsNewCallButton.tsx index 050cd37be0..4b7cb85885 100644 --- a/ts/components/CallsNewCall.tsx +++ b/ts/components/CallsNewCallButton.tsx @@ -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({ ); - return tooltipContent === '' ? ( + return inAnotherCallTooltipContent === '' ? ( buttonContent ) : ( - - {buttonContent} - + {buttonContent} ); } diff --git a/ts/components/CallsTab.tsx b/ts/components/CallsTab.tsx index f04e150866..152637389e 100644 --- a/ts/components/CallsTab.tsx +++ b/ts/components/CallsTab.tsx @@ -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, diff --git a/ts/components/conversation/CallingNotification.stories.tsx b/ts/components/conversation/CallingNotification.stories.tsx index 2ea4358d52..964b2c9bad 100644 --- a/ts/components/conversation/CallingNotification.stories.tsx +++ b/ts/components/conversation/CallingNotification.stories.tsx @@ -29,6 +29,7 @@ export default { } satisfies Meta; 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 ( + + ); +} + +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 ( + + ); +} + export function AcceptedIncomingVideoCall(): JSX.Element { return ( + ); +} + +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 ( + + ); +} + export function GroupCallEnded(): JSX.Element { return ( 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 = ( {hasActiveCall ? ( - + {videoCallButton} - + ) : ( videoCallButton )} {hasActiveCall ? ( - + {audioCallButton} - + ) : ( audioCallButton )} diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index fd01024a10..c238fab0b3 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -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 ; } + +export function DirectConversationInAnotherCall(): JSX.Element { + const props = { + ...commonProps, + hasActiveCall: true, + }; + const theme = useContext(StorybookThemeContext); + + return ; +} + +export function DirectConversationInCurrentCall(): JSX.Element { + const props = { + ...commonProps, + hasActiveCall: true, + outgoingCallButtonStyle: OutgoingCallButtonStyle.None, + }; + const theme = useContext(StorybookThemeContext); + + return ; +} + +export function GroupConversationInAnotherCall(): JSX.Element { + const props = { + ...commonProps, + conversation: getDefaultGroup(), + hasActiveCall: true, + outgoingCallButtonStyle: OutgoingCallButtonStyle.Join, + }; + const theme = useContext(StorybookThemeContext); + + return ; +} + +export function GroupConversationInCurrentCall(): JSX.Element { + const props = { + ...commonProps, + conversation: getDefaultGroup(), + hasActiveCall: true, + outgoingCallButtonStyle: OutgoingCallButtonStyle.None, + }; + const theme = useContext(StorybookThemeContext); + + return ; +} diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 11d3d69b9f..7ca06fd975 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -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 && ( ): JSX.Element | null { + const disabled = + conversation.type === 'group' && + conversation.announcementsOnly && + !conversation.areWeAdmin; + const inAnotherCall = !disabled && hasActiveCall; + const videoButton = ( ); + + return inAnotherCall ? ( + {joinButton} + ) : ( + joinButton + ); } return null; diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx index 2d6edeb8ab..0f12f96051 100644 --- a/ts/components/conversation/TimelineMessage.stories.tsx +++ b/ts/components/conversation/TimelineMessage.stories.tsx @@ -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', }; diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index 47c448e7b1..88d6510a8e 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -232,3 +232,15 @@ export function WithCallHistoryGroup(): JSX.Element { /> ); } + +export function InAnotherCallGroup(): JSX.Element { + const props = createProps(); + + return ; +} + +export function InAnotherCallIndividual(): JSX.Element { + const props = createProps(); + + return ; +} diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index cd7dcf568f..080790662f 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -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 = ( ); if (hasActiveCall) { - return ( - - {button} - - ); + return {button}; } return button; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 3ae1f1c636..0a48442ebc 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -156,6 +156,7 @@ import { copyFromQuotedMessage, copyQuoteContentFromOriginal, } from '../messages/copyQuote'; +import { getRoomIdFromCallLink } from '../util/callLinksRingrtc'; /* eslint-disable more/no-then */ @@ -1823,19 +1824,34 @@ export class MessageModel extends window.Backbone.Model { const urls = LinkPreview.findLinks(dataMessage.body || ''); const incomingPreview = dataMessage.preview || []; - const preview = incomingPreview.filter((item: LinkPreviewType) => { - if (!item.image && !item.title) { - return false; - } - // Story link previews don't have to correspond to links in the - // message body. - if (isStory(message.attributes)) { - return true; - } - return ( - urls.includes(item.url) && LinkPreview.shouldPreviewHref(item.url) - ); - }); + const preview = incomingPreview + .map((item: LinkPreviewType) => { + if (!item.image && !item.title) { + return null; + } + // Story link previews don't have to correspond to links in the + // message body. + if (isStory(message.attributes)) { + return item; + } + 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) { log.info( `${message.idForLogging()}: Eliminated ${ diff --git a/ts/services/LinkPreview.ts b/ts/services/LinkPreview.ts index ad8174f832..367c7b0975 100644 --- a/ts/services/LinkPreview.ts +++ b/ts/services/LinkPreview.ts @@ -30,8 +30,9 @@ import { imageToBlurHash } from '../util/imageToBlurHash'; import { maybeParseUrl } from '../util/url'; import { sniffImageMimeType } from '../util/sniffImageMimeType'; import { drop } from '../util/drop'; -import { linkCallRoute } from '../util/signalRoutes'; import { calling } from './calling'; +import { getKeyFromCallLink } from '../util/callLinks'; +import { getRoomIdFromCallLink } from '../util/callLinksRingrtc'; const LINK_PREVIEW_TIMEOUT = 60 * SECOND; @@ -266,29 +267,27 @@ export function getLinkPreviewForSend( export function sanitizeLinkPreview( item: LinkPreviewResult | LinkPreviewWithHydratedData ): LinkPreviewWithHydratedData { - if (item.image) { - // We eliminate the ObjectURL here, unneeded for send or save - 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 { + const isCallLink = LinkPreview.isCallLink(item.url); + const base: LinkPreviewWithHydratedData = { ...item, 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), + 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( @@ -577,12 +576,8 @@ async function getCallLinkPreview( url: string, _abortSignal: Readonly ): Promise { - const parsedUrl = linkCallRoute.fromUrl(url); - if (parsedUrl == null) { - throw new Error('Failed to parse call link URL'); - } - - const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key); + const keyString = getKeyFromCallLink(url); + const callLinkRootKey = CallLinkRootKey.parse(keyString); const callLinkState = await calling.readCallLink(callLinkRootKey); if (callLinkState == null || callLinkState.revoked) { return null; diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index a25e5bec59..eaaab1c84d 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -2104,9 +2104,7 @@ const _startCallLinkLobby = async ({ const { activeCallState } = state.calling; if (activeCallState && activeCallState.conversationId === roomId) { - dispatch({ - type: TOGGLE_PIP, - }); + dispatch(togglePip()); return; } if (activeCallState) { @@ -2263,7 +2261,13 @@ function startCallingLobby({ "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( toggleConfirmLeaveCallModal({ type: 'conversation', diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 76e1abc4f4..02711416e1 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -577,6 +577,7 @@ export const getPropsForQuote = ( export type GetPropsForMessageOptions = Pick< GetPropsForBubbleOptions, + | 'activeCall' | 'conversationSelector' | 'ourConversationId' | 'ourAci' @@ -676,6 +677,7 @@ export const getPropsForMessage = ( const payment = getPayment(message); const { + activeCall, accountSelector, conversationSelector, ourConversationId, @@ -699,6 +701,7 @@ export const getPropsForMessage = ( const { sticker } = message; const isMessageTapToView = isTapToView(message); + const activeCallConversationId = activeCall?.conversationId; const isTargeted = message.id === targetedMessageId; const isSelected = selectedMessageIds?.includes(message.id) ?? false; @@ -726,6 +729,7 @@ export const getPropsForMessage = ( attachmentDroppedDueToSize, author, bodyRanges, + activeCallConversationId, previews, quote, reactions, diff --git a/ts/state/smart/CallLinkDetails.tsx b/ts/state/smart/CallLinkDetails.tsx index 845040c214..40b4d4a370 100644 --- a/ts/state/smart/CallLinkDetails.tsx +++ b/ts/state/smart/CallLinkDetails.tsx @@ -5,7 +5,7 @@ import { useSelector } from 'react-redux'; import type { CallHistoryGroup } from '../../types/CallDisposition'; import { getIntl } from '../selectors/user'; import { CallLinkDetails } from '../../components/CallLinkDetails'; -import { getCallLinkSelector } from '../selectors/calling'; +import { getActiveCallState, getCallLinkSelector } from '../selectors/calling'; import { useGlobalModalActions } from '../ducks/globalModals'; import { useCallingActions } from '../ducks/calling'; import * as log from '../../logging/log'; @@ -60,6 +60,11 @@ export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({ [roomId, updateCallLinkRestrictions] ); + const activeCall = useSelector(getActiveCallState); + const hasActiveCall = Boolean( + activeCall && callLink && activeCall?.conversationId !== callLink?.roomId + ); + if (callLink == null) { log.error(`SmartCallLinkDetails: callLink not found for room ${roomId}`); return null; @@ -69,6 +74,7 @@ export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({ = { url: string; isStickerPack?: boolean; isCallLink?: boolean; + callLinkRoomId?: string; image?: Readonly; date?: number; }; diff --git a/ts/util/callLinks.ts b/ts/util/callLinks.ts index 36eb0a47dd..dc688b3d11 100644 --- a/ts/util/callLinks.ts +++ b/ts/util/callLinks.ts @@ -23,6 +23,18 @@ export const CALL_LINK_DEFAULT_STATE = { 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 { if (isTestOrMockEnvironment()) { return true; diff --git a/ts/util/callLinksRingrtc.ts b/ts/util/callLinksRingrtc.ts index ff3433da54..a25ac3d5fb 100644 --- a/ts/util/callLinksRingrtc.ts +++ b/ts/util/callLinksRingrtc.ts @@ -28,7 +28,11 @@ import { } from './zkgroup'; import { getCheckedCallLinkAuthCredentialsForToday } from '../services/groupCredentialFetcher'; import * as durations from './durations'; -import { fromAdminKeyBytes, toAdminKeyBytes } from './callLinks'; +import { + fromAdminKeyBytes, + getKeyFromCallLink, + toAdminKeyBytes, +} from './callLinks'; /** * RingRTC conversions @@ -64,6 +68,12 @@ export function getCallLinkRootKeyFromUrlKey(key: string): Uint8Array { 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( callLinkRootKey: CallLinkRootKey ): Promise { diff --git a/ts/util/getColorForCallLink.ts b/ts/util/getColorForCallLink.ts index c777219209..105afa1f15 100644 --- a/ts/util/getColorForCallLink.ts +++ b/ts/util/getColorForCallLink.ts @@ -18,11 +18,3 @@ export function getColorForCallLink(rootKey: string): string { 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') || ''; -}