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",
|
||||
"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": {
|
||||
"messageformat": "No recent conversations.",
|
||||
"description": "Calls Tab > New Call > Conversations List > When no results found > With no search query"
|
||||
|
|
|
@ -187,6 +187,10 @@ import {
|
|||
getCallsHistoryUnreadCountForRedux,
|
||||
loadCallsHistory,
|
||||
} from './services/callHistoryLoader';
|
||||
import {
|
||||
getCallLinksForRedux,
|
||||
loadCallLinks,
|
||||
} from './services/callLinksLoader';
|
||||
import {
|
||||
getCallIdFromEra,
|
||||
updateLocalGroupCallHistoryTimestamp,
|
||||
|
@ -1105,6 +1109,7 @@ export async function startApp(): Promise<void> {
|
|||
loadStories(),
|
||||
loadDistributionLists(),
|
||||
loadCallsHistory(),
|
||||
loadCallLinks(),
|
||||
window.textsecure.storage.protocol.hydrateCaches(),
|
||||
(async () => {
|
||||
mainWindowStats = await window.SignalContext.getMainWindowStats();
|
||||
|
@ -1155,6 +1160,7 @@ export async function startApp(): Promise<void> {
|
|||
theme: ThemeType;
|
||||
}) {
|
||||
initializeRedux({
|
||||
callLinks: getCallLinksForRedux(),
|
||||
callsHistory: getCallsHistoryForRedux(),
|
||||
callsHistoryUnreadCount: getCallsHistoryUnreadCountForRedux(),
|
||||
initialBadgesState,
|
||||
|
|
|
@ -49,8 +49,10 @@ function getCallLink(overrideProps: Partial<CallLinkType> = {}): CallLinkType {
|
|||
return {
|
||||
roomId: 'abcd1234abcd1234abcd1234abcd1234abcd1234',
|
||||
rootKey: 'abcd-abcd-abcd-abcd-abcd-abcd-abcd-abcd',
|
||||
adminKey: null,
|
||||
name: 'Axolotl Discuss',
|
||||
restrictions: CallLinkRestrictions.None,
|
||||
revoked: false,
|
||||
expiration: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
...overrideProps,
|
||||
};
|
||||
|
|
|
@ -206,7 +206,7 @@ export function CallingLobby({
|
|||
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.CallIsFull;
|
||||
} else if (isCallConnecting) {
|
||||
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Loading;
|
||||
} else if (peekedParticipants.length) {
|
||||
} else if (peekedParticipants.length || callMode === CallMode.Adhoc) {
|
||||
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Join;
|
||||
} else {
|
||||
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Start;
|
||||
|
|
|
@ -43,6 +43,10 @@ import { formatCallHistoryGroup } from '../util/callDisposition';
|
|||
import { CallsNewCallButton } from './CallsNewCall';
|
||||
import { Tooltip, TooltipPlacement } from './Tooltip';
|
||||
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({
|
||||
i18n,
|
||||
|
@ -112,6 +116,7 @@ type CallsListProps = Readonly<{
|
|||
pagination: CallHistoryPagination
|
||||
) => Promise<Array<CallHistoryGroup>>;
|
||||
callHistoryEdition: number;
|
||||
getCallLink: (id: string) => CallLinkType | undefined;
|
||||
getConversation: (id: string) => ConversationType | void;
|
||||
i18n: LocalizerType;
|
||||
selectedCallHistoryGroup: CallHistoryGroup | null;
|
||||
|
@ -121,6 +126,7 @@ type CallsListProps = Readonly<{
|
|||
conversationId: string,
|
||||
selectedCallHistoryGroup: CallHistoryGroup
|
||||
) => void;
|
||||
startCallLinkLobbyByRoomId: (roomId: string) => void;
|
||||
}>;
|
||||
|
||||
const CALL_LIST_ITEM_ROW_HEIGHT = 62;
|
||||
|
@ -141,12 +147,14 @@ export function CallsList({
|
|||
getCallHistoryGroupsCount,
|
||||
getCallHistoryGroups,
|
||||
callHistoryEdition,
|
||||
getCallLink,
|
||||
getConversation,
|
||||
i18n,
|
||||
selectedCallHistoryGroup,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
onSelectCallHistoryGroup,
|
||||
startCallLinkLobbyByRoomId,
|
||||
}: CallsListProps): JSX.Element {
|
||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||
const listRef = useRef<List>(null);
|
||||
|
@ -301,7 +309,16 @@ export function CallsList({
|
|||
const rowRenderer = useCallback(
|
||||
({ key, index, style }: ListRowProps) => {
|
||||
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 (
|
||||
searchState.state === 'pending' ||
|
||||
|
@ -337,6 +354,8 @@ export function CallsList({
|
|||
let statusText;
|
||||
if (wasMissed) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--Missed');
|
||||
} else if (callLink) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--CallLink');
|
||||
} else if (item.type === CallType.Group) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--GroupCall');
|
||||
} else if (item.direction === CallDirection.Outgoing) {
|
||||
|
@ -363,7 +382,7 @@ export function CallsList({
|
|||
<Avatar
|
||||
acceptedMessageRequest
|
||||
avatarPath={conversation.avatarPath}
|
||||
conversationType="group"
|
||||
conversationType={conversation.type}
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
title={conversation.title}
|
||||
|
@ -378,10 +397,14 @@ export function CallsList({
|
|||
callType={item.type}
|
||||
hasActiveCall={hasActiveCall}
|
||||
onClick={() => {
|
||||
if (item.type === CallType.Audio) {
|
||||
onOutgoingAudioCallInConversation(conversation.id);
|
||||
} else {
|
||||
onOutgoingVideoCallInConversation(conversation.id);
|
||||
if (callLink) {
|
||||
startCallLinkLobbyByRoomId(callLink.roomId);
|
||||
} else if (conversation) {
|
||||
if (item.type === CallType.Audio) {
|
||||
onOutgoingAudioCallInConversation(conversation.id);
|
||||
} else {
|
||||
onOutgoingVideoCallInConversation(conversation.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -403,6 +426,10 @@ export function CallsList({
|
|||
</span>
|
||||
}
|
||||
onClick={() => {
|
||||
if (callLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectCallHistoryGroup(conversation.id, item);
|
||||
}}
|
||||
/>
|
||||
|
@ -412,11 +439,13 @@ export function CallsList({
|
|||
[
|
||||
hasActiveCall,
|
||||
searchState,
|
||||
getCallLink,
|
||||
getConversation,
|
||||
selectedCallHistoryGroup,
|
||||
onSelectCallHistoryGroup,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
startCallLinkLobbyByRoomId,
|
||||
i18n,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -18,6 +18,7 @@ import { ContextMenu } from './ContextMenu';
|
|||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import type { UnreadStats } from '../util/countUnreadStats';
|
||||
import type { WidthBreakpoint } from './_util';
|
||||
import type { CallLinkType } from '../types/CallLink';
|
||||
|
||||
enum CallsTabSidebarView {
|
||||
CallsListView,
|
||||
|
@ -36,6 +37,7 @@ type CallsTabProps = Readonly<{
|
|||
pagination: CallHistoryPagination
|
||||
) => Promise<Array<CallHistoryGroup>>;
|
||||
callHistoryEdition: number;
|
||||
getCallLink: (id: string) => CallLinkType | undefined;
|
||||
getConversation: (id: string) => ConversationType | void;
|
||||
hasFailedStorySends: boolean;
|
||||
hasPendingUpdate: boolean;
|
||||
|
@ -56,6 +58,7 @@ type CallsTabProps = Readonly<{
|
|||
}) => JSX.Element;
|
||||
regionCode: string | undefined;
|
||||
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
|
||||
startCallLinkLobbyByRoomId: (roomId: string) => void;
|
||||
}>;
|
||||
|
||||
export function CallsTab({
|
||||
|
@ -65,6 +68,7 @@ export function CallsTab({
|
|||
getCallHistoryGroupsCount,
|
||||
getCallHistoryGroups,
|
||||
callHistoryEdition,
|
||||
getCallLink,
|
||||
getConversation,
|
||||
hasFailedStorySends,
|
||||
hasPendingUpdate,
|
||||
|
@ -80,6 +84,7 @@ export function CallsTab({
|
|||
renderToastManager,
|
||||
regionCode,
|
||||
savePreferredLeftPaneWidth,
|
||||
startCallLinkLobbyByRoomId,
|
||||
}: CallsTabProps): JSX.Element {
|
||||
const [sidebarView, setSidebarView] = useState(
|
||||
CallsTabSidebarView.CallsListView
|
||||
|
@ -230,6 +235,7 @@ export function CallsTab({
|
|||
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
||||
getCallHistoryGroups={getCallHistoryGroups}
|
||||
callHistoryEdition={callHistoryEdition}
|
||||
getCallLink={getCallLink}
|
||||
getConversation={getConversation}
|
||||
i18n={i18n}
|
||||
selectedCallHistoryGroup={selected?.callHistoryGroup ?? null}
|
||||
|
@ -240,6 +246,7 @@ export function CallsTab({
|
|||
onOutgoingVideoCallInConversation={
|
||||
handleOutgoingVideoCallInConversation
|
||||
}
|
||||
startCallLinkLobbyByRoomId={startCallLinkLobbyByRoomId}
|
||||
/>
|
||||
)}
|
||||
{sidebarView === CallsTabSidebarView.NewCallView && (
|
||||
|
|
|
@ -586,7 +586,7 @@ async function getCallLinkPreview(
|
|||
|
||||
const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key);
|
||||
const callLinkState = await calling.readCallLink({ callLinkRootKey });
|
||||
if (!callLinkState) {
|
||||
if (!callLinkState || callLinkState.revoked) {
|
||||
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,
|
||||
getLocalCallEventFromRingUpdate,
|
||||
convertJoinState,
|
||||
updateLocalAdhocCallHistory,
|
||||
getCallIdFromEra,
|
||||
getCallDetailsForAdhocCall,
|
||||
} from '../util/callDisposition';
|
||||
import { isNormalNumber } from '../util/isNormalNumber';
|
||||
import { LocalCallEvent } from '../types/CallDisposition';
|
||||
|
@ -181,6 +184,7 @@ type CallingReduxInterface = Pick<
|
|||
| 'setPresenting'
|
||||
| 'startCallingLobby'
|
||||
| 'startCallLinkLobby'
|
||||
| 'startCallLinkLobbyByRoomId'
|
||||
| 'peekNotConnectedGroupCall'
|
||||
> & {
|
||||
areAnyCallsActiveOrRinging(): boolean;
|
||||
|
@ -535,25 +539,25 @@ export class CallingClass {
|
|||
callLinkRootKey: CallLinkRootKey;
|
||||
}>): Promise<CallLinkState | undefined> {
|
||||
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 logId = `readCallLink(${roomId})`;
|
||||
const authCredentialPresentation =
|
||||
await getCallLinkAuthCredentialPresentation(callLinkRootKey);
|
||||
|
||||
log.info(`readCallLink: roomId ${roomId}`);
|
||||
const result = await RingRTC.readCallLink(
|
||||
this._sfuUrl,
|
||||
authCredentialPresentation.serialize(),
|
||||
callLinkRootKey
|
||||
);
|
||||
if (!result.success) {
|
||||
log.warn(`readCallLink: failed ${roomId}`);
|
||||
log.warn(`${logId}: failed`);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('readCallLink: success', result);
|
||||
log.info(`${logId}: success`);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
|
@ -980,11 +984,20 @@ export class CallingClass {
|
|||
callMode: CallMode.Group | CallMode.Adhoc
|
||||
): GroupCallObserver {
|
||||
let updateMessageState = GroupCallUpdateMessageState.SentNothing;
|
||||
const updateCallHistoryOnLocalChanged =
|
||||
callMode === CallMode.Group
|
||||
? this.updateCallHistoryForGroupCallOnLocalChanged
|
||||
: this.updateCallHistoryForAdhocCall;
|
||||
const updateCallHistoryOnPeek =
|
||||
callMode === CallMode.Group
|
||||
? this.updateCallHistoryForGroupCallOnPeek
|
||||
: this.updateCallHistoryForAdhocCall;
|
||||
|
||||
return {
|
||||
onLocalDeviceStateChanged: groupCall => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const peekInfo = groupCall.getPeekInfo() ?? null;
|
||||
const { eraId } = peekInfo ?? {};
|
||||
|
||||
log.info(
|
||||
'GroupCall#onLocalDeviceStateChanged',
|
||||
|
@ -992,43 +1005,14 @@ export class CallingClass {
|
|||
peekInfo != null ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
|
||||
const groupCallMeta = getGroupCallMeta(peekInfo);
|
||||
|
||||
// TODO: Handle call history for adhoc calls
|
||||
if (groupCallMeta != null && callMode === CallMode.Group) {
|
||||
try {
|
||||
const localCallEvent = getLocalCallEventFromJoinState(
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
// For adhoc calls, conversationId will be a roomId
|
||||
drop(
|
||||
updateCallHistoryOnLocalChanged(
|
||||
conversationId,
|
||||
convertJoinState(localDeviceState.joinState),
|
||||
peekInfo
|
||||
)
|
||||
);
|
||||
|
||||
if (localDeviceState.connectionState === ConnectionState.NotConnected) {
|
||||
// NOTE: This assumes that only one call is active at a time. For example, if
|
||||
|
@ -1040,14 +1024,11 @@ export class CallingClass {
|
|||
|
||||
if (
|
||||
updateMessageState === GroupCallUpdateMessageState.SentJoin &&
|
||||
peekInfo?.eraId != null
|
||||
eraId
|
||||
) {
|
||||
updateMessageState = GroupCallUpdateMessageState.SentLeft;
|
||||
if (callMode === CallMode.Group) {
|
||||
void this.sendGroupCallUpdateMessage(
|
||||
conversationId,
|
||||
peekInfo?.eraId
|
||||
);
|
||||
drop(this.sendGroupCallUpdateMessage(conversationId, eraId));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -1060,17 +1041,16 @@ export class CallingClass {
|
|||
this.videoCapturer.enableCaptureAndSend(groupCall);
|
||||
}
|
||||
|
||||
// Call enters the Joined state, once per call.
|
||||
// This can also happen in onPeekChanged.
|
||||
if (
|
||||
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
||||
localDeviceState.joinState === JoinState.Joined &&
|
||||
peekInfo?.eraId != null
|
||||
eraId
|
||||
) {
|
||||
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
||||
if (callMode === CallMode.Group) {
|
||||
void this.sendGroupCallUpdateMessage(
|
||||
conversationId,
|
||||
peekInfo?.eraId
|
||||
);
|
||||
drop(this.sendGroupCallUpdateMessage(conversationId, eraId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1128,6 +1108,7 @@ export class CallingClass {
|
|||
onPeekChanged: groupCall => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const peekInfo = groupCall.getPeekInfo() ?? null;
|
||||
const { eraId } = peekInfo ?? {};
|
||||
|
||||
log.info(
|
||||
'GroupCall#onPeekChanged',
|
||||
|
@ -1135,25 +1116,29 @@ export class CallingClass {
|
|||
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
|
||||
if (callMode === CallMode.Group) {
|
||||
const { eraId } = peekInfo ?? {};
|
||||
if (
|
||||
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
||||
localDeviceState.connectionState !== ConnectionState.NotConnected &&
|
||||
localDeviceState.joinState === JoinState.Joined &&
|
||||
eraId
|
||||
) {
|
||||
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
||||
void this.sendGroupCallUpdateMessage(conversationId, eraId);
|
||||
}
|
||||
// Call enters the Joined state, once per call.
|
||||
// This can also happen in onLocalDeviceStateChanged.
|
||||
if (
|
||||
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
||||
localDeviceState.connectionState !== ConnectionState.NotConnected &&
|
||||
localDeviceState.joinState === JoinState.Joined &&
|
||||
eraId
|
||||
) {
|
||||
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
||||
|
||||
void this.updateCallHistoryForGroupCall(
|
||||
if (callMode === CallMode.Group) {
|
||||
drop(this.sendGroupCallUpdateMessage(conversationId, eraId));
|
||||
}
|
||||
}
|
||||
|
||||
// For adhoc calls, conversationId will be a roomId
|
||||
drop(
|
||||
updateCallHistoryOnPeek(
|
||||
conversationId,
|
||||
convertJoinState(localDeviceState.joinState),
|
||||
peekInfo
|
||||
);
|
||||
}
|
||||
// TODO: Call history for adhoc calls
|
||||
)
|
||||
);
|
||||
|
||||
this.syncGroupCallToRedux(conversationId, groupCall, callMode);
|
||||
},
|
||||
|
@ -1381,11 +1366,12 @@ export class CallingClass {
|
|||
public formatCallLinkStateForRedux(
|
||||
callLinkState: CallLinkState
|
||||
): CallLinkStateType {
|
||||
const { name, restrictions, expiration } = callLinkState;
|
||||
const { name, restrictions, expiration, revoked } = callLinkState;
|
||||
return {
|
||||
name,
|
||||
restrictions,
|
||||
expiration: expiration.getTime(),
|
||||
revoked,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -2641,7 +2627,84 @@ export class CallingClass {
|
|||
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,
|
||||
joinState: GroupCallJoinState | null,
|
||||
peekInfo: PeekInfo | null
|
||||
|
@ -2658,7 +2721,9 @@ export class CallingClass {
|
|||
|
||||
const conversation = window.ConversationController.get(conversationId);
|
||||
if (!conversation) {
|
||||
log.error('maybeNotifyGroupCall(): could not find conversation');
|
||||
log.error(
|
||||
'updateCallHistoryForGroupCallOnPeek(): could not find conversation'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -2684,7 +2749,7 @@ export class CallingClass {
|
|||
const callEvent = getCallEventDetails(
|
||||
callDetails,
|
||||
localCallEvent,
|
||||
'CallingClass.updateCallHistoryForGroupCall'
|
||||
'CallingClass.updateCallHistoryForGroupCallOnPeek'
|
||||
);
|
||||
await updateCallHistoryFromLocalEvent(callEvent, null);
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ import type {
|
|||
CallHistoryGroup,
|
||||
CallHistoryPagination,
|
||||
} from '../types/CallDisposition';
|
||||
import type { CallLinkType, CallLinkRestrictions } from '../types/CallLink';
|
||||
|
||||
export type AdjacentMessagesByConversationOptionsType = Readonly<{
|
||||
conversationId: string;
|
||||
|
@ -696,6 +697,16 @@ export type DataInterface = {
|
|||
getRecentStaleRingsAndMarkOlderMissed(): Promise<
|
||||
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: (
|
||||
obsoleteId: string,
|
||||
currentId: string
|
||||
|
|
|
@ -166,6 +166,13 @@ import {
|
|||
GroupCallStatus,
|
||||
CallType,
|
||||
} from '../types/CallDisposition';
|
||||
import {
|
||||
callLinkExists,
|
||||
getAllCallLinks,
|
||||
insertCallLink,
|
||||
updateCallLinkState,
|
||||
} from './server/callLinks';
|
||||
import { CallMode } from '../types/Calling';
|
||||
|
||||
type ConversationRow = Readonly<{
|
||||
json: string;
|
||||
|
@ -328,6 +335,10 @@ const dataInterface: ServerInterface = {
|
|||
hasGroupCallHistoryMessage,
|
||||
markCallHistoryMissed,
|
||||
getRecentStaleRingsAndMarkOlderMissed,
|
||||
callLinkExists,
|
||||
getAllCallLinks,
|
||||
insertCallLink,
|
||||
updateCallLinkState,
|
||||
migrateConversationMessages,
|
||||
getMessagesBetween,
|
||||
getNearbyMessageFromDeletedSet,
|
||||
|
@ -439,7 +450,7 @@ type DatabaseQueryCache = Map<string, Statement<Array<unknown>>>;
|
|||
|
||||
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,
|
||||
query: string,
|
||||
{ pluck = false }: { pluck?: boolean } = {}
|
||||
|
@ -732,7 +743,7 @@ async function removeIndexedDBFiles(): Promise<void> {
|
|||
indexedDBPath = undefined;
|
||||
}
|
||||
|
||||
function getReadonlyInstance(): Database {
|
||||
export function getReadonlyInstance(): Database {
|
||||
if (!globalReadonlyInstance) {
|
||||
throw new Error('getReadonlyInstance: globalReadonlyInstance not set!');
|
||||
}
|
||||
|
@ -742,7 +753,7 @@ function getReadonlyInstance(): Database {
|
|||
|
||||
const WRITABLE_INSTANCE_MAX_WAIT = 5 * durations.MINUTE;
|
||||
|
||||
async function getWritableInstance(): Promise<Database> {
|
||||
export async function getWritableInstance(): Promise<Database> {
|
||||
if (pausedWriteQueue) {
|
||||
const { promise, resolve } = explodePromise<void>();
|
||||
pausedWriteQueue.push(resolve);
|
||||
|
@ -3499,6 +3510,7 @@ const SEEN_STATUS_SEEN = sqlConstant(SeenStatus.Seen);
|
|||
const CALL_STATUS_MISSED = sqlConstant(DirectCallStatus.Missed);
|
||||
const CALL_STATUS_DELETED = sqlConstant(DirectCallStatus.Deleted);
|
||||
const CALL_STATUS_INCOMING = sqlConstant(CallDirection.Incoming);
|
||||
const CALL_MODE_ADHOC = sqlConstant(CallMode.Adhoc);
|
||||
const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000);
|
||||
|
||||
async function getCallHistoryUnreadCount(): Promise<number> {
|
||||
|
@ -3581,6 +3593,7 @@ function getCallHistoryGroupDataSync(
|
|||
const { limit, offset } = pagination;
|
||||
const { status, conversationIds } = filter;
|
||||
|
||||
// TODO: DESKTOP-6827 Search Calls Tab for adhoc calls
|
||||
if (conversationIds != null) {
|
||||
strictAssert(conversationIds.length > 0, "can't filter by empty array");
|
||||
|
||||
|
@ -3632,8 +3645,15 @@ function getCallHistoryGroupDataSync(
|
|||
const offsetLimit =
|
||||
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
|
||||
? sqlFragment`COUNT(*) AS count`
|
||||
? sqlFragment`COUNT(*) OVER() AS count`
|
||||
: sqlFragment`peerId, ringerId, mode, type, direction, status, timestamp, possibleChildren, inPeriod`;
|
||||
|
||||
const [query, params] = sql`
|
||||
|
@ -3697,6 +3717,8 @@ function getCallHistoryGroupDataSync(
|
|||
-- Desktop Constraints:
|
||||
AND callsHistory.status IS c.status
|
||||
AND ${filterClause}
|
||||
-- Skip grouping logic for adhoc calls
|
||||
AND callsHistory.mode IS NOT ${CALL_MODE_ADHOC}
|
||||
ORDER BY timestamp DESC
|
||||
) as possibleChildren,
|
||||
|
||||
|
@ -3731,6 +3753,12 @@ function getCallHistoryGroupDataSync(
|
|||
FROM callAndGroupInfo
|
||||
) AS parentCallAndGroupInfo
|
||||
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
|
||||
${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 { updateToSchemaVersion980 } from './980-reaction-timestamp';
|
||||
import { updateToSchemaVersion990 } from './990-phone-number-sharing';
|
||||
import { updateToSchemaVersion1000 } from './1000-mark-unread-call-history-messages-as-unseen';
|
||||
import {
|
||||
version as MAX_VERSION,
|
||||
updateToSchemaVersion1000,
|
||||
} from './1000-mark-unread-call-history-messages-as-unseen';
|
||||
updateToSchemaVersion1010,
|
||||
} from './1010-call-links-table';
|
||||
|
||||
function updateToSchemaVersion1(
|
||||
currentVersion: number,
|
||||
|
@ -2021,6 +2022,7 @@ export const SCHEMA_VERSIONS = [
|
|||
updateToSchemaVersion980,
|
||||
updateToSchemaVersion990,
|
||||
updateToSchemaVersion1000,
|
||||
updateToSchemaVersion1010,
|
||||
];
|
||||
|
||||
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 { ButtonVariant } from '../../components/Button';
|
||||
import { getConversationIdForLogging } from '../../util/idForLogging';
|
||||
import dataInterface from '../../sql/Client';
|
||||
|
||||
// State
|
||||
|
||||
|
@ -171,12 +172,14 @@ export type AdhocCallsType = {
|
|||
export type CallLinkStateType = ReadonlyDeep<{
|
||||
name: string;
|
||||
restrictions: CallLinkRestrictions;
|
||||
expiration: number;
|
||||
expiration: number | null;
|
||||
revoked: boolean;
|
||||
}>;
|
||||
|
||||
export type CallLinksByRoomIdStateType = ReadonlyDeep<
|
||||
CallLinkStateType & {
|
||||
rootKey: string;
|
||||
adminKey: string | null;
|
||||
}
|
||||
>;
|
||||
|
||||
|
@ -331,6 +334,19 @@ export type StartCallLinkLobbyType = ReadonlyDeep<{
|
|||
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
|
||||
type StartCallingLobbyPayloadType =
|
||||
| {
|
||||
|
@ -513,7 +529,7 @@ const doGroupCallPeek = ({
|
|||
: null;
|
||||
|
||||
try {
|
||||
await calling.updateCallHistoryForGroupCall(
|
||||
await calling.updateCallHistoryForGroupCallOnPeek(
|
||||
conversationId,
|
||||
joinState,
|
||||
peekInfo
|
||||
|
@ -1717,86 +1733,147 @@ function onOutgoingAudioCallInConversation(
|
|||
};
|
||||
}
|
||||
|
||||
function startCallLinkLobby({
|
||||
rootKey,
|
||||
}: StartCallLinkLobbyType): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
StartCallLinkLobbyActionType | ShowErrorModalActionType
|
||||
> {
|
||||
function startCallLinkLobbyByRoomId(
|
||||
roomId: string
|
||||
): StartCallLinkLobbyThunkActionType {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const callLink = getOwn(state.calling.callLinks, roomId);
|
||||
|
||||
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;
|
||||
}
|
||||
strictAssert(
|
||||
callLink,
|
||||
`startCallLinkLobbyByRoomId(${roomId}): call link not found`
|
||||
);
|
||||
|
||||
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 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,
|
||||
},
|
||||
});
|
||||
const { rootKey } = callLink;
|
||||
await _startCallLinkLobby({ rootKey, dispatch, getState });
|
||||
};
|
||||
}
|
||||
|
||||
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({
|
||||
conversationId,
|
||||
isVideoCall,
|
||||
|
@ -2010,6 +2087,7 @@ export const actions = {
|
|||
setRendererCanvas,
|
||||
startCall,
|
||||
startCallLinkLobby,
|
||||
startCallLinkLobbyByRoomId,
|
||||
startCallingLobby,
|
||||
switchToPresentationView,
|
||||
switchFromPresentationView,
|
||||
|
@ -2194,6 +2272,7 @@ export function reducer(
|
|||
[conversationId]: {
|
||||
...action.payload.callLinkState,
|
||||
rootKey: action.payload.callLinkRootKey,
|
||||
adminKey: null,
|
||||
},
|
||||
}
|
||||
: callLinks,
|
||||
|
@ -2287,18 +2366,8 @@ export function reducer(
|
|||
case CallMode.Direct:
|
||||
return removeConversationFromState(state, activeCall.conversationId);
|
||||
case CallMode.Group:
|
||||
case CallMode.Adhoc:
|
||||
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:
|
||||
throw missingCaseError(activeCall);
|
||||
}
|
||||
|
|
|
@ -41,9 +41,11 @@ import { getInteractionMode } from '../services/InteractionMode';
|
|||
import { makeLookup } from '../util/makeLookup';
|
||||
import type { CallHistoryDetails } from '../types/CallDisposition';
|
||||
import type { ThemeType } from '../types/Util';
|
||||
import type { CallLinkType } from '../types/CallLink';
|
||||
|
||||
export function getInitialState({
|
||||
badges,
|
||||
callLinks,
|
||||
callsHistory,
|
||||
callsHistoryUnreadCount,
|
||||
stories,
|
||||
|
@ -53,6 +55,7 @@ export function getInitialState({
|
|||
theme,
|
||||
}: {
|
||||
badges: BadgesStateType;
|
||||
callLinks: ReadonlyArray<CallLinkType>;
|
||||
callsHistory: ReadonlyArray<CallHistoryDetails>;
|
||||
callsHistoryUnreadCount: number;
|
||||
stories: Array<StoryDataType>;
|
||||
|
@ -95,7 +98,10 @@ export function getInitialState({
|
|||
callHistoryByCallId: makeLookup(callsHistory, 'callId'),
|
||||
unreadCount: callsHistoryUnreadCount,
|
||||
},
|
||||
calling: calling(),
|
||||
calling: {
|
||||
...calling(),
|
||||
callLinks: makeLookup(callLinks, 'roomId'),
|
||||
},
|
||||
composer: composer(),
|
||||
conversations: {
|
||||
...conversations(),
|
||||
|
|
|
@ -12,8 +12,10 @@ import { actionCreators } from './actions';
|
|||
import { createStore } from './createStore';
|
||||
import { getInitialState } from './getInitialState';
|
||||
import type { ThemeType } from '../types/Util';
|
||||
import type { CallLinkType } from '../types/CallLink';
|
||||
|
||||
export function initializeRedux({
|
||||
callLinks,
|
||||
callsHistory,
|
||||
callsHistoryUnreadCount,
|
||||
initialBadgesState,
|
||||
|
@ -23,6 +25,7 @@ export function initializeRedux({
|
|||
storyDistributionLists,
|
||||
theme,
|
||||
}: {
|
||||
callLinks: ReadonlyArray<CallLinkType>;
|
||||
callsHistory: ReadonlyArray<CallHistoryDetails>;
|
||||
callsHistoryUnreadCount: number;
|
||||
initialBadgesState: BadgesStateType;
|
||||
|
@ -34,6 +37,7 @@ export function initializeRedux({
|
|||
}): void {
|
||||
const initialState = getInitialState({
|
||||
badges: initialBadgesState,
|
||||
callLinks,
|
||||
callsHistory,
|
||||
callsHistoryUnreadCount,
|
||||
mainWindowStats,
|
||||
|
|
|
@ -85,13 +85,9 @@ export const getCallLinkSelector = createSelector(
|
|||
return;
|
||||
}
|
||||
|
||||
const { name, restrictions, rootKey, expiration } = callLinkState;
|
||||
return {
|
||||
roomId,
|
||||
name,
|
||||
restrictions,
|
||||
rootKey,
|
||||
expiration,
|
||||
...callLinkState,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
@ -25,7 +25,7 @@ import type { ConversationType } from '../ducks/conversations';
|
|||
import { SmartConversationDetails } from './ConversationDetails';
|
||||
import { SmartToastManager } from './ToastManager';
|
||||
import { useCallingActions } from '../ducks/calling';
|
||||
import { getActiveCallState } from '../selectors/calling';
|
||||
import { getActiveCallState, getCallLinkSelector } from '../selectors/calling';
|
||||
import { useCallHistoryActions } from '../ducks/callHistory';
|
||||
import { getCallHistoryEdition } from '../selectors/callHistory';
|
||||
import { getHasPendingUpdate } from '../selectors/updates';
|
||||
|
@ -97,6 +97,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
|
|||
const allConversations = useSelector(getAllConversations);
|
||||
const regionCode = useSelector(getRegionCode);
|
||||
const getConversation = useSelector(getConversationSelector);
|
||||
const getCallLink = useSelector(getCallLinkSelector);
|
||||
|
||||
const activeCall = useSelector(getActiveCallState);
|
||||
const callHistoryEdition = useSelector(getCallHistoryEdition);
|
||||
|
@ -108,6 +109,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
|
|||
const {
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
startCallLinkLobbyByRoomId,
|
||||
} = useCallingActions();
|
||||
const {
|
||||
clearAllCallHistory: clearCallHistory,
|
||||
|
@ -167,6 +169,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
|
|||
getConversation={getConversation}
|
||||
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
||||
getCallHistoryGroups={getCallHistoryGroups}
|
||||
getCallLink={getCallLink}
|
||||
callHistoryEdition={callHistoryEdition}
|
||||
hasFailedStorySends={hasFailedStorySends}
|
||||
hasPendingUpdate={hasPendingUpdate}
|
||||
|
@ -182,6 +185,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
|
|||
renderToastManager={renderToastManager}
|
||||
regionCode={regionCode}
|
||||
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 {
|
||||
CallHistoryDetails,
|
||||
CallHistoryGroup,
|
||||
CallStatus,
|
||||
} from '../../types/CallDisposition';
|
||||
import {
|
||||
AdhocCallStatus,
|
||||
CallDirection,
|
||||
CallHistoryFilterStatus,
|
||||
CallType,
|
||||
|
@ -22,8 +24,13 @@ import {
|
|||
import { strictAssert } from '../../util/assert';
|
||||
import type { ConversationAttributesType } from '../../model-types';
|
||||
|
||||
const { removeAll, getCallHistoryGroups, saveCallHistory, saveConversation } =
|
||||
dataInterface;
|
||||
const {
|
||||
removeAll,
|
||||
getCallHistoryGroups,
|
||||
getCallHistoryGroupsCount,
|
||||
saveCallHistory,
|
||||
saveConversation,
|
||||
} = dataInterface;
|
||||
|
||||
function toGroup(calls: Array<CallHistoryDetails>): CallHistoryGroup {
|
||||
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', () => {
|
||||
beforeEach(async () => {
|
||||
await removeAll();
|
||||
|
@ -255,4 +275,158 @@ describe('sql/getCallHistoryGroups', () => {
|
|||
|
||||
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,
|
||||
'peekGroupCall'
|
||||
);
|
||||
this.callingServiceUpdateCallHistoryForGroupCall = this.sandbox.stub(
|
||||
callingService,
|
||||
'updateCallHistoryForGroupCall'
|
||||
);
|
||||
this.callingServiceUpdateCallHistoryForGroupCallOnPeek =
|
||||
this.sandbox.stub(
|
||||
callingService,
|
||||
'updateCallHistoryForGroupCallOnPeek'
|
||||
);
|
||||
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,
|
||||
}
|
||||
|
||||
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<{
|
||||
callId: string;
|
||||
|
@ -133,6 +139,7 @@ const callEventSchema = z.union([
|
|||
const callStatusSchema = z.union([
|
||||
z.nativeEnum(DirectCallStatus),
|
||||
z.nativeEnum(GroupCallStatus),
|
||||
z.nativeEnum(AdhocCallStatus),
|
||||
]);
|
||||
|
||||
export const callDetailsSchema = z.object({
|
||||
|
|
|
@ -2,16 +2,11 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { ReadonlyDeep } from 'type-fest';
|
||||
import { z } from 'zod';
|
||||
import type { CallLinkRestrictions as RingRTCCallLinkRestrictions } from '@signalapp/ringrtc';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
|
||||
export type CallLinkConversationType = ReadonlyDeep<
|
||||
Omit<ConversationType, 'type'> & {
|
||||
type: 'callLink';
|
||||
storySendMode?: undefined;
|
||||
acknowledgedGroupNameCollisions?: undefined;
|
||||
}
|
||||
>;
|
||||
/**
|
||||
* Restrictions
|
||||
*/
|
||||
|
||||
// Must match `CallLinkRestrictions` in @signalapp/ringrtc
|
||||
export enum CallLinkRestrictions {
|
||||
|
@ -20,14 +15,56 @@ export enum CallLinkRestrictions {
|
|||
Unknown = 2,
|
||||
}
|
||||
|
||||
export const callLinkRestrictionsSchema = z.nativeEnum(
|
||||
CallLinkRestrictions
|
||||
) satisfies z.ZodType<RingRTCCallLinkRestrictions>;
|
||||
export const callLinkRestrictionsSchema = z.nativeEnum(CallLinkRestrictions);
|
||||
|
||||
export function toCallLinkRestrictions(
|
||||
restrictions: number
|
||||
): CallLinkRestrictions {
|
||||
return callLinkRestrictionsSchema.parse(restrictions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link
|
||||
*/
|
||||
|
||||
export type CallLinkType = Readonly<{
|
||||
roomId: string;
|
||||
rootKey: string;
|
||||
adminKey: string | null;
|
||||
name: string;
|
||||
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,
|
||||
} from '@signalapp/ringrtc';
|
||||
import { v4 as generateGuid } from 'uuid';
|
||||
import { isEqual } from 'lodash';
|
||||
import { strictAssert } from './assert';
|
||||
import { SignalService as Proto } from '../protobuf';
|
||||
import { bytesToUuid, uuidToBytes } from './uuidToBytes';
|
||||
|
@ -57,6 +58,7 @@ import {
|
|||
RemoteCallEvent,
|
||||
callHistoryDetailsSchema,
|
||||
callDetailsSchema,
|
||||
AdhocCallStatus,
|
||||
} from '../types/CallDisposition';
|
||||
import type { ConversationType } from '../state/ducks/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
|
||||
// ------------------
|
||||
|
||||
|
@ -500,7 +517,7 @@ export function transitionCallHistory(
|
|||
}
|
||||
|
||||
const prevStatus = callHistory?.status ?? null;
|
||||
let status: DirectCallStatus | GroupCallStatus;
|
||||
let status: CallStatus;
|
||||
|
||||
if (mode === CallMode.Direct) {
|
||||
status = transitionDirectCallStatus(
|
||||
|
@ -515,8 +532,11 @@ export function transitionCallHistory(
|
|||
direction
|
||||
);
|
||||
} else if (mode === CallMode.Adhoc) {
|
||||
// TODO: DESKTOP-6653
|
||||
strictAssert(false, 'cannot transitionCallHistory for adhoc calls yet');
|
||||
status = transitionAdhocCallStatus(
|
||||
prevStatus as AdhocCallStatus | null,
|
||||
event,
|
||||
direction
|
||||
);
|
||||
} else {
|
||||
throw missingCaseError(mode);
|
||||
}
|
||||
|
@ -557,7 +577,8 @@ function transitionTimestamp(
|
|||
// Deleted call history should never be changed
|
||||
if (
|
||||
callHistory.status === DirectCallStatus.Deleted ||
|
||||
callHistory.status === GroupCallStatus.Deleted
|
||||
callHistory.status === GroupCallStatus.Deleted ||
|
||||
callHistory.status === AdhocCallStatus.Deleted
|
||||
) {
|
||||
return callHistory.timestamp;
|
||||
}
|
||||
|
@ -567,7 +588,8 @@ function transitionTimestamp(
|
|||
if (
|
||||
callHistory.status === DirectCallStatus.Accepted ||
|
||||
callHistory.status === GroupCallStatus.Accepted ||
|
||||
callHistory.status === GroupCallStatus.Joined
|
||||
callHistory.status === GroupCallStatus.Joined ||
|
||||
callHistory.status === AdhocCallStatus.Joined
|
||||
) {
|
||||
if (callEvent.event === RemoteCallEvent.Accepted) {
|
||||
return latestTimestamp;
|
||||
|
@ -595,7 +617,8 @@ function transitionTimestamp(
|
|||
callHistory.status === GroupCallStatus.OutgoingRing ||
|
||||
callHistory.status === GroupCallStatus.Ringing ||
|
||||
callHistory.status === DirectCallStatus.Missed ||
|
||||
callHistory.status === GroupCallStatus.Missed
|
||||
callHistory.status === GroupCallStatus.Missed ||
|
||||
callHistory.status === AdhocCallStatus.Pending
|
||||
) {
|
||||
return latestTimestamp;
|
||||
}
|
||||
|
@ -750,6 +773,57 @@ function transitionGroupCallStatus(
|
|||
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
|
||||
// -------
|
||||
|
||||
|
@ -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(
|
||||
callHistory: CallHistoryDetails,
|
||||
conversation: ConversationModel,
|
||||
|
|
|
@ -10,7 +10,16 @@ import {
|
|||
} from './zkgroup';
|
||||
import { getCheckedCallLinkAuthCredentialsForToday } from '../services/groupCredentialFetcher';
|
||||
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';
|
||||
|
||||
export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string {
|
||||
|
@ -71,3 +80,47 @@ export function callLinkToConversation(
|
|||
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);
|
||||
}
|
||||
if (callHistory.mode === CallMode.Adhoc) {
|
||||
// TODO: DESKTOP-6653
|
||||
return null;
|
||||
}
|
||||
throw missingCaseError(callHistory.mode);
|
||||
|
|
|
@ -36,6 +36,7 @@ window.testUtilities = {
|
|||
await Stickers.load();
|
||||
|
||||
initializeRedux({
|
||||
callLinks: [],
|
||||
callsHistory: [],
|
||||
callsHistoryUnreadCount: 0,
|
||||
initialBadgesState: { byId: {} },
|
||||
|
|
Loading…
Reference in a new issue