diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9872ff320bd1..77c299b6b7f8 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/js/modules/signal.js b/js/modules/signal.js index 1e281ae28df2..276d26342e6a 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -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, diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index bdef54933c78..9aa5e1a8a7bf 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -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; +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 65ae3f7b7dbc..54bf6c938e41 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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 { diff --git a/ts/components/Tooltip.tsx b/ts/components/Tooltip.tsx index 5668aeeb3618..373f3b0edb0d 100644 --- a/ts/components/Tooltip.tsx +++ b/ts/components/Tooltip.tsx @@ -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 . +const TooltipEventWrapper = React.forwardRef< + HTMLSpanElement, + EventWrapperPropsType +>(({ onHoverChanged, children }, ref) => { + const wrapperRef = React.useRef(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 ( + { + 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).current = el; + } + }} + > + {children} + + ); +}); + export enum TooltipPlacement { Top = 'top', Right = 'right', @@ -26,8 +93,9 @@ export const Tooltip: React.FC = ({ 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 = ({ {({ ref }) => ( - { - if (!isSticky) { - setShowTooltip(false); - } - }} - onFocus={() => { - if (!isSticky) { - setShowTooltip(true); - } - }} - onMouseEnter={() => { - if (!isSticky) { - setShowTooltip(true); - } - }} - onMouseLeave={() => { - if (!isSticky) { - setShowTooltip(false); - } - }} - ref={ref} - > + {children} - + )} diff --git a/ts/components/conversation/CallingNotification.tsx b/ts/components/conversation/CallingNotification.tsx index 76d736be294c..933c8e031cb6 100644 --- a/ts/components/conversation/CallingNotification.tsx +++ b/ts/components/conversation/CallingNotification.tsx @@ -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 = 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); + const [height, setHeight] = useState(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 ( + { + if (!bounds) { + window.log.error('We should be measuring the bounds'); + return; + } + setHeight(bounds.height); + }} + > + {({ measureRef }) => ( +
+
+ {getCallingNotificationText(props, i18n)} +
+ +
+ +
+ )} + + ); +}); + +function CallingNotificationButton(props: PropsType) { + if (props.callMode !== CallMode.Group || props.ended) { return null; } - const { acceptedTime, endedTime, wasVideoCall } = callHistoryDetails; - const callType = wasVideoCall ? 'video' : 'audio'; - return ( -
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 = ( + ); -}; + + if (disabledTooltipText) { + return ( + + {button} + + ); + } + return button; +} diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 5cbeadf3e212..ef3c65d4d80c 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -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) => ( diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index e74b32bd4977..072a4e2967a0 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -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(), }, }, ]; diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 0276cac6ba7e..c61725140860 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -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 { isSelected, item, i18n, + messageSizeChanged, renderContact, + returnToActiveCall, selectMessage, + startCallingLobby, } = this.props; if (!item) { @@ -164,7 +169,17 @@ export class TimelineItem extends React.PureComponent { ); } else if (item.type === 'callHistory') { - notification = ; + notification = ( + + ); } else if (item.type === 'linkNotification') { notification = (
diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index d80e8d025699..cdb151aa02c1 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -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; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 7dfeedd40b87..a97b62ca4654 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -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 { return { @@ -2047,14 +2050,36 @@ export class ConversationModel extends window.Backbone.Model< async addCallHistory( callHistoryDetails: CallHistoryDetailsType ): Promise { - 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 { + 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 diff --git a/ts/models/messages.ts b/ts/models/messages.ts index a584fca51ca9..fbb13027cd81 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -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 { }; } - 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 { } 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 diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 3ff9f68bc2a5..b9bed598a254 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -124,11 +124,17 @@ export class CallingClass { } async startCallingLobby( - conversation: ConversationModel, + conversationId: string, isVideoCall: boolean ): Promise { 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(); diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 44afcdacfdb7..ccba561aa22a 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -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 { + return channels.hasGroupCallHistoryMessage(conversationId, eraId); +} async function migrateConversationMessages( obsoleteId: string, currentId: string diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 3ff01e34e6fd..7a1bf1fcffbf 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -103,6 +103,10 @@ export interface DataInterface { getMessageMetricsForConversation: ( conversationId: string ) => Promise; + hasGroupCallHistoryMessage: ( + conversationId: string, + eraId: string + ) => Promise; migrateConversationMessages: ( obsoleteId: string, currentId: string diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 9d5bb1bfbfd5..104c4dd98403 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -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 { + 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)['count(*)']); + return Boolean(count); + } + return false; +} + async function migrateConversationMessages( obsoleteId: string, currentId: string diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 6baad1f2abc6..81c782959e11 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -163,6 +163,11 @@ export type SetGroupCallVideoRequestType = { resolutions: Array; }; +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 { @@ -695,6 +714,16 @@ function setGroupCallVideoRequest( }; } +function startCallingLobby( + payload: StartCallingLobbyType +): ThunkAction { + 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'); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 959f287c24ce..a74ea5c5aaa3 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -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; 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, @@ -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, diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts index 5f065399aac7..8ddbbb501dbe 100644 --- a/ts/state/selectors/calling.ts +++ b/ts/state/selectors/calling.ts @@ -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( diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 612987b895b5..2c675db7ce7b 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -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, diff --git a/ts/test-both/state/ducks/conversations_test.ts b/ts/test-both/state/ducks/conversations_test.ts index d9d41482eff7..3a87303359a4 100644 --- a/ts/test-both/state/ducks/conversations_test.ts +++ b/ts/test-both/state/ducks/conversations_test.ts @@ -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); diff --git a/ts/test-both/util/callingNotification_test.ts b/ts/test-both/util/callingNotification_test.ts new file mode 100644 index 000000000000..aebe9ba453ae --- /dev/null +++ b/ts/test-both/util/callingNotification_test.ts @@ -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' + ); + }); + }); +}); diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index c01126f3c701..2613a43efa12 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -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; diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts index 1748e8251386..2cdfc61700cf 100644 --- a/ts/test-electron/state/selectors/calling_test.ts +++ b/ts/test-electron/state/selectors/calling_test.ts @@ -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())); diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index f2ea6dbf8761..2f594bff2425 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -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 & + Partial>) + | GroupCallHistoryDetailsType; export type ChangeIODevicePayloadType = | { type: CallingDeviceType.CAMERA; selectedDevice: string } diff --git a/ts/util/callingNotification.ts b/ts/util/callingNotification.ts new file mode 100644 index 000000000000..b302994bf4dd --- /dev/null +++ b/ts/util/callingNotification.ts @@ -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 ''; + } +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 6ee5df888800..6614e3f2e1d3 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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" } -] \ No newline at end of file +] diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index e661f5bc858c..fad0a32ed314 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -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( diff --git a/ts/window.d.ts b/ts/window.d.ts index 99114c0e85cb..3e654d57c574 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -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;