Sync call link call history

This commit is contained in:
ayumi-signal 2024-04-25 10:09:05 -07:00 committed by GitHub
parent ce83195170
commit 2785501f82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 800 additions and 175 deletions

View file

@ -7048,6 +7048,10 @@
"messageformat": "Declined {type, select, Audio {voice} Video {video} Group {group} other {}} call", "messageformat": "Declined {type, select, Audio {voice} Video {video} Group {group} other {}} call",
"description": "Call History > Short description of call > When call was declined" "description": "Call History > Short description of call > When call was declined"
}, },
"icu:CallHistory__Description--Adhoc": {
"messageformat": "Call link",
"description": "Call History > Short description of call > When you joined a call link call"
},
"icu:TypingBubble__avatar--overflow-count": { "icu:TypingBubble__avatar--overflow-count": {
"messageformat": "{count, plural, one {# other is} other {# others are}} typing.", "messageformat": "{count, plural, one {# other is} other {# others are}} typing.",
"description": "Group chat multiple person typing indicator when space isn't available to show every avatar, this is the count of avatars hidden." "description": "Group chat multiple person typing indicator when space isn't available to show every avatar, this is the count of avatars hidden."

View file

@ -613,8 +613,14 @@ message SyncMessage {
} }
message CallLinkUpdate { message CallLinkUpdate {
enum Type {
UPDATE = 0;
DELETE = 1;
}
optional bytes rootKey = 1; optional bytes rootKey = 1;
optional bytes adminPasskey = 2; optional bytes adminPasskey = 2;
optional Type type = 3; // defaults to UPDATE
} }
message CallLogEvent { message CallLogEvent {

View file

@ -199,6 +199,7 @@ import {
import { deriveStorageServiceKey } from './Crypto'; import { deriveStorageServiceKey } from './Crypto';
import { getThemeType } from './util/getThemeType'; import { getThemeType } from './util/getThemeType';
import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager'; import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager';
import { onCallLinkUpdateSync } from './util/onCallLinkUpdateSync';
export function isOverHourIntoPast(timestamp: number): boolean { export function isOverHourIntoPast(timestamp: number): boolean {
return isNumber(timestamp) && isOlderThan(timestamp, HOUR); return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
@ -681,6 +682,10 @@ export async function startApp(): Promise<void> {
'callEventSync', 'callEventSync',
queuedEventListener(onCallEventSync, false) queuedEventListener(onCallEventSync, false)
); );
messageReceiver.addEventListener(
'callLinkUpdateSync',
queuedEventListener(onCallLinkUpdateSync, false)
);
messageReceiver.addEventListener( messageReceiver.addEventListener(
'callLogEventSync', 'callLogEventSync',
queuedEventListener(onCallLogEventSync, false) queuedEventListener(onCallLogEventSync, false)

View file

@ -44,9 +44,11 @@ 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 type { CallingConversationType } from '../types/Calling';
import { CallMode } from '../types/Calling';
import type { CallLinkType } from '../types/CallLink'; import type { CallLinkType } from '../types/CallLink';
import { callLinkToConversation } from '../util/callLinks'; import {
callLinkToConversation,
getPlaceholderCallLinkConversation,
} from '../util/callLinks';
function Timestamp({ function Timestamp({
i18n, i18n,
@ -299,6 +301,26 @@ export function CallsList({
[searchState] [searchState]
); );
const getConversationForItem = useCallback(
(item: CallHistoryGroup | null): CallingConversationType | null => {
if (!item) {
return null;
}
const isAdhoc = item?.type === CallType.Adhoc;
if (isAdhoc) {
const callLink = isAdhoc ? getCallLink(item.peerId) : null;
if (callLink) {
return callLinkToConversation(callLink, i18n);
}
return getPlaceholderCallLinkConversation(item.peerId, i18n);
}
return getConversation(item.peerId) ?? null;
},
[getCallLink, getConversation, i18n]
);
const isRowLoaded = useCallback( const isRowLoaded = useCallback(
(props: Index) => { (props: Index) => {
return searchState.results?.items[props.index] != null; return searchState.results?.items[props.index] != null;
@ -309,16 +331,11 @@ 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 isAdhoc = item?.mode === CallMode.Adhoc; const conversation = getConversationForItem(item);
const callLink = isAdhoc ? getCallLink(item.peerId) : null; const isAdhoc = item?.type === CallType.Adhoc;
const isNewCallVisible = Boolean(
const conversation: CallingConversationType | null = !isAdhoc || (isAdhoc && getCallLink(item.peerId))
// 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' ||
@ -354,7 +371,7 @@ 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) { } else if (isAdhoc) {
statusText = i18n('icu:CallsList__ItemCallInfo--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');
@ -393,12 +410,13 @@ export function CallsList({
/> />
} }
trailing={ trailing={
isNewCallVisible ? (
<CallsNewCallButton <CallsNewCallButton
callType={item.type} callType={item.type}
hasActiveCall={hasActiveCall} hasActiveCall={hasActiveCall}
onClick={() => { onClick={() => {
if (callLink) { if (isAdhoc) {
startCallLinkLobbyByRoomId(callLink.roomId); startCallLinkLobbyByRoomId(item.peerId);
} else if (conversation) { } else if (conversation) {
if (item.type === CallType.Audio) { if (item.type === CallType.Audio) {
onOutgoingAudioCallInConversation(conversation.id); onOutgoingAudioCallInConversation(conversation.id);
@ -408,6 +426,7 @@ export function CallsList({
} }
}} }}
/> />
) : undefined
} }
title={ title={
<span <span
@ -426,10 +445,13 @@ export function CallsList({
</span> </span>
} }
onClick={() => { onClick={() => {
if (callLink) { if (isAdhoc) {
return; return;
} }
if (conversation == null) {
return;
}
onSelectCallHistoryGroup(conversation.id, item); onSelectCallHistoryGroup(conversation.id, item);
}} }}
/> />
@ -440,7 +462,7 @@ export function CallsList({
hasActiveCall, hasActiveCall,
searchState, searchState,
getCallLink, getCallLink,
getConversation, getConversationForItem,
selectedCallHistoryGroup, selectedCallHistoryGroup,
onSelectCallHistoryGroup, onSelectCallHistoryGroup,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,

View file

@ -72,6 +72,10 @@ function describeCallHistory(
direction: CallDirection, direction: CallDirection,
status: CallStatus status: CallStatus
): string { ): string {
if (type === CallType.Adhoc) {
return i18n('icu:CallHistory__Description--Adhoc');
}
if (status === DirectCallStatus.Missed || status === GroupCallStatus.Missed) { if (status === DirectCallStatus.Missed || status === GroupCallStatus.Missed) {
if (direction === CallDirection.Incoming) { if (direction === CallDirection.Incoming) {
return i18n('icu:CallHistory__Description--Missed', { type }); return i18n('icu:CallHistory__Description--Missed', { type });

View file

@ -585,7 +585,8 @@ async function getCallLinkPreview(
} }
const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key); const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key);
const callLinkState = await calling.readCallLink({ callLinkRootKey }); const readResult = await calling.readCallLink({ callLinkRootKey });
const { callLinkState } = readResult;
if (!callLinkState || callLinkState.revoked) { if (!callLinkState || callLinkState.revoked) {
return null; return null;
} }

View file

@ -6,7 +6,7 @@ import { ipcRenderer } from 'electron';
import type { import type {
AudioDevice, AudioDevice,
CallId, CallId,
CallLinkState, CallLinkState as RingRTCCallLinkState,
DeviceId, DeviceId,
GroupCallObserver, GroupCallObserver,
PeekInfo, PeekInfo,
@ -46,7 +46,6 @@ import Long from 'long';
import type { CallLinkAuthCredentialPresentation } from '@signalapp/libsignal-client/zkgroup'; import type { CallLinkAuthCredentialPresentation } from '@signalapp/libsignal-client/zkgroup';
import type { import type {
ActionsType as CallingReduxActionsType, ActionsType as CallingReduxActionsType,
CallLinkStateType,
GroupCallParticipantInfoType, GroupCallParticipantInfoType,
GroupCallPeekInfoType, GroupCallPeekInfoType,
} from '../state/ducks/calling'; } from '../state/ducks/calling';
@ -120,7 +119,7 @@ import {
getCallIdFromRing, getCallIdFromRing,
getLocalCallEventFromRingUpdate, getLocalCallEventFromRingUpdate,
convertJoinState, convertJoinState,
updateLocalAdhocCallHistory, updateAdhocCallHistory,
getCallIdFromEra, getCallIdFromEra,
getCallDetailsForAdhocCall, getCallDetailsForAdhocCall,
} from '../util/callDisposition'; } from '../util/callDisposition';
@ -134,6 +133,7 @@ import {
} from '../util/callLinks'; } from '../util/callLinks';
import { isAdhocCallingEnabled } from '../util/isAdhocCallingEnabled'; import { isAdhocCallingEnabled } from '../util/isAdhocCallingEnabled';
import { conversationJobQueue } from '../jobs/conversationJobQueue'; import { conversationJobQueue } from '../jobs/conversationJobQueue';
import type { ReadCallLinkState } from '../types/CallLink';
const { const {
processGroupCallRingCancellation, processGroupCallRingCancellation,
@ -173,6 +173,7 @@ type CallingReduxInterface = Pick<
| 'groupCallEnded' | 'groupCallEnded'
| 'groupCallRaisedHandsChange' | 'groupCallRaisedHandsChange'
| 'groupCallStateChange' | 'groupCallStateChange'
| 'joinedAdhocCall'
| 'outgoingCall' | 'outgoingCall'
| 'receiveGroupCallReactions' | 'receiveGroupCallReactions'
| 'receiveIncomingDirectCall' | 'receiveIncomingDirectCall'
@ -538,7 +539,16 @@ export class CallingClass {
callLinkRootKey, callLinkRootKey,
}: Readonly<{ }: Readonly<{
callLinkRootKey: CallLinkRootKey; callLinkRootKey: CallLinkRootKey;
}>): Promise<CallLinkState | undefined> { }>): Promise<
| {
callLinkState: ReadCallLinkState;
errorStatusCode: undefined;
}
| {
callLinkState: undefined;
errorStatusCode: number;
}
> {
if (!this._sfuUrl) { if (!this._sfuUrl) {
throw new Error('readCallLink() missing SFU URL; not handling call link'); throw new Error('readCallLink() missing SFU URL; not handling call link');
} }
@ -555,11 +565,17 @@ export class CallingClass {
); );
if (!result.success) { if (!result.success) {
log.warn(`${logId}: failed`); log.warn(`${logId}: failed`);
return; return {
callLinkState: undefined,
errorStatusCode: result.errorStatusCode,
};
} }
log.info(`${logId}: success`); log.info(`${logId}: success`);
return result.value; return {
callLinkState: this.formatCallLinkStateForRedux(result.value),
errorStatusCode: undefined,
};
} }
async startCallLinkLobby({ async startCallLinkLobby({
@ -1050,9 +1066,13 @@ export class CallingClass {
eraId eraId
) { ) {
updateMessageState = GroupCallUpdateMessageState.SentJoin; updateMessageState = GroupCallUpdateMessageState.SentJoin;
if (callMode === CallMode.Group) { drop(
drop(this.sendGroupCallUpdateMessage(conversationId, eraId)); this.onGroupCallJoined({
} peerId: conversationId,
eraId,
callMode,
})
);
} }
} }
@ -1126,10 +1146,13 @@ export class CallingClass {
eraId eraId
) { ) {
updateMessageState = GroupCallUpdateMessageState.SentJoin; updateMessageState = GroupCallUpdateMessageState.SentJoin;
drop(
if (callMode === CallMode.Group) { this.onGroupCallJoined({
drop(this.sendGroupCallUpdateMessage(conversationId, eraId)); peerId: conversationId,
} eraId,
callMode,
})
);
} }
// For adhoc calls, conversationId will be a roomId // For adhoc calls, conversationId will be a roomId
@ -1168,6 +1191,22 @@ export class CallingClass {
}; };
} }
private async onGroupCallJoined({
callMode,
peerId,
eraId,
}: {
callMode: CallMode.Group | CallMode.Adhoc;
peerId: string;
eraId: string;
}): Promise<void> {
if (callMode === CallMode.Group) {
drop(this.sendGroupCallUpdateMessage(peerId, eraId));
} else if (callMode === CallMode.Adhoc) {
this.reduxInterface?.joinedAdhocCall(peerId);
}
}
public async joinCallLinkCall({ public async joinCallLinkCall({
roomId, roomId,
rootKey, rootKey,
@ -1365,8 +1404,8 @@ export class CallingClass {
} }
public formatCallLinkStateForRedux( public formatCallLinkStateForRedux(
callLinkState: CallLinkState callLinkState: RingRTCCallLinkState
): CallLinkStateType { ): ReadCallLinkState {
const { name, restrictions, expiration, revoked } = callLinkState; const { name, restrictions, expiration, revoked } = callLinkState;
return { return {
name, name,
@ -2658,7 +2697,7 @@ export class CallingClass {
LocalCallEvent.Accepted, LocalCallEvent.Accepted,
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged' 'CallingClass.updateCallHistoryForGroupCallOnLocalChanged'
); );
await updateLocalAdhocCallHistory(callEvent); await updateAdhocCallHistory(callEvent);
} catch (error) { } catch (error) {
log.error( log.error(
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged: Error updating state', 'CallingClass.updateCallHistoryForGroupCallOnLocalChanged: Error updating state',

View file

@ -29,7 +29,7 @@ import type {
CallHistoryGroup, CallHistoryGroup,
CallHistoryPagination, CallHistoryPagination,
} from '../types/CallDisposition'; } from '../types/CallDisposition';
import type { CallLinkType, CallLinkRestrictions } from '../types/CallLink'; import type { CallLinkStateType, CallLinkType } from '../types/CallLink';
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload'; import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
export type AdjacentMessagesByConversationOptionsType = Readonly<{ export type AdjacentMessagesByConversationOptionsType = Readonly<{
@ -681,13 +681,15 @@ export type DataInterface = {
>; >;
callLinkExists(roomId: string): Promise<boolean>; callLinkExists(roomId: string): Promise<boolean>;
getAllCallLinks: () => Promise<ReadonlyArray<CallLinkType>>; getAllCallLinks: () => Promise<ReadonlyArray<CallLinkType>>;
getCallLinkByRoomId: (roomId: string) => Promise<CallLinkType | undefined>;
insertCallLink(callLink: CallLinkType): Promise<void>; insertCallLink(callLink: CallLinkType): Promise<void>;
updateCallLinkAdminKeyByRoomId(
roomId: string,
adminKey: string
): Promise<void>;
updateCallLinkState( updateCallLinkState(
roomId: string, roomId: string,
name: string, callLinkState: CallLinkStateType
restrictions: CallLinkRestrictions,
expiration: number | null,
revoked: boolean
): Promise<void>; ): Promise<void>;
migrateConversationMessages: ( migrateConversationMessages: (
obsoleteId: string, obsoleteId: string,

View file

@ -169,7 +169,9 @@ import {
import { import {
callLinkExists, callLinkExists,
getAllCallLinks, getAllCallLinks,
getCallLinkByRoomId,
insertCallLink, insertCallLink,
updateCallLinkAdminKeyByRoomId,
updateCallLinkState, updateCallLinkState,
} from './server/callLinks'; } from './server/callLinks';
import { CallMode } from '../types/Calling'; import { CallMode } from '../types/Calling';
@ -341,7 +343,9 @@ const dataInterface: ServerInterface = {
getRecentStaleRingsAndMarkOlderMissed, getRecentStaleRingsAndMarkOlderMissed,
callLinkExists, callLinkExists,
getAllCallLinks, getAllCallLinks,
getCallLinkByRoomId,
insertCallLink, insertCallLink,
updateCallLinkAdminKeyByRoomId,
updateCallLinkState, updateCallLinkState,
migrateConversationMessages, migrateConversationMessages,
getMessagesBetween, getMessagesBetween,

View file

@ -3,12 +3,16 @@
import type { Database } from '@signalapp/better-sqlite3'; import type { Database } from '@signalapp/better-sqlite3';
import { CallLinkRootKey } from '@signalapp/ringrtc'; import { CallLinkRootKey } from '@signalapp/ringrtc';
import type { CallLinkRestrictions, CallLinkType } from '../../types/CallLink'; import type { CallLinkStateType, CallLinkType } from '../../types/CallLink';
import { import {
callLinkRestrictionsSchema, callLinkRestrictionsSchema,
callLinkRecordSchema, callLinkRecordSchema,
} from '../../types/CallLink'; } from '../../types/CallLink';
import { callLinkToRecord, callLinkFromRecord } from '../../util/callLinks'; import {
callLinkToRecord,
callLinkFromRecord,
toAdminKeyBytes,
} from '../../util/callLinks';
import { getReadonlyInstance, getWritableInstance, prepare } from '../Server'; import { getReadonlyInstance, getWritableInstance, prepare } from '../Server';
import { sql } from '../util'; import { sql } from '../util';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
@ -23,6 +27,23 @@ export async function callLinkExists(roomId: string): Promise<boolean> {
return db.prepare(query).pluck(true).get(params) === 1; return db.prepare(query).pluck(true).get(params) === 1;
} }
export async function getCallLinkByRoomId(
roomId: string
): Promise<CallLinkType | undefined> {
const db = getReadonlyInstance();
const row = prepare(db, 'SELECT * FROM callLinks WHERE roomId = $roomId').get(
{
roomId,
}
);
if (!row) {
return undefined;
}
return callLinkFromRecord(callLinkRecordSchema.parse(row));
}
export async function getAllCallLinks(): Promise<ReadonlyArray<CallLinkType>> { export async function getAllCallLinks(): Promise<ReadonlyArray<CallLinkType>> {
const db = getReadonlyInstance(); const db = getReadonlyInstance();
const [query] = sql` const [query] = sql`
@ -70,11 +91,9 @@ export async function insertCallLink(callLink: CallLinkType): Promise<void> {
export async function updateCallLinkState( export async function updateCallLinkState(
roomId: string, roomId: string,
name: string, callLinkState: CallLinkStateType
restrictions: CallLinkRestrictions,
expiration: number,
revoked: boolean
): Promise<void> { ): Promise<void> {
const { name, restrictions, expiration, revoked } = callLinkState;
const db = await getWritableInstance(); const db = await getWritableInstance();
const restrictionsValue = callLinkRestrictionsSchema.parse(restrictions); const restrictionsValue = callLinkRestrictionsSchema.parse(restrictions);
const [query, params] = sql` const [query, params] = sql`
@ -89,6 +108,22 @@ export async function updateCallLinkState(
db.prepare(query).run(params); db.prepare(query).run(params);
} }
export async function updateCallLinkAdminKeyByRoomId(
roomId: string,
adminKey: string
): Promise<void> {
const db = await getWritableInstance();
const adminKeyBytes = toAdminKeyBytes(adminKey);
prepare(
db,
`
UPDATE callLinks
SET adminKey = $adminKeyBytes
WHERE roomId = $roomId;
`
).run({ roomId, adminKeyBytes });
}
function assertRoomIdMatchesRootKey(roomId: string, rootKey: string): void { function assertRoomIdMatchesRootKey(roomId: string, rootKey: string): void {
const derivedRoomId = CallLinkRootKey.parse(rootKey) const derivedRoomId = CallLinkRootKey.parse(rootKey)
.deriveRoomId() .deriveRoomId()

View file

@ -7,7 +7,7 @@ import {
hasScreenCapturePermission, hasScreenCapturePermission,
openSystemPreferences, openSystemPreferences,
} from 'mac-screen-capture-permissions'; } from 'mac-screen-capture-permissions';
import { omit } from 'lodash'; import { omit, pick } from 'lodash';
import type { ReadonlyDeep } from 'type-fest'; import type { ReadonlyDeep } from 'type-fest';
import { import {
CallLinkRootKey, CallLinkRootKey,
@ -32,7 +32,7 @@ import type {
PresentedSource, PresentedSource,
PresentableSource, PresentableSource,
} from '../../types/Calling'; } from '../../types/Calling';
import type { CallLinkRestrictions } from '../../types/CallLink'; import type { CallLinkStateType } from '../../types/CallLink';
import { import {
CALLING_REACTIONS_LIFETIME, CALLING_REACTIONS_LIFETIME,
MAX_CALLING_REACTIONS, MAX_CALLING_REACTIONS,
@ -46,7 +46,11 @@ import {
} from '../../types/Calling'; } from '../../types/Calling';
import { callingTones } from '../../util/callingTones'; import { callingTones } from '../../util/callingTones';
import { requestCameraPermissions } from '../../util/callingPermissions'; import { requestCameraPermissions } from '../../util/callingPermissions';
import { getRoomIdFromRootKey } from '../../util/callLinks'; import {
CALL_LINK_DEFAULT_STATE,
getRoomIdFromRootKey,
} from '../../util/callLinks';
import { sendCallLinkUpdateSync } from '../../util/sendCallLinkUpdateSync';
import { sleep } from '../../util/sleep'; import { sleep } from '../../util/sleep';
import { LatestQueue } from '../../util/LatestQueue'; import { LatestQueue } from '../../util/LatestQueue';
import type { AciString } from '../../types/ServiceId'; import type { AciString } from '../../types/ServiceId';
@ -168,13 +172,6 @@ export type AdhocCallsType = {
[roomId: string]: GroupCallStateType; [roomId: string]: GroupCallStateType;
}; };
export type CallLinkStateType = ReadonlyDeep<{
name: string;
restrictions: CallLinkRestrictions;
expiration: number | null;
revoked: boolean;
}>;
export type CallLinksByRoomIdStateType = ReadonlyDeep< export type CallLinksByRoomIdStateType = ReadonlyDeep<
CallLinkStateType & { CallLinkStateType & {
rootKey: string; rootKey: string;
@ -244,10 +241,20 @@ type GroupCallStateChangeActionPayloadType =
ourAci: AciString; ourAci: AciString;
}; };
type HandleCallLinkUpdateActionPayloadType = ReadonlyDeep<{
roomId: string;
callLinkDetails: CallLinksByRoomIdStateType;
}>;
type HangUpActionPayloadType = ReadonlyDeep<{ type HangUpActionPayloadType = ReadonlyDeep<{
conversationId: string; conversationId: string;
}>; }>;
type HandleCallLinkUpdateType = ReadonlyDeep<{
rootKey: string;
adminKey: string | null;
}>;
export type IncomingDirectCallType = ReadonlyDeep<{ export type IncomingDirectCallType = ReadonlyDeep<{
conversationId: string; conversationId: string;
isVideoCall: boolean; isVideoCall: boolean;
@ -567,6 +574,7 @@ const GROUP_CALL_RAISED_HANDS_CHANGE = 'calling/GROUP_CALL_RAISED_HANDS_CHANGE';
const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE'; const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE';
const GROUP_CALL_REACTIONS_RECEIVED = 'calling/GROUP_CALL_REACTIONS_RECEIVED'; const GROUP_CALL_REACTIONS_RECEIVED = 'calling/GROUP_CALL_REACTIONS_RECEIVED';
const GROUP_CALL_REACTIONS_EXPIRED = 'calling/GROUP_CALL_REACTIONS_EXPIRED'; const GROUP_CALL_REACTIONS_EXPIRED = 'calling/GROUP_CALL_REACTIONS_EXPIRED';
const HANDLE_CALL_LINK_UPDATE = 'calling/HANDLE_CALL_LINK_UPDATE';
const HANG_UP = 'calling/HANG_UP'; const HANG_UP = 'calling/HANG_UP';
const INCOMING_DIRECT_CALL = 'calling/INCOMING_DIRECT_CALL'; const INCOMING_DIRECT_CALL = 'calling/INCOMING_DIRECT_CALL';
const INCOMING_GROUP_CALL = 'calling/INCOMING_GROUP_CALL'; const INCOMING_GROUP_CALL = 'calling/INCOMING_GROUP_CALL';
@ -699,6 +707,11 @@ type GroupCallReactionsExpiredActionType = ReadonlyDeep<{
payload: GroupCallReactionsExpiredActionPayloadType; payload: GroupCallReactionsExpiredActionPayloadType;
}>; }>;
type HandleCallLinkUpdateActionType = ReadonlyDeep<{
type: 'calling/HANDLE_CALL_LINK_UPDATE';
payload: HandleCallLinkUpdateActionPayloadType;
}>;
type HangUpActionType = ReadonlyDeep<{ type HangUpActionType = ReadonlyDeep<{
type: 'calling/HANG_UP'; type: 'calling/HANG_UP';
payload: HangUpActionPayloadType; payload: HangUpActionPayloadType;
@ -838,6 +851,7 @@ export type CallingActionType =
| GroupCallStateChangeActionType | GroupCallStateChangeActionType
| GroupCallReactionsReceivedActionType | GroupCallReactionsReceivedActionType
| GroupCallReactionsExpiredActionType | GroupCallReactionsExpiredActionType
| HandleCallLinkUpdateActionType
| HangUpActionType | HangUpActionType
| IncomingDirectCallActionType | IncomingDirectCallActionType
| IncomingGroupCallActionType | IncomingGroupCallActionType
@ -1206,6 +1220,72 @@ function groupCallStateChange(
}; };
} }
// From sync messages, to notify us that another device joined or changed a call link.
function handleCallLinkUpdate(
payload: HandleCallLinkUpdateType
): ThunkAction<void, RootStateType, unknown, HandleCallLinkUpdateActionType> {
return async dispatch => {
const { rootKey, adminKey } = payload;
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
const roomId = getRoomIdFromRootKey(callLinkRootKey);
const logId = `handleCallLinkUpdate(${roomId})`;
const readResult = await calling.readCallLink({
callLinkRootKey,
});
// Only give up when server confirms the call link is gone. If we fail to fetch
// state due to unexpected errors, continue to save rootKey and adminKey.
if (readResult.errorStatusCode === 404) {
log.info(`${logId}: Call link not found, ignoring`);
return;
}
const { callLinkState: freshCallLinkState } = readResult;
const existingCallLink = await dataInterface.getCallLinkByRoomId(roomId);
const existingCallLinkState = pick(existingCallLink, [
'name',
'restrictions',
'expiration',
'revoked',
]);
const callLinkDetails: CallLinksByRoomIdStateType = {
...CALL_LINK_DEFAULT_STATE,
...existingCallLinkState,
...freshCallLinkState,
rootKey,
adminKey,
};
if (existingCallLink) {
if (adminKey && adminKey !== existingCallLink.adminKey) {
await dataInterface.updateCallLinkAdminKeyByRoomId(roomId, adminKey);
log.info(`${logId}: Updated existing call link with new adminKey`);
}
if (freshCallLinkState) {
await dataInterface.updateCallLinkState(roomId, freshCallLinkState);
log.info(`${logId}: Updated existing call link state`);
}
} else {
await dataInterface.insertCallLink({
roomId,
...callLinkDetails,
});
log.info(`${logId}: Saved new call link`);
}
dispatch({
type: HANDLE_CALL_LINK_UPDATE,
payload: {
roomId,
callLinkDetails,
},
});
};
}
function hangUpActiveCall( function hangUpActiveCall(
reason: string reason: string
): ThunkAction<void, RootStateType, unknown, HangUpActionType> { ): ThunkAction<void, RootStateType, unknown, HangUpActionType> {
@ -1327,6 +1407,21 @@ function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType {
}; };
} }
function joinedAdhocCall(
roomId: string
): ThunkAction<void, RootStateType, unknown, never> {
return async (_dispatch, getState) => {
const state = getState();
const callLink = getOwn(state.calling.callLinks, roomId);
if (!callLink) {
log.warn(`joinedAdhocCall(${roomId}): call link not found`);
return;
}
drop(sendCallLinkUpdateSync(callLink));
};
}
function peekGroupCallForTheFirstTime( function peekGroupCallForTheFirstTime(
conversationId: string conversationId: string
): ThunkAction<void, RootStateType, unknown, PeekGroupCallFulfilledActionType> { ): ThunkAction<void, RootStateType, unknown, PeekGroupCallFulfilledActionType> {
@ -1712,7 +1807,8 @@ const _startCallLinkLobby = async ({
const callLinkRootKey = CallLinkRootKey.parse(rootKey); const callLinkRootKey = CallLinkRootKey.parse(rootKey);
const callLinkState = await calling.readCallLink({ callLinkRootKey }); const readResult = await calling.readCallLink({ callLinkRootKey });
const { callLinkState } = readResult;
if (!callLinkState) { if (!callLinkState) {
const i18n = getIntl(getState()); const i18n = getIntl(getState());
dispatch({ dispatch({
@ -1725,7 +1821,10 @@ const _startCallLinkLobby = async ({
}); });
return; return;
} }
if (callLinkState.revoked || callLinkState.expiration < new Date()) { if (
callLinkState.revoked ||
callLinkState.expiration < new Date().getTime()
) {
const i18n = getIntl(getState()); const i18n = getIntl(getState());
dispatch({ dispatch({
type: SHOW_ERROR_MODAL, type: SHOW_ERROR_MODAL,
@ -1739,21 +1838,13 @@ const _startCallLinkLobby = async ({
} }
const roomId = getRoomIdFromRootKey(callLinkRootKey); const roomId = getRoomIdFromRootKey(callLinkRootKey);
const formattedCallLinkState =
calling.formatCallLinkStateForRedux(callLinkState);
try { try {
const { name, restrictions, expiration, revoked } = formattedCallLinkState;
const callLinkExists = await dataInterface.callLinkExists(roomId); const callLinkExists = await dataInterface.callLinkExists(roomId);
if (callLinkExists) { if (callLinkExists) {
await dataInterface.updateCallLinkState( await dataInterface.updateCallLinkState(roomId, callLinkState);
roomId,
name,
restrictions,
expiration,
revoked
);
log.info('startCallLinkLobby: Updated existing call link', roomId); log.info('startCallLinkLobby: Updated existing call link', roomId);
} else { } else {
const { name, restrictions, expiration, revoked } = callLinkState;
await dataInterface.insertCallLink({ await dataInterface.insertCallLink({
roomId, roomId,
rootKey, rootKey,
@ -1790,7 +1881,7 @@ const _startCallLinkLobby = async ({
type: START_CALL_LINK_LOBBY, type: START_CALL_LINK_LOBBY,
payload: { payload: {
...callLobbyData, ...callLobbyData,
callLinkState: formattedCallLinkState, callLinkState,
callLinkRootKey: rootKey, callLinkRootKey: rootKey,
conversationId: roomId, conversationId: roomId,
isConversationTooBigToRing: false, isConversationTooBigToRing: false,
@ -1983,6 +2074,8 @@ export const actions = {
groupCallRaisedHandsChange, groupCallRaisedHandsChange,
groupCallStateChange, groupCallStateChange,
hangUpActiveCall, hangUpActiveCall,
handleCallLinkUpdate,
joinedAdhocCall,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
openSystemPreferencesAction, openSystemPreferencesAction,
@ -3100,5 +3193,18 @@ export function reducer(
}; };
} }
if (action.type === HANDLE_CALL_LINK_UPDATE) {
const { callLinks } = state;
const { roomId, callLinkDetails } = action.payload;
return {
...state,
callLinks: {
...callLinks,
[roomId]: callLinkDetails,
},
};
}
return state; return state;
} }

View file

@ -0,0 +1,26 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { CallLinkType } from '../../types/CallLink';
import { CallLinkRestrictions } from '../../types/CallLink';
import { MONTH } from '../../util/durations/constants';
export const FAKE_CALL_LINK: CallLinkType = {
adminKey: null,
expiration: Date.now() + MONTH, // set me
name: 'Fun Link',
restrictions: CallLinkRestrictions.None,
revoked: false,
roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4',
rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg',
};
// Please set expiration
export const FAKE_CALL_LINK_WITH_ADMIN_KEY: CallLinkType = {
adminKey: 'xXPI77e6MoVHYREW8iKYmQ==',
expiration: Date.now() + MONTH, // set me
name: 'Fun Link',
restrictions: CallLinkRestrictions.None,
revoked: false,
roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4',
rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg',
};

View file

@ -4,29 +4,10 @@
import { assert } from 'chai'; import { assert } from 'chai';
import { callLinkToRecord, callLinkFromRecord } from '../../util/callLinks'; import { callLinkToRecord, callLinkFromRecord } from '../../util/callLinks';
import type { CallLinkType } from '../../types/CallLink'; import {
import { CallLinkRestrictions } from '../../types/CallLink'; FAKE_CALL_LINK as CALL_LINK,
import { MONTH } from '../../util/durations/constants'; FAKE_CALL_LINK_WITH_ADMIN_KEY as CALL_LINK_WITH_ADMIN_KEY,
} from '../helpers/fakeCallLink';
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', () => { describe('callLinks', () => {
it('callLinkToRecord() and callLinkFromRecord() can convert to record and back', () => { it('callLinkToRecord() and callLinkFromRecord() can convert to record and back', () => {

View file

@ -321,21 +321,21 @@ describe('sql/getCallHistoryGroups', () => {
const now = Date.now(); const now = Date.now();
const roomId = generateUuid(); const roomId = generateUuid();
function toCall(callId: string, timestamp: number, type: CallType) { function toCall(callId: string, timestamp: number) {
return { return {
callId, callId,
peerId: roomId, peerId: roomId,
ringerId: null, ringerId: null,
mode: CallMode.Adhoc, mode: CallMode.Adhoc,
type, type: CallType.Adhoc,
direction: CallDirection.Outgoing, direction: CallDirection.Outgoing,
timestamp, timestamp,
status: AdhocCallStatus.Joined, status: AdhocCallStatus.Joined,
}; };
} }
const call1 = toCall('1', now - 10, CallType.Group); const call1 = toCall('1', now - 10);
const call2 = toCall('2', now, CallType.Group); const call2 = toCall('2', now);
await saveCallHistory(call1); await saveCallHistory(call1);
await saveCallHistory(call2); await saveCallHistory(call2);
@ -394,28 +394,23 @@ describe('sql/getCallHistoryGroupsCount', () => {
const roomId1 = generateUuid(); const roomId1 = generateUuid();
const roomId2 = generateUuid(); const roomId2 = generateUuid();
function toCall( function toCall(callId: string, roomId: string, timestamp: number) {
callId: string,
roomId: string,
timestamp: number,
type: CallType
) {
return { return {
callId, callId,
peerId: roomId, peerId: roomId,
ringerId: null, ringerId: null,
mode: CallMode.Adhoc, mode: CallMode.Adhoc,
type, type: CallType.Adhoc,
direction: CallDirection.Outgoing, direction: CallDirection.Outgoing,
timestamp, timestamp,
status: AdhocCallStatus.Joined, status: AdhocCallStatus.Joined,
}; };
} }
const call1 = toCall('1', roomId1, now - 20, CallType.Group); const call1 = toCall('1', roomId1, now - 20);
const call2 = toCall('2', roomId1, now - 10, CallType.Group); const call2 = toCall('2', roomId1, now - 10);
const call3 = toCall('3', roomId1, now, CallType.Group); const call3 = toCall('3', roomId1, now);
const call4 = toCall('4', roomId2, now, CallType.Group); const call4 = toCall('4', roomId2, now);
await saveCallHistory(call1); await saveCallHistory(call1);
await saveCallHistory(call2); await saveCallHistory(call2);

View file

@ -36,6 +36,8 @@ import {
import { generateAci } from '../../../types/ServiceId'; import { generateAci } from '../../../types/ServiceId';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
import type { UnwrapPromise } from '../../../types/Util'; import type { UnwrapPromise } from '../../../types/Util';
import { CallLinkRestrictions } from '../../../types/CallLink';
import { FAKE_CALL_LINK } from '../../../test-both/helpers/fakeCallLink';
const ACI_1 = generateAci(); const ACI_1 = generateAci();
const NOW = new Date('2020-01-23T04:56:00.000'); const NOW = new Date('2020-01-23T04:56:00.000');
@ -1301,6 +1303,62 @@ describe('calling duck', () => {
}); });
}); });
describe('handleCallLinkUpdate', () => {
const { roomId, rootKey, expiration } = FAKE_CALL_LINK;
beforeEach(function (this: Mocha.Context) {
this.callingServiceReadCallLink = this.sandbox
.stub(callingService, 'readCallLink')
.resolves({
callLinkState: {
name: 'Signal Call',
restrictions: CallLinkRestrictions.None,
expiration,
revoked: false,
},
errorStatusCode: undefined,
});
});
it('reads the call link from calling service', async function (this: Mocha.Context) {
const { handleCallLinkUpdate } = actions;
const dispatch = sinon.spy();
await handleCallLinkUpdate({ rootKey, adminKey: null })(
dispatch,
getEmptyRootState,
null
);
sinon.assert.calledOnce(this.callingServiceReadCallLink);
});
it('dispatches HANDLE_CALL_LINK_UPDATE', async () => {
const { handleCallLinkUpdate } = actions;
const dispatch = sinon.spy();
await handleCallLinkUpdate({ rootKey, adminKey: null })(
dispatch,
getEmptyRootState,
null
);
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWith(dispatch, {
type: 'calling/HANDLE_CALL_LINK_UPDATE',
payload: {
roomId,
callLinkDetails: {
name: 'Signal Call',
restrictions: CallLinkRestrictions.None,
expiration,
revoked: false,
rootKey,
adminKey: null,
},
},
});
});
});
describe('peekNotConnectedGroupCall', () => { describe('peekNotConnectedGroupCall', () => {
const { peekNotConnectedGroupCall } = actions; const { peekNotConnectedGroupCall } = actions;

View file

@ -129,6 +129,7 @@ import {
ContactSyncEvent, ContactSyncEvent,
StoryRecipientUpdateEvent, StoryRecipientUpdateEvent,
CallLogEventSyncEvent, CallLogEventSyncEvent,
CallLinkUpdateSyncEvent,
} from './messageReceiverEvents'; } from './messageReceiverEvents';
import * as log from '../logging/log'; import * as log from '../logging/log';
import * as durations from '../util/durations'; import * as durations from '../util/durations';
@ -145,6 +146,7 @@ import { filterAndClean } from '../types/BodyRange';
import { getCallEventForProto } from '../util/callDisposition'; import { getCallEventForProto } from '../util/callDisposition';
import { checkOurPniIdentityKey } from '../util/checkOurPniIdentityKey'; import { checkOurPniIdentityKey } from '../util/checkOurPniIdentityKey';
import { CallLogEvent } from '../types/CallDisposition'; import { CallLogEvent } from '../types/CallDisposition';
import { CallLinkUpdateSyncType } from '../types/CallLink';
const GROUPV2_ID_LENGTH = 32; const GROUPV2_ID_LENGTH = 32;
const RETRY_TIMEOUT = 2 * 60 * 1000; const RETRY_TIMEOUT = 2 * 60 * 1000;
@ -674,6 +676,11 @@ export default class MessageReceiver
handler: (ev: CallEventSyncEvent) => void handler: (ev: CallEventSyncEvent) => void
): void; ): void;
public override addEventListener(
name: 'callLinkUpdateSync',
handler: (ev: CallLinkUpdateSyncEvent) => void
): void;
public override addEventListener( public override addEventListener(
name: 'callLogEventSync', name: 'callLogEventSync',
handler: (ev: CallLogEventSyncEvent) => void handler: (ev: CallLogEventSyncEvent) => void
@ -3152,6 +3159,9 @@ export default class MessageReceiver
if (syncMessage.callEvent) { if (syncMessage.callEvent) {
return this.handleCallEvent(envelope, syncMessage.callEvent); return this.handleCallEvent(envelope, syncMessage.callEvent);
} }
if (syncMessage.callLinkUpdate) {
return this.handleCallLinkUpdate(envelope, syncMessage.callLinkUpdate);
}
if (syncMessage.callLogEvent) { if (syncMessage.callLogEvent) {
return this.handleCallLogEvent(envelope, syncMessage.callLogEvent); return this.handleCallLogEvent(envelope, syncMessage.callLogEvent);
} }
@ -3510,6 +3520,53 @@ export default class MessageReceiver
log.info('handleCallEvent: finished'); log.info('handleCallEvent: finished');
} }
private async handleCallLinkUpdate(
envelope: ProcessedEnvelope,
callLinkUpdate: Proto.SyncMessage.ICallLinkUpdate
): Promise<void> {
const logId = getEnvelopeId(envelope);
log.info('MessageReceiver.handleCallLinkUpdate', logId);
logUnexpectedUrgentValue(envelope, 'callLinkUpdateSync');
let callLinkUpdateSyncType: CallLinkUpdateSyncType;
if (callLinkUpdate.type == null) {
throw new Error('MessageReceiver.handleCallLinkUpdate: type was null');
} else if (
callLinkUpdate.type === Proto.SyncMessage.CallLinkUpdate.Type.UPDATE
) {
callLinkUpdateSyncType = CallLinkUpdateSyncType.Update;
} else if (
callLinkUpdate.type === Proto.SyncMessage.CallLinkUpdate.Type.DELETE
) {
callLinkUpdateSyncType = CallLinkUpdateSyncType.Delete;
} else {
throw new Error(
`MessageReceiver.handleCallLinkUpdate: unknown type ${callLinkUpdate.type}`
);
}
const rootKey = Bytes.isNotEmpty(callLinkUpdate.rootKey)
? callLinkUpdate.rootKey
: undefined;
const adminKey = Bytes.isNotEmpty(callLinkUpdate.adminPasskey)
? callLinkUpdate.adminPasskey
: undefined;
const ev = new CallLinkUpdateSyncEvent(
{
type: callLinkUpdateSyncType,
rootKey,
adminKey,
},
this.removeFromCache.bind(this, envelope)
);
await this.dispatchAndWait(logId, ev);
log.info('handleCallLinkUpdate: finished');
}
private async handleCallLogEvent( private async handleCallLogEvent(
envelope: ProcessedEnvelope, envelope: ProcessedEnvelope,
callLogEvent: Proto.SyncMessage.ICallLogEvent callLogEvent: Proto.SyncMessage.ICallLogEvent

View file

@ -14,6 +14,7 @@ import type {
} from './Types.d'; } from './Types.d';
import type { ContactDetailsWithAvatar } from './ContactsParser'; import type { ContactDetailsWithAvatar } from './ContactsParser';
import type { CallEventDetails, CallLogEvent } from '../types/CallDisposition'; import type { CallEventDetails, CallLogEvent } from '../types/CallDisposition';
import type { CallLinkUpdateSyncType } from '../types/CallLink';
export class EmptyEvent extends Event { export class EmptyEvent extends Event {
constructor() { constructor() {
@ -439,6 +440,21 @@ export class CallEventSyncEvent extends ConfirmableEvent {
} }
} }
export type CallLinkUpdateSyncEventData = Readonly<{
type: CallLinkUpdateSyncType;
rootKey: Uint8Array | undefined;
adminKey: Uint8Array | undefined;
}>;
export class CallLinkUpdateSyncEvent extends ConfirmableEvent {
constructor(
public readonly callLinkUpdate: CallLinkUpdateSyncEventData,
confirm: ConfirmCallback
) {
super('callLinkUpdateSync', confirm);
}
}
export type CallLogEventSyncEventData = Readonly<{ export type CallLogEventSyncEventData = Readonly<{
event: CallLogEvent; event: CallLogEvent;
timestamp: number; timestamp: number;

View file

@ -9,11 +9,13 @@ import { aciSchema } from './ServiceId';
import { bytesToUuid } from '../util/uuidToBytes'; import { bytesToUuid } from '../util/uuidToBytes';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import { UUID_BYTE_SIZE } from './Crypto';
export enum CallType { export enum CallType {
Audio = 'Audio', Audio = 'Audio',
Video = 'Video', Video = 'Video',
Group = 'Group', Group = 'Group',
Adhoc = 'Adhoc',
} }
export enum CallDirection { export enum CallDirection {
@ -45,30 +47,42 @@ export enum RemoteCallEvent {
export type CallEvent = LocalCallEvent | RemoteCallEvent; export type CallEvent = LocalCallEvent | RemoteCallEvent;
export enum DirectCallStatus { export enum CallStatusValue {
Pending = 'Pending', Pending = 'Pending',
Accepted = 'Accepted', Accepted = 'Accepted',
Missed = 'Missed', Missed = 'Missed',
Declined = 'Declined', Declined = 'Declined',
Deleted = 'Deleted', Deleted = 'Deleted',
}
export enum GroupCallStatus {
GenericGroupCall = 'GenericGroupCall', GenericGroupCall = 'GenericGroupCall',
OutgoingRing = 'OutgoingRing', OutgoingRing = 'OutgoingRing',
Ringing = 'Ringing', Ringing = 'Ringing',
Joined = 'Joined', Joined = 'Joined',
// keep these in sync with direct JoinedAdhoc = 'JoinedAdhoc',
Accepted = DirectCallStatus.Accepted, }
Missed = DirectCallStatus.Missed,
Declined = DirectCallStatus.Declined, export enum DirectCallStatus {
Deleted = DirectCallStatus.Deleted, Pending = CallStatusValue.Pending,
Accepted = CallStatusValue.Accepted,
Missed = CallStatusValue.Missed,
Declined = CallStatusValue.Declined,
Deleted = CallStatusValue.Deleted,
}
export enum GroupCallStatus {
GenericGroupCall = CallStatusValue.GenericGroupCall,
OutgoingRing = CallStatusValue.OutgoingRing,
Ringing = CallStatusValue.Ringing,
Joined = CallStatusValue.Joined,
Accepted = CallStatusValue.Accepted,
Missed = CallStatusValue.Missed,
Declined = CallStatusValue.Declined,
Deleted = CallStatusValue.Deleted,
} }
export enum AdhocCallStatus { export enum AdhocCallStatus {
Pending = DirectCallStatus.Pending, Pending = CallStatusValue.Pending,
Joined = GroupCallStatus.Joined, Joined = CallStatusValue.JoinedAdhoc,
Deleted = DirectCallStatus.Deleted, Deleted = CallStatusValue.Deleted,
} }
export type CallStatus = DirectCallStatus | GroupCallStatus | AdhocCallStatus; export type CallStatus = DirectCallStatus | GroupCallStatus | AdhocCallStatus;
@ -177,11 +191,15 @@ export const callHistoryGroupSchema = z.object({
}) satisfies z.ZodType<CallHistoryGroup>; }) satisfies z.ZodType<CallHistoryGroup>;
const peerIdInBytesSchema = z.instanceof(Uint8Array).transform(value => { const peerIdInBytesSchema = z.instanceof(Uint8Array).transform(value => {
// direct conversationId
if (value.byteLength === UUID_BYTE_SIZE) {
const uuid = bytesToUuid(value); const uuid = bytesToUuid(value);
if (uuid != null) { if (uuid != null) {
return uuid; return uuid;
} }
// assuming groupId }
// groupId or call link roomId
return Bytes.toBase64(value); return Bytes.toBase64(value);
}); });

View file

@ -4,6 +4,16 @@ import type { ReadonlyDeep } from 'type-fest';
import { z } from 'zod'; import { z } from 'zod';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
export enum CallLinkUpdateSyncType {
Update = 'Update',
Delete = 'Delete',
}
export type CallLinkUpdateData = Readonly<{
rootKey: Uint8Array;
adminKey: Uint8Array | undefined;
}>;
/** /**
* Restrictions * Restrictions
*/ */
@ -33,10 +43,25 @@ export type CallLinkType = Readonly<{
adminKey: string | null; adminKey: string | null;
name: string; name: string;
restrictions: CallLinkRestrictions; restrictions: CallLinkRestrictions;
// Revocation is not supported currently but still returned by the server
revoked: boolean; revoked: boolean;
// Guaranteed from RingRTC readCallLink, but locally may be null immediately after
// CallLinkUpdate sync and before readCallLink
expiration: number | null; expiration: number | null;
}>; }>;
export type CallLinkStateType = Pick<
CallLinkType,
'name' | 'restrictions' | 'revoked' | 'expiration'
>;
export type ReadCallLinkState = Readonly<{
name: string;
restrictions: CallLinkRestrictions;
revoked: boolean;
expiration: number;
}>;
// Ephemeral conversation-like type to satisfy components // Ephemeral conversation-like type to satisfy components
export type CallLinkConversationType = ReadonlyDeep< export type CallLinkConversationType = ReadonlyDeep<
Omit<ConversationType, 'type'> & { Omit<ConversationType, 'type'> & {
@ -65,6 +90,6 @@ export const callLinkRecordSchema = z.object({
// state // state
name: z.string(), name: z.string(),
restrictions: callLinkRestrictionsSchema, restrictions: callLinkRestrictionsSchema,
expiration: z.number().int(), expiration: z.number().int().nullable(),
revoked: z.union([z.literal(1), z.literal(0)]), revoked: z.union([z.literal(1), z.literal(0)]),
}) satisfies z.ZodType<CallLinkRecord>; }) satisfies z.ZodType<CallLinkRecord>;

View file

@ -59,13 +59,20 @@ import {
callHistoryDetailsSchema, callHistoryDetailsSchema,
callDetailsSchema, callDetailsSchema,
AdhocCallStatus, AdhocCallStatus,
CallStatusValue,
} 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';
import { drop } from './drop';
// utils // utils
// ----- // -----
// call link roomIds are automatically redacted via redactCallLinkRoomIds()
export function peerIdToLog(peerId: string, mode: CallMode): string {
return mode === CallMode.Group ? `groupv2(${peerId})` : peerId;
}
export function formatCallEvent(callEvent: CallEventDetails): string { export function formatCallEvent(callEvent: CallEventDetails): string {
const { const {
callId, callId,
@ -78,14 +85,14 @@ export function formatCallEvent(callEvent: CallEventDetails): string {
ringerId, ringerId,
timestamp, timestamp,
} = callEvent; } = callEvent;
const peerIdLog = mode === CallMode.Group ? `groupv2(${peerId})` : peerId; const peerIdLog = peerIdToLog(peerId, mode);
return `CallEvent (${callId}, ${peerIdLog}, ${mode}, ${event}, ${direction}, ${type}, ${mode}, ${timestamp}, ${ringerId}, ${eventSource})`; return `CallEvent (${callId}, ${peerIdLog}, ${mode}, ${event}, ${direction}, ${type}, ${mode}, ${timestamp}, ${ringerId}, ${eventSource})`;
} }
export function formatCallHistory(callHistory: CallHistoryDetails): string { export function formatCallHistory(callHistory: CallHistoryDetails): string {
const { callId, peerId, direction, status, type, mode, timestamp, ringerId } = const { callId, peerId, direction, status, type, mode, timestamp, ringerId } =
callHistory; callHistory;
const peerIdLog = mode === CallMode.Group ? `groupv2(${peerId})` : peerId; const peerIdLog = peerIdToLog(peerId, mode);
return `CallHistory (${callId}, ${peerIdLog}, ${mode}, ${status}, ${direction}, ${type}, ${mode}, ${timestamp}, ${ringerId})`; return `CallHistory (${callId}, ${peerIdLog}, ${mode}, ${status}, ${direction}, ${type}, ${mode}, ${timestamp}, ${ringerId})`;
} }
@ -189,6 +196,8 @@ export function getCallEventForProto(
type = CallType.Audio; type = CallType.Audio;
} else if (callEvent.type === Proto.SyncMessage.CallEvent.Type.VIDEO_CALL) { } else if (callEvent.type === Proto.SyncMessage.CallEvent.Type.VIDEO_CALL) {
type = CallType.Video; type = CallType.Video;
} else if (callEvent.type === Proto.SyncMessage.CallEvent.Type.AD_HOC_CALL) {
type = CallType.Adhoc;
} else { } else {
throw new TypeError(`Unknown call type ${callEvent.type}`); throw new TypeError(`Unknown call type ${callEvent.type}`);
} }
@ -196,6 +205,8 @@ export function getCallEventForProto(
let mode: CallMode; let mode: CallMode;
if (type === CallType.Group) { if (type === CallType.Group) {
mode = CallMode.Group; mode = CallMode.Group;
} else if (type === CallType.Adhoc) {
mode = CallMode.Adhoc;
} else { } else {
mode = CallMode.Direct; mode = CallMode.Direct;
} }
@ -246,21 +257,23 @@ const typeToProto = {
[CallType.Audio]: Proto.SyncMessage.CallEvent.Type.AUDIO_CALL, [CallType.Audio]: Proto.SyncMessage.CallEvent.Type.AUDIO_CALL,
[CallType.Video]: Proto.SyncMessage.CallEvent.Type.VIDEO_CALL, [CallType.Video]: Proto.SyncMessage.CallEvent.Type.VIDEO_CALL,
[CallType.Group]: Proto.SyncMessage.CallEvent.Type.GROUP_CALL, [CallType.Group]: Proto.SyncMessage.CallEvent.Type.GROUP_CALL,
[CallType.Adhoc]: Proto.SyncMessage.CallEvent.Type.AD_HOC_CALL,
}; };
const statusToProto: Record< const statusToProto: Record<
CallStatus, CallStatus,
Proto.SyncMessage.CallEvent.Event | null Proto.SyncMessage.CallEvent.Event | null
> = { > = {
[DirectCallStatus.Accepted]: Proto.SyncMessage.CallEvent.Event.ACCEPTED, // and GroupCallStatus.Accepted [CallStatusValue.Accepted]: Proto.SyncMessage.CallEvent.Event.ACCEPTED,
[DirectCallStatus.Declined]: Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED, // and GroupCallStatus.Declined [CallStatusValue.Declined]: Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED,
[DirectCallStatus.Deleted]: Proto.SyncMessage.CallEvent.Event.DELETE, // and GroupCallStatus.Deleted [CallStatusValue.Deleted]: Proto.SyncMessage.CallEvent.Event.DELETE,
[DirectCallStatus.Missed]: null, // and GroupCallStatus.Missed [CallStatusValue.Missed]: null,
[DirectCallStatus.Pending]: null, [CallStatusValue.Pending]: null,
[GroupCallStatus.GenericGroupCall]: null, [CallStatusValue.GenericGroupCall]: null,
[GroupCallStatus.Joined]: null, [CallStatusValue.OutgoingRing]: null,
[GroupCallStatus.OutgoingRing]: null, [CallStatusValue.Ringing]: null,
[GroupCallStatus.Ringing]: null, [CallStatusValue.Joined]: null,
[CallStatusValue.JoinedAdhoc]: Proto.SyncMessage.CallEvent.Event.ACCEPTED,
}; };
function shouldSyncStatus(callStatus: CallStatus) { function shouldSyncStatus(callStatus: CallStatus) {
@ -279,7 +292,10 @@ function getProtoForCallHistory(
)}` )}`
); );
let peerId = uuidToBytes(callHistory.peerId); let peerId =
callHistory.mode === CallMode.Adhoc
? Bytes.fromBase64(callHistory.peerId)
: uuidToBytes(callHistory.peerId);
if (peerId.length === 0) { if (peerId.length === 0) {
peerId = Bytes.fromBase64(callHistory.peerId); peerId = Bytes.fromBase64(callHistory.peerId);
} }
@ -479,8 +495,10 @@ export function getCallDetailsForAdhocCall(
peerId, peerId,
ringerId: null, ringerId: null,
mode: CallMode.Adhoc, mode: CallMode.Adhoc,
type: CallType.Group, type: CallType.Adhoc,
direction: CallDirection.Outgoing, // Direction is only outgoing when your action causes ringing for others.
// As Adhoc calls do not support ringing, this is always incoming for now
direction: CallDirection.Incoming,
timestamp: Date.now(), timestamp: Date.now(),
}); });
} }
@ -884,6 +902,17 @@ async function updateLocalCallHistory(
); );
} }
export async function updateAdhocCallHistory(
callEvent: CallEventDetails
): Promise<void> {
const callHistory = await updateLocalAdhocCallHistory(callEvent);
if (!callHistory) {
return;
}
drop(updateRemoteCallHistory(callHistory));
}
export async function updateLocalAdhocCallHistory( export async function updateLocalAdhocCallHistory(
callEvent: CallEventDetails callEvent: CallEventDetails
): Promise<CallHistoryDetails | null> { ): Promise<CallHistoryDetails | null> {
@ -900,11 +929,11 @@ export async function updateLocalAdhocCallHistory(
if (prevCallHistory != null) { if (prevCallHistory != null) {
log.info( log.info(
'updateLocalAdhocCallHistory: Found previous call history:', 'updateAdhocCallHistory: Found previous call history:',
formatCallHistory(prevCallHistory) formatCallHistory(prevCallHistory)
); );
} else { } else {
log.info('updateLocalAdhocCallHistory: No previous call history'); log.info('updateAdhocCallHistory: No previous call history');
} }
let callHistory: CallHistoryDetails; let callHistory: CallHistoryDetails;
@ -912,7 +941,7 @@ export async function updateLocalAdhocCallHistory(
callHistory = transitionCallHistory(prevCallHistory, callEvent); callHistory = transitionCallHistory(prevCallHistory, callEvent);
} catch (error) { } catch (error) {
log.error( log.error(
"updateLocalAdhocCallHistory: Couldn't transition call history:", "updateAdhocCallHistory: Couldn't transition call history:",
formatCallEvent(callEvent), formatCallEvent(callEvent),
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
@ -923,17 +952,17 @@ export async function updateLocalAdhocCallHistory(
callHistory.status === AdhocCallStatus.Pending || callHistory.status === AdhocCallStatus.Pending ||
callHistory.status === AdhocCallStatus.Joined || callHistory.status === AdhocCallStatus.Joined ||
callHistory.status === AdhocCallStatus.Deleted, callHistory.status === AdhocCallStatus.Deleted,
`updateLocalAdhocCallHistory: callHistory must have adhoc status (was ${callHistory.status})` `updateAdhocCallHistory: callHistory must have adhoc status (was ${callHistory.status})`
); );
const isDeleted = callHistory.status === AdhocCallStatus.Deleted; const isDeleted = callHistory.status === AdhocCallStatus.Deleted;
if (prevCallHistory != null && isEqual(prevCallHistory, callHistory)) { if (prevCallHistory != null && isEqual(prevCallHistory, callHistory)) {
log.info( log.info(
'updateLocalAdhocCallHistory: Next call history is identical, skipping save' 'updateAdhocCallHistory: Next call history is identical, skipping save'
); );
} else { } else {
log.info( log.info(
'updateLocalAdhocCallHistory: Saving call history:', 'updateAdhocCallHistory: Saving call history:',
formatCallHistory(callHistory) formatCallHistory(callHistory)
); );
await window.Signal.Data.saveCallHistory(callHistory); await window.Signal.Data.saveCallHistory(callHistory);
@ -1126,7 +1155,11 @@ export async function updateCallHistoryFromRemoteEvent(
callEvent: CallEventDetails, callEvent: CallEventDetails,
receivedAtCounter: number receivedAtCounter: number
): Promise<void> { ): Promise<void> {
if (callEvent.mode === CallMode.Direct || callEvent.mode === CallMode.Group) {
await updateLocalCallHistory(callEvent, receivedAtCounter); await updateLocalCallHistory(callEvent, receivedAtCounter);
} else if (callEvent.mode === CallMode.Adhoc) {
await updateLocalAdhocCallHistory(callEvent);
}
} }
export async function updateCallHistoryFromLocalEvent( export async function updateCallHistoryFromLocalEvent(

View file

@ -18,10 +18,18 @@ import type {
} from '../types/CallLink'; } from '../types/CallLink';
import { import {
callLinkRecordSchema, callLinkRecordSchema,
CallLinkRestrictions,
toCallLinkRestrictions, toCallLinkRestrictions,
} from '../types/CallLink'; } from '../types/CallLink';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
export const CALL_LINK_DEFAULT_STATE = {
name: '',
restrictions: CallLinkRestrictions.Unknown,
revoked: false,
expiration: null,
};
export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string { export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string {
return rootKey.deriveRoomId().toString('hex'); return rootKey.deriveRoomId().toString('hex');
} }
@ -81,6 +89,37 @@ export function callLinkToConversation(
}; };
} }
export function getPlaceholderCallLinkConversation(
roomId: string,
i18n: LocalizerType
): CallLinkConversationType {
return {
id: roomId,
type: 'callLink',
isMe: false,
title: i18n('icu:calling__call-link-default-title'),
sharedGroupNames: [],
acceptedMessageRequest: true,
badges: [],
};
}
export function toRootKeyBytes(rootKey: string): Uint8Array {
return CallLinkRootKey.parse(rootKey).bytes;
}
export function fromRootKeyBytes(rootKey: Uint8Array): string {
return CallLinkRootKey.fromBytes(rootKey as Buffer).toString();
}
export function toAdminKeyBytes(adminKey: string): Uint8Array {
return Bytes.fromBase64(adminKey);
}
export function fromAdminKeyBytes(adminKey: Uint8Array): string {
return Bytes.toBase64(adminKey);
}
/** /**
* DB record conversions * DB record conversions
*/ */
@ -90,12 +129,9 @@ export function callLinkToRecord(callLink: CallLinkType): CallLinkRecord {
throw new Error('CallLink.callLinkToRecord: rootKey is null'); throw new Error('CallLink.callLinkToRecord: rootKey is null');
} }
// rootKey has a RingRTC parsing function, adminKey is just bytes const rootKey = toRootKeyBytes(callLink.rootKey);
const rootKey = callLink.rootKey
? CallLinkRootKey.parse(callLink.rootKey).bytes
: null;
const adminKey = callLink.adminKey const adminKey = callLink.adminKey
? Bytes.fromBase64(callLink.adminKey) ? toAdminKeyBytes(callLink.adminKey)
: null; : null;
return callLinkRecordSchema.parse({ return callLinkRecordSchema.parse({
roomId: callLink.roomId, roomId: callLink.roomId,
@ -109,11 +145,13 @@ export function callLinkToRecord(callLink: CallLinkType): CallLinkRecord {
} }
export function callLinkFromRecord(record: CallLinkRecord): CallLinkType { export function callLinkFromRecord(record: CallLinkRecord): CallLinkType {
if (record.rootKey == null) {
throw new Error('CallLink.callLinkFromRecord: rootKey is null');
}
// root keys in memory are strings for simplicity // root keys in memory are strings for simplicity
const rootKey = CallLinkRootKey.fromBytes( const rootKey = fromRootKeyBytes(record.rootKey);
record.rootKey as Buffer const adminKey = record.adminKey ? fromAdminKeyBytes(record.adminKey) : null;
).toString();
const adminKey = record.adminKey ? Bytes.toBase64(record.adminKey) : null;
return { return {
roomId: record.roomId, roomId: record.roomId,
rootKey, rootKey,

View file

@ -177,7 +177,7 @@ export function getCallingIcon(
} }
return 'video'; return 'video';
} }
if (callType === CallType.Group) { if (callType === CallType.Group || callType === CallType.Adhoc) {
return 'video'; return 'video';
} }
throw missingCaseError(callType); throw missingCaseError(callType);

View file

@ -65,6 +65,7 @@ export const sendTypesEnum = z.enum([
'viewOnceSync', 'viewOnceSync',
'viewSync', 'viewSync',
'callEventSync', 'callEventSync',
'callLinkUpdateSync',
'callLogEventSync', 'callLogEventSync',
// No longer used, all non-urgent // No longer used, all non-urgent

View file

@ -3,23 +3,33 @@
import type { CallEventSyncEvent } from '../textsecure/messageReceiverEvents'; import type { CallEventSyncEvent } from '../textsecure/messageReceiverEvents';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { updateCallHistoryFromRemoteEvent } from './callDisposition'; import {
peerIdToLog,
updateCallHistoryFromRemoteEvent,
} from './callDisposition';
import { CallMode } from '../types/Calling';
export async function onCallEventSync( export async function onCallEventSync(
syncEvent: CallEventSyncEvent syncEvent: CallEventSyncEvent
): Promise<void> { ): Promise<void> {
const { callEvent, confirm } = syncEvent; const { callEvent, confirm } = syncEvent;
const { callEventDetails, receivedAtCounter } = callEvent; const { callEventDetails, receivedAtCounter } = callEvent;
const { peerId } = callEventDetails;
if (
callEventDetails.mode === CallMode.Direct ||
callEventDetails.mode === CallMode.Group
) {
const { peerId } = callEventDetails;
const conversation = window.ConversationController.get(peerId); const conversation = window.ConversationController.get(peerId);
if (!conversation) { if (!conversation) {
const peerIdLog = peerIdToLog(peerId, callEventDetails.mode);
log.warn( log.warn(
`onCallEventSync: No conversation found for conversationId ${peerId}` `onCallEventSync: No conversation found for conversationId ${peerIdLog}`
); );
return; return;
} }
}
await updateCallHistoryFromRemoteEvent(callEventDetails, receivedAtCounter); await updateCallHistoryFromRemoteEvent(callEventDetails, receivedAtCounter);
confirm(); confirm();

View file

@ -0,0 +1,57 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { CallLinkRootKey } from '@signalapp/ringrtc';
import type { CallLinkUpdateSyncEvent } from '../textsecure/messageReceiverEvents';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { fromAdminKeyBytes, getRoomIdFromRootKey } from './callLinks';
import { strictAssert } from './assert';
import { CallLinkUpdateSyncType } from '../types/CallLink';
export async function onCallLinkUpdateSync(
syncEvent: CallLinkUpdateSyncEvent
): Promise<void> {
const { callLinkUpdate, confirm } = syncEvent;
const { type, rootKey, adminKey } = callLinkUpdate;
if (!rootKey) {
log.warn('onCallLinkUpdateSync: Missing rootKey, invalid sync message');
return;
}
let callLinkRootKey: CallLinkRootKey;
let roomId: string;
try {
callLinkRootKey = CallLinkRootKey.fromBytes(rootKey as Buffer);
roomId = getRoomIdFromRootKey(callLinkRootKey);
strictAssert(
roomId,
'onCallLinkUpdateSync: roomId is required in sync message'
);
} catch (err) {
log.error('onCallLinkUpdateSync: Could not parse root key');
return;
}
const logId = `onCallLinkUpdateSync(${roomId}, ${type})`;
log.info(`${logId}: Processing`);
try {
if (type === CallLinkUpdateSyncType.Update) {
const rootKeyString = callLinkRootKey.toString();
const adminKeyString = adminKey ? fromAdminKeyBytes(adminKey) : null;
window.reduxActions.calling.handleCallLinkUpdate({
rootKey: rootKeyString,
adminKey: adminKeyString,
});
} else if (type === CallLinkUpdateSyncType.Delete) {
// TODO: DESKTOP-6951
log.warn(`${logId}: Deleting call links is not supported`);
}
} catch (err) {
log.error(`${logId}: Failed to process`, Errors.toLogFormat(err));
}
confirm();
}

View file

@ -0,0 +1,82 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as Bytes from '../Bytes';
import { CallLinkUpdateSyncType } from '../types/CallLink';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { SignalService as Proto } from '../protobuf';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import MessageSender from '../textsecure/SendMessage';
import { toAdminKeyBytes, toRootKeyBytes } from './callLinks';
export type sendCallLinkUpdateSyncCallLinkType = {
rootKey: string;
adminKey: string | null;
};
export async function sendCallLinkUpdateSync(
callLink: sendCallLinkUpdateSyncCallLinkType
): Promise<void> {
return _sendCallLinkUpdateSync(callLink, CallLinkUpdateSyncType.Update);
}
/**
* Underlying sync message is CallLinkUpdate with type set to DELETE.
*/
export async function sendCallLinkDeleteSync(
callLink: sendCallLinkUpdateSyncCallLinkType
): Promise<void> {
return _sendCallLinkUpdateSync(callLink, CallLinkUpdateSyncType.Delete);
}
async function _sendCallLinkUpdateSync(
callLink: sendCallLinkUpdateSyncCallLinkType,
type: CallLinkUpdateSyncType
): Promise<void> {
let protoType: Proto.SyncMessage.CallLinkUpdate.Type;
if (type === CallLinkUpdateSyncType.Update) {
protoType = Proto.SyncMessage.CallLinkUpdate.Type.UPDATE;
} else if (type === CallLinkUpdateSyncType.Delete) {
protoType = Proto.SyncMessage.CallLinkUpdate.Type.DELETE;
} else {
throw new Error(`sendCallLinkUpdateSync: unknown type ${type}`);
}
log.info(`sendCallLinkUpdateSync: Sending CallLinkUpdate type=${type}`);
try {
const ourAci = window.textsecure.storage.user.getCheckedAci();
const callLinkUpdate = new Proto.SyncMessage.CallLinkUpdate({
type: protoType,
rootKey: toRootKeyBytes(callLink.rootKey),
adminPasskey: callLink.adminKey
? toAdminKeyBytes(callLink.adminKey)
: null,
});
const syncMessage = MessageSender.createSyncMessage();
syncMessage.callLinkUpdate = callLinkUpdate;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
await singleProtoJobQueue.add({
contentHint: ContentHint.RESENDABLE,
serviceId: ourAci,
isSyncMessage: true,
protoBase64: Bytes.toBase64(
Proto.Content.encode(contentMessage).finish()
),
type: 'callLinkUpdateSync',
urgent: false,
});
} catch (error) {
log.error(
'sendCallLinkUpdateSync: Failed to queue sync message:',
Errors.toLogFormat(error)
);
}
}