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

@ -1166,6 +1166,10 @@
"message": "Join Call",
"description": "Button label in the call lobby for joining a call"
},
"calling__return": {
"message": "Return to Call",
"description": "Button label in the call lobby for returning to a call"
},
"calling__call-is-full": {
"message": "Call is full",
"description": "Button label in the call lobby when you can't join because the call is full"
@ -3083,6 +3087,42 @@
}
}
},
"calling__call-notification__ended": {
"message": "The group call has ended",
"description": "Notification message when a group call has ended"
},
"calling__call-notification__started-by-someone": {
"message": "A group call was started",
"description": "Notification message when a group call has started, but we don't know who started it"
},
"calling__call-notification__started-by-you": {
"message": "You started a group call",
"description": "Notification message when a group call has started by you"
},
"calling__call-notification__started": {
"message": "$name$ started a group call",
"description": "Notification message when a group call has started",
"placeholders": {
"name": {
"content": "$1",
"example": "Alice"
}
}
},
"calling__call-notification__button__in-another-call-tooltip": {
"message": "You are already in a call",
"description": "Tooltip in disabled notification button when you're on another call"
},
"calling__call-notification__button__call-full-tooltip": {
"message": "Call has reached capacity of $max$ participants",
"description": "Tooltip in disabled notification button when the call is full",
"placeholders": {
"max": {
"content": "$1",
"example": "5"
}
}
},
"calling__pip--on": {
"message": "Minimize call",
"description": "Title for picture-in-picture toggle"

View file

@ -57,9 +57,6 @@ const {
const {
StagedLinkPreview,
} = require('../../ts/components/conversation/StagedLinkPreview');
const {
getCallingNotificationText,
} = require('../../ts/components/conversation/CallingNotification');
// State
const { createTimeline } = require('../../ts/state/roots/createTimeline');
@ -310,7 +307,6 @@ exports.setup = (options = {}) => {
ContactModal,
Emojify,
ErrorModal,
getCallingNotificationText,
Lightbox,
LightboxGallery,
MediaGallery,

View file

@ -393,3 +393,39 @@
@include button-focus-outline;
}
@mixin button-green {
$background-color: $color-accent-green;
background-color: $background-color;
color: $color-white;
&:active {
// We need to include all four here for specificity precedence
@include mouse-mode {
background-color: mix($color-black, $background-color, 25%);
}
@include dark-mouse-mode {
background-color: mix($color-white, $background-color, 25%);
}
@include keyboard-mode {
background-color: mix($color-black, $background-color, 25%);
}
@include dark-keyboard-mode {
background-color: mix($color-white, $background-color, 25%);
}
}
&[disabled] {
opacity: 0.6;
}
@include button-focus-outline;
}
@mixin button-small {
border-radius: 9999px; // This ensures the borders are completely rounded. (A value like 100% would make it an ellipse.)
padding: 7px 14px;
}

View file

@ -2432,6 +2432,16 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
color: $color-gray-05;
}
}
&__button {
@include button-reset;
@include button-small;
@include button-green;
@include font-body-1-bold;
display: block;
margin: 0.5rem auto 0 auto;
}
}
.module-safety-number__bold-name {

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">

4
ts/model-types.d.ts vendored
View file

@ -5,7 +5,7 @@ import * as Backbone from 'backbone';
import { GroupV2ChangeType } from './groups';
import { LocalizerType, BodyRangeType, BodyRangesType } from './types/Util';
import { CallHistoryDetailsType } from './types/Calling';
import { CallHistoryDetailsFromDiskType } from './types/Calling';
import { ColorType } from './types/Colors';
import {
ConversationType,
@ -59,7 +59,7 @@ export type GroupMigrationType = {
export type MessageAttributesType = {
bodyPending: boolean;
bodyRanges: BodyRangesType;
callHistoryDetails: CallHistoryDetailsType;
callHistoryDetails: CallHistoryDetailsFromDiskType;
changedId: string;
dataMessage: ArrayBuffer | null;
decrypted_at: number;

View file

@ -10,7 +10,7 @@ import {
ConversationAttributesType,
VerificationOptions,
} from '../model-types.d';
import { CallHistoryDetailsType } from '../types/Calling';
import { CallMode, CallHistoryDetailsType } from '../types/Calling';
import { CallbackResultType, GroupV2InfoType } from '../textsecure/SendMessage';
import {
ConversationType,
@ -19,6 +19,7 @@ import {
import { ColorType } from '../types/Colors';
import { MessageModel } from './messages';
import { isMuted } from '../util/isMuted';
import { missingCaseError } from '../util/missingCaseError';
import { sniffImageMimeType } from '../util/sniffImageMimeType';
import { MIMEType, IMAGE_WEBP } from '../types/MIME';
import {
@ -128,6 +129,8 @@ export class ConversationModel extends window.Backbone.Model<
intlCollator = new Intl.Collator();
private cachedLatestGroupCallEraId?: string;
// eslint-disable-next-line class-methods-use-this
defaults(): Partial<ConversationAttributesType> {
return {
@ -2047,14 +2050,36 @@ export class ConversationModel extends window.Backbone.Model<
async addCallHistory(
callHistoryDetails: CallHistoryDetailsType
): Promise<void> {
const { acceptedTime, endedTime, wasDeclined } = callHistoryDetails;
let timestamp: number;
let unread: boolean;
let detailsToSave: CallHistoryDetailsType;
switch (callHistoryDetails.callMode) {
case CallMode.Direct:
timestamp = callHistoryDetails.endedTime;
unread =
!callHistoryDetails.wasDeclined && !callHistoryDetails.acceptedTime;
detailsToSave = {
...callHistoryDetails,
callMode: CallMode.Direct,
};
break;
case CallMode.Group:
timestamp = callHistoryDetails.startedTime;
unread = false;
detailsToSave = callHistoryDetails;
break;
default:
throw missingCaseError(callHistoryDetails);
}
const message = ({
conversationId: this.id,
type: 'call-history',
sent_at: endedTime,
received_at: endedTime,
unread: !wasDeclined && !acceptedTime,
callHistoryDetails,
sent_at: timestamp,
received_at: timestamp,
unread,
callHistoryDetails: detailsToSave,
// TODO: DESKTOP-722
} as unknown) as typeof window.Whisper.MessageAttributesType;
@ -2072,6 +2097,27 @@ export class ConversationModel extends window.Backbone.Model<
this.trigger('newmessage', model);
}
async updateCallHistoryForGroupCall(
eraId: string,
creatorUuid: string
): Promise<void> {
const alreadyHasMessage =
(this.cachedLatestGroupCallEraId &&
this.cachedLatestGroupCallEraId === eraId) ||
(await window.Signal.Data.hasGroupCallHistoryMessage(this.id, eraId));
if (!alreadyHasMessage) {
this.addCallHistory({
callMode: CallMode.Group,
creatorUuid,
eraId,
startedTime: Date.now(),
});
}
this.cachedLatestGroupCallEraId = eraId;
}
async addProfileChange(
profileChange: unknown,
conversationId?: string

View file

@ -12,9 +12,13 @@ import {
LastMessageStatus,
ConversationType,
} from '../state/ducks/conversations';
import { getActiveCall } from '../state/ducks/calling';
import { getCallSelector } from '../state/selectors/calling';
import { PropsData } from '../components/conversation/Message';
import { CallbackResultType } from '../textsecure/SendMessage';
import { ExpirationTimerOptions } from '../util/ExpirationTimerOptions';
import { missingCaseError } from '../util/missingCaseError';
import { CallMode } from '../types/Calling';
import { BodyRangesType } from '../types/Util';
import { PropsDataType as GroupsV2Props } from '../components/conversation/GroupV2Change';
import {
@ -29,7 +33,10 @@ import {
ChangeType,
} from '../components/conversation/GroupNotification';
import { Props as ResetSessionNotificationProps } from '../components/conversation/ResetSessionNotification';
import { PropsData as CallingNotificationProps } from '../components/conversation/CallingNotification';
import {
CallingNotificationType,
getCallingNotificationText,
} from '../util/callingNotification';
import { PropsType as ProfileChangeNotificationPropsType } from '../components/conversation/ProfileChangeNotification';
/* eslint-disable camelcase */
@ -704,10 +711,67 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
};
}
getPropsForCallHistory(): CallingNotificationProps {
return {
callHistoryDetails: this.get('callHistoryDetails'),
};
getPropsForCallHistory(): CallingNotificationType | undefined {
const callHistoryDetails = this.get('callHistoryDetails');
if (!callHistoryDetails) {
return undefined;
}
switch (callHistoryDetails.callMode) {
// Old messages weren't saved with a call mode.
case undefined:
case CallMode.Direct:
return {
...callHistoryDetails,
callMode: CallMode.Direct,
};
case CallMode.Group: {
const conversationId = this.get('conversationId');
if (!conversationId) {
window.log.error(
'Message.prototype.getPropsForCallHistory: missing conversation ID; assuming there is no call'
);
return undefined;
}
const creatorConversation = this.findContact(
window.ConversationController.ensureContactIds({
uuid: callHistoryDetails.creatorUuid,
})
);
if (!creatorConversation) {
window.log.error(
'Message.prototype.getPropsForCallHistory: could not find creator by UUID; bailing'
);
return undefined;
}
const reduxState = window.reduxStore.getState();
let call = getCallSelector(reduxState)(conversationId);
if (call && call.callMode !== CallMode.Group) {
window.log.error(
'Message.prototype.getPropsForCallHistory: there is an unexpected non-group call; pretending it does not exist'
);
call = undefined;
}
return {
activeCallConversationId: getActiveCall(reduxState.calling)
?.conversationId,
callMode: CallMode.Group,
conversationId,
creator: creatorConversation.format(),
deviceCount: call?.peekInfo.deviceCount ?? 0,
ended: callHistoryDetails.eraId !== call?.peekInfo.eraId,
maxDevices: call?.peekInfo.maxDevices ?? Infinity,
startedTime: callHistoryDetails.startedTime,
};
}
default:
window.log.error(missingCaseError(callHistoryDetails));
return undefined;
}
}
getPropsForProfileChange(): ProfileChangeNotificationPropsType {
@ -1345,12 +1409,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
if (this.isCallHistory()) {
return {
text: window.Signal.Components.getCallingNotificationText(
this.get('callHistoryDetails'),
window.i18n
),
};
const callingNotification = this.getPropsForCallHistory();
if (callingNotification) {
return {
text: getCallingNotificationText(callingNotification, window.i18n),
};
}
window.log.error(
"This call history message doesn't have valid call history"
);
}
if (this.isExpirationTimerUpdate()) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion

View file

@ -124,11 +124,17 @@ export class CallingClass {
}
async startCallingLobby(
conversation: ConversationModel,
conversationId: string,
isVideoCall: boolean
): Promise<void> {
window.log.info('CallingClass.startCallingLobby()');
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
window.log.error('Could not find conversation, cannot start call lobby');
return;
}
const conversationProps = conversation.format();
const callMode = getConversationCallMode(conversationProps);
switch (callMode) {
@ -450,6 +456,10 @@ export class CallingClass {
this.syncGroupCallToRedux(conversationId, groupCall);
},
onPeekChanged: groupCall => {
this.updateCallHistoryForGroupCall(
conversationId,
groupCall.getPeekInfo()
);
this.syncGroupCallToRedux(conversationId, groupCall);
},
async requestMembershipProof(groupCall) {
@ -1459,6 +1469,7 @@ export class CallingClass {
}
conversation.addCallHistory({
callMode: CallMode.Direct,
wasIncoming: call.isIncoming,
wasVideoCall: call.isVideoCall,
wasDeclined,
@ -1472,6 +1483,7 @@ export class CallingClass {
wasVideoCall: boolean
) {
conversation.addCallHistory({
callMode: CallMode.Direct,
wasIncoming: true,
wasVideoCall,
// Since the user didn't decline, make sure it shows up as a missed call instead
@ -1486,6 +1498,7 @@ export class CallingClass {
_reason: CallEndedReason
) {
conversation.addCallHistory({
callMode: CallMode.Direct,
wasIncoming: true,
// We don't actually know, but it doesn't seem that important in this case,
// but we could maybe plumb this info through RingRTC
@ -1496,6 +1509,31 @@ export class CallingClass {
endedTime: Date.now(),
});
}
public updateCallHistoryForGroupCall(
conversationId: string,
peekInfo: undefined | PeekInfo
): void {
// If we don't have the necessary pieces to peek, bail. (It's okay if we don't.)
if (!peekInfo || !peekInfo.eraId || !peekInfo.creator) {
return;
}
const creatorUuid = arrayBufferToUuid(peekInfo.creator);
if (!creatorUuid) {
window.log.error('updateCallHistoryForGroupCall(): bad creator UUID');
return;
}
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
window.log.error(
'updateCallHistoryForGroupCall(): could not find conversation'
);
return;
}
conversation.updateCallHistoryForGroupCall(peekInfo.eraId, creatorUuid);
}
}
export const calling = new CallingClass();

View file

@ -166,6 +166,7 @@ const dataInterface: ClientInterface = {
getLastConversationActivity,
getLastConversationPreview,
getMessageMetricsForConversation,
hasGroupCallHistoryMessage,
migrateConversationMessages,
getUnprocessedCount,
@ -1056,6 +1057,12 @@ async function getMessageMetricsForConversation(conversationId: string) {
return result;
}
function hasGroupCallHistoryMessage(
conversationId: string,
eraId: string
): Promise<boolean> {
return channels.hasGroupCallHistoryMessage(conversationId, eraId);
}
async function migrateConversationMessages(
obsoleteId: string,
currentId: string

View file

@ -103,6 +103,10 @@ export interface DataInterface {
getMessageMetricsForConversation: (
conversationId: string
) => Promise<ConverationMetricsType>;
hasGroupCallHistoryMessage: (
conversationId: string,
eraId: string
) => Promise<boolean>;
migrateConversationMessages: (
obsoleteId: string,
currentId: string

View file

@ -145,6 +145,7 @@ const dataInterface: ServerInterface = {
getMessageMetricsForConversation,
getLastConversationActivity,
getLastConversationPreview,
hasGroupCallHistoryMessage,
migrateConversationMessages,
getUnprocessedCount,
@ -2880,6 +2881,34 @@ async function getMessageMetricsForConversation(conversationId: string) {
}
getMessageMetricsForConversation.needsSerial = true;
async function hasGroupCallHistoryMessage(
conversationId: string,
eraId: string
): Promise<boolean> {
const db = getInstance();
const row: unknown = await db.get(
`
SELECT count(*) FROM messages
WHERE conversationId = $conversationId
AND type = 'call-history'
AND json_extract(json, '$.callHistoryDetails.callMode') = 'Group'
AND json_extract(json, '$.callHistoryDetails.eraId') = $eraId
LIMIT 1;
`,
{
$conversationId: conversationId,
$eraId: eraId,
}
);
if (typeof row === 'object' && row && !Array.isArray(row)) {
const count = Number((row as Record<string, unknown>)['count(*)']);
return Boolean(count);
}
return false;
}
async function migrateConversationMessages(
obsoleteId: string,
currentId: string

View file

@ -163,6 +163,11 @@ export type SetGroupCallVideoRequestType = {
resolutions: Array<GroupCallVideoRequest>;
};
export type StartCallingLobbyType = {
conversationId: string;
isVideoCall: boolean;
};
export type ShowCallLobbyType =
| {
callMode: CallMode.Direct;
@ -220,6 +225,7 @@ const PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED =
'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED';
const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES';
const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE';
const RETURN_TO_ACTIVE_CALL = 'calling/RETURN_TO_ACTIVE_CALL';
const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED';
const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED';
const START_DIRECT_CALL = 'calling/START_DIRECT_CALL';
@ -281,7 +287,7 @@ type OutgoingCallActionType = {
payload: StartDirectCallType;
};
type PeekNotConnectedGroupCallFulfilledActionType = {
export type PeekNotConnectedGroupCallFulfilledActionType = {
type: 'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED';
payload: {
conversationId: string;
@ -300,6 +306,10 @@ type RemoteVideoChangeActionType = {
payload: RemoteVideoChangeType;
};
type ReturnToActiveCallActionType = {
type: 'calling/RETURN_TO_ACTIVE_CALL';
};
type SetLocalAudioActionType = {
type: 'calling/SET_LOCAL_AUDIO_FULFILLED';
payload: SetLocalAudioType;
@ -347,6 +357,7 @@ export type CallingActionType =
| PeekNotConnectedGroupCallFulfilledActionType
| RefreshIODevicesActionType
| RemoteVideoChangeActionType
| ReturnToActiveCallActionType
| SetLocalAudioActionType
| SetLocalVideoFulfilledActionType
| ShowCallLobbyActionType
@ -577,6 +588,8 @@ function peekNotConnectedGroupCall(
return;
}
calling.updateCallHistoryForGroupCall(conversationId, peekInfo);
dispatch({
type: PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED,
payload: {
@ -607,6 +620,12 @@ function remoteVideoChange(
};
}
function returnToActiveCall(): ReturnToActiveCallActionType {
return {
type: RETURN_TO_ACTIVE_CALL,
};
}
function setLocalPreview(
payload: SetLocalPreviewType
): ThunkAction<void, RootStateType, unknown, never> {
@ -695,6 +714,16 @@ function setGroupCallVideoRequest(
};
}
function startCallingLobby(
payload: StartCallingLobbyType
): ThunkAction<void, RootStateType, unknown, never> {
return () => {
calling.startCallingLobby(payload.conversationId, payload.isVideoCall);
};
}
// TODO: This action should be replaced with an action dispatched in the
// `startCallingLobby` thunk.
function showCallLobby(payload: ShowCallLobbyType): CallLobbyActionType {
return {
type: SHOW_CALL_LOBBY,
@ -765,11 +794,13 @@ export const actions = {
peekNotConnectedGroupCall,
refreshIODevices,
remoteVideoChange,
returnToActiveCall,
setLocalPreview,
setRendererCanvas,
setLocalAudio,
setLocalVideo,
setGroupCallVideoRequest,
startCallingLobby,
showCallLobby,
startCall,
toggleParticipants,
@ -1159,6 +1190,24 @@ export function reducer(
};
}
if (action.type === RETURN_TO_ACTIVE_CALL) {
const { activeCallState } = state;
if (!activeCallState) {
window.log.warn(
'Cannot return to active call if there is no active call'
);
return state;
}
return {
...state,
activeCallState: {
...activeCallState,
pip: false,
},
};
}
if (action.type === SET_LOCAL_AUDIO_FULFILLED) {
if (!state.activeCallState) {
window.log.warn('Cannot set local audio with no active call');

View file

@ -20,7 +20,7 @@ import { NoopActionType } from './noop';
import { AttachmentType } from '../../types/Attachment';
import { ColorType } from '../../types/Colors';
import { BodyRangeType } from '../../types/Util';
import { CallMode } from '../../types/Calling';
import { CallMode, CallHistoryDetailsFromDiskType } from '../../types/Calling';
// State
@ -147,6 +147,7 @@ export type MessageType = {
errors?: Array<Error>;
group_update?: unknown;
callHistoryDetails?: CallHistoryDetailsFromDiskType;
// No need to go beyond this; unused at this stage, since this goes into
// a reducer still in plain JavaScript and comes out well-formed
@ -274,6 +275,13 @@ export type MessageDeletedActionType = {
conversationId: string;
};
};
type MessageSizeChangedActionType = {
type: 'MESSAGE_SIZE_CHANGED';
payload: {
id: string;
conversationId: string;
};
};
export type MessagesAddedActionType = {
type: 'MESSAGES_ADDED';
payload: {
@ -379,6 +387,7 @@ export type ConversationActionType =
| ConversationUnloadedActionType
| RemoveAllConversationsActionType
| MessageSelectedActionType
| MessageSizeChangedActionType
| MessageChangedActionType
| MessageDeletedActionType
| MessagesAddedActionType
@ -410,6 +419,7 @@ export const actions = {
selectMessage,
messageDeleted,
messageChanged,
messageSizeChanged,
messagesAdded,
messagesReset,
setMessagesLoading,
@ -514,6 +524,18 @@ function messageDeleted(
},
};
}
function messageSizeChanged(
id: string,
conversationId: string
): MessageSizeChangedActionType {
return {
type: 'MESSAGE_SIZE_CHANGED',
payload: {
id,
conversationId,
},
};
}
function messagesAdded(
conversationId: string,
messages: Array<MessageType>,
@ -697,7 +719,7 @@ function showArchivedConversations(): ShowArchivedConversationsActionType {
// Reducer
function getEmptyState(): ConversationsStateType {
export function getEmptyState(): ConversationsStateType {
return {
conversationLookup: {},
messagesByConversation: {},
@ -926,6 +948,31 @@ export function reducer(
},
};
}
if (action.type === 'MESSAGE_SIZE_CHANGED') {
const { id, conversationId } = action.payload;
const existingConversation = getOwn(
state.messagesByConversation,
conversationId
);
if (!existingConversation) {
return state;
}
return {
...state,
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: {
...existingConversation,
heightChangeMessageIds: uniq([
...existingConversation.heightChangeMessageIds,
id,
]),
},
},
};
}
if (action.type === 'MESSAGES_RESET') {
const {
conversationId,

View file

@ -10,15 +10,22 @@ import {
DirectCallStateType,
} from '../ducks/calling';
import { CallMode, CallState } from '../../types/Calling';
import { getOwn } from '../../util/getOwn';
const getCalling = (state: StateType): CallingStateType => state.calling;
const getCallsByConversation = createSelector(
export const getCallsByConversation = createSelector(
getCalling,
(state: CallingStateType): CallsByConversationType =>
state.callsByConversation
);
export const getCallSelector = createSelector(
getCallsByConversation,
(callsByConversation: CallsByConversationType) => (conversationId: string) =>
getOwn(callsByConversation, conversationId)
);
// In theory, there could be multiple incoming calls. In practice, neither RingRTC nor the
// UI are ready to handle this.
export const getIncomingCall = createSelector(

View file

@ -15,7 +15,8 @@ import {
MessagesByConversationType,
MessageType,
} from '../ducks/conversations';
import type { CallsByConversationType } from '../ducks/calling';
import { getCallsByConversation } from './calling';
import { getBubbleProps } from '../../shims/Whisper';
import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline';
import { TimelineItemType } from '../../components/conversation/TimelineItem';
@ -254,6 +255,7 @@ export function _messageSelector(
_ourNumber: string,
_regionCode: string,
interactionMode: 'mouse' | 'keyboard',
_callsByConversation: CallsByConversationType,
_conversation?: ConversationType,
_author?: ConversationType,
_quoted?: ConversationType,
@ -292,6 +294,7 @@ type CachedMessageSelectorType = (
ourNumber: string,
regionCode: string,
interactionMode: 'mouse' | 'keyboard',
callsByConversation: CallsByConversationType,
conversation?: ConversationType,
author?: ConversationType,
quoted?: ConversationType,
@ -317,6 +320,7 @@ export const getMessageSelector = createSelector(
getRegionCode,
getUserNumber,
getInteractionMode,
getCallsByConversation,
(
messageSelector: CachedMessageSelectorType,
messageLookup: MessageLookupType,
@ -324,7 +328,8 @@ export const getMessageSelector = createSelector(
conversationSelector: GetConversationByIdType,
regionCode: string,
ourNumber: string,
interactionMode: 'keyboard' | 'mouse'
interactionMode: 'keyboard' | 'mouse',
callsByConversation: CallsByConversationType
): GetMessageByIdType => {
return (id: string) => {
const message = messageLookup[id];
@ -352,6 +357,7 @@ export const getMessageSelector = createSelector(
ourNumber,
regionCode,
interactionMode,
callsByConversation,
conversation,
author,
quoted,

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { set } from 'lodash/fp';
import {
actions,
ConversationMessageType,
@ -13,7 +14,11 @@ import {
} from '../../../state/ducks/conversations';
import { CallMode } from '../../../types/Calling';
const { repairNewestMessage, repairOldestMessage } = actions;
const {
messageSizeChanged,
repairNewestMessage,
repairOldestMessage,
} = actions;
describe('both/state/ducks/conversations', () => {
describe('helpers', () => {
@ -167,6 +172,76 @@ describe('both/state/ducks/conversations', () => {
};
}
describe('MESSAGE_SIZE_CHANGED', () => {
const stateWithActiveConversation = {
...getDefaultState(),
messagesByConversation: {
[conversationId]: {
heightChangeMessageIds: [],
isLoadingMessages: false,
isNearBottom: true,
messageIds: [messageId],
metrics: { totalUnread: 0 },
resetCounter: 0,
scrollToMessageCounter: 0,
},
},
messagesLookup: {
[messageId]: getDefaultMessage(messageId),
},
};
it('does nothing if no conversation is active', () => {
const state = getDefaultState();
assert.strictEqual(
reducer(state, messageSizeChanged('messageId', 'convoId')),
state
);
});
it('does nothing if a different conversation is active', () => {
assert.deepEqual(
reducer(
stateWithActiveConversation,
messageSizeChanged(messageId, 'another-conversation-guid')
),
stateWithActiveConversation
);
});
it('adds the message ID to the list of messages with changed heights', () => {
const result = reducer(
stateWithActiveConversation,
messageSizeChanged(messageId, conversationId)
);
assert.sameMembers(
result.messagesByConversation[conversationId]
?.heightChangeMessageIds || [],
[messageId]
);
});
it("doesn't add duplicates to the list of changed-heights messages", () => {
const state = set(
['messagesByConversation', conversationId, 'heightChangeMessageIds'],
[messageId],
stateWithActiveConversation
);
const result = reducer(
state,
messageSizeChanged(messageId, conversationId)
);
assert.sameMembers(
result.messagesByConversation[conversationId]
?.heightChangeMessageIds || [],
[messageId]
);
});
});
describe('REPAIR_NEWEST_MESSAGE', () => {
it('updates newest', () => {
const action = repairNewestMessage(conversationId);

View file

@ -0,0 +1,115 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { getCallingNotificationText } from '../../util/callingNotification';
import { CallMode } from '../../types/Calling';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
describe('calling notification helpers', () => {
const i18n = setupI18n('en', enMessages);
describe('getCallingNotificationText', () => {
// Direct call behavior is not tested here.
it('says that the call has ended', () => {
assert.strictEqual(
getCallingNotificationText(
{
callMode: CallMode.Group,
conversationId: 'abc123',
ended: true,
deviceCount: 1,
maxDevices: 23,
startedTime: Date.now(),
},
i18n
),
'The group call has ended'
);
});
it("includes the creator's first name when describing a call", () => {
assert.strictEqual(
getCallingNotificationText(
{
callMode: CallMode.Group,
conversationId: 'abc123',
creator: {
firstName: 'Luigi',
isMe: false,
title: 'Luigi Mario',
},
ended: false,
deviceCount: 1,
maxDevices: 23,
startedTime: Date.now(),
},
i18n
),
'Luigi started a group call'
);
});
it("if the creator doesn't have a first name, falls back to their title", () => {
assert.strictEqual(
getCallingNotificationText(
{
callMode: CallMode.Group,
conversationId: 'abc123',
creator: {
isMe: false,
title: 'Luigi Mario',
},
ended: false,
deviceCount: 1,
maxDevices: 23,
startedTime: Date.now(),
},
i18n
),
'Luigi Mario started a group call'
);
});
it('has a special message if you were the one to start the call', () => {
assert.strictEqual(
getCallingNotificationText(
{
callMode: CallMode.Group,
conversationId: 'abc123',
creator: {
firstName: 'ShouldBeIgnored',
isMe: true,
title: 'ShouldBeIgnored Smith',
},
ended: false,
deviceCount: 1,
maxDevices: 23,
startedTime: Date.now(),
},
i18n
),
'You started a group call'
);
});
it('handles an unknown creator', () => {
assert.strictEqual(
getCallingNotificationText(
{
callMode: CallMode.Group,
conversationId: 'abc123',
ended: false,
deviceCount: 1,
maxDevices: 23,
startedTime: Date.now(),
},
i18n
),
'A group call was started'
);
});
});
});

View file

@ -27,7 +27,7 @@ describe('calling duck', () => {
...getEmptyState(),
callsByConversation: {
'fake-direct-call-conversation-id': {
callMode: CallMode.Direct,
callMode: CallMode.Direct as CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Accepted,
isIncoming: false,
@ -37,7 +37,7 @@ describe('calling duck', () => {
},
};
const stateWithActiveDirectCall: CallingStateType = {
const stateWithActiveDirectCall = {
...stateWithDirectCall,
activeCallState: {
conversationId: 'fake-direct-call-conversation-id',
@ -49,11 +49,11 @@ describe('calling duck', () => {
},
};
const stateWithIncomingDirectCall: CallingStateType = {
const stateWithIncomingDirectCall = {
...getEmptyState(),
callsByConversation: {
'fake-direct-call-conversation-id': {
callMode: CallMode.Direct,
callMode: CallMode.Direct as CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Ringing,
isIncoming: true,
@ -63,11 +63,11 @@ describe('calling duck', () => {
},
};
const stateWithGroupCall: CallingStateType = {
const stateWithGroupCall = {
...getEmptyState(),
callsByConversation: {
'fake-group-call-conversation-id': {
callMode: CallMode.Group,
callMode: CallMode.Group as CallMode.Group,
conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined,
@ -91,7 +91,7 @@ describe('calling duck', () => {
},
};
const stateWithActiveGroupCall: CallingStateType = {
const stateWithActiveGroupCall = {
...stateWithGroupCall,
activeCallState: {
conversationId: 'fake-group-call-conversation-id',
@ -624,6 +624,10 @@ describe('calling duck', () => {
callingService,
'peekGroupCall'
);
this.callingServiceUpdateCallHistoryForGroupCall = this.sandbox.stub(
callingService,
'updateCallHistoryForGroupCall'
);
this.clock = this.sandbox.useFakeTimers();
});
@ -677,6 +681,29 @@ describe('calling duck', () => {
});
});
describe('returnToActiveCall', () => {
const { returnToActiveCall } = actions;
it('does nothing if not in PiP mode', () => {
const result = reducer(stateWithActiveDirectCall, returnToActiveCall());
assert.deepEqual(result, stateWithActiveDirectCall);
});
it('closes the PiP', () => {
const state = {
...stateWithActiveDirectCall,
activeCallState: {
...stateWithActiveDirectCall.activeCallState,
pip: true,
},
};
const result = reducer(state, returnToActiveCall());
assert.deepEqual(result, stateWithActiveDirectCall);
});
});
describe('setLocalAudio', () => {
const { setLocalAudio } = actions;

View file

@ -5,7 +5,11 @@ import { assert } from 'chai';
import { reducer as rootReducer } from '../../../state/reducer';
import { noopAction } from '../../../state/ducks/noop';
import { CallMode, CallState } from '../../../types/Calling';
import { getIncomingCall } from '../../../state/selectors/calling';
import {
getCallsByConversation,
getCallSelector,
getIncomingCall,
} from '../../../state/selectors/calling';
import { getEmptyState, CallingStateType } from '../../../state/ducks/calling';
describe('state/selectors/calling', () => {
@ -56,6 +60,50 @@ describe('state/selectors/calling', () => {
},
};
describe('getCallsByConversation', () => {
it('returns state.calling.callsByConversation', () => {
assert.deepEqual(getCallsByConversation(getEmptyRootState()), {});
assert.deepEqual(
getCallsByConversation(getCallingState(stateWithDirectCall)),
{
'fake-direct-call-conversation-id': {
callMode: CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Accepted,
isIncoming: false,
isVideoCall: false,
hasRemoteVideo: false,
},
}
);
});
});
describe('getCallSelector', () => {
it('returns a selector that returns undefined if selecting a conversation with no call', () => {
assert.isUndefined(
getCallSelector(getEmptyRootState())('conversation-id')
);
});
it("returns a selector that returns a conversation's call", () => {
assert.deepEqual(
getCallSelector(getCallingState(stateWithDirectCall))(
'fake-direct-call-conversation-id'
),
{
callMode: CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Accepted,
isIncoming: false,
isVideoCall: false,
hasRemoteVideo: false,
}
);
});
});
describe('getIncomingCall', () => {
it('returns undefined if there are no calls', () => {
assert.isUndefined(getIncomingCall(getEmptyRootState()));

View file

@ -4,6 +4,7 @@
import { ColorType } from './Colors';
import { ConversationType } from '../state/ducks/conversations';
// These are strings (1) for the database (2) for Storybook.
export enum CallMode {
None = 'None',
Direct = 'Direct',
@ -153,13 +154,31 @@ export type MediaDeviceSettings = {
selectedCamera: string | undefined;
};
export type CallHistoryDetailsType = {
interface DirectCallHistoryDetailsType {
callMode: CallMode.Direct;
wasIncoming: boolean;
wasVideoCall: boolean;
wasDeclined: boolean;
acceptedTime?: number;
endedTime: number;
};
}
interface GroupCallHistoryDetailsType {
callMode: CallMode.Group;
creatorUuid: string;
eraId: string;
startedTime: number;
}
export type CallHistoryDetailsType =
| DirectCallHistoryDetailsType
| GroupCallHistoryDetailsType;
// Old messages weren't saved with a `callMode`.
export type CallHistoryDetailsFromDiskType =
| (Omit<DirectCallHistoryDetailsType, 'callMode'> &
Partial<Pick<DirectCallHistoryDetailsType, 'callMode'>>)
| GroupCallHistoryDetailsType;
export type ChangeIODevicePayloadType =
| { type: CallingDeviceType.CAMERA; selectedDevice: string }

View file

@ -0,0 +1,108 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { LocalizerType } from '../types/Util';
import { CallMode } from '../types/Calling';
import { missingCaseError } from './missingCaseError';
interface DirectCallNotificationType {
callMode: CallMode.Direct;
wasIncoming: boolean;
wasVideoCall: boolean;
wasDeclined: boolean;
acceptedTime?: number;
endedTime: number;
}
interface GroupCallNotificationType {
activeCallConversationId?: string;
callMode: CallMode.Group;
conversationId: string;
creator?: {
firstName?: string;
isMe?: boolean;
title: string;
};
ended: boolean;
deviceCount: number;
maxDevices: number;
startedTime: number;
}
export type CallingNotificationType =
| DirectCallNotificationType
| GroupCallNotificationType;
function getDirectCallNotificationText(
{
wasIncoming,
wasVideoCall,
wasDeclined,
acceptedTime,
}: DirectCallNotificationType,
i18n: LocalizerType
): string {
const wasAccepted = Boolean(acceptedTime);
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');
}
function getGroupCallNotificationText(
notification: GroupCallNotificationType,
i18n: LocalizerType
): string {
if (notification.ended) {
return i18n('calling__call-notification__ended');
}
if (!notification.creator) {
return i18n('calling__call-notification__started-by-someone');
}
if (notification.creator.isMe) {
return i18n('calling__call-notification__started-by-you');
}
return i18n('calling__call-notification__started', [
notification.creator.firstName || notification.creator.title,
]);
}
export function getCallingNotificationText(
notification: CallingNotificationType,
i18n: LocalizerType
): string {
switch (notification.callMode) {
case CallMode.Direct:
return getDirectCallNotificationText(notification, i18n);
case CallMode.Group:
return getGroupCallNotificationText(notification, i18n);
default:
window.log.error(missingCaseError(notification));
return '';
}
}

View file

@ -14699,6 +14699,24 @@
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Only used to focus the element."
},
{
"rule": "React-useRef",
"path": "ts/components/Tooltip.js",
"line": " const wrapperRef = react_1.default.useRef(null);",
"lineNumber": 17,
"reasonCategory": "usageTrusted",
"updated": "2020-12-04T00:11:08.128Z",
"reasonDetail": "Used to add (and remove) event listeners."
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/CallingNotification.js",
"line": " const previousHeightRef = react_1.useRef(null);",
"lineNumber": 24,
"reasonCategory": "usageTrusted",
"updated": "2020-12-04T00:11:08.128Z",
"reasonDetail": "Doesn't interact with the DOM."
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/ContactModal.js",
@ -15167,4 +15185,4 @@
"reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z"
}
]
]

View file

@ -478,7 +478,6 @@ Whisper.ConversationView = Whisper.View.extend({
'onOutgoingAudioCallInConversation: about to start an audio call'
);
const conversation = this.model;
const isVideoCall = false;
if (await this.isCallSafe()) {
@ -486,7 +485,7 @@ Whisper.ConversationView = Whisper.View.extend({
'onOutgoingAudioCallInConversation: call is deemed "safe". Making call'
);
await window.Signal.Services.calling.startCallingLobby(
conversation,
this.model.id,
isVideoCall
);
window.log.info(
@ -503,7 +502,6 @@ Whisper.ConversationView = Whisper.View.extend({
window.log.info(
'onOutgoingVideoCallInConversation: about to start a video call'
);
const conversation = this.model;
const isVideoCall = true;
if (await this.isCallSafe()) {
@ -511,7 +509,7 @@ Whisper.ConversationView = Whisper.View.extend({
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
);
await window.Signal.Services.calling.startCallingLobby(
conversation,
this.model.id,
isVideoCall
);
window.log.info(

6
ts/window.d.ts vendored
View file

@ -27,7 +27,6 @@ import * as Crypto from './Crypto';
import * as RemoteConfig from './RemoteConfig';
import * as zkgroup from './util/zkgroup';
import { LocalizerType, BodyRangesType, BodyRangeType } from './types/Util';
import { CallHistoryDetailsType } from './types/Calling';
import { ColorType } from './types/Colors';
import { ConversationController } from './ConversationController';
import { ReduxActions } from './state/types';
@ -409,11 +408,6 @@ declare global {
ProgressModal: typeof ProgressModal;
Quote: any;
StagedLinkPreview: any;
getCallingNotificationText: (
callHistoryDetails: unknown,
i18n: unknown
) => string;
};
OS: {
isLinux: () => boolean;