diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a8c8e31bb5..21541a5da8 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1322,6 +1322,14 @@ "message": "Calling", "description": "Header for calling options on the settings screen" }, + "calling__call-back": { + "message": "Call Back", + "description": "Button to call someone back" + }, + "calling__call-again": { + "message": "Call Again", + "description": "Button to call someone again" + }, "calling__start": { "message": "Start Call", "description": "Button label in the call lobby for starting a call" diff --git a/ts/components/conversation/CallingNotification.stories.tsx b/ts/components/conversation/CallingNotification.stories.tsx index d642ec4479..a522cdaf7f 100644 --- a/ts/components/conversation/CallingNotification.stories.tsx +++ b/ts/components/conversation/CallingNotification.stories.tsx @@ -9,6 +9,7 @@ import { setup as setupI18n } from '../../../js/modules/i18n'; import enMessages from '../../../_locales/en/messages.json'; import { CallMode } from '../../types/Calling'; import { CallingNotification } from './CallingNotification'; +import type { CallingNotificationType } from '../../util/callingNotification'; const i18n = setupI18n('en', enMessages); @@ -19,6 +20,7 @@ const getCommonProps = () => ({ i18n, messageId: 'fake-message-id', messageSizeChanged: action('messageSizeChanged'), + nextItem: undefined, returnToActiveCall: action('returnToActiveCall'), startCallingLobby: action('startCallingLobby'), }); @@ -46,6 +48,64 @@ const getCommonProps = () => ({ }); }); +story.add('Two incoming direct calls back-to-back', () => { + const call1: CallingNotificationType = { + callMode: CallMode.Direct, + wasIncoming: true, + wasVideoCall: true, + wasDeclined: false, + acceptedTime: 1618894800000, + endedTime: 1618894800000, + }; + const call2: CallingNotificationType = { + callMode: CallMode.Direct, + wasIncoming: true, + wasVideoCall: false, + wasDeclined: false, + endedTime: 1618894800000, + }; + + return ( + <> + + + + ); +}); + +story.add('Two outgoing direct calls back-to-back', () => { + const call1: CallingNotificationType = { + callMode: CallMode.Direct, + wasIncoming: false, + wasVideoCall: true, + wasDeclined: false, + acceptedTime: 1618894800000, + endedTime: 1618894800000, + }; + const call2: CallingNotificationType = { + callMode: CallMode.Direct, + wasIncoming: false, + wasVideoCall: false, + wasDeclined: false, + endedTime: 1618894800000, + }; + + return ( + <> + + + + ); +}); + [ undefined, { isMe: false, title: 'Alice' }, diff --git a/ts/components/conversation/CallingNotification.tsx b/ts/components/conversation/CallingNotification.tsx index 259f90fa6f..e9200f7cdd 100644 --- a/ts/components/conversation/CallingNotification.tsx +++ b/ts/components/conversation/CallingNotification.tsx @@ -1,7 +1,7 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useState, useEffect } from 'react'; +import React, { ReactNode, useState, useEffect } from 'react'; import Measure from 'react-measure'; import { noop } from 'lodash'; @@ -18,6 +18,7 @@ import { import { usePrevious } from '../../util/hooks'; import { missingCaseError } from '../../util/missingCaseError'; import { Tooltip, TooltipPlacement } from '../Tooltip'; +import type { TimelineItemType } from './TimelineItem'; export type PropsActionsType = { messageSizeChanged: (messageId: string, conversationId: string) => void; @@ -32,6 +33,7 @@ type PropsHousekeeping = { i18n: LocalizerType; conversationId: string; messageId: string; + nextItem: undefined | TimelineItemType; }; type PropsType = CallingNotificationType & PropsActionsType & PropsHousekeeping; @@ -52,7 +54,6 @@ export const CallingNotification: React.FC = React.memo(props => { } }, [height, previousHeight, conversationId, messageId, messageSizeChanged]); - let hasButton = false; let timestamp: number; let wasMissed = false; switch (props.callMode) { @@ -62,7 +63,6 @@ export const CallingNotification: React.FC = React.memo(props => { props.wasIncoming && !props.acceptedTime && !props.wasDeclined; break; case CallMode.Group: - hasButton = !props.ended; timestamp = props.startedTime; break; default: @@ -87,9 +87,7 @@ export const CallingNotification: React.FC = React.memo(props => { > {({ measureRef }) => ( : undefined - } + button={renderCallingNotificationButton(props)} contents={ <> {getCallingNotificationText(props, i18n)} ·{' '} @@ -113,47 +111,70 @@ export const CallingNotification: React.FC = React.memo(props => { ); }); -function CallingNotificationButton(props: PropsType) { - if (props.callMode !== CallMode.Group || props.ended) { - return null; - } - +function renderCallingNotificationButton( + props: Readonly +): ReactNode { const { - activeCallConversationId, conversationId, - deviceCount, i18n, - maxDevices, + nextItem, returnToActiveCall, startCallingLobby, } = props; + if (nextItem?.type === 'callHistory') { + return null; + } + let buttonText: string; let disabledTooltipText: undefined | string; let onClick: () => void; - if (activeCallConversationId) { - if (activeCallConversationId === conversationId) { - buttonText = i18n('calling__return'); - onClick = returnToActiveCall; - } else { - buttonText = i18n('calling__join'); - disabledTooltipText = i18n( - 'calling__call-notification__button__in-another-call-tooltip' - ); - onClick = noop; + + switch (props.callMode) { + case CallMode.Direct: { + const { wasIncoming, wasVideoCall } = props; + buttonText = wasIncoming + ? i18n('calling__call-back') + : i18n('calling__call-again'); + onClick = () => { + startCallingLobby({ conversationId, isVideoCall: wasVideoCall }); + }; + break; } - } else if (deviceCount >= maxDevices) { - buttonText = i18n('calling__call-is-full'); - disabledTooltipText = i18n( - 'calling__call-notification__button__call-full-tooltip', - [String(deviceCount)] - ); - onClick = noop; - } else { - buttonText = i18n('calling__join'); - onClick = () => { - startCallingLobby({ conversationId, isVideoCall: true }); - }; + case CallMode.Group: { + if (props.ended) { + return null; + } + const { activeCallConversationId, deviceCount, maxDevices } = props; + if (activeCallConversationId) { + if (activeCallConversationId === conversationId) { + buttonText = i18n('calling__return'); + onClick = returnToActiveCall; + } else { + buttonText = i18n('calling__join'); + disabledTooltipText = i18n( + 'calling__call-notification__button__in-another-call-tooltip' + ); + onClick = noop; + } + } else if (deviceCount >= maxDevices) { + buttonText = i18n('calling__call-is-full'); + disabledTooltipText = i18n( + 'calling__call-notification__button__call-full-tooltip', + [String(deviceCount)] + ); + onClick = noop; + } else { + buttonText = i18n('calling__join'); + onClick = () => { + startCallingLobby({ conversationId, isVideoCall: true }); + }; + } + break; + } + default: + window.log.error(missingCaseError(props)); + return null; } const button = ( diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index e2e3514c04..1bf59c6e00 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -376,19 +376,21 @@ const actions = () => ({ unblurAvatar: action('unblurAvatar'), }); -const renderItem = ( - id: string, - _conversationId: unknown, - _onHeightChange: unknown, - _actionProps: unknown, - containerElementRef: React.RefObject -) => ( +const renderItem = ({ + messageId, + containerElementRef, +}: { + messageId: string; + containerElementRef: React.RefObject; +}) => (
} renderReactionPicker={() =>
} - item={items[id]} + item={items[messageId]} + previousItem={undefined} + nextItem={undefined} i18n={i18n} interactionMode="keyboard" containerElementRef={containerElementRef} diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index b8ed557285..04f845d744 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -103,13 +103,15 @@ type PropsHousekeepingType = { i18n: LocalizerType; - renderItem: ( - id: string, - conversationId: string, - onHeightChange: (messageId: string) => unknown, - actions: PropsActionsType, - containerElementRef: RefObject - ) => JSX.Element; + renderItem: (props: { + actions: PropsActionsType; + containerElementRef: RefObject; + conversationId: string; + messageId: string; + nextMessageId: undefined | string; + onHeightChange: (messageId: string) => unknown; + previousMessageId: undefined | string; + }) => JSX.Element; renderLastSeenIndicator: (id: string) => JSX.Element; renderHeroRow: ( id: string, @@ -797,7 +799,9 @@ export class Timeline extends React.PureComponent { `Attempted to render item with undefined index - row ${row}` ); } + const previousMessageId: undefined | string = items[itemIndex - 1]; const messageId = items[itemIndex]; + const nextMessageId: undefined | string = items[itemIndex + 1]; stableKey = messageId; const actions = getActions(this.props); @@ -811,13 +815,15 @@ export class Timeline extends React.PureComponent { role="row" > window.showDebugLog()}> - {renderItem( - messageId, - id, - this.resizeMessage, + {renderItem({ actions, - this.containerRef - )} + containerElementRef: this.containerRef, + conversationId: id, + messageId, + nextMessageId, + onHeightChange: this.resizeMessage, + previousMessageId, + })}
); diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 8f50c42d4f..e9e2a60ac3 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -86,6 +86,8 @@ const getDefaultProps = () => ({ messageSizeChanged: action('messageSizeChanged'), startCallingLobby: action('startCallingLobby'), returnToActiveCall: action('returnToActiveCall'), + previousItem: undefined, + nextItem: undefined, renderContact, renderUniversalTimerNotification, diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index cb8aed0585..5fa7d2e466 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -165,6 +165,8 @@ type PropsLocalType = { i18n: LocalizerType; interactionMode: InteractionModeType; theme?: ThemeType; + previousItem: undefined | TimelineItemType; + nextItem: undefined | TimelineItemType; }; type PropsActionsType = MessageActionsType & @@ -192,6 +194,7 @@ export class TimelineItem extends React.PureComponent { i18n, theme, messageSizeChanged, + nextItem, renderContact, renderUniversalTimerNotification, returnToActiveCall, @@ -231,6 +234,7 @@ export class TimelineItem extends React.PureComponent { i18n={i18n} messageId={id} messageSizeChanged={messageSizeChanged} + nextItem={nextItem} returnToActiveCall={returnToActiveCall} startCallingLobby={startCallingLobby} {...item.data} diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index a6bfeed324..7778954740 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -63,19 +63,31 @@ const createBoundOnHeightChange = memoizee( { max: 500 } ); -function renderItem( - messageId: string, - conversationId: string, - onHeightChange: (messageId: string) => unknown, - actionProps: TimelineActionsType, - containerElementRef: RefObject -): JSX.Element { +function renderItem({ + actionProps, + containerElementRef, + conversationId, + messageId, + nextMessageId, + onHeightChange, + previousMessageId, +}: { + actionProps: TimelineActionsType; + containerElementRef: RefObject; + conversationId: string; + messageId: string; + nextMessageId: undefined | string; + onHeightChange: (messageId: string) => unknown; + previousMessageId: undefined | string; +}): JSX.Element { return ( ; + conversationId: string; + messageId: string; + nextMessageId: undefined | string; + previousMessageId: undefined | string; }; // Workaround: A react component's required properties are filtering up through connect() @@ -39,19 +41,34 @@ function renderUniversalTimerNotification(): JSX.Element { } const mapStateToProps = (state: StateType, props: ExternalProps) => { - const { id, conversationId, containerElementRef } = props; + const { + containerElementRef, + conversationId, + messageId, + nextMessageId, + previousMessageId, + } = props; const messageSelector = getMessageSelector(state); - const item = messageSelector(id); + + const item = messageSelector(messageId); + const previousItem = previousMessageId + ? messageSelector(previousMessageId) + : undefined; + const nextItem = nextMessageId ? messageSelector(nextMessageId) : undefined; const selectedMessage = getSelectedMessage(state); - const isSelected = Boolean(selectedMessage && id === selectedMessage.id); + const isSelected = Boolean( + selectedMessage && messageId === selectedMessage.id + ); const conversation = getConversationSelector(state)(conversationId); return { item, - id, + previousItem, + nextItem, + id: messageId, containerElementRef, conversationId, conversationColor: conversation?.conversationColor,