Call link call history

This commit is contained in:
ayumi-signal 2024-04-01 12:19:35 -07:00 committed by GitHub
parent ed940f6f83
commit 00d6379bae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1124 additions and 204 deletions

View file

@ -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"

View file

@ -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,

View file

@ -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,
}; };

View file

@ -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;

View file

@ -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,
] ]
); );

View file

@ -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 && (

View file

@ -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;
} }

View 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;
}

View file

@ -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);
} }

View file

@ -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

View file

@ -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};
`; `;

View 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!');
}

View file

@ -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
View 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'
);
}

View file

@ -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);
} }

View file

@ -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(),

View file

@ -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,

View file

@ -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,
}; };
} }
); );

View file

@ -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}
/> />
); );
}); });

View 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);
});
});
});

View file

@ -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);
});
}); });

View file

@ -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();
}); });

View 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
);
});
});
});

View file

@ -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({

View file

@ -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>;

View file

@ -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,

View file

@ -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,
};
}

View file

@ -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);

View file

@ -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: {} },