Add group calling events to the message timeline
This commit is contained in:
parent
a2f285d243
commit
0c039bf431
29 changed files with 1275 additions and 239 deletions
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) => (
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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
4
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -103,6 +103,10 @@ export interface DataInterface {
|
|||
getMessageMetricsForConversation: (
|
||||
conversationId: string
|
||||
) => Promise<ConverationMetricsType>;
|
||||
hasGroupCallHistoryMessage: (
|
||||
conversationId: string,
|
||||
eraId: string
|
||||
) => Promise<boolean>;
|
||||
migrateConversationMessages: (
|
||||
obsoleteId: string,
|
||||
currentId: string
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
115
ts/test-both/util/callingNotification_test.ts
Normal file
115
ts/test-both/util/callingNotification_test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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()));
|
||||
|
|
|
@ -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 }
|
||||
|
|
108
ts/util/callingNotification.ts
Normal file
108
ts/util/callingNotification.ts
Normal 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 '';
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
|
@ -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
6
ts/window.d.ts
vendored
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue