Add group calling events to the message timeline

This commit is contained in:
Evan Hahn 2020-12-07 14:43:19 -06:00 committed by GitHub
parent a2f285d243
commit 0c039bf431
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1275 additions and 239 deletions

View file

@ -2,9 +2,76 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { noop } from 'lodash';
import { Manager, Reference, Popper } from 'react-popper';
import { Theme, themeClassName } from '../util/theme';
interface EventWrapperPropsType {
children: React.ReactNode;
onHoverChanged: (_: boolean) => void;
}
// React doesn't reliably fire `onMouseLeave` or `onMouseOut` events if wrapping a
// disabled button. This uses native browser events to avoid that.
//
// See <https://lecstor.com/react-disabled-button-onmouseleave/>.
const TooltipEventWrapper = React.forwardRef<
HTMLSpanElement,
EventWrapperPropsType
>(({ onHoverChanged, children }, ref) => {
const wrapperRef = React.useRef<HTMLSpanElement | null>(null);
React.useEffect(() => {
const wrapperEl = wrapperRef.current;
if (!wrapperEl) {
return noop;
}
const on = () => {
onHoverChanged(true);
};
const off = () => {
onHoverChanged(false);
};
wrapperEl.addEventListener('focus', on);
wrapperEl.addEventListener('blur', off);
wrapperEl.addEventListener('mouseenter', on);
wrapperEl.addEventListener('mouseleave', off);
return () => {
wrapperEl.removeEventListener('focus', on);
wrapperEl.removeEventListener('blur', off);
wrapperEl.removeEventListener('mouseenter', on);
wrapperEl.removeEventListener('mouseleave', off);
};
}, [onHoverChanged]);
return (
<span
// This is a forward ref that also needs a ref of its own, so we set both here.
ref={el => {
wrapperRef.current = el;
// This is a simplified version of [what React does][0] to set a ref.
// [0]: https://github.com/facebook/react/blob/29b7b775f2ecf878eaf605be959d959030598b07/packages/react-reconciler/src/ReactFiberCommitWork.js#L661-L677
if (typeof ref === 'function') {
ref(el);
} else if (ref) {
// I believe the types for `ref` are wrong in this case, as `ref.current` should
// not be `readonly`. That's why we do this cast. See [the React source][1].
// [1]: https://github.com/facebook/react/blob/29b7b775f2ecf878eaf605be959d959030598b07/packages/shared/ReactTypes.js#L78-L80
// eslint-disable-next-line no-param-reassign
(ref as React.MutableRefObject<HTMLSpanElement | null>).current = el;
}
}}
>
{children}
</span>
);
});
export enum TooltipPlacement {
Top = 'top',
Right = 'right',
@ -26,8 +93,9 @@ export const Tooltip: React.FC<PropsType> = ({
sticky,
theme,
}) => {
const isSticky = Boolean(sticky);
const [showTooltip, setShowTooltip] = React.useState(isSticky);
const [isHovering, setIsHovering] = React.useState(false);
const showTooltip = isHovering || Boolean(sticky);
const tooltipTheme = theme ? themeClassName(theme) : undefined;
@ -35,31 +103,9 @@ export const Tooltip: React.FC<PropsType> = ({
<Manager>
<Reference>
{({ ref }) => (
<span
onBlur={() => {
if (!isSticky) {
setShowTooltip(false);
}
}}
onFocus={() => {
if (!isSticky) {
setShowTooltip(true);
}
}}
onMouseEnter={() => {
if (!isSticky) {
setShowTooltip(true);
}
}}
onMouseLeave={() => {
if (!isSticky) {
setShowTooltip(false);
}
}}
ref={ref}
>
<TooltipEventWrapper ref={ref} onHoverChanged={setIsHovering}>
{children}
</span>
</TooltipEventWrapper>
)}
</Reference>
<Popper placement={direction}>

View file

@ -1,90 +1,168 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useState, useRef, useEffect } from 'react';
import Measure from 'react-measure';
import { Timestamp } from './Timestamp';
import { LocalizerType } from '../../types/Util';
import { CallHistoryDetailsType } from '../../types/Calling';
import { CallMode } from '../../types/Calling';
import {
CallingNotificationType,
getCallingNotificationText,
} from '../../util/callingNotification';
import { missingCaseError } from '../../util/missingCaseError';
import { Tooltip, TooltipPlacement } from '../Tooltip';
export type PropsData = {
// Can be undefined because it comes from JS.
callHistoryDetails?: CallHistoryDetailsType;
};
export interface PropsActionsType {
messageSizeChanged: (messageId: string, conversationId: string) => void;
returnToActiveCall: () => void;
startCallingLobby: (_: {
conversationId: string;
isVideoCall: boolean;
}) => void;
}
type PropsHousekeeping = {
i18n: LocalizerType;
conversationId: string;
messageId: string;
};
type Props = PropsData & PropsHousekeeping;
type PropsType = CallingNotificationType & PropsActionsType & PropsHousekeeping;
export function getCallingNotificationText(
callHistoryDetails: CallHistoryDetailsType,
i18n: LocalizerType
): string {
const {
wasIncoming,
wasVideoCall,
wasDeclined,
acceptedTime,
} = callHistoryDetails;
const wasAccepted = Boolean(acceptedTime);
export const CallingNotification: React.FC<PropsType> = React.memo(props => {
const { conversationId, i18n, messageId, messageSizeChanged } = props;
if (wasIncoming) {
if (wasDeclined) {
if (wasVideoCall) {
return i18n('declinedIncomingVideoCall');
}
return i18n('declinedIncomingAudioCall');
}
if (wasAccepted) {
if (wasVideoCall) {
return i18n('acceptedIncomingVideoCall');
}
return i18n('acceptedIncomingAudioCall');
}
if (wasVideoCall) {
return i18n('missedIncomingVideoCall');
}
return i18n('missedIncomingAudioCall');
}
if (wasAccepted) {
if (wasVideoCall) {
return i18n('acceptedOutgoingVideoCall');
}
return i18n('acceptedOutgoingAudioCall');
}
if (wasVideoCall) {
return i18n('missedOrDeclinedOutgoingVideoCall');
}
return i18n('missedOrDeclinedOutgoingAudioCall');
}
const previousHeightRef = useRef<null | number>(null);
const [height, setHeight] = useState<null | number>(null);
export const CallingNotification = (props: Props): JSX.Element | null => {
const { callHistoryDetails, i18n } = props;
if (!callHistoryDetails) {
useEffect(() => {
if (height === null) {
return;
}
if (
previousHeightRef.current !== null &&
height !== previousHeightRef.current
) {
messageSizeChanged(messageId, conversationId);
}
previousHeightRef.current = height;
}, [height, conversationId, messageId, messageSizeChanged]);
let timestamp: number;
let callType: 'audio' | 'video';
switch (props.callMode) {
case CallMode.Direct:
timestamp = props.acceptedTime || props.endedTime;
callType = props.wasVideoCall ? 'video' : 'audio';
break;
case CallMode.Group:
timestamp = props.startedTime;
callType = 'video';
break;
default:
window.log.error(missingCaseError(props));
return null;
}
return (
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds) {
window.log.error('We should be measuring the bounds');
return;
}
setHeight(bounds.height);
}}
>
{({ measureRef }) => (
<div
className={`module-message-calling--notification module-message-calling--${callType}`}
ref={measureRef}
>
<div className={`module-message-calling--${callType}__icon`} />
{getCallingNotificationText(props, i18n)}
<div>
<Timestamp
i18n={i18n}
timestamp={timestamp}
extended
direction="outgoing"
withImageNoCaption={false}
withSticker={false}
withTapToViewExpired={false}
module="module-message__metadata__date"
/>
</div>
<CallingNotificationButton {...props} />
</div>
)}
</Measure>
);
});
function CallingNotificationButton(props: PropsType) {
if (props.callMode !== CallMode.Group || props.ended) {
return null;
}
const { acceptedTime, endedTime, wasVideoCall } = callHistoryDetails;
const callType = wasVideoCall ? 'video' : 'audio';
return (
<div
className={`module-message-calling--notification module-message-calling--${callType}`}
const {
activeCallConversationId,
conversationId,
deviceCount,
i18n,
maxDevices,
returnToActiveCall,
startCallingLobby,
} = props;
let buttonText: string;
let disabledTooltipText: undefined | string;
let onClick: undefined | (() => 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'
);
}
} else if (deviceCount >= maxDevices) {
buttonText = i18n('calling__call-is-full');
disabledTooltipText = i18n(
'calling__call-notification__button__call-full-tooltip',
[String(deviceCount)]
);
} else {
buttonText = i18n('calling__join');
onClick = () => {
startCallingLobby({ conversationId, isVideoCall: true });
};
}
const button = (
<button
className="module-message-calling--notification__button"
disabled={Boolean(disabledTooltipText)}
onClick={onClick}
type="button"
>
<div className={`module-message-calling--${callType}__icon`} />
{getCallingNotificationText(callHistoryDetails, i18n)}
<div>
<Timestamp
i18n={i18n}
timestamp={acceptedTime || endedTime}
extended
direction="outgoing"
withImageNoCaption={false}
withSticker={false}
withTapToViewExpired={false}
module="module-message__metadata__date"
/>
</div>
</div>
{buttonText}
</button>
);
};
if (disabledTooltipText) {
return (
<Tooltip content={disabledTooltipText} direction={TooltipPlacement.Top}>
{button}
</Tooltip>
);
}
return button;
}

