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;