Call link call history
This commit is contained in:
parent
ed940f6f83
commit
00d6379bae
29 changed files with 1124 additions and 204 deletions
|
@ -7408,6 +7408,10 @@
|
||||||
"messageformat": "Group call",
|
"messageformat": "Group call",
|
||||||
"description": "Calls Tab > Calls List > Call Item > Call Status > When group call is in its default state"
|
"description": "Calls Tab > Calls List > Call Item > Call Status > When group call is in its default state"
|
||||||
},
|
},
|
||||||
|
"icu:CallsList__ItemCallInfo--CallLink": {
|
||||||
|
"messageformat": "Call link",
|
||||||
|
"description": "On the Calls Tab, the subtitle text for a Call Link entry."
|
||||||
|
},
|
||||||
"icu:CallsNewCall__EmptyState--noQuery": {
|
"icu:CallsNewCall__EmptyState--noQuery": {
|
||||||
"messageformat": "No recent conversations.",
|
"messageformat": "No recent conversations.",
|
||||||
"description": "Calls Tab > New Call > Conversations List > When no results found > With no search query"
|
"description": "Calls Tab > New Call > Conversations List > When no results found > With no search query"
|
||||||
|
|
|
@ -187,6 +187,10 @@ import {
|
||||||
getCallsHistoryUnreadCountForRedux,
|
getCallsHistoryUnreadCountForRedux,
|
||||||
loadCallsHistory,
|
loadCallsHistory,
|
||||||
} from './services/callHistoryLoader';
|
} from './services/callHistoryLoader';
|
||||||
|
import {
|
||||||
|
getCallLinksForRedux,
|
||||||
|
loadCallLinks,
|
||||||
|
} from './services/callLinksLoader';
|
||||||
import {
|
import {
|
||||||
getCallIdFromEra,
|
getCallIdFromEra,
|
||||||
updateLocalGroupCallHistoryTimestamp,
|
updateLocalGroupCallHistoryTimestamp,
|
||||||
|
@ -1105,6 +1109,7 @@ export async function startApp(): Promise<void> {
|
||||||
loadStories(),
|
loadStories(),
|
||||||
loadDistributionLists(),
|
loadDistributionLists(),
|
||||||
loadCallsHistory(),
|
loadCallsHistory(),
|
||||||
|
loadCallLinks(),
|
||||||
window.textsecure.storage.protocol.hydrateCaches(),
|
window.textsecure.storage.protocol.hydrateCaches(),
|
||||||
(async () => {
|
(async () => {
|
||||||
mainWindowStats = await window.SignalContext.getMainWindowStats();
|
mainWindowStats = await window.SignalContext.getMainWindowStats();
|
||||||
|
@ -1155,6 +1160,7 @@ export async function startApp(): Promise<void> {
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
}) {
|
}) {
|
||||||
initializeRedux({
|
initializeRedux({
|
||||||
|
callLinks: getCallLinksForRedux(),
|
||||||
callsHistory: getCallsHistoryForRedux(),
|
callsHistory: getCallsHistoryForRedux(),
|
||||||
callsHistoryUnreadCount: getCallsHistoryUnreadCountForRedux(),
|
callsHistoryUnreadCount: getCallsHistoryUnreadCountForRedux(),
|
||||||
initialBadgesState,
|
initialBadgesState,
|
||||||
|
|
|
@ -49,8 +49,10 @@ function getCallLink(overrideProps: Partial<CallLinkType> = {}): CallLinkType {
|
||||||
return {
|
return {
|
||||||
roomId: 'abcd1234abcd1234abcd1234abcd1234abcd1234',
|
roomId: 'abcd1234abcd1234abcd1234abcd1234abcd1234',
|
||||||
rootKey: 'abcd-abcd-abcd-abcd-abcd-abcd-abcd-abcd',
|
rootKey: 'abcd-abcd-abcd-abcd-abcd-abcd-abcd-abcd',
|
||||||
|
adminKey: null,
|
||||||
name: 'Axolotl Discuss',
|
name: 'Axolotl Discuss',
|
||||||
restrictions: CallLinkRestrictions.None,
|
restrictions: CallLinkRestrictions.None,
|
||||||
|
revoked: false,
|
||||||
expiration: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
expiration: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||||
...overrideProps,
|
...overrideProps,
|
||||||
};
|
};
|
||||||
|
|
|
@ -206,7 +206,7 @@ export function CallingLobby({
|
||||||
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.CallIsFull;
|
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.CallIsFull;
|
||||||
} else if (isCallConnecting) {
|
} else if (isCallConnecting) {
|
||||||
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Loading;
|
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Loading;
|
||||||
} else if (peekedParticipants.length) {
|
} else if (peekedParticipants.length || callMode === CallMode.Adhoc) {
|
||||||
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Join;
|
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Join;
|
||||||
} else {
|
} else {
|
||||||
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Start;
|
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Start;
|
||||||
|
|
|
@ -43,6 +43,10 @@ import { formatCallHistoryGroup } from '../util/callDisposition';
|
||||||
import { CallsNewCallButton } from './CallsNewCall';
|
import { CallsNewCallButton } from './CallsNewCall';
|
||||||
import { Tooltip, TooltipPlacement } from './Tooltip';
|
import { Tooltip, TooltipPlacement } from './Tooltip';
|
||||||
import { Theme } from '../util/theme';
|
import { Theme } from '../util/theme';
|
||||||
|
import type { CallingConversationType } from '../types/Calling';
|
||||||
|
import { CallMode } from '../types/Calling';
|
||||||
|
import type { CallLinkType } from '../types/CallLink';
|
||||||
|
import { callLinkToConversation } from '../util/callLinks';
|
||||||
|
|
||||||
function Timestamp({
|
function Timestamp({
|
||||||
i18n,
|
i18n,
|
||||||
|
@ -112,6 +116,7 @@ type CallsListProps = Readonly<{
|
||||||
pagination: CallHistoryPagination
|
pagination: CallHistoryPagination
|
||||||
) => Promise<Array<CallHistoryGroup>>;
|
) => Promise<Array<CallHistoryGroup>>;
|
||||||
callHistoryEdition: number;
|
callHistoryEdition: number;
|
||||||
|
getCallLink: (id: string) => CallLinkType | undefined;
|
||||||
getConversation: (id: string) => ConversationType | void;
|
getConversation: (id: string) => ConversationType | void;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
selectedCallHistoryGroup: CallHistoryGroup | null;
|
selectedCallHistoryGroup: CallHistoryGroup | null;
|
||||||
|
@ -121,6 +126,7 @@ type CallsListProps = Readonly<{
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
selectedCallHistoryGroup: CallHistoryGroup
|
selectedCallHistoryGroup: CallHistoryGroup
|
||||||
) => void;
|
) => void;
|
||||||
|
startCallLinkLobbyByRoomId: (roomId: string) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const CALL_LIST_ITEM_ROW_HEIGHT = 62;
|
const CALL_LIST_ITEM_ROW_HEIGHT = 62;
|
||||||
|
@ -141,12 +147,14 @@ export function CallsList({
|
||||||
getCallHistoryGroupsCount,
|
getCallHistoryGroupsCount,
|
||||||
getCallHistoryGroups,
|
getCallHistoryGroups,
|
||||||
callHistoryEdition,
|
callHistoryEdition,
|
||||||
|
getCallLink,
|
||||||
getConversation,
|
getConversation,
|
||||||
i18n,
|
i18n,
|
||||||
selectedCallHistoryGroup,
|
selectedCallHistoryGroup,
|
||||||
onOutgoingAudioCallInConversation,
|
onOutgoingAudioCallInConversation,
|
||||||
onOutgoingVideoCallInConversation,
|
onOutgoingVideoCallInConversation,
|
||||||
onSelectCallHistoryGroup,
|
onSelectCallHistoryGroup,
|
||||||
|
startCallLinkLobbyByRoomId,
|
||||||
}: CallsListProps): JSX.Element {
|
}: CallsListProps): JSX.Element {
|
||||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||||
const listRef = useRef<List>(null);
|
const listRef = useRef<List>(null);
|
||||||
|
@ -301,7 +309,16 @@ export function CallsList({
|
||||||
const rowRenderer = useCallback(
|
const rowRenderer = useCallback(
|
||||||
({ key, index, style }: ListRowProps) => {
|
({ key, index, style }: ListRowProps) => {
|
||||||
const item = searchState.results?.items.at(index) ?? null;
|
const item = searchState.results?.items.at(index) ?? null;
|
||||||
const conversation = item != null ? getConversation(item.peerId) : null;
|
const isAdhoc = item?.mode === CallMode.Adhoc;
|
||||||
|
const callLink = isAdhoc ? getCallLink(item.peerId) : null;
|
||||||
|
|
||||||
|
const conversation: CallingConversationType | null =
|
||||||
|
// eslint-disable-next-line no-nested-ternary
|
||||||
|
item
|
||||||
|
? callLink
|
||||||
|
? callLinkToConversation(callLink, i18n)
|
||||||
|
: getConversation(item.peerId) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
searchState.state === 'pending' ||
|
searchState.state === 'pending' ||
|
||||||
|
@ -337,6 +354,8 @@ export function CallsList({
|
||||||
let statusText;
|
let statusText;
|
||||||
if (wasMissed) {
|
if (wasMissed) {
|
||||||
statusText = i18n('icu:CallsList__ItemCallInfo--Missed');
|
statusText = i18n('icu:CallsList__ItemCallInfo--Missed');
|
||||||
|
} else if (callLink) {
|
||||||
|
statusText = i18n('icu:CallsList__ItemCallInfo--CallLink');
|
||||||
} else if (item.type === CallType.Group) {
|
} else if (item.type === CallType.Group) {
|
||||||
statusText = i18n('icu:CallsList__ItemCallInfo--GroupCall');
|
statusText = i18n('icu:CallsList__ItemCallInfo--GroupCall');
|
||||||
} else if (item.direction === CallDirection.Outgoing) {
|
} else if (item.direction === CallDirection.Outgoing) {
|
||||||
|
@ -363,7 +382,7 @@ export function CallsList({
|
||||||
<Avatar
|
<Avatar
|
||||||
acceptedMessageRequest
|
acceptedMessageRequest
|
||||||
avatarPath={conversation.avatarPath}
|
avatarPath={conversation.avatarPath}
|
||||||
conversationType="group"
|
conversationType={conversation.type}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
isMe={false}
|
isMe={false}
|
||||||
title={conversation.title}
|
title={conversation.title}
|
||||||
|
@ -378,10 +397,14 @@ export function CallsList({
|
||||||
callType={item.type}
|
callType={item.type}
|
||||||
hasActiveCall={hasActiveCall}
|
hasActiveCall={hasActiveCall}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.type === CallType.Audio) {
|
if (callLink) {
|
||||||
onOutgoingAudioCallInConversation(conversation.id);
|
startCallLinkLobbyByRoomId(callLink.roomId);
|
||||||
} else {
|
} else if (conversation) {
|
||||||
onOutgoingVideoCallInConversation(conversation.id);
|
if (item.type === CallType.Audio) {
|
||||||
|
onOutgoingAudioCallInConversation(conversation.id);
|
||||||
|
} else {
|
||||||
|
onOutgoingVideoCallInConversation(conversation.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -403,6 +426,10 @@ export function CallsList({
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (callLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onSelectCallHistoryGroup(conversation.id, item);
|
onSelectCallHistoryGroup(conversation.id, item);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -412,11 +439,13 @@ export function CallsList({
|
||||||
[
|
[
|
||||||
hasActiveCall,
|
hasActiveCall,
|
||||||
searchState,
|
searchState,
|
||||||
|
getCallLink,
|
||||||
getConversation,
|
getConversation,
|
||||||
selectedCallHistoryGroup,
|
selectedCallHistoryGroup,
|
||||||
onSelectCallHistoryGroup,
|
onSelectCallHistoryGroup,
|
||||||
onOutgoingAudioCallInConversation,
|
onOutgoingAudioCallInConversation,
|
||||||
onOutgoingVideoCallInConversation,
|
onOutgoingVideoCallInConversation,
|
||||||
|
startCallLinkLobbyByRoomId,
|
||||||
i18n,
|
i18n,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { ContextMenu } from './ContextMenu';
|
||||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
import type { UnreadStats } from '../util/countUnreadStats';
|
import type { UnreadStats } from '../util/countUnreadStats';
|
||||||
import type { WidthBreakpoint } from './_util';
|
import type { WidthBreakpoint } from './_util';
|
||||||
|
import type { CallLinkType } from '../types/CallLink';
|
||||||
|
|
||||||
enum CallsTabSidebarView {
|
enum CallsTabSidebarView {
|
||||||
CallsListView,
|
CallsListView,
|
||||||
|
@ -36,6 +37,7 @@ type CallsTabProps = Readonly<{
|
||||||
pagination: CallHistoryPagination
|
pagination: CallHistoryPagination
|
||||||
) => Promise<Array<CallHistoryGroup>>;
|
) => Promise<Array<CallHistoryGroup>>;
|
||||||
callHistoryEdition: number;
|
callHistoryEdition: number;
|
||||||
|
getCallLink: (id: string) => CallLinkType | undefined;
|
||||||
getConversation: (id: string) => ConversationType | void;
|
getConversation: (id: string) => ConversationType | void;
|
||||||
hasFailedStorySends: boolean;
|
hasFailedStorySends: boolean;
|
||||||
hasPendingUpdate: boolean;
|
hasPendingUpdate: boolean;
|
||||||
|
@ -56,6 +58,7 @@ type CallsTabProps = Readonly<{
|
||||||
}) => JSX.Element;
|
}) => JSX.Element;
|
||||||
regionCode: string | undefined;
|
regionCode: string | undefined;
|
||||||
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
|
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
|
||||||
|
startCallLinkLobbyByRoomId: (roomId: string) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function CallsTab({
|
export function CallsTab({
|
||||||
|
@ -65,6 +68,7 @@ export function CallsTab({
|
||||||
getCallHistoryGroupsCount,
|
getCallHistoryGroupsCount,
|
||||||
getCallHistoryGroups,
|
getCallHistoryGroups,
|
||||||
callHistoryEdition,
|
callHistoryEdition,
|
||||||
|
getCallLink,
|
||||||
getConversation,
|
getConversation,
|
||||||
hasFailedStorySends,
|
hasFailedStorySends,
|
||||||
hasPendingUpdate,
|
hasPendingUpdate,
|
||||||
|
@ -80,6 +84,7 @@ export function CallsTab({
|
||||||
renderToastManager,
|
renderToastManager,
|
||||||
regionCode,
|
regionCode,
|
||||||
savePreferredLeftPaneWidth,
|
savePreferredLeftPaneWidth,
|
||||||
|
startCallLinkLobbyByRoomId,
|
||||||
}: CallsTabProps): JSX.Element {
|
}: CallsTabProps): JSX.Element {
|
||||||
const [sidebarView, setSidebarView] = useState(
|
const [sidebarView, setSidebarView] = useState(
|
||||||
CallsTabSidebarView.CallsListView
|
CallsTabSidebarView.CallsListView
|
||||||
|
@ -230,6 +235,7 @@ export function CallsTab({
|
||||||
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
||||||
getCallHistoryGroups={getCallHistoryGroups}
|
getCallHistoryGroups={getCallHistoryGroups}
|
||||||
callHistoryEdition={callHistoryEdition}
|
callHistoryEdition={callHistoryEdition}
|
||||||
|
getCallLink={getCallLink}
|
||||||
getConversation={getConversation}
|
getConversation={getConversation}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
selectedCallHistoryGroup={selected?.callHistoryGroup ?? null}
|
selectedCallHistoryGroup={selected?.callHistoryGroup ?? null}
|
||||||
|
@ -240,6 +246,7 @@ export function CallsTab({
|
||||||
onOutgoingVideoCallInConversation={
|
onOutgoingVideoCallInConversation={
|
||||||
handleOutgoingVideoCallInConversation
|
handleOutgoingVideoCallInConversation
|
||||||
}
|
}
|
||||||
|
startCallLinkLobbyByRoomId={startCallLinkLobbyByRoomId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sidebarView === CallsTabSidebarView.NewCallView && (
|
{sidebarView === CallsTabSidebarView.NewCallView && (
|
||||||
|
|
|
@ -586,7 +586,7 @@ async function getCallLinkPreview(
|
||||||
|
|
||||||
const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key);
|
const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key);
|
||||||
const callLinkState = await calling.readCallLink({ callLinkRootKey });
|
const callLinkState = await calling.readCallLink({ callLinkRootKey });
|
||||||
if (!callLinkState) {
|
if (!callLinkState || callLinkState.revoked) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
18
ts/services/callLinksLoader.ts
Normal file
18
ts/services/callLinksLoader.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import dataInterface from '../sql/Client';
|
||||||
|
import type { CallLinkType } from '../types/CallLink';
|
||||||
|
import { strictAssert } from '../util/assert';
|
||||||
|
|
||||||
|
let callLinksData: ReadonlyArray<CallLinkType>;
|
||||||
|
|
||||||
|
export async function loadCallLinks(): Promise<void> {
|
||||||
|
await dataInterface.cleanupCallHistoryMessages();
|
||||||
|
callLinksData = await dataInterface.getAllCallLinks();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCallLinksForRedux(): ReadonlyArray<CallLinkType> {
|
||||||
|
strictAssert(callLinksData != null, 'callLinks has not been loaded');
|
||||||
|
return callLinksData;
|
||||||
|
}
|
|
@ -124,6 +124,9 @@ import {
|
||||||
getCallIdFromRing,
|
getCallIdFromRing,
|
||||||
getLocalCallEventFromRingUpdate,
|
getLocalCallEventFromRingUpdate,
|
||||||
convertJoinState,
|
convertJoinState,
|
||||||
|
updateLocalAdhocCallHistory,
|
||||||
|
getCallIdFromEra,
|
||||||
|
getCallDetailsForAdhocCall,
|
||||||
} from '../util/callDisposition';
|
} from '../util/callDisposition';
|
||||||
import { isNormalNumber } from '../util/isNormalNumber';
|
import { isNormalNumber } from '../util/isNormalNumber';
|
||||||
import { LocalCallEvent } from '../types/CallDisposition';
|
import { LocalCallEvent } from '../types/CallDisposition';
|
||||||
|
@ -181,6 +184,7 @@ type CallingReduxInterface = Pick<
|
||||||
| 'setPresenting'
|
| 'setPresenting'
|
||||||
| 'startCallingLobby'
|
| 'startCallingLobby'
|
||||||
| 'startCallLinkLobby'
|
| 'startCallLinkLobby'
|
||||||
|
| 'startCallLinkLobbyByRoomId'
|
||||||
| 'peekNotConnectedGroupCall'
|
| 'peekNotConnectedGroupCall'
|
||||||
> & {
|
> & {
|
||||||
areAnyCallsActiveOrRinging(): boolean;
|
areAnyCallsActiveOrRinging(): boolean;
|
||||||
|
@ -535,25 +539,25 @@ export class CallingClass {
|
||||||
callLinkRootKey: CallLinkRootKey;
|
callLinkRootKey: CallLinkRootKey;
|
||||||
}>): Promise<CallLinkState | undefined> {
|
}>): Promise<CallLinkState | undefined> {
|
||||||
if (!this._sfuUrl) {
|
if (!this._sfuUrl) {
|
||||||
throw new Error('Missing SFU URL; not handling call link');
|
throw new Error('readCallLink() missing SFU URL; not handling call link');
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomId = getRoomIdFromRootKey(callLinkRootKey);
|
const roomId = getRoomIdFromRootKey(callLinkRootKey);
|
||||||
|
const logId = `readCallLink(${roomId})`;
|
||||||
const authCredentialPresentation =
|
const authCredentialPresentation =
|
||||||
await getCallLinkAuthCredentialPresentation(callLinkRootKey);
|
await getCallLinkAuthCredentialPresentation(callLinkRootKey);
|
||||||
|
|
||||||
log.info(`readCallLink: roomId ${roomId}`);
|
|
||||||
const result = await RingRTC.readCallLink(
|
const result = await RingRTC.readCallLink(
|
||||||
this._sfuUrl,
|
this._sfuUrl,
|
||||||
authCredentialPresentation.serialize(),
|
authCredentialPresentation.serialize(),
|
||||||
callLinkRootKey
|
callLinkRootKey
|
||||||
);
|
);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
log.warn(`readCallLink: failed ${roomId}`);
|
log.warn(`${logId}: failed`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('readCallLink: success', result);
|
log.info(`${logId}: success`);
|
||||||
return result.value;
|
return result.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -980,11 +984,20 @@ export class CallingClass {
|
||||||
callMode: CallMode.Group | CallMode.Adhoc
|
callMode: CallMode.Group | CallMode.Adhoc
|
||||||
): GroupCallObserver {
|
): GroupCallObserver {
|
||||||
let updateMessageState = GroupCallUpdateMessageState.SentNothing;
|
let updateMessageState = GroupCallUpdateMessageState.SentNothing;
|
||||||
|
const updateCallHistoryOnLocalChanged =
|
||||||
|
callMode === CallMode.Group
|
||||||
|
? this.updateCallHistoryForGroupCallOnLocalChanged
|
||||||
|
: this.updateCallHistoryForAdhocCall;
|
||||||
|
const updateCallHistoryOnPeek =
|
||||||
|
callMode === CallMode.Group
|
||||||
|
? this.updateCallHistoryForGroupCallOnPeek
|
||||||
|
: this.updateCallHistoryForAdhocCall;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onLocalDeviceStateChanged: groupCall => {
|
onLocalDeviceStateChanged: groupCall => {
|
||||||
const localDeviceState = groupCall.getLocalDeviceState();
|
const localDeviceState = groupCall.getLocalDeviceState();
|
||||||
const peekInfo = groupCall.getPeekInfo() ?? null;
|
const peekInfo = groupCall.getPeekInfo() ?? null;
|
||||||
|
const { eraId } = peekInfo ?? {};
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
'GroupCall#onLocalDeviceStateChanged',
|
'GroupCall#onLocalDeviceStateChanged',
|
||||||
|
@ -992,43 +1005,14 @@ export class CallingClass {
|
||||||
peekInfo != null ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
peekInfo != null ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupCallMeta = getGroupCallMeta(peekInfo);
|
// For adhoc calls, conversationId will be a roomId
|
||||||
|
drop(
|
||||||
// TODO: Handle call history for adhoc calls
|
updateCallHistoryOnLocalChanged(
|
||||||
if (groupCallMeta != null && callMode === CallMode.Group) {
|
conversationId,
|
||||||
try {
|
convertJoinState(localDeviceState.joinState),
|
||||||
const localCallEvent = getLocalCallEventFromJoinState(
|
peekInfo
|
||||||
convertJoinState(localDeviceState.joinState),
|
)
|
||||||
groupCallMeta
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (localCallEvent != null && peekInfo != null) {
|
|
||||||
const conversation =
|
|
||||||
window.ConversationController.get(conversationId);
|
|
||||||
strictAssert(
|
|
||||||
conversation != null,
|
|
||||||
'GroupCall#onLocalDeviceStateChanged: Missing conversation'
|
|
||||||
);
|
|
||||||
const peerId = getPeerIdFromConversation(conversation.attributes);
|
|
||||||
|
|
||||||
const callDetails = getCallDetailsFromGroupCallMeta(
|
|
||||||
peerId,
|
|
||||||
groupCallMeta
|
|
||||||
);
|
|
||||||
const callEvent = getCallEventDetails(
|
|
||||||
callDetails,
|
|
||||||
localCallEvent,
|
|
||||||
'RingRTC.onLocalDeviceStateChanged'
|
|
||||||
);
|
|
||||||
drop(updateCallHistoryFromLocalEvent(callEvent, null));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error(
|
|
||||||
'GroupCall#onLocalDeviceStateChanged: Error updating state',
|
|
||||||
Errors.toLogFormat(error)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localDeviceState.connectionState === ConnectionState.NotConnected) {
|
if (localDeviceState.connectionState === ConnectionState.NotConnected) {
|
||||||
// NOTE: This assumes that only one call is active at a time. For example, if
|
// NOTE: This assumes that only one call is active at a time. For example, if
|
||||||
|
@ -1040,14 +1024,11 @@ export class CallingClass {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
updateMessageState === GroupCallUpdateMessageState.SentJoin &&
|
updateMessageState === GroupCallUpdateMessageState.SentJoin &&
|
||||||
peekInfo?.eraId != null
|
eraId
|
||||||
) {
|
) {
|
||||||
updateMessageState = GroupCallUpdateMessageState.SentLeft;
|
updateMessageState = GroupCallUpdateMessageState.SentLeft;
|
||||||
if (callMode === CallMode.Group) {
|
if (callMode === CallMode.Group) {
|
||||||
void this.sendGroupCallUpdateMessage(
|
drop(this.sendGroupCallUpdateMessage(conversationId, eraId));
|
||||||
conversationId,
|
|
||||||
peekInfo?.eraId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -1060,17 +1041,16 @@ export class CallingClass {
|
||||||
this.videoCapturer.enableCaptureAndSend(groupCall);
|
this.videoCapturer.enableCaptureAndSend(groupCall);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Call enters the Joined state, once per call.
|
||||||
|
// This can also happen in onPeekChanged.
|
||||||
if (
|
if (
|
||||||
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
||||||
localDeviceState.joinState === JoinState.Joined &&
|
localDeviceState.joinState === JoinState.Joined &&
|
||||||
peekInfo?.eraId != null
|
eraId
|
||||||
) {
|
) {
|
||||||
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
||||||
if (callMode === CallMode.Group) {
|
if (callMode === CallMode.Group) {
|
||||||
void this.sendGroupCallUpdateMessage(
|
drop(this.sendGroupCallUpdateMessage(conversationId, eraId));
|
||||||
conversationId,
|
|
||||||
peekInfo?.eraId
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1128,6 +1108,7 @@ export class CallingClass {
|
||||||
onPeekChanged: groupCall => {
|
onPeekChanged: groupCall => {
|
||||||
const localDeviceState = groupCall.getLocalDeviceState();
|
const localDeviceState = groupCall.getLocalDeviceState();
|
||||||
const peekInfo = groupCall.getPeekInfo() ?? null;
|
const peekInfo = groupCall.getPeekInfo() ?? null;
|
||||||
|
const { eraId } = peekInfo ?? {};
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
'GroupCall#onPeekChanged',
|
'GroupCall#onPeekChanged',
|
||||||
|
@ -1135,25 +1116,29 @@ export class CallingClass {
|
||||||
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (callMode === CallMode.Group) {
|
// Call enters the Joined state, once per call.
|
||||||
const { eraId } = peekInfo ?? {};
|
// This can also happen in onLocalDeviceStateChanged.
|
||||||
if (
|
if (
|
||||||
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
||||||
localDeviceState.connectionState !== ConnectionState.NotConnected &&
|
localDeviceState.connectionState !== ConnectionState.NotConnected &&
|
||||||
localDeviceState.joinState === JoinState.Joined &&
|
localDeviceState.joinState === JoinState.Joined &&
|
||||||
eraId
|
eraId
|
||||||
) {
|
) {
|
||||||
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
||||||
void this.sendGroupCallUpdateMessage(conversationId, eraId);
|
|
||||||
}
|
|
||||||
|
|
||||||
void this.updateCallHistoryForGroupCall(
|
if (callMode === CallMode.Group) {
|
||||||
|
drop(this.sendGroupCallUpdateMessage(conversationId, eraId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For adhoc calls, conversationId will be a roomId
|
||||||
|
drop(
|
||||||
|
updateCallHistoryOnPeek(
|
||||||
conversationId,
|
conversationId,
|
||||||
convertJoinState(localDeviceState.joinState),
|
convertJoinState(localDeviceState.joinState),
|
||||||
peekInfo
|
peekInfo
|
||||||
);
|
)
|
||||||
}
|
);
|
||||||
// TODO: Call history for adhoc calls
|
|
||||||
|
|
||||||
this.syncGroupCallToRedux(conversationId, groupCall, callMode);
|
this.syncGroupCallToRedux(conversationId, groupCall, callMode);
|
||||||
},
|
},
|
||||||
|
@ -1381,11 +1366,12 @@ export class CallingClass {
|
||||||
public formatCallLinkStateForRedux(
|
public formatCallLinkStateForRedux(
|
||||||
callLinkState: CallLinkState
|
callLinkState: CallLinkState
|
||||||
): CallLinkStateType {
|
): CallLinkStateType {
|
||||||
const { name, restrictions, expiration } = callLinkState;
|
const { name, restrictions, expiration, revoked } = callLinkState;
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
restrictions,
|
restrictions,
|
||||||
expiration: expiration.getTime(),
|
expiration: expiration.getTime(),
|
||||||
|
revoked,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2641,7 +2627,84 @@ export class CallingClass {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async updateCallHistoryForGroupCall(
|
public async updateCallHistoryForAdhocCall(
|
||||||
|
roomId: string,
|
||||||
|
joinState: GroupCallJoinState | null,
|
||||||
|
peekInfo: PeekInfo | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (!peekInfo?.eraId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const callId = getCallIdFromEra(peekInfo.eraId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We only log events confirmed joined. If admin approval is required, then
|
||||||
|
// the call begins in the Pending state and we don't want history for that.
|
||||||
|
if (joinState !== GroupCallJoinState.Joined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callDetails = getCallDetailsForAdhocCall(roomId, callId);
|
||||||
|
const callEvent = getCallEventDetails(
|
||||||
|
callDetails,
|
||||||
|
LocalCallEvent.Accepted,
|
||||||
|
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged'
|
||||||
|
);
|
||||||
|
await updateLocalAdhocCallHistory(callEvent);
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged: Error updating state',
|
||||||
|
Errors.toLogFormat(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateCallHistoryForGroupCallOnLocalChanged(
|
||||||
|
conversationId: string,
|
||||||
|
joinState: GroupCallJoinState | null,
|
||||||
|
peekInfo: PeekInfo | null
|
||||||
|
): Promise<void> {
|
||||||
|
const groupCallMeta = getGroupCallMeta(peekInfo);
|
||||||
|
if (!groupCallMeta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localCallEvent = getLocalCallEventFromJoinState(
|
||||||
|
joinState,
|
||||||
|
groupCallMeta
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!localCallEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = window.ConversationController.get(conversationId);
|
||||||
|
strictAssert(
|
||||||
|
conversation != null,
|
||||||
|
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged: Missing conversation'
|
||||||
|
);
|
||||||
|
const peerId = getPeerIdFromConversation(conversation.attributes);
|
||||||
|
|
||||||
|
const callDetails = getCallDetailsFromGroupCallMeta(
|
||||||
|
peerId,
|
||||||
|
groupCallMeta
|
||||||
|
);
|
||||||
|
const callEvent = getCallEventDetails(
|
||||||
|
callDetails,
|
||||||
|
localCallEvent,
|
||||||
|
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged'
|
||||||
|
);
|
||||||
|
await updateCallHistoryFromLocalEvent(callEvent, null);
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged: Error updating state',
|
||||||
|
Errors.toLogFormat(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateCallHistoryForGroupCallOnPeek(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
joinState: GroupCallJoinState | null,
|
joinState: GroupCallJoinState | null,
|
||||||
peekInfo: PeekInfo | null
|
peekInfo: PeekInfo | null
|
||||||
|
@ -2658,7 +2721,9 @@ export class CallingClass {
|
||||||
|
|
||||||
const conversation = window.ConversationController.get(conversationId);
|
const conversation = window.ConversationController.get(conversationId);
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
log.error('maybeNotifyGroupCall(): could not find conversation');
|
log.error(
|
||||||
|
'updateCallHistoryForGroupCallOnPeek(): could not find conversation'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2684,7 +2749,7 @@ export class CallingClass {
|
||||||
const callEvent = getCallEventDetails(
|
const callEvent = getCallEventDetails(
|
||||||
callDetails,
|
callDetails,
|
||||||
localCallEvent,
|
localCallEvent,
|
||||||
'CallingClass.updateCallHistoryForGroupCall'
|
'CallingClass.updateCallHistoryForGroupCallOnPeek'
|
||||||
);
|
);
|
||||||
await updateCallHistoryFromLocalEvent(callEvent, null);
|
await updateCallHistoryFromLocalEvent(callEvent, null);
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ import type {
|
||||||
CallHistoryGroup,
|
CallHistoryGroup,
|
||||||
CallHistoryPagination,
|
CallHistoryPagination,
|
||||||
} from '../types/CallDisposition';
|
} from '../types/CallDisposition';
|
||||||
|
import type { CallLinkType, CallLinkRestrictions } from '../types/CallLink';
|
||||||
|
|
||||||
export type AdjacentMessagesByConversationOptionsType = Readonly<{
|
export type AdjacentMessagesByConversationOptionsType = Readonly<{
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
@ -696,6 +697,16 @@ export type DataInterface = {
|
||||||
getRecentStaleRingsAndMarkOlderMissed(): Promise<
|
getRecentStaleRingsAndMarkOlderMissed(): Promise<
|
||||||
ReadonlyArray<MaybeStaleCallHistory>
|
ReadonlyArray<MaybeStaleCallHistory>
|
||||||
>;
|
>;
|
||||||
|
callLinkExists(roomId: string): Promise<boolean>;
|
||||||
|
getAllCallLinks: () => Promise<ReadonlyArray<CallLinkType>>;
|
||||||
|
insertCallLink(callLink: CallLinkType): Promise<void>;
|
||||||
|
updateCallLinkState(
|
||||||
|
roomId: string,
|
||||||
|
name: string,
|
||||||
|
restrictions: CallLinkRestrictions,
|
||||||
|
expiration: number | null,
|
||||||
|
revoked: boolean
|
||||||
|
): Promise<void>;
|
||||||
migrateConversationMessages: (
|
migrateConversationMessages: (
|
||||||
obsoleteId: string,
|
obsoleteId: string,
|
||||||
currentId: string
|
currentId: string
|
||||||
|
|
|
@ -166,6 +166,13 @@ import {
|
||||||
GroupCallStatus,
|
GroupCallStatus,
|
||||||
CallType,
|
CallType,
|
||||||
} from '../types/CallDisposition';
|
} from '../types/CallDisposition';
|
||||||
|
import {
|
||||||
|
callLinkExists,
|
||||||
|
getAllCallLinks,
|
||||||
|
insertCallLink,
|
||||||
|
updateCallLinkState,
|
||||||
|
} from './server/callLinks';
|
||||||
|
import { CallMode } from '../types/Calling';
|
||||||
|
|
||||||
type ConversationRow = Readonly<{
|
type ConversationRow = Readonly<{
|
||||||
json: string;
|
json: string;
|
||||||
|
@ -328,6 +335,10 @@ const dataInterface: ServerInterface = {
|
||||||
hasGroupCallHistoryMessage,
|
hasGroupCallHistoryMessage,
|
||||||
markCallHistoryMissed,
|
markCallHistoryMissed,
|
||||||
getRecentStaleRingsAndMarkOlderMissed,
|
getRecentStaleRingsAndMarkOlderMissed,
|
||||||
|
callLinkExists,
|
||||||
|
getAllCallLinks,
|
||||||
|
insertCallLink,
|
||||||
|
updateCallLinkState,
|
||||||
migrateConversationMessages,
|
migrateConversationMessages,
|
||||||
getMessagesBetween,
|
getMessagesBetween,
|
||||||
getNearbyMessageFromDeletedSet,
|
getNearbyMessageFromDeletedSet,
|
||||||
|
@ -439,7 +450,7 @@ type DatabaseQueryCache = Map<string, Statement<Array<unknown>>>;
|
||||||
|
|
||||||
const statementCache = new WeakMap<Database, DatabaseQueryCache>();
|
const statementCache = new WeakMap<Database, DatabaseQueryCache>();
|
||||||
|
|
||||||
function prepare<T extends Array<unknown> | Record<string, unknown>>(
|
export function prepare<T extends Array<unknown> | Record<string, unknown>>(
|
||||||
db: Database,
|
db: Database,
|
||||||
query: string,
|
query: string,
|
||||||
{ pluck = false }: { pluck?: boolean } = {}
|
{ pluck = false }: { pluck?: boolean } = {}
|
||||||
|
@ -732,7 +743,7 @@ async function removeIndexedDBFiles(): Promise<void> {
|
||||||
indexedDBPath = undefined;
|
indexedDBPath = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReadonlyInstance(): Database {
|
export function getReadonlyInstance(): Database {
|
||||||
if (!globalReadonlyInstance) {
|
if (!globalReadonlyInstance) {
|
||||||
throw new Error('getReadonlyInstance: globalReadonlyInstance not set!');
|
throw new Error('getReadonlyInstance: globalReadonlyInstance not set!');
|
||||||
}
|
}
|
||||||
|
@ -742,7 +753,7 @@ function getReadonlyInstance(): Database {
|
||||||
|
|
||||||
const WRITABLE_INSTANCE_MAX_WAIT = 5 * durations.MINUTE;
|
const WRITABLE_INSTANCE_MAX_WAIT = 5 * durations.MINUTE;
|
||||||
|
|
||||||
async function getWritableInstance(): Promise<Database> {
|
export async function getWritableInstance(): Promise<Database> {
|
||||||
if (pausedWriteQueue) {
|
if (pausedWriteQueue) {
|
||||||
const { promise, resolve } = explodePromise<void>();
|
const { promise, resolve } = explodePromise<void>();
|
||||||
pausedWriteQueue.push(resolve);
|
pausedWriteQueue.push(resolve);
|
||||||
|
@ -3499,6 +3510,7 @@ const SEEN_STATUS_SEEN = sqlConstant(SeenStatus.Seen);
|
||||||
const CALL_STATUS_MISSED = sqlConstant(DirectCallStatus.Missed);
|
const CALL_STATUS_MISSED = sqlConstant(DirectCallStatus.Missed);
|
||||||
const CALL_STATUS_DELETED = sqlConstant(DirectCallStatus.Deleted);
|
const CALL_STATUS_DELETED = sqlConstant(DirectCallStatus.Deleted);
|
||||||
const CALL_STATUS_INCOMING = sqlConstant(CallDirection.Incoming);
|
const CALL_STATUS_INCOMING = sqlConstant(CallDirection.Incoming);
|
||||||
|
const CALL_MODE_ADHOC = sqlConstant(CallMode.Adhoc);
|
||||||
const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000);
|
const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000);
|
||||||
|
|
||||||
async function getCallHistoryUnreadCount(): Promise<number> {
|
async function getCallHistoryUnreadCount(): Promise<number> {
|
||||||
|
@ -3581,6 +3593,7 @@ function getCallHistoryGroupDataSync(
|
||||||
const { limit, offset } = pagination;
|
const { limit, offset } = pagination;
|
||||||
const { status, conversationIds } = filter;
|
const { status, conversationIds } = filter;
|
||||||
|
|
||||||
|
// TODO: DESKTOP-6827 Search Calls Tab for adhoc calls
|
||||||
if (conversationIds != null) {
|
if (conversationIds != null) {
|
||||||
strictAssert(conversationIds.length > 0, "can't filter by empty array");
|
strictAssert(conversationIds.length > 0, "can't filter by empty array");
|
||||||
|
|
||||||
|
@ -3632,8 +3645,15 @@ function getCallHistoryGroupDataSync(
|
||||||
const offsetLimit =
|
const offsetLimit =
|
||||||
limit > 0 ? sqlFragment`LIMIT ${limit} OFFSET ${offset}` : sqlFragment``;
|
limit > 0 ? sqlFragment`LIMIT ${limit} OFFSET ${offset}` : sqlFragment``;
|
||||||
|
|
||||||
|
// COUNT(*) OVER(): As a result of GROUP BY in the query (to limit adhoc call history
|
||||||
|
// to the single latest call), COUNT(*) changes to counting each group's counts rather
|
||||||
|
// than the total number of rows. Example: Say we have 2 group calls (A and B) and
|
||||||
|
// 10 adhoc calls on a single link. COUNT(*) ... GROUP BY returns [1, 1, 10]
|
||||||
|
// corresponding with callId A, callId B, adhoc peerId (the GROUP conditions).
|
||||||
|
// However we want COUNT(*) to do the normal thing and return total rows
|
||||||
|
// (so in the example above we want 3). COUNT(*) OVER achieves this.
|
||||||
const projection = isCount
|
const projection = isCount
|
||||||
? sqlFragment`COUNT(*) AS count`
|
? sqlFragment`COUNT(*) OVER() AS count`
|
||||||
: sqlFragment`peerId, ringerId, mode, type, direction, status, timestamp, possibleChildren, inPeriod`;
|
: sqlFragment`peerId, ringerId, mode, type, direction, status, timestamp, possibleChildren, inPeriod`;
|
||||||
|
|
||||||
const [query, params] = sql`
|
const [query, params] = sql`
|
||||||
|
@ -3697,6 +3717,8 @@ function getCallHistoryGroupDataSync(
|
||||||
-- Desktop Constraints:
|
-- Desktop Constraints:
|
||||||
AND callsHistory.status IS c.status
|
AND callsHistory.status IS c.status
|
||||||
AND ${filterClause}
|
AND ${filterClause}
|
||||||
|
-- Skip grouping logic for adhoc calls
|
||||||
|
AND callsHistory.mode IS NOT ${CALL_MODE_ADHOC}
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
) as possibleChildren,
|
) as possibleChildren,
|
||||||
|
|
||||||
|
@ -3731,6 +3753,12 @@ function getCallHistoryGroupDataSync(
|
||||||
FROM callAndGroupInfo
|
FROM callAndGroupInfo
|
||||||
) AS parentCallAndGroupInfo
|
) AS parentCallAndGroupInfo
|
||||||
WHERE parent = parentCallAndGroupInfo.callId
|
WHERE parent = parentCallAndGroupInfo.callId
|
||||||
|
GROUP BY
|
||||||
|
CASE
|
||||||
|
-- By spec, limit adhoc call history to the most recent call
|
||||||
|
WHEN mode IS ${CALL_MODE_ADHOC} THEN peerId
|
||||||
|
ELSE callId
|
||||||
|
END
|
||||||
ORDER BY parentCallAndGroupInfo.timestamp DESC
|
ORDER BY parentCallAndGroupInfo.timestamp DESC
|
||||||
${offsetLimit};
|
${offsetLimit};
|
||||||
`;
|
`;
|
||||||
|
|
40
ts/sql/migrations/1010-call-links-table.ts
Normal file
40
ts/sql/migrations/1010-call-links-table.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { Database } from '@signalapp/better-sqlite3';
|
||||||
|
|
||||||
|
import type { LoggerType } from '../../types/Logging';
|
||||||
|
import { sql } from '../util';
|
||||||
|
|
||||||
|
export const version = 1010;
|
||||||
|
|
||||||
|
export function updateToSchemaVersion1010(
|
||||||
|
currentVersion: number,
|
||||||
|
db: Database,
|
||||||
|
logger: LoggerType
|
||||||
|
): void {
|
||||||
|
if (currentVersion >= 1010) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
const [createTable] = sql`
|
||||||
|
CREATE TABLE callLinks (
|
||||||
|
roomId TEXT NOT NULL PRIMARY KEY,
|
||||||
|
rootKey BLOB NOT NULL,
|
||||||
|
adminKey BLOB,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
-- Enum which stores CallLinkRestrictions from ringrtc
|
||||||
|
restrictions INTEGER NOT NULL,
|
||||||
|
revoked INTEGER NOT NULL,
|
||||||
|
expiration INTEGER
|
||||||
|
) STRICT;
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.exec(createTable);
|
||||||
|
|
||||||
|
db.pragma('user_version = 1010');
|
||||||
|
})();
|
||||||
|
|
||||||
|
logger.info('updateToSchemaVersion1010: success!');
|
||||||
|
}
|
|
@ -75,10 +75,11 @@ import { updateToSchemaVersion960 } from './960-untag-pni';
|
||||||
import { updateToSchemaVersion970 } from './970-fts5-optimize';
|
import { updateToSchemaVersion970 } from './970-fts5-optimize';
|
||||||
import { updateToSchemaVersion980 } from './980-reaction-timestamp';
|
import { updateToSchemaVersion980 } from './980-reaction-timestamp';
|
||||||
import { updateToSchemaVersion990 } from './990-phone-number-sharing';
|
import { updateToSchemaVersion990 } from './990-phone-number-sharing';
|
||||||
|
import { updateToSchemaVersion1000 } from './1000-mark-unread-call-history-messages-as-unseen';
|
||||||
import {
|
import {
|
||||||
version as MAX_VERSION,
|
version as MAX_VERSION,
|
||||||
updateToSchemaVersion1000,
|
updateToSchemaVersion1010,
|
||||||
} from './1000-mark-unread-call-history-messages-as-unseen';
|
} from './1010-call-links-table';
|
||||||
|
|
||||||
function updateToSchemaVersion1(
|
function updateToSchemaVersion1(
|
||||||
currentVersion: number,
|
currentVersion: number,
|
||||||
|
@ -2021,6 +2022,7 @@ export const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion980,
|
updateToSchemaVersion980,
|
||||||
updateToSchemaVersion990,
|
updateToSchemaVersion990,
|
||||||
updateToSchemaVersion1000,
|
updateToSchemaVersion1000,
|
||||||
|
updateToSchemaVersion1010,
|
||||||
];
|
];
|
||||||
|
|
||||||
export class DBVersionFromFutureError extends Error {
|
export class DBVersionFromFutureError extends Error {
|
||||||
|
|
100
ts/sql/server/callLinks.ts
Normal file
100
ts/sql/server/callLinks.ts
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { Database } from '@signalapp/better-sqlite3';
|
||||||
|
import { CallLinkRootKey } from '@signalapp/ringrtc';
|
||||||
|
import type { CallLinkRestrictions, CallLinkType } from '../../types/CallLink';
|
||||||
|
import {
|
||||||
|
callLinkRestrictionsSchema,
|
||||||
|
callLinkRecordSchema,
|
||||||
|
} from '../../types/CallLink';
|
||||||
|
import { callLinkToRecord, callLinkFromRecord } from '../../util/callLinks';
|
||||||
|
import { getReadonlyInstance, getWritableInstance, prepare } from '../Server';
|
||||||
|
import { sql } from '../util';
|
||||||
|
import { strictAssert } from '../../util/assert';
|
||||||
|
|
||||||
|
export async function callLinkExists(roomId: string): Promise<boolean> {
|
||||||
|
const db = getReadonlyInstance();
|
||||||
|
const [query, params] = sql`
|
||||||
|
SELECT 1
|
||||||
|
FROM callLinks
|
||||||
|
WHERE roomId = ${roomId};
|
||||||
|
`;
|
||||||
|
return db.prepare(query).pluck(true).get(params) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllCallLinks(): Promise<ReadonlyArray<CallLinkType>> {
|
||||||
|
const db = getReadonlyInstance();
|
||||||
|
const [query] = sql`
|
||||||
|
SELECT * FROM callLinks;
|
||||||
|
`;
|
||||||
|
return db
|
||||||
|
.prepare(query)
|
||||||
|
.all()
|
||||||
|
.map(item => callLinkFromRecord(callLinkRecordSchema.parse(item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _insertCallLink(db: Database, callLink: CallLinkType): void {
|
||||||
|
const { roomId, rootKey } = callLink;
|
||||||
|
assertRoomIdMatchesRootKey(roomId, rootKey);
|
||||||
|
|
||||||
|
const data = callLinkToRecord(callLink);
|
||||||
|
prepare(
|
||||||
|
db,
|
||||||
|
`
|
||||||
|
INSERT INTO callLinks (
|
||||||
|
roomId,
|
||||||
|
rootKey,
|
||||||
|
adminKey,
|
||||||
|
name,
|
||||||
|
restrictions,
|
||||||
|
revoked,
|
||||||
|
expiration
|
||||||
|
) VALUES (
|
||||||
|
$roomId,
|
||||||
|
$rootKey,
|
||||||
|
$adminKey,
|
||||||
|
$name,
|
||||||
|
$restrictions,
|
||||||
|
$revoked,
|
||||||
|
$expiration
|
||||||
|
)
|
||||||
|
`
|
||||||
|
).run(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertCallLink(callLink: CallLinkType): Promise<void> {
|
||||||
|
const db = await getWritableInstance();
|
||||||
|
_insertCallLink(db, callLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCallLinkState(
|
||||||
|
roomId: string,
|
||||||
|
name: string,
|
||||||
|
restrictions: CallLinkRestrictions,
|
||||||
|
expiration: number,
|
||||||
|
revoked: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getWritableInstance();
|
||||||
|
const restrictionsValue = callLinkRestrictionsSchema.parse(restrictions);
|
||||||
|
const [query, params] = sql`
|
||||||
|
UPDATE callLinks
|
||||||
|
SET
|
||||||
|
name = ${name},
|
||||||
|
restrictions = ${restrictionsValue},
|
||||||
|
expiration = ${expiration},
|
||||||
|
revoked = ${revoked ? 1 : 0}
|
||||||
|
WHERE roomId = ${roomId};
|
||||||
|
`;
|
||||||
|
db.prepare(query).run(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRoomIdMatchesRootKey(roomId: string, rootKey: string): void {
|
||||||
|
const derivedRoomId = CallLinkRootKey.parse(rootKey)
|
||||||
|
.deriveRoomId()
|
||||||
|
.toString('hex');
|
||||||
|
strictAssert(
|
||||||
|
roomId === derivedRoomId,
|
||||||
|
'passed roomId must match roomId derived from root key'
|
||||||
|
);
|
||||||
|
}
|
|
@ -76,6 +76,7 @@ import type { ShowErrorModalActionType } from './globalModals';
|
||||||
import { SHOW_ERROR_MODAL } from './globalModals';
|
import { SHOW_ERROR_MODAL } from './globalModals';
|
||||||
import { ButtonVariant } from '../../components/Button';
|
import { ButtonVariant } from '../../components/Button';
|
||||||
import { getConversationIdForLogging } from '../../util/idForLogging';
|
import { getConversationIdForLogging } from '../../util/idForLogging';
|
||||||
|
import dataInterface from '../../sql/Client';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
|
@ -171,12 +172,14 @@ export type AdhocCallsType = {
|
||||||
export type CallLinkStateType = ReadonlyDeep<{
|
export type CallLinkStateType = ReadonlyDeep<{
|
||||||
name: string;
|
name: string;
|
||||||
restrictions: CallLinkRestrictions;
|
restrictions: CallLinkRestrictions;
|
||||||
expiration: number;
|
expiration: number | null;
|
||||||
|
revoked: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type CallLinksByRoomIdStateType = ReadonlyDeep<
|
export type CallLinksByRoomIdStateType = ReadonlyDeep<
|
||||||
CallLinkStateType & {
|
CallLinkStateType & {
|
||||||
rootKey: string;
|
rootKey: string;
|
||||||
|
adminKey: string | null;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
@ -331,6 +334,19 @@ export type StartCallLinkLobbyType = ReadonlyDeep<{
|
||||||
rootKey: string;
|
rootKey: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type StartCallLinkLobbyByRoomIdType = ReadonlyDeep<{
|
||||||
|
roomId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type StartCallLinkLobbyThunkActionType = ReadonlyDeep<
|
||||||
|
ThunkAction<
|
||||||
|
void,
|
||||||
|
RootStateType,
|
||||||
|
unknown,
|
||||||
|
StartCallLinkLobbyActionType | ShowErrorModalActionType
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
// eslint-disable-next-line local-rules/type-alias-readonlydeep
|
// eslint-disable-next-line local-rules/type-alias-readonlydeep
|
||||||
type StartCallingLobbyPayloadType =
|
type StartCallingLobbyPayloadType =
|
||||||
| {
|
| {
|
||||||
|
@ -513,7 +529,7 @@ const doGroupCallPeek = ({
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await calling.updateCallHistoryForGroupCall(
|
await calling.updateCallHistoryForGroupCallOnPeek(
|
||||||
conversationId,
|
conversationId,
|
||||||
joinState,
|
joinState,
|
||||||
peekInfo
|
peekInfo
|
||||||
|
@ -1717,86 +1733,147 @@ function onOutgoingAudioCallInConversation(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function startCallLinkLobby({
|
function startCallLinkLobbyByRoomId(
|
||||||
rootKey,
|
roomId: string
|
||||||
}: StartCallLinkLobbyType): ThunkAction<
|
): StartCallLinkLobbyThunkActionType {
|
||||||
void,
|
|
||||||
RootStateType,
|
|
||||||
unknown,
|
|
||||||
StartCallLinkLobbyActionType | ShowErrorModalActionType
|
|
||||||
> {
|
|
||||||
return async (dispatch, getState) => {
|
return async (dispatch, getState) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
|
const callLink = getOwn(state.calling.callLinks, roomId);
|
||||||
|
|
||||||
if (state.calling.activeCallState) {
|
strictAssert(
|
||||||
const i18n = getIntl(getState());
|
callLink,
|
||||||
dispatch({
|
`startCallLinkLobbyByRoomId(${roomId}): call link not found`
|
||||||
type: SHOW_ERROR_MODAL,
|
);
|
||||||
payload: {
|
|
||||||
title: i18n('icu:calling__cant-join'),
|
|
||||||
description: i18n('icu:calling__dialog-already-in-call'),
|
|
||||||
buttonVariant: ButtonVariant.Primary,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
|
const { rootKey } = callLink;
|
||||||
|
await _startCallLinkLobby({ rootKey, dispatch, getState });
|
||||||
const callLinkState = await calling.readCallLink({ callLinkRootKey });
|
|
||||||
if (!callLinkState) {
|
|
||||||
const i18n = getIntl(getState());
|
|
||||||
dispatch({
|
|
||||||
type: SHOW_ERROR_MODAL,
|
|
||||||
payload: {
|
|
||||||
title: i18n('icu:calling__cant-join'),
|
|
||||||
description: i18n('icu:calling__call-link-connection-issues'),
|
|
||||||
buttonVariant: ButtonVariant.Primary,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (callLinkState.revoked || callLinkState.expiration < new Date()) {
|
|
||||||
const i18n = getIntl(getState());
|
|
||||||
dispatch({
|
|
||||||
type: SHOW_ERROR_MODAL,
|
|
||||||
payload: {
|
|
||||||
title: i18n('icu:calling__cant-join'),
|
|
||||||
description: i18n('icu:calling__call-link-no-longer-valid'),
|
|
||||||
buttonVariant: ButtonVariant.Primary,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomId = getRoomIdFromRootKey(callLinkRootKey);
|
|
||||||
const groupCall = getGroupCall(roomId, state.calling, CallMode.Adhoc);
|
|
||||||
const groupCallDeviceCount =
|
|
||||||
groupCall?.peekInfo?.deviceCount ||
|
|
||||||
groupCall?.remoteParticipants.length ||
|
|
||||||
0;
|
|
||||||
|
|
||||||
const callLobbyData = await calling.startCallLinkLobby({
|
|
||||||
callLinkRootKey,
|
|
||||||
hasLocalAudio: groupCallDeviceCount < 8,
|
|
||||||
});
|
|
||||||
if (!callLobbyData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: START_CALL_LINK_LOBBY,
|
|
||||||
payload: {
|
|
||||||
...callLobbyData,
|
|
||||||
callLinkState: calling.formatCallLinkStateForRedux(callLinkState),
|
|
||||||
callLinkRootKey: rootKey,
|
|
||||||
conversationId: roomId,
|
|
||||||
isConversationTooBigToRing: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startCallLinkLobby({
|
||||||
|
rootKey,
|
||||||
|
}: StartCallLinkLobbyType): StartCallLinkLobbyThunkActionType {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
await _startCallLinkLobby({ rootKey, dispatch, getState });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const _startCallLinkLobby = async ({
|
||||||
|
rootKey,
|
||||||
|
dispatch,
|
||||||
|
getState,
|
||||||
|
}: {
|
||||||
|
rootKey: string;
|
||||||
|
dispatch: ThunkDispatch<
|
||||||
|
RootStateType,
|
||||||
|
unknown,
|
||||||
|
StartCallLinkLobbyActionType | ShowErrorModalActionType
|
||||||
|
>;
|
||||||
|
getState: () => RootStateType;
|
||||||
|
}) => {
|
||||||
|
const state = getState();
|
||||||
|
|
||||||
|
if (state.calling.activeCallState) {
|
||||||
|
const i18n = getIntl(getState());
|
||||||
|
dispatch({
|
||||||
|
type: SHOW_ERROR_MODAL,
|
||||||
|
payload: {
|
||||||
|
title: i18n('icu:calling__cant-join'),
|
||||||
|
description: i18n('icu:calling__dialog-already-in-call'),
|
||||||
|
buttonVariant: ButtonVariant.Primary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
|
||||||
|
|
||||||
|
const callLinkState = await calling.readCallLink({ callLinkRootKey });
|
||||||
|
if (!callLinkState) {
|
||||||
|
const i18n = getIntl(getState());
|
||||||
|
dispatch({
|
||||||
|
type: SHOW_ERROR_MODAL,
|
||||||
|
payload: {
|
||||||
|
title: i18n('icu:calling__cant-join'),
|
||||||
|
description: i18n('icu:calling__call-link-connection-issues'),
|
||||||
|
buttonVariant: ButtonVariant.Primary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (callLinkState.revoked || callLinkState.expiration < new Date()) {
|
||||||
|
const i18n = getIntl(getState());
|
||||||
|
dispatch({
|
||||||
|
type: SHOW_ERROR_MODAL,
|
||||||
|
payload: {
|
||||||
|
title: i18n('icu:calling__cant-join'),
|
||||||
|
description: i18n('icu:calling__call-link-no-longer-valid'),
|
||||||
|
buttonVariant: ButtonVariant.Primary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = getRoomIdFromRootKey(callLinkRootKey);
|
||||||
|
const formattedCallLinkState =
|
||||||
|
calling.formatCallLinkStateForRedux(callLinkState);
|
||||||
|
try {
|
||||||
|
const { name, restrictions, expiration, revoked } = formattedCallLinkState;
|
||||||
|
const callLinkExists = await dataInterface.callLinkExists(roomId);
|
||||||
|
if (callLinkExists) {
|
||||||
|
await dataInterface.updateCallLinkState(
|
||||||
|
roomId,
|
||||||
|
name,
|
||||||
|
restrictions,
|
||||||
|
expiration,
|
||||||
|
revoked
|
||||||
|
);
|
||||||
|
log.info('startCallLinkLobby: Updated existing call link', roomId);
|
||||||
|
} else {
|
||||||
|
await dataInterface.insertCallLink({
|
||||||
|
roomId,
|
||||||
|
rootKey,
|
||||||
|
adminKey: null,
|
||||||
|
name,
|
||||||
|
restrictions,
|
||||||
|
revoked,
|
||||||
|
expiration,
|
||||||
|
});
|
||||||
|
log.info('startCallLinkLobby: Saved new call link', roomId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error(
|
||||||
|
'startCallLinkLobby: Call link DB error',
|
||||||
|
Errors.toLogFormat(err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupCall = getGroupCall(roomId, state.calling, CallMode.Adhoc);
|
||||||
|
const groupCallDeviceCount =
|
||||||
|
groupCall?.peekInfo?.deviceCount ||
|
||||||
|
groupCall?.remoteParticipants.length ||
|
||||||
|
0;
|
||||||
|
|
||||||
|
const callLobbyData = await calling.startCallLinkLobby({
|
||||||
|
callLinkRootKey,
|
||||||
|
hasLocalAudio: groupCallDeviceCount < 8,
|
||||||
|
});
|
||||||
|
if (!callLobbyData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: START_CALL_LINK_LOBBY,
|
||||||
|
payload: {
|
||||||
|
...callLobbyData,
|
||||||
|
callLinkState: formattedCallLinkState,
|
||||||
|
callLinkRootKey: rootKey,
|
||||||
|
conversationId: roomId,
|
||||||
|
isConversationTooBigToRing: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
function startCallingLobby({
|
function startCallingLobby({
|
||||||
conversationId,
|
conversationId,
|
||||||
isVideoCall,
|
isVideoCall,
|
||||||
|
@ -2010,6 +2087,7 @@ export const actions = {
|
||||||
setRendererCanvas,
|
setRendererCanvas,
|
||||||
startCall,
|
startCall,
|
||||||
startCallLinkLobby,
|
startCallLinkLobby,
|
||||||
|
startCallLinkLobbyByRoomId,
|
||||||
startCallingLobby,
|
startCallingLobby,
|
||||||
switchToPresentationView,
|
switchToPresentationView,
|
||||||
switchFromPresentationView,
|
switchFromPresentationView,
|
||||||
|
@ -2194,6 +2272,7 @@ export function reducer(
|
||||||
[conversationId]: {
|
[conversationId]: {
|
||||||
...action.payload.callLinkState,
|
...action.payload.callLinkState,
|
||||||
rootKey: action.payload.callLinkRootKey,
|
rootKey: action.payload.callLinkRootKey,
|
||||||
|
adminKey: null,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: callLinks,
|
: callLinks,
|
||||||
|
@ -2287,18 +2366,8 @@ export function reducer(
|
||||||
case CallMode.Direct:
|
case CallMode.Direct:
|
||||||
return removeConversationFromState(state, activeCall.conversationId);
|
return removeConversationFromState(state, activeCall.conversationId);
|
||||||
case CallMode.Group:
|
case CallMode.Group:
|
||||||
|
case CallMode.Adhoc:
|
||||||
return omit(state, 'activeCallState');
|
return omit(state, 'activeCallState');
|
||||||
case CallMode.Adhoc: {
|
|
||||||
// TODO: When call links persist in the DB, we can remove the removal logic here.
|
|
||||||
log.info(
|
|
||||||
`Removing active adhoc call with roomId ${activeCall.conversationId}`
|
|
||||||
);
|
|
||||||
const { callLinks } = state;
|
|
||||||
return {
|
|
||||||
...omit(state, 'activeCallState'),
|
|
||||||
callLinks: omit(callLinks, activeCall.conversationId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
throw missingCaseError(activeCall);
|
throw missingCaseError(activeCall);
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,9 +41,11 @@ import { getInteractionMode } from '../services/InteractionMode';
|
||||||
import { makeLookup } from '../util/makeLookup';
|
import { makeLookup } from '../util/makeLookup';
|
||||||
import type { CallHistoryDetails } from '../types/CallDisposition';
|
import type { CallHistoryDetails } from '../types/CallDisposition';
|
||||||
import type { ThemeType } from '../types/Util';
|
import type { ThemeType } from '../types/Util';
|
||||||
|
import type { CallLinkType } from '../types/CallLink';
|
||||||
|
|
||||||
export function getInitialState({
|
export function getInitialState({
|
||||||
badges,
|
badges,
|
||||||
|
callLinks,
|
||||||
callsHistory,
|
callsHistory,
|
||||||
callsHistoryUnreadCount,
|
callsHistoryUnreadCount,
|
||||||
stories,
|
stories,
|
||||||
|
@ -53,6 +55,7 @@ export function getInitialState({
|
||||||
theme,
|
theme,
|
||||||
}: {
|
}: {
|
||||||
badges: BadgesStateType;
|
badges: BadgesStateType;
|
||||||
|
callLinks: ReadonlyArray<CallLinkType>;
|
||||||
callsHistory: ReadonlyArray<CallHistoryDetails>;
|
callsHistory: ReadonlyArray<CallHistoryDetails>;
|
||||||
callsHistoryUnreadCount: number;
|
callsHistoryUnreadCount: number;
|
||||||
stories: Array<StoryDataType>;
|
stories: Array<StoryDataType>;
|
||||||
|
@ -95,7 +98,10 @@ export function getInitialState({
|
||||||
callHistoryByCallId: makeLookup(callsHistory, 'callId'),
|
callHistoryByCallId: makeLookup(callsHistory, 'callId'),
|
||||||
unreadCount: callsHistoryUnreadCount,
|
unreadCount: callsHistoryUnreadCount,
|
||||||
},
|
},
|
||||||
calling: calling(),
|
calling: {
|
||||||
|
...calling(),
|
||||||
|
callLinks: makeLookup(callLinks, 'roomId'),
|
||||||
|
},
|
||||||
composer: composer(),
|
composer: composer(),
|
||||||
conversations: {
|
conversations: {
|
||||||
...conversations(),
|
...conversations(),
|
||||||
|
|
|
@ -12,8 +12,10 @@ import { actionCreators } from './actions';
|
||||||
import { createStore } from './createStore';
|
import { createStore } from './createStore';
|
||||||
import { getInitialState } from './getInitialState';
|
import { getInitialState } from './getInitialState';
|
||||||
import type { ThemeType } from '../types/Util';
|
import type { ThemeType } from '../types/Util';
|
||||||
|
import type { CallLinkType } from '../types/CallLink';
|
||||||
|
|
||||||
export function initializeRedux({
|
export function initializeRedux({
|
||||||
|
callLinks,
|
||||||
callsHistory,
|
callsHistory,
|
||||||
callsHistoryUnreadCount,
|
callsHistoryUnreadCount,
|
||||||
initialBadgesState,
|
initialBadgesState,
|
||||||
|
@ -23,6 +25,7 @@ export function initializeRedux({
|
||||||
storyDistributionLists,
|
storyDistributionLists,
|
||||||
theme,
|
theme,
|
||||||
}: {
|
}: {
|
||||||
|
callLinks: ReadonlyArray<CallLinkType>;
|
||||||
callsHistory: ReadonlyArray<CallHistoryDetails>;
|
callsHistory: ReadonlyArray<CallHistoryDetails>;
|
||||||
callsHistoryUnreadCount: number;
|
callsHistoryUnreadCount: number;
|
||||||
initialBadgesState: BadgesStateType;
|
initialBadgesState: BadgesStateType;
|
||||||
|
@ -34,6 +37,7 @@ export function initializeRedux({
|
||||||
}): void {
|
}): void {
|
||||||
const initialState = getInitialState({
|
const initialState = getInitialState({
|
||||||
badges: initialBadgesState,
|
badges: initialBadgesState,
|
||||||
|
callLinks,
|
||||||
callsHistory,
|
callsHistory,
|
||||||
callsHistoryUnreadCount,
|
callsHistoryUnreadCount,
|
||||||
mainWindowStats,
|
mainWindowStats,
|
||||||
|
|
|
@ -85,13 +85,9 @@ export const getCallLinkSelector = createSelector(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, restrictions, rootKey, expiration } = callLinkState;
|
|
||||||
return {
|
return {
|
||||||
roomId,
|
roomId,
|
||||||
name,
|
...callLinkState,
|
||||||
restrictions,
|
|
||||||
rootKey,
|
|
||||||
expiration,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -25,7 +25,7 @@ import type { ConversationType } from '../ducks/conversations';
|
||||||
import { SmartConversationDetails } from './ConversationDetails';
|
import { SmartConversationDetails } from './ConversationDetails';
|
||||||
import { SmartToastManager } from './ToastManager';
|
import { SmartToastManager } from './ToastManager';
|
||||||
import { useCallingActions } from '../ducks/calling';
|
import { useCallingActions } from '../ducks/calling';
|
||||||
import { getActiveCallState } from '../selectors/calling';
|
import { getActiveCallState, getCallLinkSelector } from '../selectors/calling';
|
||||||
import { useCallHistoryActions } from '../ducks/callHistory';
|
import { useCallHistoryActions } from '../ducks/callHistory';
|
||||||
import { getCallHistoryEdition } from '../selectors/callHistory';
|
import { getCallHistoryEdition } from '../selectors/callHistory';
|
||||||
import { getHasPendingUpdate } from '../selectors/updates';
|
import { getHasPendingUpdate } from '../selectors/updates';
|
||||||
|
@ -97,6 +97,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
|
||||||
const allConversations = useSelector(getAllConversations);
|
const allConversations = useSelector(getAllConversations);
|
||||||
const regionCode = useSelector(getRegionCode);
|
const regionCode = useSelector(getRegionCode);
|
||||||
const getConversation = useSelector(getConversationSelector);
|
const getConversation = useSelector(getConversationSelector);
|
||||||
|
const getCallLink = useSelector(getCallLinkSelector);
|
||||||
|
|
||||||
const activeCall = useSelector(getActiveCallState);
|
const activeCall = useSelector(getActiveCallState);
|
||||||
const callHistoryEdition = useSelector(getCallHistoryEdition);
|
const callHistoryEdition = useSelector(getCallHistoryEdition);
|
||||||
|
@ -108,6 +109,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
|
||||||
const {
|
const {
|
||||||
onOutgoingAudioCallInConversation,
|
onOutgoingAudioCallInConversation,
|
||||||
onOutgoingVideoCallInConversation,
|
onOutgoingVideoCallInConversation,
|
||||||
|
startCallLinkLobbyByRoomId,
|
||||||
} = useCallingActions();
|
} = useCallingActions();
|
||||||
const {
|
const {
|
||||||
clearAllCallHistory: clearCallHistory,
|
clearAllCallHistory: clearCallHistory,
|
||||||
|
@ -167,6 +169,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
|
||||||
getConversation={getConversation}
|
getConversation={getConversation}
|
||||||
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
||||||
getCallHistoryGroups={getCallHistoryGroups}
|
getCallHistoryGroups={getCallHistoryGroups}
|
||||||
|
getCallLink={getCallLink}
|
||||||
callHistoryEdition={callHistoryEdition}
|
callHistoryEdition={callHistoryEdition}
|
||||||
hasFailedStorySends={hasFailedStorySends}
|
hasFailedStorySends={hasFailedStorySends}
|
||||||
hasPendingUpdate={hasPendingUpdate}
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
|
@ -182,6 +185,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
|
||||||
renderToastManager={renderToastManager}
|
renderToastManager={renderToastManager}
|
||||||
regionCode={regionCode}
|
regionCode={regionCode}
|
||||||
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||||
|
startCallLinkLobbyByRoomId={startCallLinkLobbyByRoomId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
39
ts/test-both/util/callLinks_test.ts
Normal file
39
ts/test-both/util/callLinks_test.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import { callLinkToRecord, callLinkFromRecord } from '../../util/callLinks';
|
||||||
|
import type { CallLinkType } from '../../types/CallLink';
|
||||||
|
import { CallLinkRestrictions } from '../../types/CallLink';
|
||||||
|
import { MONTH } from '../../util/durations/constants';
|
||||||
|
|
||||||
|
const CALL_LINK: CallLinkType = {
|
||||||
|
adminKey: null,
|
||||||
|
expiration: Date.now() + MONTH,
|
||||||
|
name: 'Fun Link',
|
||||||
|
restrictions: CallLinkRestrictions.None,
|
||||||
|
revoked: false,
|
||||||
|
roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4',
|
||||||
|
rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CALL_LINK_WITH_ADMIN_KEY: CallLinkType = {
|
||||||
|
adminKey: 'xXPI77e6MoVHYREW8iKYmQ==',
|
||||||
|
expiration: Date.now() + MONTH,
|
||||||
|
name: 'Fun Link',
|
||||||
|
restrictions: CallLinkRestrictions.None,
|
||||||
|
revoked: false,
|
||||||
|
roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4',
|
||||||
|
rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('callLinks', () => {
|
||||||
|
it('callLinkToRecord() and callLinkFromRecord() can convert to record and back', () => {
|
||||||
|
[CALL_LINK, CALL_LINK_WITH_ADMIN_KEY].forEach(callLink => {
|
||||||
|
const record = callLinkToRecord(callLink);
|
||||||
|
const returnedCallLink = callLinkFromRecord(record);
|
||||||
|
assert.deepEqual(returnedCallLink, callLink);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -12,8 +12,10 @@ import type { ServiceIdString } from '../../types/ServiceId';
|
||||||
import type {
|
import type {
|
||||||
CallHistoryDetails,
|
CallHistoryDetails,
|
||||||
CallHistoryGroup,
|
CallHistoryGroup,
|
||||||
|
CallStatus,
|
||||||
} from '../../types/CallDisposition';
|
} from '../../types/CallDisposition';
|
||||||
import {
|
import {
|
||||||
|
AdhocCallStatus,
|
||||||
CallDirection,
|
CallDirection,
|
||||||
CallHistoryFilterStatus,
|
CallHistoryFilterStatus,
|
||||||
CallType,
|
CallType,
|
||||||
|
@ -22,8 +24,13 @@ import {
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
import type { ConversationAttributesType } from '../../model-types';
|
import type { ConversationAttributesType } from '../../model-types';
|
||||||
|
|
||||||
const { removeAll, getCallHistoryGroups, saveCallHistory, saveConversation } =
|
const {
|
||||||
dataInterface;
|
removeAll,
|
||||||
|
getCallHistoryGroups,
|
||||||
|
getCallHistoryGroupsCount,
|
||||||
|
saveCallHistory,
|
||||||
|
saveConversation,
|
||||||
|
} = dataInterface;
|
||||||
|
|
||||||
function toGroup(calls: Array<CallHistoryDetails>): CallHistoryGroup {
|
function toGroup(calls: Array<CallHistoryDetails>): CallHistoryGroup {
|
||||||
const firstCall = calls.at(0);
|
const firstCall = calls.at(0);
|
||||||
|
@ -41,6 +48,19 @@ function toGroup(calls: Array<CallHistoryDetails>): CallHistoryGroup {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toAdhocGroup(call: CallHistoryDetails): CallHistoryGroup {
|
||||||
|
strictAssert(call != null, 'needs call');
|
||||||
|
return {
|
||||||
|
peerId: call.peerId,
|
||||||
|
mode: call.mode,
|
||||||
|
type: call.type,
|
||||||
|
direction: call.direction,
|
||||||
|
timestamp: call.timestamp,
|
||||||
|
status: call.status,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('sql/getCallHistoryGroups', () => {
|
describe('sql/getCallHistoryGroups', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await removeAll();
|
await removeAll();
|
||||||
|
@ -255,4 +275,158 @@ describe('sql/getCallHistoryGroups', () => {
|
||||||
|
|
||||||
assert.deepEqual(groups, [toGroup([call])]);
|
assert.deepEqual(groups, [toGroup([call])]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should support Missed status filter', async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const conversationId = generateUuid();
|
||||||
|
|
||||||
|
function toCall(
|
||||||
|
callId: string,
|
||||||
|
timestamp: number,
|
||||||
|
type: CallType,
|
||||||
|
status: CallStatus
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
callId,
|
||||||
|
peerId: conversationId,
|
||||||
|
ringerId: generateAci(),
|
||||||
|
mode: CallMode.Direct,
|
||||||
|
type,
|
||||||
|
direction: CallDirection.Incoming,
|
||||||
|
timestamp,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const call1 = toCall(
|
||||||
|
'1',
|
||||||
|
now - 10,
|
||||||
|
CallType.Video,
|
||||||
|
DirectCallStatus.Accepted
|
||||||
|
);
|
||||||
|
const call2 = toCall('2', now, CallType.Audio, DirectCallStatus.Missed);
|
||||||
|
|
||||||
|
await saveCallHistory(call1);
|
||||||
|
await saveCallHistory(call2);
|
||||||
|
|
||||||
|
const groups = await getCallHistoryGroups(
|
||||||
|
{ status: CallHistoryFilterStatus.Missed, conversationIds: null },
|
||||||
|
{ offset: 0, limit: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(groups, [toGroup([call2])]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only return the newest call for an adhoc call roomId', async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const roomId = generateUuid();
|
||||||
|
|
||||||
|
function toCall(callId: string, timestamp: number, type: CallType) {
|
||||||
|
return {
|
||||||
|
callId,
|
||||||
|
peerId: roomId,
|
||||||
|
ringerId: null,
|
||||||
|
mode: CallMode.Adhoc,
|
||||||
|
type,
|
||||||
|
direction: CallDirection.Outgoing,
|
||||||
|
timestamp,
|
||||||
|
status: AdhocCallStatus.Joined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const call1 = toCall('1', now - 10, CallType.Group);
|
||||||
|
const call2 = toCall('2', now, CallType.Group);
|
||||||
|
|
||||||
|
await saveCallHistory(call1);
|
||||||
|
await saveCallHistory(call2);
|
||||||
|
|
||||||
|
const groups = await getCallHistoryGroups(
|
||||||
|
{ status: CallHistoryFilterStatus.All, conversationIds: null },
|
||||||
|
{ offset: 0, limit: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(groups, [toAdhocGroup(call2)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sql/getCallHistoryGroupsCount', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await removeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts', async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const conversationId = generateUuid();
|
||||||
|
|
||||||
|
function toCall(callId: string, timestamp: number, type: CallType) {
|
||||||
|
return {
|
||||||
|
callId,
|
||||||
|
peerId: conversationId,
|
||||||
|
ringerId: generateAci(),
|
||||||
|
mode: CallMode.Direct,
|
||||||
|
type,
|
||||||
|
direction: CallDirection.Incoming,
|
||||||
|
timestamp,
|
||||||
|
status: DirectCallStatus.Accepted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const call1 = toCall('1', now - 30, CallType.Video);
|
||||||
|
const call2 = toCall('2', now - 20, CallType.Video);
|
||||||
|
const call3 = toCall('3', now - 10, CallType.Audio);
|
||||||
|
const call4 = toCall('4', now, CallType.Video);
|
||||||
|
|
||||||
|
await saveCallHistory(call1);
|
||||||
|
await saveCallHistory(call2);
|
||||||
|
await saveCallHistory(call3);
|
||||||
|
await saveCallHistory(call4);
|
||||||
|
|
||||||
|
const result = await getCallHistoryGroupsCount({
|
||||||
|
status: CallHistoryFilterStatus.All,
|
||||||
|
conversationIds: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only count each call link roomId once if it had multiple calls', async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const roomId1 = generateUuid();
|
||||||
|
const roomId2 = generateUuid();
|
||||||
|
|
||||||
|
function toCall(
|
||||||
|
callId: string,
|
||||||
|
roomId: string,
|
||||||
|
timestamp: number,
|
||||||
|
type: CallType
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
callId,
|
||||||
|
peerId: roomId,
|
||||||
|
ringerId: null,
|
||||||
|
mode: CallMode.Adhoc,
|
||||||
|
type,
|
||||||
|
direction: CallDirection.Outgoing,
|
||||||
|
timestamp,
|
||||||
|
status: AdhocCallStatus.Joined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const call1 = toCall('1', roomId1, now - 20, CallType.Group);
|
||||||
|
const call2 = toCall('2', roomId1, now - 10, CallType.Group);
|
||||||
|
const call3 = toCall('3', roomId1, now, CallType.Group);
|
||||||
|
const call4 = toCall('4', roomId2, now, CallType.Group);
|
||||||
|
|
||||||
|
await saveCallHistory(call1);
|
||||||
|
await saveCallHistory(call2);
|
||||||
|
await saveCallHistory(call3);
|
||||||
|
await saveCallHistory(call4);
|
||||||
|
|
||||||
|
const result = await getCallHistoryGroupsCount({
|
||||||
|
status: CallHistoryFilterStatus.All,
|
||||||
|
conversationIds: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result, 2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1314,10 +1314,11 @@ describe('calling duck', () => {
|
||||||
callingService,
|
callingService,
|
||||||
'peekGroupCall'
|
'peekGroupCall'
|
||||||
);
|
);
|
||||||
this.callingServiceUpdateCallHistoryForGroupCall = this.sandbox.stub(
|
this.callingServiceUpdateCallHistoryForGroupCallOnPeek =
|
||||||
callingService,
|
this.sandbox.stub(
|
||||||
'updateCallHistoryForGroupCall'
|
callingService,
|
||||||
);
|
'updateCallHistoryForGroupCallOnPeek'
|
||||||
|
);
|
||||||
this.clock = this.sandbox.useFakeTimers();
|
this.clock = this.sandbox.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
80
ts/test-node/util/callDisposition_test.ts
Normal file
80
ts/test-node/util/callDisposition_test.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import type { PeekInfo } from '@signalapp/ringrtc';
|
||||||
|
import uuid from 'uuid';
|
||||||
|
import {
|
||||||
|
getPeerIdFromConversation,
|
||||||
|
getCallIdFromEra,
|
||||||
|
getGroupCallMeta,
|
||||||
|
} from '../../util/callDisposition';
|
||||||
|
import {
|
||||||
|
getDefaultConversation,
|
||||||
|
getDefaultGroup,
|
||||||
|
} from '../../test-both/helpers/getDefaultConversation';
|
||||||
|
import { uuidToBytes } from '../../util/uuidToBytes';
|
||||||
|
|
||||||
|
const MOCK_ERA = 'abc';
|
||||||
|
const MOCK_CALL_ID = '16919744041952114874';
|
||||||
|
|
||||||
|
const MOCK_PEEK_INFO_BASE: PeekInfo = {
|
||||||
|
devices: [],
|
||||||
|
deviceCount: 0,
|
||||||
|
deviceCountIncludingPendingDevices: 0,
|
||||||
|
deviceCountExcludingPendingDevices: 0,
|
||||||
|
pendingUsers: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('utils/callDisposition', () => {
|
||||||
|
describe('getCallIdFromEra', () => {
|
||||||
|
it('returns callId from era', () => {
|
||||||
|
// just to ensure the mock is correct
|
||||||
|
assert.strictEqual(getCallIdFromEra(MOCK_ERA), MOCK_CALL_ID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGroupCallMeta', () => {
|
||||||
|
it('returns null if missing eraId or creator', () => {
|
||||||
|
assert.isNull(getGroupCallMeta({ ...MOCK_PEEK_INFO_BASE }));
|
||||||
|
assert.isNull(
|
||||||
|
getGroupCallMeta({ ...MOCK_PEEK_INFO_BASE, eraId: MOCK_ERA })
|
||||||
|
);
|
||||||
|
assert.isNull(
|
||||||
|
getGroupCallMeta({
|
||||||
|
...MOCK_PEEK_INFO_BASE,
|
||||||
|
creator: Buffer.from(uuidToBytes(uuid())),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns group call meta when all fields are provided', () => {
|
||||||
|
const id = uuid();
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
getGroupCallMeta({
|
||||||
|
...MOCK_PEEK_INFO_BASE,
|
||||||
|
eraId: MOCK_ERA,
|
||||||
|
creator: Buffer.from(uuidToBytes(id)),
|
||||||
|
}),
|
||||||
|
{ callId: MOCK_CALL_ID, ringerId: id }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPeerIdFromConversation', () => {
|
||||||
|
it('returns serviceId for direct conversation', () => {
|
||||||
|
const conversation = getDefaultConversation();
|
||||||
|
assert.strictEqual(
|
||||||
|
getPeerIdFromConversation(conversation),
|
||||||
|
conversation.serviceId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('returns groupId for group conversation', () => {
|
||||||
|
const conversation = getDefaultGroup();
|
||||||
|
assert.strictEqual(
|
||||||
|
getPeerIdFromConversation(conversation),
|
||||||
|
conversation.groupId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -65,7 +65,13 @@ export enum GroupCallStatus {
|
||||||
Deleted = DirectCallStatus.Deleted,
|
Deleted = DirectCallStatus.Deleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CallStatus = DirectCallStatus | GroupCallStatus;
|
export enum AdhocCallStatus {
|
||||||
|
Pending = DirectCallStatus.Pending,
|
||||||
|
Joined = GroupCallStatus.Joined,
|
||||||
|
Deleted = DirectCallStatus.Deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CallStatus = DirectCallStatus | GroupCallStatus | AdhocCallStatus;
|
||||||
|
|
||||||
export type CallDetails = Readonly<{
|
export type CallDetails = Readonly<{
|
||||||
callId: string;
|
callId: string;
|
||||||
|
@ -133,6 +139,7 @@ const callEventSchema = z.union([
|
||||||
const callStatusSchema = z.union([
|
const callStatusSchema = z.union([
|
||||||
z.nativeEnum(DirectCallStatus),
|
z.nativeEnum(DirectCallStatus),
|
||||||
z.nativeEnum(GroupCallStatus),
|
z.nativeEnum(GroupCallStatus),
|
||||||
|
z.nativeEnum(AdhocCallStatus),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const callDetailsSchema = z.object({
|
export const callDetailsSchema = z.object({
|
||||||
|
|
|
@ -2,16 +2,11 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import type { ReadonlyDeep } from 'type-fest';
|
import type { ReadonlyDeep } from 'type-fest';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { CallLinkRestrictions as RingRTCCallLinkRestrictions } from '@signalapp/ringrtc';
|
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
|
|
||||||
export type CallLinkConversationType = ReadonlyDeep<
|
/**
|
||||||
Omit<ConversationType, 'type'> & {
|
* Restrictions
|
||||||
type: 'callLink';
|
*/
|
||||||
storySendMode?: undefined;
|
|
||||||
acknowledgedGroupNameCollisions?: undefined;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
// Must match `CallLinkRestrictions` in @signalapp/ringrtc
|
// Must match `CallLinkRestrictions` in @signalapp/ringrtc
|
||||||
export enum CallLinkRestrictions {
|
export enum CallLinkRestrictions {
|
||||||
|
@ -20,14 +15,56 @@ export enum CallLinkRestrictions {
|
||||||
Unknown = 2,
|
Unknown = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const callLinkRestrictionsSchema = z.nativeEnum(
|
export const callLinkRestrictionsSchema = z.nativeEnum(CallLinkRestrictions);
|
||||||
CallLinkRestrictions
|
|
||||||
) satisfies z.ZodType<RingRTCCallLinkRestrictions>;
|
export function toCallLinkRestrictions(
|
||||||
|
restrictions: number
|
||||||
|
): CallLinkRestrictions {
|
||||||
|
return callLinkRestrictionsSchema.parse(restrictions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link
|
||||||
|
*/
|
||||||
|
|
||||||
export type CallLinkType = Readonly<{
|
export type CallLinkType = Readonly<{
|
||||||
roomId: string;
|
roomId: string;
|
||||||
rootKey: string;
|
rootKey: string;
|
||||||
|
adminKey: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
restrictions: CallLinkRestrictions;
|
restrictions: CallLinkRestrictions;
|
||||||
expiration: number;
|
revoked: boolean;
|
||||||
|
expiration: number | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
// Ephemeral conversation-like type to satisfy components
|
||||||
|
export type CallLinkConversationType = ReadonlyDeep<
|
||||||
|
Omit<ConversationType, 'type'> & {
|
||||||
|
type: 'callLink';
|
||||||
|
storySendMode?: undefined;
|
||||||
|
acknowledgedGroupNameCollisions?: undefined;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
// DB Record
|
||||||
|
export type CallLinkRecord = Readonly<{
|
||||||
|
roomId: string;
|
||||||
|
rootKey: Uint8Array | null;
|
||||||
|
adminKey: Uint8Array | null;
|
||||||
|
name: string;
|
||||||
|
restrictions: number;
|
||||||
|
expiration: number | null;
|
||||||
|
revoked: 1 | 0; // sqlite's version of boolean
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const callLinkRecordSchema = z.object({
|
||||||
|
roomId: z.string(),
|
||||||
|
// credentials
|
||||||
|
rootKey: z.instanceof(Uint8Array).nullable(),
|
||||||
|
adminKey: z.instanceof(Uint8Array).nullable(),
|
||||||
|
// state
|
||||||
|
name: z.string(),
|
||||||
|
restrictions: callLinkRestrictionsSchema,
|
||||||
|
expiration: z.number().int(),
|
||||||
|
revoked: z.union([z.literal(1), z.literal(0)]),
|
||||||
|
}) satisfies z.ZodType<CallLinkRecord>;
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
RingUpdate,
|
RingUpdate,
|
||||||
} from '@signalapp/ringrtc';
|
} from '@signalapp/ringrtc';
|
||||||
import { v4 as generateGuid } from 'uuid';
|
import { v4 as generateGuid } from 'uuid';
|
||||||
|
import { isEqual } from 'lodash';
|
||||||
import { strictAssert } from './assert';
|
import { strictAssert } from './assert';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
import { bytesToUuid, uuidToBytes } from './uuidToBytes';
|
import { bytesToUuid, uuidToBytes } from './uuidToBytes';
|
||||||
|
@ -57,6 +58,7 @@ import {
|
||||||
RemoteCallEvent,
|
RemoteCallEvent,
|
||||||
callHistoryDetailsSchema,
|
callHistoryDetailsSchema,
|
||||||
callDetailsSchema,
|
callDetailsSchema,
|
||||||
|
AdhocCallStatus,
|
||||||
} from '../types/CallDisposition';
|
} from '../types/CallDisposition';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import type { ConversationModel } from '../models/conversations';
|
import type { ConversationModel } from '../models/conversations';
|
||||||
|
@ -467,6 +469,21 @@ export function getCallDetailsFromGroupCallMeta(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCallDetailsForAdhocCall(
|
||||||
|
peerId: AciString | string,
|
||||||
|
callId: string
|
||||||
|
): CallDetails {
|
||||||
|
return callDetailsSchema.parse({
|
||||||
|
callId,
|
||||||
|
peerId,
|
||||||
|
ringerId: null,
|
||||||
|
mode: CallMode.Adhoc,
|
||||||
|
type: CallType.Group,
|
||||||
|
direction: CallDirection.Outgoing,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Call Event Details
|
// Call Event Details
|
||||||
// ------------------
|
// ------------------
|
||||||
|
|
||||||
|
@ -500,7 +517,7 @@ export function transitionCallHistory(
|
||||||
}
|
}
|
||||||
|
|
||||||
const prevStatus = callHistory?.status ?? null;
|
const prevStatus = callHistory?.status ?? null;
|
||||||
let status: DirectCallStatus | GroupCallStatus;
|
let status: CallStatus;
|
||||||
|
|
||||||
if (mode === CallMode.Direct) {
|
if (mode === CallMode.Direct) {
|
||||||
status = transitionDirectCallStatus(
|
status = transitionDirectCallStatus(
|
||||||
|
@ -515,8 +532,11 @@ export function transitionCallHistory(
|
||||||
direction
|
direction
|
||||||
);
|
);
|
||||||
} else if (mode === CallMode.Adhoc) {
|
} else if (mode === CallMode.Adhoc) {
|
||||||
// TODO: DESKTOP-6653
|
status = transitionAdhocCallStatus(
|
||||||
strictAssert(false, 'cannot transitionCallHistory for adhoc calls yet');
|
prevStatus as AdhocCallStatus | null,
|
||||||
|
event,
|
||||||
|
direction
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
throw missingCaseError(mode);
|
throw missingCaseError(mode);
|
||||||
}
|
}
|
||||||
|
@ -557,7 +577,8 @@ function transitionTimestamp(
|
||||||
// Deleted call history should never be changed
|
// Deleted call history should never be changed
|
||||||
if (
|
if (
|
||||||
callHistory.status === DirectCallStatus.Deleted ||
|
callHistory.status === DirectCallStatus.Deleted ||
|
||||||
callHistory.status === GroupCallStatus.Deleted
|
callHistory.status === GroupCallStatus.Deleted ||
|
||||||
|
callHistory.status === AdhocCallStatus.Deleted
|
||||||
) {
|
) {
|
||||||
return callHistory.timestamp;
|
return callHistory.timestamp;
|
||||||
}
|
}
|
||||||
|
@ -567,7 +588,8 @@ function transitionTimestamp(
|
||||||
if (
|
if (
|
||||||
callHistory.status === DirectCallStatus.Accepted ||
|
callHistory.status === DirectCallStatus.Accepted ||
|
||||||
callHistory.status === GroupCallStatus.Accepted ||
|
callHistory.status === GroupCallStatus.Accepted ||
|
||||||
callHistory.status === GroupCallStatus.Joined
|
callHistory.status === GroupCallStatus.Joined ||
|
||||||
|
callHistory.status === AdhocCallStatus.Joined
|
||||||
) {
|
) {
|
||||||
if (callEvent.event === RemoteCallEvent.Accepted) {
|
if (callEvent.event === RemoteCallEvent.Accepted) {
|
||||||
return latestTimestamp;
|
return latestTimestamp;
|
||||||
|
@ -595,7 +617,8 @@ function transitionTimestamp(
|
||||||
callHistory.status === GroupCallStatus.OutgoingRing ||
|
callHistory.status === GroupCallStatus.OutgoingRing ||
|
||||||
callHistory.status === GroupCallStatus.Ringing ||
|
callHistory.status === GroupCallStatus.Ringing ||
|
||||||
callHistory.status === DirectCallStatus.Missed ||
|
callHistory.status === DirectCallStatus.Missed ||
|
||||||
callHistory.status === GroupCallStatus.Missed
|
callHistory.status === GroupCallStatus.Missed ||
|
||||||
|
callHistory.status === AdhocCallStatus.Pending
|
||||||
) {
|
) {
|
||||||
return latestTimestamp;
|
return latestTimestamp;
|
||||||
}
|
}
|
||||||
|
@ -750,6 +773,57 @@ function transitionGroupCallStatus(
|
||||||
throw missingCaseError(event);
|
throw missingCaseError(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transitionAdhocCallStatus(
|
||||||
|
status: AdhocCallStatus | null,
|
||||||
|
callEvent: CallEvent,
|
||||||
|
direction: CallDirection
|
||||||
|
): AdhocCallStatus {
|
||||||
|
log.info(
|
||||||
|
`transitionAdhocCallStatus: status=${status} callEvent=${callEvent} direction=${direction}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// In all cases if we get a delete event, we need to delete the call, and never
|
||||||
|
// transition from deleted.
|
||||||
|
if (
|
||||||
|
callEvent === RemoteCallEvent.Delete ||
|
||||||
|
callEvent === LocalCallEvent.Delete ||
|
||||||
|
status === AdhocCallStatus.Deleted
|
||||||
|
) {
|
||||||
|
return AdhocCallStatus.Deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Accepted event is used to indicate we have fully joined an adhoc call.
|
||||||
|
// If admin approval was required for a call link, this event indicates approval
|
||||||
|
// was granted.
|
||||||
|
if (
|
||||||
|
callEvent === RemoteCallEvent.Accepted ||
|
||||||
|
callEvent === LocalCallEvent.Accepted
|
||||||
|
) {
|
||||||
|
return AdhocCallStatus.Joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === AdhocCallStatus.Joined) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Adhoc calls, ringing and corresponding events are not supported currently.
|
||||||
|
// However we handle those events here to be exhaustive.
|
||||||
|
if (
|
||||||
|
callEvent === RemoteCallEvent.NotAccepted ||
|
||||||
|
callEvent === LocalCallEvent.Missed ||
|
||||||
|
callEvent === LocalCallEvent.Declined ||
|
||||||
|
callEvent === LocalCallEvent.Hangup ||
|
||||||
|
callEvent === LocalCallEvent.RemoteHangup ||
|
||||||
|
callEvent === LocalCallEvent.Started ||
|
||||||
|
// never actually happens, but need for exhaustive check
|
||||||
|
callEvent === LocalCallEvent.Ringing
|
||||||
|
) {
|
||||||
|
return AdhocCallStatus.Pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw missingCaseError(callEvent);
|
||||||
|
}
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
// -------
|
// -------
|
||||||
|
|
||||||
|
@ -809,6 +883,70 @@ async function updateLocalCallHistory(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateLocalAdhocCallHistory(
|
||||||
|
callEvent: CallEventDetails
|
||||||
|
): Promise<CallHistoryDetails | null> {
|
||||||
|
log.info(
|
||||||
|
'updateLocalAdhocCallHistory: Processing call event:',
|
||||||
|
formatCallEvent(callEvent)
|
||||||
|
);
|
||||||
|
|
||||||
|
const prevCallHistory =
|
||||||
|
(await window.Signal.Data.getCallHistory(
|
||||||
|
callEvent.callId,
|
||||||
|
callEvent.peerId
|
||||||
|
)) ?? null;
|
||||||
|
|
||||||
|
if (prevCallHistory != null) {
|
||||||
|
log.info(
|
||||||
|
'updateLocalAdhocCallHistory: Found previous call history:',
|
||||||
|
formatCallHistory(prevCallHistory)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.info('updateLocalAdhocCallHistory: No previous call history');
|
||||||
|
}
|
||||||
|
|
||||||
|
let callHistory: CallHistoryDetails;
|
||||||
|
try {
|
||||||
|
callHistory = transitionCallHistory(prevCallHistory, callEvent);
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
"updateLocalAdhocCallHistory: Couldn't transition call history:",
|
||||||
|
formatCallEvent(callEvent),
|
||||||
|
Errors.toLogFormat(error)
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
strictAssert(
|
||||||
|
callHistory.status === AdhocCallStatus.Pending ||
|
||||||
|
callHistory.status === AdhocCallStatus.Joined ||
|
||||||
|
callHistory.status === AdhocCallStatus.Deleted,
|
||||||
|
`updateLocalAdhocCallHistory: callHistory must have adhoc status (was ${callHistory.status})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDeleted = callHistory.status === AdhocCallStatus.Deleted;
|
||||||
|
if (prevCallHistory != null && isEqual(prevCallHistory, callHistory)) {
|
||||||
|
log.info(
|
||||||
|
'updateLocalAdhocCallHistory: Next call history is identical, skipping save'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.info(
|
||||||
|
'updateLocalAdhocCallHistory: Saving call history:',
|
||||||
|
formatCallHistory(callHistory)
|
||||||
|
);
|
||||||
|
await window.Signal.Data.saveCallHistory(callHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDeleted) {
|
||||||
|
window.reduxActions.callHistory.removeCallHistory(callHistory.callId);
|
||||||
|
} else {
|
||||||
|
window.reduxActions.callHistory.addCallHistory(callHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return callHistory;
|
||||||
|
}
|
||||||
|
|
||||||
async function saveCallHistory(
|
async function saveCallHistory(
|
||||||
callHistory: CallHistoryDetails,
|
callHistory: CallHistoryDetails,
|
||||||
conversation: ConversationModel,
|
conversation: ConversationModel,
|
||||||
|
|
|
@ -10,7 +10,16 @@ import {
|
||||||
} from './zkgroup';
|
} from './zkgroup';
|
||||||
import { getCheckedCallLinkAuthCredentialsForToday } from '../services/groupCredentialFetcher';
|
import { getCheckedCallLinkAuthCredentialsForToday } from '../services/groupCredentialFetcher';
|
||||||
import * as durations from './durations';
|
import * as durations from './durations';
|
||||||
import type { CallLinkConversationType, CallLinkType } from '../types/CallLink';
|
import * as Bytes from '../Bytes';
|
||||||
|
import type {
|
||||||
|
CallLinkConversationType,
|
||||||
|
CallLinkType,
|
||||||
|
CallLinkRecord,
|
||||||
|
} from '../types/CallLink';
|
||||||
|
import {
|
||||||
|
callLinkRecordSchema,
|
||||||
|
toCallLinkRestrictions,
|
||||||
|
} from '../types/CallLink';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
|
|
||||||
export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string {
|
export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string {
|
||||||
|
@ -71,3 +80,47 @@ export function callLinkToConversation(
|
||||||
badges: [],
|
badges: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB record conversions
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function callLinkToRecord(callLink: CallLinkType): CallLinkRecord {
|
||||||
|
if (callLink.rootKey == null) {
|
||||||
|
throw new Error('CallLink.callLinkToRecord: rootKey is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
// rootKey has a RingRTC parsing function, adminKey is just bytes
|
||||||
|
const rootKey = callLink.rootKey
|
||||||
|
? CallLinkRootKey.parse(callLink.rootKey).bytes
|
||||||
|
: null;
|
||||||
|
const adminKey = callLink.adminKey
|
||||||
|
? Bytes.fromBase64(callLink.adminKey)
|
||||||
|
: null;
|
||||||
|
return callLinkRecordSchema.parse({
|
||||||
|
roomId: callLink.roomId,
|
||||||
|
rootKey,
|
||||||
|
adminKey,
|
||||||
|
name: callLink.name,
|
||||||
|
restrictions: callLink.restrictions,
|
||||||
|
revoked: callLink.revoked ? 1 : 0,
|
||||||
|
expiration: callLink.expiration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function callLinkFromRecord(record: CallLinkRecord): CallLinkType {
|
||||||
|
// root keys in memory are strings for simplicity
|
||||||
|
const rootKey = CallLinkRootKey.fromBytes(
|
||||||
|
record.rootKey as Buffer
|
||||||
|
).toString();
|
||||||
|
const adminKey = record.adminKey ? Bytes.toBase64(record.adminKey) : null;
|
||||||
|
return {
|
||||||
|
roomId: record.roomId,
|
||||||
|
rootKey,
|
||||||
|
adminKey,
|
||||||
|
name: record.name,
|
||||||
|
restrictions: toCallLinkRestrictions(record.restrictions),
|
||||||
|
revoked: record.revoked === 1,
|
||||||
|
expiration: record.expiration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -129,7 +129,6 @@ export function getCallingNotificationText(
|
||||||
return getGroupCallNotificationText(groupCallEnded, callCreator, i18n);
|
return getGroupCallNotificationText(groupCallEnded, callCreator, i18n);
|
||||||
}
|
}
|
||||||
if (callHistory.mode === CallMode.Adhoc) {
|
if (callHistory.mode === CallMode.Adhoc) {
|
||||||
// TODO: DESKTOP-6653
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
throw missingCaseError(callHistory.mode);
|
throw missingCaseError(callHistory.mode);
|
||||||
|
|
|
@ -36,6 +36,7 @@ window.testUtilities = {
|
||||||
await Stickers.load();
|
await Stickers.load();
|
||||||
|
|
||||||
initializeRedux({
|
initializeRedux({
|
||||||
|
callLinks: [],
|
||||||
callsHistory: [],
|
callsHistory: [],
|
||||||
callsHistoryUnreadCount: 0,
|
callsHistoryUnreadCount: 0,
|
||||||
initialBadgesState: { byId: {} },
|
initialBadgesState: { byId: {} },
|
||||||
|
|
Loading…
Reference in a new issue