View file

@ -247,6 +247,10 @@ const actions = () => ({
showIdentity: action('showIdentity'),
downloadNewVersion: action('downloadNewVersion'),
messageSizeChanged: action('messageSizeChanged'),
startCallingLobby: action('startCallingLobby'),
returnToActiveCall: action('returnToActiveCall'),
});
const renderItem = (id: string) => (

View file

@ -10,6 +10,7 @@ import { EmojiPicker } from '../emoji/EmojiPicker';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { PropsType as TimelineItemProps, TimelineItem } from './TimelineItem';
import { CallMode } from '../../types/Calling';
const i18n = setupI18n('en', enMessages);
@ -61,6 +62,9 @@ const getDefaultProps = () => ({
scrollToQuotedMessage: action('scrollToQuotedMessage'),
downloadNewVersion: action('downloadNewVersion'),
showIdentity: action('showIdentity'),
messageSizeChanged: action('messageSizeChanged'),
startCallingLobby: action('startCallingLobby'),
returnToActiveCall: action('returnToActiveCall'),
renderContact,
renderEmojiPicker,
@ -95,149 +99,253 @@ storiesOf('Components/Conversation/TimelineItem', module)
{
type: 'callHistory',
data: {
callHistoryDetails: {
// declined incoming audio
wasDeclined: true,
wasIncoming: true,
wasVideoCall: false,
endedTime: Date.now(),
},
// declined incoming audio
callMode: CallMode.Direct,
wasDeclined: true,
wasIncoming: true,
wasVideoCall: false,
endedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
callHistoryDetails: {
// declined incoming video
wasDeclined: true,
wasIncoming: true,
wasVideoCall: true,
endedTime: Date.now(),
},
// declined incoming video
callMode: CallMode.Direct,
wasDeclined: true,
wasIncoming: true,
wasVideoCall: true,
endedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
callHistoryDetails: {
// accepted incoming audio
acceptedTime: Date.now() - 300,
wasDeclined: false,
wasIncoming: true,
wasVideoCall: false,
endedTime: Date.now(),
},
// accepted incoming audio
callMode: CallMode.Direct,
acceptedTime: Date.now() - 300,
wasDeclined: false,
wasIncoming: true,
wasVideoCall: false,
endedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
callHistoryDetails: {
// accepted incoming video
acceptedTime: Date.now() - 400,
wasDeclined: false,
wasIncoming: true,
wasVideoCall: true,
endedTime: Date.now(),
},
// accepted incoming video
callMode: CallMode.Direct,
acceptedTime: Date.now() - 400,
wasDeclined: false,
wasIncoming: true,
wasVideoCall: true,
endedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
callHistoryDetails: {
// missed (neither accepted nor declined) incoming audio
wasDeclined: false,
wasIncoming: true,
wasVideoCall: false,
endedTime: Date.now(),
},
// missed (neither accepted nor declined) incoming audio
callMode: CallMode.Direct,
wasDeclined: false,
wasIncoming: true,
wasVideoCall: false,
endedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
callHistoryDetails: {
// missed (neither accepted nor declined) incoming video
wasDeclined: false,
wasIncoming: true,
wasVideoCall: true,
endedTime: Date.now(),
},
// missed (neither accepted nor declined) incoming video
callMode: CallMode.Direct,
wasDeclined: false,
wasIncoming: true,
wasVideoCall: true,
endedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
callHistoryDetails: {
// accepted outgoing audio
acceptedTime: Date.now() - 200,
wasDeclined: false,
wasIncoming: false,
wasVideoCall: false,
endedTime: Date.now(),
},
// accepted outgoing audio
callMode: CallMode.Direct,
acceptedTime: Date.now() - 200,
wasDeclined: false,
wasIncoming: false,
wasVideoCall: false,
endedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
callHistoryDetails: {
// accepted outgoing video
acceptedTime: Date.now() - 200,
wasDeclined: false,
wasIncoming: false,
wasVideoCall: true,
endedTime: Date.now(),
},
// accepted outgoing video
callMode: CallMode.Direct,
acceptedTime: Date.now() - 200,
wasDeclined: false,
wasIncoming: false,
wasVideoCall: true,
endedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
callHistoryDetails: {
// declined outgoing audio
wasDeclined: true,
wasIncoming: false,
wasVideoCall: false,
endedTime: Date.now(),
},
// declined outgoing audio
callMode: CallMode.Direct,
wasDeclined: true,
wasIncoming: false,
wasVideoCall: false,
endedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
callHistoryDetails: {
// declined outgoing video
wasDeclined: true,
wasIncoming: false,
wasVideoCall: true,
endedTime: Date.now(),
},
// declined outgoing video
callMode: CallMode.Direct,
wasDeclined: true,
wasIncoming: false,
wasVideoCall: true,
endedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
callHistoryDetails: {
// missed (neither accepted nor declined) outgoing audio
wasDeclined: false,
wasIncoming: false,
wasVideoCall: false,
endedTime: Date.now(),
},
// missed (neither accepted nor declined) outgoing audio
callMode: CallMode.Direct,
wasDeclined: false,
wasIncoming: false,
wasVideoCall: false,
endedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
callHistoryDetails: {
// missed (neither accepted nor declined) outgoing video
wasDeclined: false,
wasIncoming: false,
wasVideoCall: true,
endedTime: Date.now(),
// missed (neither accepted nor declined) outgoing video
callMode: CallMode.Direct,
wasDeclined: false,
wasIncoming: false,
wasVideoCall: true,
endedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
// ongoing group call
callMode: CallMode.Group,
conversationId: 'abc123',
creator: {
firstName: 'Luigi',
isMe: false,
title: 'Luigi Mario',
},
ended: false,
deviceCount: 1,
maxDevices: 16,
startedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
// ongoing group call started by you
callMode: CallMode.Group,
conversationId: 'abc123',
creator: {
firstName: 'Peach',
isMe: true,
title: 'Princess Peach',
},
ended: false,
deviceCount: 1,
maxDevices: 16,
startedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
// ongoing group call, creator unknown
callMode: CallMode.Group,
conversationId: 'abc123',
ended: false,
deviceCount: 1,
maxDevices: 16,
startedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
// ongoing and active group call
callMode: CallMode.Group,
activeCallConversationId: 'abc123',
conversationId: 'abc123',
creator: {
firstName: 'Luigi',
isMe: false,
title: 'Luigi Mario',
},
ended: false,
deviceCount: 1,
maxDevices: 16,
startedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
// ongoing group call, but you're in another one
callMode: CallMode.Group,
activeCallConversationId: 'abc123',
conversationId: 'xyz987',
creator: {
firstName: 'Luigi',
isMe: false,
title: 'Luigi Mario',
},
ended: false,
deviceCount: 1,
maxDevices: 16,
startedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
// ongoing full group call
callMode: CallMode.Group,
conversationId: 'abc123',
creator: {
firstName: 'Luigi',
isMe: false,
title: 'Luigi Mario',
},
ended: false,
deviceCount: 16,
maxDevices: 16,
startedTime: Date.now(),
},
},
{
type: 'callHistory',
data: {
// finished call
callMode: CallMode.Group,
conversationId: 'abc123',
creator: {
firstName: 'Luigi',
isMe: false,
title: 'Luigi Mario',
},
ended: true,
deviceCount: 0,
maxDevices: 16,
startedTime: Date.now(),
},
},
];

View file

@ -13,8 +13,9 @@ import {
import {
CallingNotification,
PropsData as CallingNotificationProps,
PropsActionsType as CallingNotificationActionsType,
} from './CallingNotification';
import { CallingNotificationType } from '../../util/callingNotification';
import { InlineNotificationWrapper } from './InlineNotificationWrapper';
import {
PropsActions as UnsupportedMessageActionsType,
@ -55,7 +56,7 @@ import {
type CallHistoryType = {
type: 'callHistory';
data: CallingNotificationProps;
data: CallingNotificationType;
};
type LinkNotificationType = {
type: 'linkNotification';
@ -128,6 +129,7 @@ type PropsLocalType = {
};
type PropsActionsType = MessageActionsType &
CallingNotificationActionsType &
UnsupportedMessageActionsType &
SafetyNumberActionsType;
@ -143,8 +145,11 @@ export class TimelineItem extends React.PureComponent<PropsType> {
isSelected,
item,
i18n,
messageSizeChanged,
renderContact,
returnToActiveCall,
selectMessage,
startCallingLobby,
} = this.props;
if (!item) {
@ -164,7 +169,17 @@ export class TimelineItem extends React.PureComponent<PropsType> {
<UnsupportedMessage {...this.props} {...item.data} i18n={i18n} />
);
} else if (item.type === 'callHistory') {
notification = <CallingNotification i18n={i18n} {...item.data} />;
notification = (
<CallingNotification
conversationId={conversationId}
i18n={i18n}
messageId={id}
messageSizeChanged={messageSizeChanged}
returnToActiveCall={returnToActiveCall}
startCallingLobby={startCallingLobby}
{...item.data}
/>
);
} else if (item.type === 'linkNotification') {
notification = (
<div className="module-message-unsynced">