Add "call back", "call again" buttons to timeline

This commit is contained in:
Evan Hahn 2021-09-10 18:59:41 -05:00 committed by GitHub
parent d94f1151b1
commit bfa0bbf7da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 203 additions and 71 deletions

View file

@ -1322,6 +1322,14 @@
"message": "Calling", "message": "Calling",
"description": "Header for calling options on the settings screen" "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": { "calling__start": {
"message": "Start Call", "message": "Start Call",
"description": "Button label in the call lobby for starting a call" "description": "Button label in the call lobby for starting a call"

View file

@ -9,6 +9,7 @@ import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import { CallMode } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
import { CallingNotification } from './CallingNotification'; import { CallingNotification } from './CallingNotification';
import type { CallingNotificationType } from '../../util/callingNotification';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -19,6 +20,7 @@ const getCommonProps = () => ({
i18n, i18n,
messageId: 'fake-message-id', messageId: 'fake-message-id',
messageSizeChanged: action('messageSizeChanged'), messageSizeChanged: action('messageSizeChanged'),
nextItem: undefined,
returnToActiveCall: action('returnToActiveCall'), returnToActiveCall: action('returnToActiveCall'),
startCallingLobby: action('startCallingLobby'), 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 (
<>
<CallingNotification
{...getCommonProps()}
{...call1}
nextItem={{ type: 'callHistory', data: call2 }}
/>
<CallingNotification {...getCommonProps()} {...call2} />
</>
);
});
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 (
<>
<CallingNotification
{...getCommonProps()}
{...call1}
nextItem={{ type: 'callHistory', data: call2 }}
/>
<CallingNotification {...getCommonProps()} {...call2} />
</>
);
});
[ [
undefined, undefined,
{ isMe: false, title: 'Alice' }, { isMe: false, title: 'Alice' },

View file

@ -1,7 +1,7 @@
// Copyright 2020-2021 Signal Messenger, LLC // Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 Measure from 'react-measure';
import { noop } from 'lodash'; import { noop } from 'lodash';
@ -18,6 +18,7 @@ import {
import { usePrevious } from '../../util/hooks'; import { usePrevious } from '../../util/hooks';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { Tooltip, TooltipPlacement } from '../Tooltip'; import { Tooltip, TooltipPlacement } from '../Tooltip';
import type { TimelineItemType } from './TimelineItem';
export type PropsActionsType = { export type PropsActionsType = {
messageSizeChanged: (messageId: string, conversationId: string) => void; messageSizeChanged: (messageId: string, conversationId: string) => void;
@ -32,6 +33,7 @@ type PropsHousekeeping = {
i18n: LocalizerType; i18n: LocalizerType;
conversationId: string; conversationId: string;
messageId: string; messageId: string;
nextItem: undefined | TimelineItemType;
}; };
type PropsType = CallingNotificationType & PropsActionsType & PropsHousekeeping; type PropsType = CallingNotificationType & PropsActionsType & PropsHousekeeping;
@ -52,7 +54,6 @@ export const CallingNotification: React.FC<PropsType> = React.memo(props => {
} }
}, [height, previousHeight, conversationId, messageId, messageSizeChanged]); }, [height, previousHeight, conversationId, messageId, messageSizeChanged]);
let hasButton = false;
let timestamp: number; let timestamp: number;
let wasMissed = false; let wasMissed = false;
switch (props.callMode) { switch (props.callMode) {
@ -62,7 +63,6 @@ export const CallingNotification: React.FC<PropsType> = React.memo(props => {
props.wasIncoming && !props.acceptedTime && !props.wasDeclined; props.wasIncoming && !props.acceptedTime && !props.wasDeclined;
break; break;
case CallMode.Group: case CallMode.Group:
hasButton = !props.ended;
timestamp = props.startedTime; timestamp = props.startedTime;
break; break;
default: default:
@ -87,9 +87,7 @@ export const CallingNotification: React.FC<PropsType> = React.memo(props => {
> >
{({ measureRef }) => ( {({ measureRef }) => (
<SystemMessage <SystemMessage
button={ button={renderCallingNotificationButton(props)}
hasButton ? <CallingNotificationButton {...props} /> : undefined
}
contents={ contents={
<> <>
{getCallingNotificationText(props, i18n)} &middot;{' '} {getCallingNotificationText(props, i18n)} &middot;{' '}
@ -113,47 +111,70 @@ export const CallingNotification: React.FC<PropsType> = React.memo(props => {
); );
}); });
function CallingNotificationButton(props: PropsType) { function renderCallingNotificationButton(
if (props.callMode !== CallMode.Group || props.ended) { props: Readonly<PropsType>
return null; ): ReactNode {
}
const { const {
activeCallConversationId,
conversationId, conversationId,
deviceCount,
i18n, i18n,
maxDevices, nextItem,
returnToActiveCall, returnToActiveCall,
startCallingLobby, startCallingLobby,
} = props; } = props;
if (nextItem?.type === 'callHistory') {
return null;
}
let buttonText: string; let buttonText: string;
let disabledTooltipText: undefined | string; let disabledTooltipText: undefined | string;
let onClick: () => void; let onClick: () => void;
if (activeCallConversationId) {
if (activeCallConversationId === conversationId) { switch (props.callMode) {
buttonText = i18n('calling__return'); case CallMode.Direct: {
onClick = returnToActiveCall; const { wasIncoming, wasVideoCall } = props;
} else { buttonText = wasIncoming
buttonText = i18n('calling__join'); ? i18n('calling__call-back')
disabledTooltipText = i18n( : i18n('calling__call-again');
'calling__call-notification__button__in-another-call-tooltip' onClick = () => {
); startCallingLobby({ conversationId, isVideoCall: wasVideoCall });
onClick = noop; };
break;
} }
} else if (deviceCount >= maxDevices) { case CallMode.Group: {
buttonText = i18n('calling__call-is-full'); if (props.ended) {
disabledTooltipText = i18n( return null;
'calling__call-notification__button__call-full-tooltip', }
[String(deviceCount)] const { activeCallConversationId, deviceCount, maxDevices } = props;
); if (activeCallConversationId) {
onClick = noop; if (activeCallConversationId === conversationId) {
} else { buttonText = i18n('calling__return');
buttonText = i18n('calling__join'); onClick = returnToActiveCall;
onClick = () => { } else {
startCallingLobby({ conversationId, isVideoCall: true }); 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 = ( const button = (

View file

@ -376,19 +376,21 @@ const actions = () => ({
unblurAvatar: action('unblurAvatar'), unblurAvatar: action('unblurAvatar'),
}); });
const renderItem = ( const renderItem = ({
id: string, messageId,
_conversationId: unknown, containerElementRef,
_onHeightChange: unknown, }: {
_actionProps: unknown, messageId: string;
containerElementRef: React.RefObject<HTMLElement> containerElementRef: React.RefObject<HTMLElement>;
) => ( }) => (
<TimelineItem <TimelineItem
id="" id=""
isSelected={false} isSelected={false}
renderEmojiPicker={() => <div />} renderEmojiPicker={() => <div />}
renderReactionPicker={() => <div />} renderReactionPicker={() => <div />}
item={items[id]} item={items[messageId]}
previousItem={undefined}
nextItem={undefined}
i18n={i18n} i18n={i18n}
interactionMode="keyboard" interactionMode="keyboard"
containerElementRef={containerElementRef} containerElementRef={containerElementRef}

View file

@ -103,13 +103,15 @@ type PropsHousekeepingType = {
i18n: LocalizerType; i18n: LocalizerType;
renderItem: ( renderItem: (props: {
id: string, actions: PropsActionsType;
conversationId: string, containerElementRef: RefObject<HTMLElement>;
onHeightChange: (messageId: string) => unknown, conversationId: string;
actions: PropsActionsType, messageId: string;
containerElementRef: RefObject<HTMLElement> nextMessageId: undefined | string;
) => JSX.Element; onHeightChange: (messageId: string) => unknown;
previousMessageId: undefined | string;
}) => JSX.Element;
renderLastSeenIndicator: (id: string) => JSX.Element; renderLastSeenIndicator: (id: string) => JSX.Element;
renderHeroRow: ( renderHeroRow: (
id: string, id: string,
@ -797,7 +799,9 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
`Attempted to render item with undefined index - row ${row}` `Attempted to render item with undefined index - row ${row}`
); );
} }
const previousMessageId: undefined | string = items[itemIndex - 1];
const messageId = items[itemIndex]; const messageId = items[itemIndex];
const nextMessageId: undefined | string = items[itemIndex + 1];
stableKey = messageId; stableKey = messageId;
const actions = getActions(this.props); const actions = getActions(this.props);
@ -811,13 +815,15 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
role="row" role="row"
> >
<ErrorBoundary i18n={i18n} showDebugLog={() => window.showDebugLog()}> <ErrorBoundary i18n={i18n} showDebugLog={() => window.showDebugLog()}>
{renderItem( {renderItem({
messageId,
id,
this.resizeMessage,
actions, actions,
this.containerRef containerElementRef: this.containerRef,
)} conversationId: id,
messageId,
nextMessageId,
onHeightChange: this.resizeMessage,
previousMessageId,
})}
</ErrorBoundary> </ErrorBoundary>
</div> </div>
); );

View file

@ -86,6 +86,8 @@ const getDefaultProps = () => ({
messageSizeChanged: action('messageSizeChanged'), messageSizeChanged: action('messageSizeChanged'),
startCallingLobby: action('startCallingLobby'), startCallingLobby: action('startCallingLobby'),
returnToActiveCall: action('returnToActiveCall'), returnToActiveCall: action('returnToActiveCall'),
previousItem: undefined,
nextItem: undefined,
renderContact, renderContact,
renderUniversalTimerNotification, renderUniversalTimerNotification,

View file

@ -165,6 +165,8 @@ type PropsLocalType = {
i18n: LocalizerType; i18n: LocalizerType;
interactionMode: InteractionModeType; interactionMode: InteractionModeType;
theme?: ThemeType; theme?: ThemeType;
previousItem: undefined | TimelineItemType;
nextItem: undefined | TimelineItemType;
}; };
type PropsActionsType = MessageActionsType & type PropsActionsType = MessageActionsType &
@ -192,6 +194,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
i18n, i18n,
theme, theme,
messageSizeChanged, messageSizeChanged,
nextItem,
renderContact, renderContact,
renderUniversalTimerNotification, renderUniversalTimerNotification,
returnToActiveCall, returnToActiveCall,
@ -231,6 +234,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
i18n={i18n} i18n={i18n}
messageId={id} messageId={id}
messageSizeChanged={messageSizeChanged} messageSizeChanged={messageSizeChanged}
nextItem={nextItem}
returnToActiveCall={returnToActiveCall} returnToActiveCall={returnToActiveCall}
startCallingLobby={startCallingLobby} startCallingLobby={startCallingLobby}
{...item.data} {...item.data}

View file

@ -63,19 +63,31 @@ const createBoundOnHeightChange = memoizee(
{ max: 500 } { max: 500 }
); );
function renderItem( function renderItem({
messageId: string, actionProps,
conversationId: string, containerElementRef,
onHeightChange: (messageId: string) => unknown, conversationId,
actionProps: TimelineActionsType, messageId,
containerElementRef: RefObject<HTMLElement> nextMessageId,
): JSX.Element { onHeightChange,
previousMessageId,
}: {
actionProps: TimelineActionsType;
containerElementRef: RefObject<HTMLElement>;
conversationId: string;
messageId: string;
nextMessageId: undefined | string;
onHeightChange: (messageId: string) => unknown;
previousMessageId: undefined | string;
}): JSX.Element {
return ( return (
<SmartTimelineItem <SmartTimelineItem
{...actionProps} {...actionProps}
containerElementRef={containerElementRef} containerElementRef={containerElementRef}
conversationId={conversationId} conversationId={conversationId}
id={messageId} messageId={messageId}
previousMessageId={previousMessageId}
nextMessageId={nextMessageId}
onHeightChange={createBoundOnHeightChange(onHeightChange, messageId)} onHeightChange={createBoundOnHeightChange(onHeightChange, messageId)}
renderEmojiPicker={renderEmojiPicker} renderEmojiPicker={renderEmojiPicker}
renderReactionPicker={renderReactionPicker} renderReactionPicker={renderReactionPicker}

View file

@ -19,9 +19,11 @@ import { SmartContactName } from './ContactName';
import { SmartUniversalTimerNotification } from './UniversalTimerNotification'; import { SmartUniversalTimerNotification } from './UniversalTimerNotification';
type ExternalProps = { type ExternalProps = {
id: string;
conversationId: string;
containerElementRef: RefObject<HTMLElement>; containerElementRef: RefObject<HTMLElement>;
conversationId: string;
messageId: string;
nextMessageId: undefined | string;
previousMessageId: undefined | string;
}; };
// Workaround: A react component's required properties are filtering up through connect() // 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 mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id, conversationId, containerElementRef } = props; const {
containerElementRef,
conversationId,
messageId,
nextMessageId,
previousMessageId,
} = props;
const messageSelector = getMessageSelector(state); 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 selectedMessage = getSelectedMessage(state);
const isSelected = Boolean(selectedMessage && id === selectedMessage.id); const isSelected = Boolean(
selectedMessage && messageId === selectedMessage.id
);
const conversation = getConversationSelector(state)(conversationId); const conversation = getConversationSelector(state)(conversationId);
return { return {
item, item,
id, previousItem,
nextItem,
id: messageId,
containerElementRef, containerElementRef,
conversationId, conversationId,
conversationColor: conversation?.conversationColor, conversationColor: conversation?.conversationColor,