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", "message": "Join Call",
"description": "Button label in the call lobby for joining a 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": { "calling__call-is-full": {
"message": "Call is full", "message": "Call is full",
"description": "Button label in the call lobby when you can't join because the 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": { "calling__pip--on": {
"message": "Minimize call", "message": "Minimize call",
"description": "Title for picture-in-picture toggle" "description": "Title for picture-in-picture toggle"

View file

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

View file

@ -393,3 +393,39 @@
@include button-focus-outline; @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; 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 { .module-safety-number__bold-name {

View file

@ -2,9 +2,76 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React from 'react';
import { noop } from 'lodash';
import { Manager, Reference, Popper } from 'react-popper'; import { Manager, Reference, Popper } from 'react-popper';
import { Theme, themeClassName } from '../util/theme'; 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 { export enum TooltipPlacement {
Top = 'top', Top = 'top',
Right = 'right', Right = 'right',
@ -26,8 +93,9 @@ export const Tooltip: React.FC<PropsType> = ({
sticky, sticky,
theme, theme,
}) => { }) => {
const isSticky = Boolean(sticky); const [isHovering, setIsHovering] = React.useState(false);
const [showTooltip, setShowTooltip] = React.useState(isSticky);
const showTooltip = isHovering || Boolean(sticky);
const tooltipTheme = theme ? themeClassName(theme) : undefined; const tooltipTheme = theme ? themeClassName(theme) : undefined;
@ -35,31 +103,9 @@ export const Tooltip: React.FC<PropsType> = ({
<Manager> <Manager>
<Reference> <Reference>
{({ ref }) => ( {({ ref }) => (
<span <TooltipEventWrapper ref={ref} onHoverChanged={setIsHovering}>
onBlur={() => {
if (!isSticky) {
setShowTooltip(false);
}
}}
onFocus={() => {
if (!isSticky) {
setShowTooltip(true);
}
}}
onMouseEnter={() => {
if (!isSticky) {
setShowTooltip(true);
}
}}
onMouseLeave={() => {
if (!isSticky) {
setShowTooltip(false);
}
}}
ref={ref}
>
{children} {children}
</span> </TooltipEventWrapper>
)} )}
</Reference> </Reference>
<Popper placement={direction}> <Popper placement={direction}>

View file

@ -1,90 +1,168 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { Timestamp } from './Timestamp';
import { LocalizerType } from '../../types/Util'; 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 = { export interface PropsActionsType {
// Can be undefined because it comes from JS. messageSizeChanged: (messageId: string, conversationId: string) => void;
callHistoryDetails?: CallHistoryDetailsType; returnToActiveCall: () => void;
}; startCallingLobby: (_: {
conversationId: string;
isVideoCall: boolean;
}) => void;
}
type PropsHousekeeping = { type PropsHousekeeping = {
i18n: LocalizerType; i18n: LocalizerType;
conversationId: string;
messageId: string;
}; };
type Props = PropsData & PropsHousekeeping; type PropsType = CallingNotificationType & PropsActionsType & PropsHousekeeping;
export function getCallingNotificationText( export const CallingNotification: React.FC<PropsType> = React.memo(props => {
callHistoryDetails: CallHistoryDetailsType, const { conversationId, i18n, messageId, messageSizeChanged } = props;
i18n: LocalizerType
): string {
const {
wasIncoming,
wasVideoCall,
wasDeclined,
acceptedTime,
} = callHistoryDetails;
const wasAccepted = Boolean(acceptedTime);
if (wasIncoming) { const previousHeightRef = useRef<null | number>(null);
if (wasDeclined) { const [height, setHeight] = useState<null | number>(null);
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');
}
export const CallingNotification = (props: Props): JSX.Element | null => { useEffect(() => {
const { callHistoryDetails, i18n } = props; if (height === null) {
if (!callHistoryDetails) { 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; return null;
} }
const { acceptedTime, endedTime, wasVideoCall } = callHistoryDetails;
const callType = wasVideoCall ? 'video' : 'audio'; const {
return ( activeCallConversationId,
<div conversationId,
className={`module-message-calling--notification module-message-calling--${callType}`} 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`} /> {buttonText}
{getCallingNotificationText(callHistoryDetails, i18n)} </button>
<div>
<Timestamp
i18n={i18n}
timestamp={acceptedTime || endedTime}
extended
direction="outgoing"
withImageNoCaption={false}
withSticker={false}
withTapToViewExpired={false}
module="module-message__metadata__date"
/>
</div>
</div>
); );
};
if (disabledTooltipText) {
return (
<Tooltip content={disabledTooltipText} direction={TooltipPlacement.Top}>
{button}
</Tooltip>
);
}
return button;
}

View file

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

View file

@ -10,6 +10,7 @@ import { EmojiPicker } from '../emoji/EmojiPicker';
import { setup as setupI18n } from '../../../js/modules/i18n'; import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import { PropsType as TimelineItemProps, TimelineItem } from './TimelineItem'; import { PropsType as TimelineItemProps, TimelineItem } from './TimelineItem';
import { CallMode } from '../../types/Calling';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -61,6 +62,9 @@ const getDefaultProps = () => ({
scrollToQuotedMessage: action('scrollToQuotedMessage'), scrollToQuotedMessage: action('scrollToQuotedMessage'),
downloadNewVersion: action('downloadNewVersion'), downloadNewVersion: action('downloadNewVersion'),
showIdentity: action('showIdentity'), showIdentity: action('showIdentity'),
messageSizeChanged: action('messageSizeChanged'),
startCallingLobby: action('startCallingLobby'),
returnToActiveCall: action('returnToActiveCall'),
renderContact, renderContact,
renderEmojiPicker, renderEmojiPicker,
@ -95,149 +99,253 @@ storiesOf('Components/Conversation/TimelineItem', module)
{ {
type: 'callHistory', type: 'callHistory',
data: { data: {
callHistoryDetails: { // declined incoming audio
// declined incoming audio callMode: CallMode.Direct,
wasDeclined: true, wasDeclined: true,
wasIncoming: true, wasIncoming: true,
wasVideoCall: false, wasVideoCall: false,
endedTime: Date.now(), endedTime: Date.now(),
},
}, },
}, },
{ {
type: 'callHistory', type: 'callHistory',
data: { data: {
callHistoryDetails: { // declined incoming video
// declined incoming video callMode: CallMode.Direct,
wasDeclined: true, wasDeclined: true,
wasIncoming: true, wasIncoming: true,
wasVideoCall: true, wasVideoCall: true,
endedTime: Date.now(), endedTime: Date.now(),
},
}, },
}, },
{ {
type: 'callHistory', type: 'callHistory',
data: { data: {
callHistoryDetails: { // accepted incoming audio
// accepted incoming audio callMode: CallMode.Direct,
acceptedTime: Date.now() - 300, acceptedTime: Date.now() - 300,
wasDeclined: false, wasDeclined: false,
wasIncoming: true, wasIncoming: true,
wasVideoCall: false, wasVideoCall: false,
endedTime: Date.now(), endedTime: Date.now(),
},
}, },
}, },
{ {
type: 'callHistory', type: 'callHistory',
data: { data: {
callHistoryDetails: { // accepted incoming video
// accepted incoming video callMode: CallMode.Direct,
acceptedTime: Date.now() - 400, acceptedTime: Date.now() - 400,
wasDeclined: false, wasDeclined: false,
wasIncoming: true, wasIncoming: true,
wasVideoCall: true, wasVideoCall: true,
endedTime: Date.now(), endedTime: Date.now(),
},
}, },
}, },
{ {
type: 'callHistory', type: 'callHistory',
data: { data: {
callHistoryDetails: { // missed (neither accepted nor declined) incoming audio
// missed (neither accepted nor declined) incoming audio callMode: CallMode.Direct,
wasDeclined: false, wasDeclined: false,
wasIncoming: true, wasIncoming: true,
wasVideoCall: false, wasVideoCall: false,
endedTime: Date.now(), endedTime: Date.now(),
},
}, },
}, },
{ {
type: 'callHistory', type: 'callHistory',
data: { data: {
callHistoryDetails: { // missed (neither accepted nor declined) incoming video
// missed (neither accepted nor declined) incoming video callMode: CallMode.Direct,
wasDeclined: false, wasDeclined: false,
wasIncoming: true, wasIncoming: true,
wasVideoCall: true, wasVideoCall: true,
endedTime: Date.now(), endedTime: Date.now(),
},
}, },
}, },
{ {
type: 'callHistory', type: 'callHistory',
data: { data: {
callHistoryDetails: { // accepted outgoing audio
// accepted outgoing audio callMode: CallMode.Direct,
acceptedTime: Date.now() - 200, acceptedTime: Date.now() - 200,
wasDeclined: false, wasDeclined: false,
wasIncoming: false, wasIncoming: false,
wasVideoCall: false, wasVideoCall: false,
endedTime: Date.now(), endedTime: Date.now(),
},
}, },
}, },
{ {
type: 'callHistory', type: 'callHistory',
data: { data: {
callHistoryDetails: { // accepted outgoing video
// accepted outgoing video callMode: CallMode.Direct,
acceptedTime: Date.now() - 200, acceptedTime: Date.now() - 200,
wasDeclined: false, wasDeclined: false,
wasIncoming: false, wasIncoming: false,
wasVideoCall: true, wasVideoCall: true,
endedTime: Date.now(), endedTime: Date.now(),
},
}, },
}, },
{ {
type: 'callHistory', type: 'callHistory',
data: { data: {
callHistoryDetails: { // declined outgoing audio
// declined outgoing audio callMode: CallMode.Direct,
wasDeclined: true, wasDeclined: true,
wasIncoming: false, wasIncoming: false,
wasVideoCall: false, wasVideoCall: false,
endedTime: Date.now(), endedTime: Date.now(),
},
}, },
}, },
{ {
type: 'callHistory', type: 'callHistory',
data: { data: {
callHistoryDetails: { // declined outgoing video
// declined outgoing video callMode: CallMode.Direct,
wasDeclined: true, wasDeclined: true,
wasIncoming: false, wasIncoming: false,
wasVideoCall: true, wasVideoCall: true,
endedTime: Date.now(), endedTime: Date.now(),
},
}, },
}, },
{ {
type: 'callHistory', type: 'callHistory',
data: { data: {
callHistoryDetails: { // missed (neither accepted nor declined) outgoing audio
// missed (neither accepted nor declined) outgoing audio callMode: CallMode.Direct,
wasDeclined: false, wasDeclined: false,
wasIncoming: false, wasIncoming: false,
wasVideoCall: false, wasVideoCall: false,
endedTime: Date.now(), endedTime: Date.now(),
},
}, },
}, },
{ {
type: 'callHistory', type: 'callHistory',
data: { data: {
callHistoryDetails: { // missed (neither accepted nor declined) outgoing video
// missed (neither accepted nor declined) outgoing video callMode: CallMode.Direct,
wasDeclined: false, wasDeclined: false,
wasIncoming: false, wasIncoming: false,
wasVideoCall: true, wasVideoCall: true,
endedTime: Date.now(), 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 { import {
CallingNotification, CallingNotification,
PropsData as CallingNotificationProps, PropsActionsType as CallingNotificationActionsType,
} from './CallingNotification'; } from './CallingNotification';
import { CallingNotificationType } from '../../util/callingNotification';
import { InlineNotificationWrapper } from './InlineNotificationWrapper'; import { InlineNotificationWrapper } from './InlineNotificationWrapper';
import { import {
PropsActions as UnsupportedMessageActionsType, PropsActions as UnsupportedMessageActionsType,
@ -55,7 +56,7 @@ import {
type CallHistoryType = { type CallHistoryType = {
type: 'callHistory'; type: 'callHistory';
data: CallingNotificationProps; data: CallingNotificationType;
}; };
type LinkNotificationType = { type LinkNotificationType = {
type: 'linkNotification'; type: 'linkNotification';
@ -128,6 +129,7 @@ type PropsLocalType = {
}; };
type PropsActionsType = MessageActionsType & type PropsActionsType = MessageActionsType &
CallingNotificationActionsType &
UnsupportedMessageActionsType & UnsupportedMessageActionsType &
SafetyNumberActionsType; SafetyNumberActionsType;
@ -143,8 +145,11 @@ export class TimelineItem extends React.PureComponent<PropsType> {
isSelected, isSelected,
item, item,
i18n, i18n,
messageSizeChanged,
renderContact, renderContact,
returnToActiveCall,
selectMessage, selectMessage,
startCallingLobby,
} = this.props; } = this.props;
if (!item) { if (!item) {
@ -164,7 +169,17 @@ export class TimelineItem extends React.PureComponent<PropsType> {
<UnsupportedMessage {...this.props} {...item.data} i18n={i18n} /> <UnsupportedMessage {...this.props} {...item.data} i18n={i18n} />
); );
} else if (item.type === 'callHistory') { } 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') { } else if (item.type === 'linkNotification') {
notification = ( notification = (
<div className="module-message-unsynced"> <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 { GroupV2ChangeType } from './groups';
import { LocalizerType, BodyRangeType, BodyRangesType } from './types/Util'; import { LocalizerType, BodyRangeType, BodyRangesType } from './types/Util';
import { CallHistoryDetailsType } from './types/Calling'; import { CallHistoryDetailsFromDiskType } from './types/Calling';
import { ColorType } from './types/Colors'; import { ColorType } from './types/Colors';
import { import {
ConversationType, ConversationType,
@ -59,7 +59,7 @@ export type GroupMigrationType = {
export type MessageAttributesType = { export type MessageAttributesType = {
bodyPending: boolean; bodyPending: boolean;
bodyRanges: BodyRangesType; bodyRanges: BodyRangesType;
callHistoryDetails: CallHistoryDetailsType; callHistoryDetails: CallHistoryDetailsFromDiskType;
changedId: string; changedId: string;
dataMessage: ArrayBuffer | null; dataMessage: ArrayBuffer | null;
decrypted_at: number; decrypted_at: number;

View file

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

View file

@ -12,9 +12,13 @@ import {
LastMessageStatus, LastMessageStatus,
ConversationType, ConversationType,
} from '../state/ducks/conversations'; } from '../state/ducks/conversations';
import { getActiveCall } from '../state/ducks/calling';
import { getCallSelector } from '../state/selectors/calling';
import { PropsData } from '../components/conversation/Message'; import { PropsData } from '../components/conversation/Message';
import { CallbackResultType } from '../textsecure/SendMessage'; import { CallbackResultType } from '../textsecure/SendMessage';
import { ExpirationTimerOptions } from '../util/ExpirationTimerOptions'; import { ExpirationTimerOptions } from '../util/ExpirationTimerOptions';
import { missingCaseError } from '../util/missingCaseError';
import { CallMode } from '../types/Calling';
import { BodyRangesType } from '../types/Util'; import { BodyRangesType } from '../types/Util';
import { PropsDataType as GroupsV2Props } from '../components/conversation/GroupV2Change'; import { PropsDataType as GroupsV2Props } from '../components/conversation/GroupV2Change';
import { import {
@ -29,7 +33,10 @@ import {
ChangeType, ChangeType,
} from '../components/conversation/GroupNotification'; } from '../components/conversation/GroupNotification';
import { Props as ResetSessionNotificationProps } from '../components/conversation/ResetSessionNotification'; 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'; import { PropsType as ProfileChangeNotificationPropsType } from '../components/conversation/ProfileChangeNotification';
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -704,10 +711,67 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}; };
} }
getPropsForCallHistory(): CallingNotificationProps { getPropsForCallHistory(): CallingNotificationType | undefined {
return { const callHistoryDetails = this.get('callHistoryDetails');
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 { getPropsForProfileChange(): ProfileChangeNotificationPropsType {
@ -1345,12 +1409,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
if (this.isCallHistory()) { if (this.isCallHistory()) {
return { const callingNotification = this.getPropsForCallHistory();
text: window.Signal.Components.getCallingNotificationText( if (callingNotification) {
this.get('callHistoryDetails'), return {
window.i18n text: getCallingNotificationText(callingNotification, window.i18n),
), };
}; }
window.log.error(
"This call history message doesn't have valid call history"
);
} }
if (this.isExpirationTimerUpdate()) { if (this.isExpirationTimerUpdate()) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion

View file

@ -124,11 +124,17 @@ export class CallingClass {
} }
async startCallingLobby( async startCallingLobby(
conversation: ConversationModel, conversationId: string,
isVideoCall: boolean isVideoCall: boolean
): Promise<void> { ): Promise<void> {
window.log.info('CallingClass.startCallingLobby()'); 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 conversationProps = conversation.format();
const callMode = getConversationCallMode(conversationProps); const callMode = getConversationCallMode(conversationProps);
switch (callMode) { switch (callMode) {
@ -450,6 +456,10 @@ export class CallingClass {
this.syncGroupCallToRedux(conversationId, groupCall); this.syncGroupCallToRedux(conversationId, groupCall);
}, },
onPeekChanged: groupCall => { onPeekChanged: groupCall => {
this.updateCallHistoryForGroupCall(
conversationId,
groupCall.getPeekInfo()
);
this.syncGroupCallToRedux(conversationId, groupCall); this.syncGroupCallToRedux(conversationId, groupCall);
}, },
async requestMembershipProof(groupCall) { async requestMembershipProof(groupCall) {
@ -1459,6 +1469,7 @@ export class CallingClass {
} }
conversation.addCallHistory({ conversation.addCallHistory({
callMode: CallMode.Direct,
wasIncoming: call.isIncoming, wasIncoming: call.isIncoming,
wasVideoCall: call.isVideoCall, wasVideoCall: call.isVideoCall,
wasDeclined, wasDeclined,
@ -1472,6 +1483,7 @@ export class CallingClass {
wasVideoCall: boolean wasVideoCall: boolean
) { ) {
conversation.addCallHistory({ conversation.addCallHistory({
callMode: CallMode.Direct,
wasIncoming: true, wasIncoming: true,
wasVideoCall, wasVideoCall,
// Since the user didn't decline, make sure it shows up as a missed call instead // 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 _reason: CallEndedReason
) { ) {
conversation.addCallHistory({ conversation.addCallHistory({
callMode: CallMode.Direct,
wasIncoming: true, wasIncoming: true,
// We don't actually know, but it doesn't seem that important in this case, // We don't actually know, but it doesn't seem that important in this case,
// but we could maybe plumb this info through RingRTC // but we could maybe plumb this info through RingRTC
@ -1496,6 +1509,31 @@ export class CallingClass {
endedTime: Date.now(), 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(); export const calling = new CallingClass();

View file

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

View file

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

View file

@ -145,6 +145,7 @@ const dataInterface: ServerInterface = {
getMessageMetricsForConversation, getMessageMetricsForConversation,
getLastConversationActivity, getLastConversationActivity,
getLastConversationPreview, getLastConversationPreview,
hasGroupCallHistoryMessage,
migrateConversationMessages, migrateConversationMessages,
getUnprocessedCount, getUnprocessedCount,
@ -2880,6 +2881,34 @@ async function getMessageMetricsForConversation(conversationId: string) {
} }
getMessageMetricsForConversation.needsSerial = true; 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( async function migrateConversationMessages(
obsoleteId: string, obsoleteId: string,
currentId: string currentId: string

View file

@ -163,6 +163,11 @@ export type SetGroupCallVideoRequestType = {
resolutions: Array<GroupCallVideoRequest>; resolutions: Array<GroupCallVideoRequest>;
}; };
export type StartCallingLobbyType = {
conversationId: string;
isVideoCall: boolean;
};
export type ShowCallLobbyType = export type ShowCallLobbyType =
| { | {
callMode: CallMode.Direct; callMode: CallMode.Direct;
@ -220,6 +225,7 @@ const PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED =
'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED'; 'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED';
const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES'; const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES';
const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE'; 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_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED';
const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED'; const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED';
const START_DIRECT_CALL = 'calling/START_DIRECT_CALL'; const START_DIRECT_CALL = 'calling/START_DIRECT_CALL';
@ -281,7 +287,7 @@ type OutgoingCallActionType = {
payload: StartDirectCallType; payload: StartDirectCallType;
}; };
type PeekNotConnectedGroupCallFulfilledActionType = { export type PeekNotConnectedGroupCallFulfilledActionType = {
type: 'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED'; type: 'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED';
payload: { payload: {
conversationId: string; conversationId: string;
@ -300,6 +306,10 @@ type RemoteVideoChangeActionType = {
payload: RemoteVideoChangeType; payload: RemoteVideoChangeType;
}; };
type ReturnToActiveCallActionType = {
type: 'calling/RETURN_TO_ACTIVE_CALL';
};
type SetLocalAudioActionType = { type SetLocalAudioActionType = {
type: 'calling/SET_LOCAL_AUDIO_FULFILLED'; type: 'calling/SET_LOCAL_AUDIO_FULFILLED';
payload: SetLocalAudioType; payload: SetLocalAudioType;
@ -347,6 +357,7 @@ export type CallingActionType =
| PeekNotConnectedGroupCallFulfilledActionType | PeekNotConnectedGroupCallFulfilledActionType
| RefreshIODevicesActionType | RefreshIODevicesActionType
| RemoteVideoChangeActionType | RemoteVideoChangeActionType
| ReturnToActiveCallActionType
| SetLocalAudioActionType | SetLocalAudioActionType
| SetLocalVideoFulfilledActionType | SetLocalVideoFulfilledActionType
| ShowCallLobbyActionType | ShowCallLobbyActionType
@ -577,6 +588,8 @@ function peekNotConnectedGroupCall(
return; return;
} }
calling.updateCallHistoryForGroupCall(conversationId, peekInfo);
dispatch({ dispatch({
type: PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED, type: PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED,
payload: { payload: {
@ -607,6 +620,12 @@ function remoteVideoChange(
}; };
} }
function returnToActiveCall(): ReturnToActiveCallActionType {
return {
type: RETURN_TO_ACTIVE_CALL,
};
}
function setLocalPreview( function setLocalPreview(
payload: SetLocalPreviewType payload: SetLocalPreviewType
): ThunkAction<void, RootStateType, unknown, never> { ): 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 { function showCallLobby(payload: ShowCallLobbyType): CallLobbyActionType {
return { return {
type: SHOW_CALL_LOBBY, type: SHOW_CALL_LOBBY,
@ -765,11 +794,13 @@ export const actions = {
peekNotConnectedGroupCall, peekNotConnectedGroupCall,
refreshIODevices, refreshIODevices,
remoteVideoChange, remoteVideoChange,
returnToActiveCall,
setLocalPreview, setLocalPreview,
setRendererCanvas, setRendererCanvas,
setLocalAudio, setLocalAudio,
setLocalVideo, setLocalVideo,
setGroupCallVideoRequest, setGroupCallVideoRequest,
startCallingLobby,
showCallLobby, showCallLobby,
startCall, startCall,
toggleParticipants, 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 (action.type === SET_LOCAL_AUDIO_FULFILLED) {
if (!state.activeCallState) { if (!state.activeCallState) {
window.log.warn('Cannot set local audio with no active call'); 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 { AttachmentType } from '../../types/Attachment';
import { ColorType } from '../../types/Colors'; import { ColorType } from '../../types/Colors';
import { BodyRangeType } from '../../types/Util'; import { BodyRangeType } from '../../types/Util';
import { CallMode } from '../../types/Calling'; import { CallMode, CallHistoryDetailsFromDiskType } from '../../types/Calling';
// State // State
@ -147,6 +147,7 @@ export type MessageType = {
errors?: Array<Error>; errors?: Array<Error>;
group_update?: unknown; group_update?: unknown;
callHistoryDetails?: CallHistoryDetailsFromDiskType;
// No need to go beyond this; unused at this stage, since this goes into // 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 // a reducer still in plain JavaScript and comes out well-formed
@ -274,6 +275,13 @@ export type MessageDeletedActionType = {
conversationId: string; conversationId: string;
}; };
}; };
type MessageSizeChangedActionType = {
type: 'MESSAGE_SIZE_CHANGED';
payload: {
id: string;
conversationId: string;
};
};
export type MessagesAddedActionType = { export type MessagesAddedActionType = {
type: 'MESSAGES_ADDED'; type: 'MESSAGES_ADDED';
payload: { payload: {
@ -379,6 +387,7 @@ export type ConversationActionType =
| ConversationUnloadedActionType | ConversationUnloadedActionType
| RemoveAllConversationsActionType | RemoveAllConversationsActionType
| MessageSelectedActionType | MessageSelectedActionType
| MessageSizeChangedActionType
| MessageChangedActionType | MessageChangedActionType
| MessageDeletedActionType | MessageDeletedActionType
| MessagesAddedActionType | MessagesAddedActionType
@ -410,6 +419,7 @@ export const actions = {
selectMessage, selectMessage,
messageDeleted, messageDeleted,
messageChanged, messageChanged,
messageSizeChanged,
messagesAdded, messagesAdded,
messagesReset, messagesReset,
setMessagesLoading, setMessagesLoading,
@ -514,6 +524,18 @@ function messageDeleted(
}, },
}; };
} }
function messageSizeChanged(
id: string,
conversationId: string
): MessageSizeChangedActionType {
return {
type: 'MESSAGE_SIZE_CHANGED',
payload: {
id,
conversationId,
},
};
}
function messagesAdded( function messagesAdded(
conversationId: string, conversationId: string,
messages: Array<MessageType>, messages: Array<MessageType>,
@ -697,7 +719,7 @@ function showArchivedConversations(): ShowArchivedConversationsActionType {
// Reducer // Reducer
function getEmptyState(): ConversationsStateType { export function getEmptyState(): ConversationsStateType {
return { return {
conversationLookup: {}, conversationLookup: {},
messagesByConversation: {}, 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') { if (action.type === 'MESSAGES_RESET') {
const { const {
conversationId, conversationId,

View file

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

View file

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

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai'; import { assert } from 'chai';
import { set } from 'lodash/fp';
import { import {
actions, actions,
ConversationMessageType, ConversationMessageType,
@ -13,7 +14,11 @@ import {
} from '../../../state/ducks/conversations'; } from '../../../state/ducks/conversations';
import { CallMode } from '../../../types/Calling'; import { CallMode } from '../../../types/Calling';
const { repairNewestMessage, repairOldestMessage } = actions; const {
messageSizeChanged,
repairNewestMessage,
repairOldestMessage,
} = actions;
describe('both/state/ducks/conversations', () => { describe('both/state/ducks/conversations', () => {
describe('helpers', () => { 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', () => { describe('REPAIR_NEWEST_MESSAGE', () => {
it('updates newest', () => { it('updates newest', () => {
const action = repairNewestMessage(conversationId); 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(), ...getEmptyState(),
callsByConversation: { callsByConversation: {
'fake-direct-call-conversation-id': { 'fake-direct-call-conversation-id': {
callMode: CallMode.Direct, callMode: CallMode.Direct as CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id', conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Accepted, callState: CallState.Accepted,
isIncoming: false, isIncoming: false,
@ -37,7 +37,7 @@ describe('calling duck', () => {
}, },
}; };
const stateWithActiveDirectCall: CallingStateType = { const stateWithActiveDirectCall = {
...stateWithDirectCall, ...stateWithDirectCall,
activeCallState: { activeCallState: {
conversationId: 'fake-direct-call-conversation-id', conversationId: 'fake-direct-call-conversation-id',
@ -49,11 +49,11 @@ describe('calling duck', () => {
}, },
}; };
const stateWithIncomingDirectCall: CallingStateType = { const stateWithIncomingDirectCall = {
...getEmptyState(), ...getEmptyState(),
callsByConversation: { callsByConversation: {
'fake-direct-call-conversation-id': { 'fake-direct-call-conversation-id': {
callMode: CallMode.Direct, callMode: CallMode.Direct as CallMode.Direct,
conversationId: 'fake-direct-call-conversation-id', conversationId: 'fake-direct-call-conversation-id',
callState: CallState.Ringing, callState: CallState.Ringing,
isIncoming: true, isIncoming: true,
@ -63,11 +63,11 @@ describe('calling duck', () => {
}, },
}; };
const stateWithGroupCall: CallingStateType = { const stateWithGroupCall = {
...getEmptyState(), ...getEmptyState(),
callsByConversation: { callsByConversation: {
'fake-group-call-conversation-id': { 'fake-group-call-conversation-id': {
callMode: CallMode.Group, callMode: CallMode.Group as CallMode.Group,
conversationId: 'fake-group-call-conversation-id', conversationId: 'fake-group-call-conversation-id',
connectionState: GroupCallConnectionState.Connected, connectionState: GroupCallConnectionState.Connected,
joinState: GroupCallJoinState.NotJoined, joinState: GroupCallJoinState.NotJoined,
@ -91,7 +91,7 @@ describe('calling duck', () => {
}, },
}; };
const stateWithActiveGroupCall: CallingStateType = { const stateWithActiveGroupCall = {
...stateWithGroupCall, ...stateWithGroupCall,
activeCallState: { activeCallState: {
conversationId: 'fake-group-call-conversation-id', conversationId: 'fake-group-call-conversation-id',
@ -624,6 +624,10 @@ describe('calling duck', () => {
callingService, callingService,
'peekGroupCall' 'peekGroupCall'
); );
this.callingServiceUpdateCallHistoryForGroupCall = this.sandbox.stub(
callingService,
'updateCallHistoryForGroupCall'
);
this.clock = this.sandbox.useFakeTimers(); 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', () => { describe('setLocalAudio', () => {
const { setLocalAudio } = actions; const { setLocalAudio } = actions;

View file

@ -5,7 +5,11 @@ import { assert } from 'chai';
import { reducer as rootReducer } from '../../../state/reducer'; import { reducer as rootReducer } from '../../../state/reducer';
import { noopAction } from '../../../state/ducks/noop'; import { noopAction } from '../../../state/ducks/noop';
import { CallMode, CallState } from '../../../types/Calling'; 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'; import { getEmptyState, CallingStateType } from '../../../state/ducks/calling';
describe('state/selectors/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', () => { describe('getIncomingCall', () => {
it('returns undefined if there are no calls', () => { it('returns undefined if there are no calls', () => {
assert.isUndefined(getIncomingCall(getEmptyRootState())); assert.isUndefined(getIncomingCall(getEmptyRootState()));

View file

@ -4,6 +4,7 @@
import { ColorType } from './Colors'; import { ColorType } from './Colors';
import { ConversationType } from '../state/ducks/conversations'; import { ConversationType } from '../state/ducks/conversations';
// These are strings (1) for the database (2) for Storybook.
export enum CallMode { export enum CallMode {
None = 'None', None = 'None',
Direct = 'Direct', Direct = 'Direct',
@ -153,13 +154,31 @@ export type MediaDeviceSettings = {
selectedCamera: string | undefined; selectedCamera: string | undefined;
}; };
export type CallHistoryDetailsType = { interface DirectCallHistoryDetailsType {
callMode: CallMode.Direct;
wasIncoming: boolean; wasIncoming: boolean;
wasVideoCall: boolean; wasVideoCall: boolean;
wasDeclined: boolean; wasDeclined: boolean;
acceptedTime?: number; acceptedTime?: number;
endedTime: 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 = export type ChangeIODevicePayloadType =
| { type: CallingDeviceType.CAMERA; selectedDevice: string } | { 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", "updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Only used to focus the element." "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", "rule": "React-useRef",
"path": "ts/components/conversation/ContactModal.js", "path": "ts/components/conversation/ContactModal.js",
@ -15167,4 +15185,4 @@
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z" "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' 'onOutgoingAudioCallInConversation: about to start an audio call'
); );
const conversation = this.model;
const isVideoCall = false; const isVideoCall = false;
if (await this.isCallSafe()) { if (await this.isCallSafe()) {
@ -486,7 +485,7 @@ Whisper.ConversationView = Whisper.View.extend({
'onOutgoingAudioCallInConversation: call is deemed "safe". Making call' 'onOutgoingAudioCallInConversation: call is deemed "safe". Making call'
); );
await window.Signal.Services.calling.startCallingLobby( await window.Signal.Services.calling.startCallingLobby(
conversation, this.model.id,
isVideoCall isVideoCall
); );
window.log.info( window.log.info(
@ -503,7 +502,6 @@ Whisper.ConversationView = Whisper.View.extend({
window.log.info( window.log.info(
'onOutgoingVideoCallInConversation: about to start a video call' 'onOutgoingVideoCallInConversation: about to start a video call'
); );
const conversation = this.model;
const isVideoCall = true; const isVideoCall = true;
if (await this.isCallSafe()) { if (await this.isCallSafe()) {
@ -511,7 +509,7 @@ Whisper.ConversationView = Whisper.View.extend({
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call' 'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
); );
await window.Signal.Services.calling.startCallingLobby( await window.Signal.Services.calling.startCallingLobby(
conversation, this.model.id,
isVideoCall isVideoCall
); );
window.log.info( 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 RemoteConfig from './RemoteConfig';
import * as zkgroup from './util/zkgroup'; import * as zkgroup from './util/zkgroup';
import { LocalizerType, BodyRangesType, BodyRangeType } from './types/Util'; import { LocalizerType, BodyRangesType, BodyRangeType } from './types/Util';
import { CallHistoryDetailsType } from './types/Calling';
import { ColorType } from './types/Colors'; import { ColorType } from './types/Colors';
import { ConversationController } from './ConversationController'; import { ConversationController } from './ConversationController';
import { ReduxActions } from './state/types'; import { ReduxActions } from './state/types';
@ -409,11 +408,6 @@ declare global {
ProgressModal: typeof ProgressModal; ProgressModal: typeof ProgressModal;
Quote: any; Quote: any;
StagedLinkPreview: any; StagedLinkPreview: any;
getCallingNotificationText: (
callHistoryDetails: unknown,
i18n: unknown
) => string;
}; };
OS: { OS: {
isLinux: () => boolean; isLinux: () => boolean;