Support disappearing message timers for call history

Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2026-06-24 19:14:21 -05:00
committed by GitHub
parent 21f819184d
commit 8667bb4bd1
17 changed files with 1092 additions and 190 deletions
+1 -1
View File
@@ -157,7 +157,7 @@
"@react-spring/web": "10.0.3",
"@signalapp/lame": "workspace:*",
"@signalapp/minimask": "1.0.1",
"@signalapp/mock-server": "22.2.0",
"@signalapp/mock-server": "23.0.0",
"@signalapp/parchment-cjs": "3.0.1",
"@signalapp/quill-cjs": "2.1.2",
"@storybook/addon-a11y": "8.4.4",
+5 -5
View File
@@ -147,8 +147,8 @@ importers:
specifier: 1.0.1
version: 1.0.1
'@signalapp/mock-server':
specifier: 22.2.0
version: 22.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
specifier: 23.0.0
version: 23.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@signalapp/parchment-cjs':
specifier: 3.0.1
version: 3.0.1
@@ -4199,8 +4199,8 @@ packages:
'@signalapp/minimask@1.0.1':
resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==, tarball: https://registry.npmjs.org/@signalapp/minimask/-/minimask-1.0.1.tgz}
'@signalapp/mock-server@22.2.0':
resolution: {integrity: sha512-NGLdxx2iUY6VxZbsISryLXlxXu545UEYRo0W/Ixpi+Ufisw+++fUTLH4gqr8/q69Ft6eGMN6rjCvJ2nkZBSUqw==, tarball: https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-22.2.0.tgz}
'@signalapp/mock-server@23.0.0':
resolution: {integrity: sha512-w+Olk/o6kO0DuerE+PNGFSUp1bzsr6S49nvMv7a9H97WVNMqwqz89fX7I8FYUtQ7XWtNtXWewriIOWEmWSPH9g==, tarball: https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-23.0.0.tgz}
'@signalapp/parchment-cjs@3.0.1':
resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==, tarball: https://registry.npmjs.org/@signalapp/parchment-cjs/-/parchment-cjs-3.0.1.tgz}
@@ -14569,7 +14569,7 @@ snapshots:
'@signalapp/minimask@1.0.1': {}
'@signalapp/mock-server@22.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
'@signalapp/mock-server@23.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
dependencies:
'@indutny/parallel-prettier': 3.0.0(prettier@3.8.3)
'@indutny/protopiler': 4.0.0
+1 -1
View File
@@ -5087,7 +5087,7 @@ export class ConversationModel {
): Promise<void> {
await markConversationRead(this.attributes, readMessage, options);
this.throttledUpdateUnread();
window.reduxActions.callHistory.updateCallHistoryUnreadCount();
window.reduxActions.callHistory.updateCallHistoryUnreadCount([]);
}
async #updateUnread(): Promise<void> {
+113 -56
View File
@@ -1255,6 +1255,31 @@ class CallingClass {
return call instanceof GroupCall ? call : undefined;
}
public getActiveCallIds(): Set<string> {
const callIds = new Set<string>();
for (const call of Object.values(this.#callsLookup)) {
if (call instanceof Call) {
callIds.add(call.callId.toString());
} else if (call instanceof GroupCall) {
const callId = call.getCallId();
if (callId != null) {
callIds.add(callId.toString());
}
} else {
throw missingCaseError(call);
}
}
return callIds;
}
public isCallActive(conversationId: string): boolean {
return (
this.#getDirectCall(conversationId) != null ||
this.#getGroupCall(conversationId) != null
);
}
#getGroupCallMembers(conversationId: string) {
return getMembershipList(conversationId).map(
member =>
@@ -3172,19 +3197,21 @@ class CallingClass {
Proto.CallMessage.Offer.Type.OFFER_VIDEO_CALL;
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromEndedDirectCall(
callId.toString(),
const eventTimestamp = envelope.timestamp;
const callDetails = getCallDetailsFromEndedDirectCall({
callId,
peerId,
remoteUserId, // Incoming call
ringerId: remoteUserId, // Incoming call
wasVideoCall,
envelope.timestamp
);
eventTimestamp,
});
const localCallEvent = LocalCallEvent.Missed;
const callEvent = getCallEventDetails(
const callEvent = getCallEventDetails({
callDetails,
localCallEvent,
'CallingClass.handleCallingMessage'
);
event: localCallEvent,
eventSource: 'CallingClass.handleCallingMessage',
eventTimestamp,
});
const {
receivedAtCounter,
receivedAtDate: receivedAtMS,
@@ -3459,9 +3486,14 @@ class CallingClass {
const localEventFromRing = getLocalCallEventFromRingUpdate(update);
if (localEventFromRing != null) {
const callId = getCallIdFromRing(ringId);
const callDetails = getCallDetailsFromGroupCallMeta(groupId, {
callId,
ringerId: ringerAci,
const eventTimestamp = Date.now();
const callDetails = getCallDetailsFromGroupCallMeta({
peerId: groupId,
groupCallMeta: {
callId,
ringerId: ringerAci,
},
eventTimestamp,
});
let localEventForCall;
if (localEventFromRing === LocalCallEvent.Missed) {
@@ -3471,11 +3503,12 @@ class CallingClass {
? LocalCallEvent.Ringing
: LocalCallEvent.Started;
}
const callEvent = getCallEventDetails(
const callEvent = getCallEventDetails({
callDetails,
localEventForCall,
'CallingClass.handleGroupCallRingUpdate'
);
event: localEventForCall,
eventSource: 'CallingClass.handleGroupCallRingUpdate',
eventTimestamp,
});
await updateCallHistoryFromLocalEvent({
callEvent,
});
@@ -3567,12 +3600,18 @@ class CallingClass {
const localCallEvent = LocalCallEvent.Missed;
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromDirectCall(peerId, call);
const callEvent = getCallEventDetails(
const eventTimestamp = Date.now();
const callDetails = getCallDetailsFromDirectCall({
peerId,
call,
eventTimestamp,
});
const callEvent = getCallEventDetails({
callDetails,
localCallEvent,
'CallingClass.handleIncomingCall'
);
event: localCallEvent,
eventSource: 'CallingClass.handleIncomingCall',
eventTimestamp,
});
await updateCallHistoryFromLocalEvent({
callEvent,
});
@@ -3604,7 +3643,7 @@ class CallingClass {
}
async #handleRejectedIncomingCallRequest(
callIdValue: CallId,
callId: CallId,
remoteUserId: UserId,
callRejectReason: CallRejectReason,
ageInSeconds: number,
@@ -3629,7 +3668,6 @@ class CallingClass {
const callEndedReason =
this.#convertRingRtcCallRejectReason(callRejectReason);
const callId = callIdValue.toString();
const peerId = getPeerIdFromConversation(conversation.attributes);
// This is extra defensive, just in case RingRTC passes us a bad value. (It probably
@@ -3638,22 +3676,23 @@ class CallingClass {
isNormalNumber(ageInSeconds) && ageInSeconds >= 0
? ageInSeconds * durations.SECOND
: 0;
const timestamp = Date.now() - ageInMilliseconds;
const eventTimestamp = Date.now() - ageInMilliseconds;
const callDetails = getCallDetailsFromEndedDirectCall(
const callDetails = getCallDetailsFromEndedDirectCall({
callId,
peerId,
remoteUserId,
ringerId: remoteUserId,
wasVideoCall,
timestamp
);
eventTimestamp,
});
// We classify all of the call rejection events as 'Missed'.
const callEvent = getCallEventDetails(
const callEvent = getCallEventDetails({
callDetails,
LocalCallEvent.Missed,
'CallingClass.handleRejectedIncomingCallRequest'
);
event: LocalCallEvent.Missed,
eventSource: 'CallingClass.handleRejectedIncomingCallRequest',
eventTimestamp,
});
if (callRejectReason === CallRejectReason.ReceivedOfferWhileActive) {
// This is a special case where we won't update our local call, because we have
@@ -3731,12 +3770,18 @@ class CallingClass {
);
if (localCallEvent != null) {
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromDirectCall(peerId, call);
const callEvent = getCallEventDetails(
const eventTimestamp = Date.now();
const callDetails = getCallDetailsFromDirectCall({
peerId,
call,
eventTimestamp,
});
const callEvent = getCallEventDetails({
callDetails,
localCallEvent,
'call.handleStateChanged'
);
event: localCallEvent,
eventSource: 'call.handleStateChanged',
eventTimestamp,
});
await updateCallHistoryFromLocalEvent({
callEvent,
});
@@ -4104,12 +4149,18 @@ class CallingClass {
return;
}
const callDetails = getCallDetailsForAdhocCall(roomId, callId);
const callEvent = getCallEventDetails(
const eventTimestamp = Date.now();
const callDetails = getCallDetailsForAdhocCall({
peerId: roomId,
callId,
eventTimestamp,
});
const callEvent = getCallEventDetails({
callDetails,
localCallEvent,
'CallingClass.updateCallHistoryForAdhocCall'
);
event: localCallEvent,
eventSource: 'CallingClass.updateCallHistoryForAdhocCall',
eventTimestamp: callDetails.timestamp,
});
await updateAdhocCallHistory(callEvent);
} catch (error) {
log.error(
@@ -4146,15 +4197,18 @@ class CallingClass {
);
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromGroupCallMeta(
const eventTimestamp = Date.now();
const callDetails = getCallDetailsFromGroupCallMeta({
peerId,
groupCallMeta
);
const callEvent = getCallEventDetails(
groupCallMeta,
eventTimestamp,
});
const callEvent = getCallEventDetails({
callDetails,
localCallEvent,
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged'
);
event: localCallEvent,
eventSource: 'CallingClass.updateCallHistoryForGroupCallOnLocalChanged',
eventTimestamp,
});
await updateCallHistoryFromLocalEvent({
callEvent,
});
@@ -4202,16 +4256,19 @@ class CallingClass {
groupCallMeta
);
if (localCallEvent != null) {
const eventTimestamp = Date.now();
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromGroupCallMeta(
const callDetails = getCallDetailsFromGroupCallMeta({
peerId,
groupCallMeta
);
const callEvent = getCallEventDetails(
groupCallMeta,
eventTimestamp,
});
const callEvent = getCallEventDetails({
callDetails,
localCallEvent,
'CallingClass.updateCallHistoryForGroupCallOnPeek'
);
event: localCallEvent,
eventSource: 'CallingClass.updateCallHistoryForGroupCallOnPeek',
eventTimestamp,
});
await updateCallHistoryFromLocalEvent({
callEvent,
});
+16 -2
View File
@@ -984,6 +984,7 @@ type ReadableInterface = {
conversationId: string;
}) => MessageType | undefined;
getAllCallHistory: () => ReadonlyArray<CallHistoryDetails>;
getCallHistoryUnreadCallConversationIds: () => ReadonlyArray<string>;
getCallHistoryUnreadCount: () => number;
getCallHistoryMessageByCallId: (options: {
conversationId: string;
@@ -1231,6 +1232,11 @@ type WritableInterface = {
readAt?: number;
storyId?: string;
}) => GetUnreadByConversationAndMarkReadResultType;
getUnreadCallMessagesAndMarkRead: (options: {
conversationId: string;
readMessageReceivedAt: number;
activeCallIds: Set<string>;
}) => GetUnreadByConversationAndMarkReadResultType;
getUnreadEditedMessagesAndMarkRead: (options: {
conversationId: string;
readMessageReceivedAt: number;
@@ -1275,8 +1281,16 @@ type WritableInterface = {
markCallHistoryDeleted: (callId: string) => void;
cleanupCallHistoryMessages: () => void;
markCallHistoryRead: (callId: string) => void;
markAllCallHistoryRead: (target: CallLogEventTarget) => number;
markAllCallHistoryReadInConversation: (target: CallLogEventTarget) => number;
markAllCallHistoryRead: (
target: CallLogEventTarget,
readAt: number,
activeCallIds: Set<string>
) => number;
markAllCallHistoryReadInConversation: (
target: CallLogEventTarget,
readAt: number,
activeCallIds: Set<string>
) => number;
saveCallHistory: (callHistory: CallHistoryDetails) => void;
markCallHistoryMissed: (callIds: ReadonlyArray<string>) => void;
getRecentStaleRingsAndMarkOlderMissed: () => ReadonlyArray<MaybeStaleCallHistory>;
+74 -5
View File
@@ -486,6 +486,7 @@ export const DataReader: ServerReadableInterface = {
getConversationMessageStats,
getLastConversationMessage,
getAllCallHistory,
getCallHistoryUnreadCallConversationIds,
getCallHistoryUnreadCount,
getCallHistoryMessageByCallId,
getCallHistory,
@@ -646,6 +647,8 @@ export const DataWriter: ServerWritableInterface = {
updateAllConversationColors,
removeAllProfileKeyCredentials,
getUnreadByConversationAndMarkRead,
getUnreadCallMessagesAndMarkRead,
getUnreadEditedMessagesAndMarkRead,
getUnreadReactionsAndMarkRead,
getUnreadPollVotesAndMarkRead,
@@ -664,7 +667,6 @@ export const DataWriter: ServerWritableInterface = {
_removeAllReactions,
_removeAllMessages,
_removeMessage: removeMessage,
getUnreadEditedMessagesAndMarkRead,
clearCallHistory,
_removeAllCallHistory,
markCallHistoryDeleted,
@@ -3620,7 +3622,8 @@ function getUnreadByConversationAndMarkRead(
}
): GetUnreadByConversationAndMarkReadResultType {
return db.transaction(() => {
const expirationStartTimestamp = Math.min(now, readAt ?? Infinity);
const expirationStartTimestamp =
readAt != null ? Math.min(now, readAt) : now;
const { predicate: storyReplyFilter, isFilteringOnStoryId } =
_storyIdPredicateAndInfo(storyId, includeStoryReplies);
@@ -3634,6 +3637,7 @@ function getUnreadByConversationAndMarkRead(
conversationId = ${conversationId} AND
${storyReplyFilter} AND
type IS NOT 'outgoing' AND
type IS NOT 'call-history' AND
hasExpireTimer IS 1 AND
received_at <= ${readMessageReceivedAt}
`;
@@ -4842,13 +4846,25 @@ const CALL_STATUS_INCOMING = sqlConstant(CallDirection.Incoming);
const CALL_MODE_ADHOC = sqlConstant(CallMode.Adhoc);
const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000);
function getCallHistoryUnreadCallConversationIds(
db: ReadableDB
): ReadonlyArray<string> {
const [query, params] = sql`
SELECT DISTINCT(messages.conversationId) FROM messages
INNER JOIN callsHistory ON callsHistory.callId = messages.callId
WHERE messages.type IS 'call-history'
AND messages.seenStatus IS ${SEEN_STATUS_UNSEEN}
AND callsHistory.direction IS ${CALL_STATUS_INCOMING}
`;
return db.prepare(query, { pluck: true }).all<string>(params);
}
function getCallHistoryUnreadCount(db: ReadableDB): number {
const [query, params] = sql`
SELECT count(*) FROM messages
INNER JOIN callsHistory ON callsHistory.callId = messages.callId
WHERE messages.type IS 'call-history'
AND messages.seenStatus IS ${SEEN_STATUS_UNSEEN}
AND callsHistory.status IS ${CALL_STATUS_MISSED}
AND callsHistory.direction IS ${CALL_STATUS_INCOMING}
`;
const row = db
@@ -5017,6 +5033,8 @@ function getMessageReceivedAtForCall(
export function markAllCallHistoryRead(
db: WritableDB,
target: CallLogEventTarget,
readAt: number,
activeCallIds: Set<string>,
inConversation = false
): number {
return db.transaction(() => {
@@ -5089,15 +5107,32 @@ export function markAllCallHistoryRead(
`;
const result = db.prepare(updateQuery).run(updateParams);
const [updateExpirationQuery, updateExpirationParams] = sql`
UPDATE messages
SET
expirationStartTimestamp = ${readAt}
WHERE messages.type IS 'call-history'
AND ${predicate}
AND messages.seenStatus IS ${SEEN_STATUS_SEEN}
AND messages.received_at <= ${receivedAt}
AND hasExpireTimer IS 1
AND expirationStartTimestamp IS NULL
AND messages.callId NOT IN (${sqlJoin(Array.from(activeCallIds))})
`;
db.prepare(updateExpirationQuery).run(updateExpirationParams);
return result.changes;
})();
}
function markAllCallHistoryReadInConversation(
db: WritableDB,
target: CallLogEventTarget
target: CallLogEventTarget,
readAt: number,
activeCallIds: Set<string>
): number {
return markAllCallHistoryRead(db, target, true);
return markAllCallHistoryRead(db, target, readAt, activeCallIds, true);
}
function getCallHistoryGroupData(
@@ -9478,6 +9513,40 @@ function _getAllEditedMessages(
.all({});
}
function getUnreadCallMessagesAndMarkRead(
db: WritableDB,
{
conversationId,
readMessageReceivedAt,
activeCallIds,
}: {
conversationId: string;
readMessageReceivedAt: number;
activeCallIds: Set<string>;
}
): GetUnreadByConversationAndMarkReadResultType {
const [query, params] = sql`
UPDATE messages
SET
expirationStartTimestamp = ${readMessageReceivedAt}
WHERE type IS 'call-history'
AND messages.conversationId IS ${conversationId}
AND messages.seenStatus IS ${SEEN_STATUS_SEEN}
AND messages.received_at <= ${readMessageReceivedAt}
AND messages.hasExpireTimer IS 1
AND messages.expirationStartTimestamp IS NULL
AND messages.callId NOT IN (${sqlJoin(Array.from(activeCallIds))})
RETURNING *
`;
const rows = db.prepare(query).all<MessageTypeUnhydrated>(params);
const messages = hydrateMessages(db, rows);
return messages.map(message => {
return { ...message, originalReadStatus: undefined };
});
}
function getUnreadEditedMessagesAndMarkRead(
db: WritableDB,
{
+28 -12
View File
@@ -36,6 +36,7 @@ import { getIntl } from '../selectors/user.std.ts';
import type { ShowErrorModalActionType } from './globalModals.preload.ts';
import { SHOW_ERROR_MODAL } from './globalModals.preload.ts';
import type { ErrorModalDataProps } from '../../components/ErrorModal.dom.tsx';
import { strictAssert } from '../../util/assert.std.ts';
const { debounce, omit } = lodash;
@@ -114,13 +115,21 @@ const updateCallHistoryUnreadCountDebounced = debounce(
300
);
function updateCallHistoryUnreadCount(): ThunkAction<
void,
RootStateType,
unknown,
CallHistoryUpdateUnread
> {
function updateConversationsUnreadCounts(
conversationIdsOrConversationPeerIds: ReadonlyArray<string>
) {
for (const id of conversationIdsOrConversationPeerIds) {
const conversation = window.ConversationController.get(id);
strictAssert(conversation, `Missing conversation: ${id}`);
conversation.throttledUpdateUnread();
}
}
function updateCallHistoryUnreadCount(
conversationIdsOrConversationPeerIds: ReadonlyArray<string>
): ThunkAction<void, RootStateType, unknown, CallHistoryUpdateUnread> {
return async dispatch => {
updateConversationsUnreadCounts(conversationIdsOrConversationPeerIds);
await updateCallHistoryUnreadCountDebounced(dispatch);
};
}
@@ -132,16 +141,13 @@ function markCallHistoryRead(
return async dispatch => {
try {
await DataWriter.markCallHistoryRead(callId);
window.ConversationController.get(
conversationId
)?.throttledUpdateUnread();
} catch (error) {
log.error(
'markCallHistoryRead: Error marking call history read',
Errors.toLogFormat(error)
);
} finally {
dispatch(updateCallHistoryUnreadCount());
dispatch(updateCallHistoryUnreadCount([conversationId]));
}
};
}
@@ -158,7 +164,7 @@ export function markCallHistoryReadInConversation(
try {
await markAllCallHistoryReadAndSync(callHistory, true);
} finally {
dispatch(updateCallHistoryUnreadCount());
dispatch(updateCallHistoryUnreadCount([callHistory.peerId]));
}
};
}
@@ -171,9 +177,12 @@ function markCallsTabViewed(): ThunkAction<
> {
return async (dispatch, getState) => {
const latestCall = getCallHistoryLatestCall(getState());
if (latestCall != null) {
const conversationIds =
await DataReader.getCallHistoryUnreadCallConversationIds();
await markAllCallHistoryReadAndSync(latestCall, false);
dispatch(updateCallHistoryUnreadCount());
dispatch(updateCallHistoryUnreadCount(conversationIds));
}
};
}
@@ -207,13 +216,17 @@ function clearAllCallHistory(): ThunkAction<
CallHistoryReset | ToastActionType | ShowErrorModalActionType
> {
return async (dispatch, getState) => {
let unreadConversationIds: ReadonlyArray<string> = [];
try {
const latestCall = getCallHistoryLatestCall(getState());
if (latestCall == null) {
return;
}
unreadConversationIds =
await DataReader.getCallHistoryUnreadCallConversationIds();
const result = await clearCallHistoryDataAndSync(latestCall);
if (result === ClearCallHistoryResult.Success) {
dispatch(showToast({ toastType: ToastType.CallHistoryCleared }));
} else if (result === ClearCallHistoryResult.Error) {
@@ -241,6 +254,9 @@ function clearAllCallHistory(): ThunkAction<
} catch (error) {
log.error('Error clearing call history', Errors.toLogFormat(error));
} finally {
// Ensure previously unread conversations are updated
updateConversationsUnreadCounts(unreadConversationIds);
// Just force a reload, even if the clear failed.
dispatch(reloadCallHistory());
}
+1 -1
View File
@@ -970,7 +970,7 @@ export class Bootstrap {
serverUrl: url,
storageUrl: `${url}/storageService`,
resourcesUrl: `${url}/updates2`,
sfuUrl: url,
sfuUrl: `${url}/callingService`,
cdn: {
'0': url,
'2': url,
@@ -0,0 +1,610 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import createDebug from 'debug';
import type { Page } from 'playwright';
import { expect } from 'playwright/test';
import type { Group, PrimaryDevice } from '@signalapp/mock-server';
import {
Proto,
StorageState,
EMPTY_DATA_MESSAGE,
SignalingProto,
} from '@signalapp/mock-server';
import * as Bytes from '../../Bytes.std.ts';
import { uuidToBytes } from '../../util/uuidToBytes.std.ts';
import * as durations from '../../util/durations/index.std.ts';
import { Bootstrap } from '../bootstrap.node.ts';
import type { App } from '../bootstrap.node.ts';
import { createGroup } from '../helpers.node.ts';
const debug = createDebug('mock:test:calling:callHistoryExpiration');
const EXPIRE_TIMER_SECONDS: 1 | 5 = 1;
const EXPIRE_TIMER_MS = EXPIRE_TIMER_SECONDS * 1000;
const WAIT_FOR_SLOW_EXPIRE_TIMER_MS = EXPIRE_TIMER_MS * 2;
function delay(ms: number) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
function $navTab(page: Page, name: string) {
return page.getByRole('tab', { name });
}
function $navTabUnreadBadge(page: Page, name: string) {
return $navTab(page, name).locator('.NavTabs__ItemIconLabel');
}
function $navSidebar(page: Page) {
return page.locator('.NavSidebar');
}
function $chatListItem(page: Page, id: string) {
return $navSidebar(page).getByTestId(id);
}
function $callListItem(page: Page, title: string) {
return $navSidebar(page).locator('.CallsList__Item', { hasText: title });
}
function $unreadBadge(page: Page, id: string) {
return $chatListItem(page, id)
.locator(
'.module-conversation-list__item--contact-or-conversation__content'
)
.locator(
'.module-conversation-list__item--contact-or-conversation__unread-indicator--unread-messages'
);
}
function $systemMessage(page: Page) {
return page.locator('.SystemMessage__contents');
}
function $incomingCall(page: Page) {
return $systemMessage(page).getByText('Incoming voice call');
}
function $missedCall(page: Page) {
return $systemMessage(page).getByText('Missed voice call');
}
function $groupCallEnded(page: Page) {
return $systemMessage(page).getByText('The video call has ended');
}
function $missedGroupCall(page: Page) {
return $systemMessage(page).getByText('Missed video call');
}
function $youStartedCall(page: Page) {
return $systemMessage(page).getByText('You started a video call');
}
describe('calling/callHistoryExpiration', function (this: Mocha.Suite) {
this.timeout(durations.MINUTE);
let bootstrap: Bootstrap;
let app: App;
let contact: PrimaryDevice;
let group: Group;
beforeEach(async () => {
bootstrap = new Bootstrap({ contactCount: 0 });
await bootstrap.init();
const { server, phone } = bootstrap;
contact = await server.createPrimaryDevice({ profileName: 'Alice' });
let state = StorageState.getEmpty();
state = state.updateAccount({
profileKey: phone.profileKey.serialize(),
givenName: phone.profileName,
});
state = state.addContact(contact, {
identityKey: contact.publicKey.serialize(),
profileKey: contact.profileKey.serialize(),
whitelisted: true,
});
state = state.pin(contact);
await phone.setStorageState(state);
group = await createGroup(phone, [contact], 'Test Group');
app = await bootstrap.link();
const { desktop } = bootstrap;
const ourKey = await desktop.popSingleUseKey();
await contact.addSingleUseKey(desktop, ourKey);
});
afterEach(async function (this: Mocha.Context) {
await bootstrap.maybeSaveLogs(this.currentTest, app);
await app.close();
await bootstrap.teardown();
});
async function setExpireTimer() {
const { desktop } = bootstrap;
const key = await desktop.popSingleUseKey();
await contact.addSingleUseKey(desktop, key);
const timestamp = bootstrap.getTimestamp();
const content: Proto.Content.Params = {
content: {
dataMessage: {
...EMPTY_DATA_MESSAGE,
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
expireTimer: EXPIRE_TIMER_SECONDS,
expireTimerVersion: 1,
timestamp: BigInt(timestamp),
},
},
pniSignatureMessage: null,
senderKeyDistributionMessage: null,
};
await contact.sendRaw(desktop, content, { timestamp });
}
async function setGroupExpireTimer() {
const { desktop, phone } = bootstrap;
await phone.modifyGroupDisappearingMessageTimer(
group,
EXPIRE_TIMER_SECONDS,
{
timestamp: bootstrap.getTimestamp(),
sendUpdateTo: [{ device: desktop }],
}
);
}
async function sendCallEventSync(params: {
callTimestamp: number;
wasAccepted: boolean;
}) {
const { desktop, phone } = bootstrap;
const content: Proto.Content.Params = {
content: {
syncMessage: {
content: {
callEvent: {
conversationId: uuidToBytes(contact.device.aci),
callId: BigInt(params.callTimestamp),
timestamp: BigInt(params.callTimestamp),
type: Proto.SyncMessage.CallEvent.Type.AUDIO_CALL,
direction: Proto.SyncMessage.CallEvent.Direction.INCOMING,
event: params.wasAccepted
? Proto.SyncMessage.CallEvent.Event.ACCEPTED
: Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED,
},
},
read: null,
stickerPackOperation: null,
viewed: null,
padding: null,
},
},
pniSignatureMessage: null,
senderKeyDistributionMessage: null,
};
await phone.sendRaw(desktop, content, { timestamp: params.callTimestamp });
}
async function sendGroupCallMessageOffer(params: { callTimestamp: number }) {
const { callTimestamp } = params;
const { desktop } = bootstrap;
const content: Proto.Content.Params = {
content: {
callMessage: {
offer: {
id: BigInt(callTimestamp),
type: Proto.CallMessage.Offer.Type.OFFER_VIDEO_CALL,
opaque: null,
},
answer: null,
iceUpdate: null,
busy: null,
hangup: null,
destinationDeviceId: null,
opaque: {
urgency: null,
data: SignalingProto.CallMessage.encode({
ringIntention: {
type: SignalingProto.CallMessage.RingIntention.Type.RING,
groupId: Buffer.from(group.id, 'base64'),
ringId: BigInt(callTimestamp),
},
groupCallMessage: {
groupId: Buffer.from(group.id, 'base64'),
},
}),
},
},
},
pniSignatureMessage: null,
senderKeyDistributionMessage: null,
};
await contact.sendRaw(desktop, content, {
timestamp: callTimestamp,
sealed: true,
group,
});
}
async function sendCallLogEventSync(params: {
callTimestamp: number;
groupCall?: boolean;
}) {
const { desktop, phone } = bootstrap;
const timestamp = Date.now();
const conversationId = params.groupCall
? Bytes.fromBase64(group.id)
: uuidToBytes(contact.device.aci);
const content: Proto.Content.Params = {
content: {
syncMessage: {
content: {
callLogEvent: {
type: Proto.SyncMessage.CallLogEvent.Type
.MARKED_AS_READ_IN_CONVERSATION,
timestamp: BigInt(params.callTimestamp),
conversationId,
callId: BigInt(params.callTimestamp),
},
},
read: null,
stickerPackOperation: null,
viewed: null,
padding: null,
},
},
pniSignatureMessage: null,
senderKeyDistributionMessage: null,
};
await phone.sendRaw(desktop, content, { timestamp });
}
describe('1:1 calls', () => {
it('expire missed call: read by opening chat', async () => {
const page = await app.getWindow();
const chatListItem = $chatListItem(page, contact.device.aci);
const unreadBadge = $unreadBadge(page, contact.device.aci);
debug('waiting for chat list item');
await expect(chatListItem).toBeVisible();
debug('setting expire timer via contact message');
await setExpireTimer();
await expect(unreadBadge).toContainText('1');
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
await expect(chatListItem).toContainText(/Timer set to \d seconds?/);
debug('sending incoming missed call event sync');
const callTimestamp = bootstrap.getTimestamp();
await sendCallEventSync({ callTimestamp, wasAccepted: false });
await expect(unreadBadge).toContainText('1');
await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1');
await expect(chatListItem).toContainText('Missed voice call');
debug('wait to make sure message does not expire before its been read');
await delay(WAIT_FOR_SLOW_EXPIRE_TIMER_MS);
await expect(unreadBadge).toContainText('1');
await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1');
await expect(chatListItem).toContainText('Missed voice call');
debug('checking the call doesnt need to be the latest message');
await contact.sendText(bootstrap.desktop, 'Another message');
await expect(unreadBadge).toContainText('2');
await expect(chatListItem).toContainText('Another message');
debug('opening conversation to mark read');
await chatListItem.click();
await expect(unreadBadge).not.toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
await expect($missedCall(page)).toBeVisible();
debug('waiting for message to expire');
await delay(EXPIRE_TIMER_MS);
await expect($missedCall(page)).not.toBeVisible();
});
it('missed call is expired by navigating to calls tab', async () => {
const page = await app.getWindow();
const chatListItem = $chatListItem(page, contact.device.aci);
const unreadBadge = $unreadBadge(page, contact.device.aci);
debug('waiting for chat list item');
await expect(chatListItem).toBeVisible();
debug('setting expire timer via contact message');
await setExpireTimer();
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
await expect(chatListItem).toContainText(/Timer set to \d seconds?/);
debug('sending incoming missed call event sync');
const callTimestamp = bootstrap.getTimestamp();
await sendCallEventSync({ callTimestamp, wasAccepted: false });
await expect(unreadBadge).toContainText('1');
await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1');
await expect(chatListItem).toContainText('Missed voice call');
debug('navigating to calls tab');
await $navTab(page, 'Calls').click();
debug('clicking on the call to mark it read');
await $callListItem(page, 'Alice').click();
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
debug('navigating back to chats tab');
await $navTab(page, 'Chats').click();
await expect(chatListItem).toContainText('Missed voice call');
debug('waiting for missed call to expire');
await delay(EXPIRE_TIMER_MS);
await expect(chatListItem).toContainText(/Timer set to \d seconds?/);
debug('checking in conversation');
await chatListItem.click();
await expect($missedCall(page)).not.toBeVisible();
});
it('expire missed call: read via CallLogEvent sync', async () => {
const page = await app.getWindow();
const chatListItem = $chatListItem(page, contact.device.aci);
const unreadBadge = $unreadBadge(page, contact.device.aci);
debug('waiting for chat list item');
await expect(chatListItem).toBeVisible();
debug('setting expire timer via contact message');
await setExpireTimer();
await expect(unreadBadge).toContainText('1');
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
await expect(chatListItem).toContainText(/Timer set to \d seconds?/);
debug('sending incoming missed call event sync');
const callTimestamp = bootstrap.getTimestamp();
await sendCallEventSync({ callTimestamp, wasAccepted: false });
debug('waiting for missed call unread badge');
await expect(unreadBadge).toContainText('1');
await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1');
await expect(chatListItem).toContainText('Missed voice call');
debug('marking calls read in conversation via sync');
await sendCallLogEventSync({ callTimestamp });
await expect(unreadBadge).toContainText('1');
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
await expect(chatListItem).toContainText('Missed voice call');
debug('waiting for message to expire');
await delay(EXPIRE_TIMER_MS);
await expect(chatListItem).toContainText(/Timer set to \d seconds?/);
debug('checking in conversation');
await chatListItem.click();
await expect($missedCall(page)).not.toBeVisible();
});
it('expire accepted call', async () => {
const page = await app.getWindow();
const chatListItem = $chatListItem(page, contact.device.aci);
const unreadBadge = $unreadBadge(page, contact.device.aci);
debug('waiting for chat list item');
await expect(chatListItem).toBeVisible();
debug('setting expire timer via contact message');
await setExpireTimer();
await expect(unreadBadge).toContainText('1');
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
await expect(chatListItem).toContainText(/Timer set to \d seconds?/);
debug('sending incoming missed call event sync');
const callTimestamp = bootstrap.getTimestamp();
await sendCallEventSync({ callTimestamp, wasAccepted: false });
await expect(unreadBadge).toContainText('1');
await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1');
await expect(chatListItem).toContainText('Missed voice call');
debug('marking call event accepted from sync');
await sendCallEventSync({ callTimestamp, wasAccepted: true });
await expect(unreadBadge).toContainText('1');
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
await expect(chatListItem).toContainText(/Timer set to \d seconds?/);
debug('waiting for message to expire');
await delay(EXPIRE_TIMER_MS);
debug('checking in conversation');
await chatListItem.click();
await expect($incomingCall(page)).not.toBeVisible();
});
});
describe('group calls', () => {
it('expire missed call: read by opening chat', async () => {
const page = await app.getWindow();
const chatListItem = $chatListItem(page, group.id);
const unreadBadge = $unreadBadge(page, group.id);
debug('waiting for group chat list item');
await expect(chatListItem).toBeVisible();
debug('setting expire timer for group');
await setGroupExpireTimer();
await expect(unreadBadge).not.toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
await expect(chatListItem).toContainText(/Timer set to \d seconds?/);
debug('sending incoming missed group call via offer');
const callTimestamp = bootstrap.getTimestamp();
await sendGroupCallMessageOffer({ callTimestamp });
await expect(unreadBadge).not.toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1');
await expect(chatListItem).toContainText('The video call has ended');
debug('wait to make sure message does not expire before its been read');
await delay(WAIT_FOR_SLOW_EXPIRE_TIMER_MS);
await expect(unreadBadge).not.toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1');
await expect(chatListItem).toContainText('The video call has ended');
debug('opening group conversation');
await chatListItem.click();
await expect($groupCallEnded(page)).toBeVisible();
await expect(unreadBadge).not.toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
debug('waiting for message to expire');
await delay(EXPIRE_TIMER_MS);
await expect($groupCallEnded(page)).not.toBeVisible();
await expect(chatListItem).toContainText(/Timer set to \d seconds?/);
});
it('missed call is expired by navigating to calls tab', async () => {
const page = await app.getWindow();
const chatListItem = $chatListItem(page, group.id);
const unreadBadge = $unreadBadge(page, group.id);
debug('waiting for group chat list item');
await expect(chatListItem).toBeVisible();
debug('setting expire timer via group state');
await setGroupExpireTimer();
await expect(unreadBadge).not.toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
await expect(chatListItem).toContainText(/Timer set to \d seconds?/);
debug('sending incoming missed group call via offer');
const callTimestamp = bootstrap.getTimestamp();
await sendGroupCallMessageOffer({ callTimestamp });
await expect(unreadBadge).not.toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1');
await expect(chatListItem).toContainText('The video call has ended');
debug('navigating to calls tab');
await $navTab(page, 'Calls').click();
debug('clicking on the call to mark it read');
await $callListItem(page, 'Test Group').click();
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
debug('navigating back to chats tab');
await $navTab(page, 'Chats').click();
await expect(unreadBadge).not.toBeVisible();
await expect(chatListItem).toContainText('The video call has ended');
debug('waiting for missed call to expire');
await delay(EXPIRE_TIMER_MS);
debug('checking in conversation');
await chatListItem.click();
await expect($missedCall(page)).not.toBeVisible();
});
it('expire missed call: read via CallLogEvent sync', async () => {
const page = await app.getWindow();
const chatListItem = $chatListItem(page, group.id);
const unreadBadge = $unreadBadge(page, group.id);
debug('waiting for group chat list item');
await expect(chatListItem).toBeVisible();
debug('setting expire timer via group state');
await setGroupExpireTimer();
await expect(unreadBadge).not.toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
await expect(chatListItem).toContainText(/Timer set to \d seconds?/);
debug('creating incoming missed group call via ring notification');
const callTimestamp = bootstrap.getTimestamp();
await sendGroupCallMessageOffer({ callTimestamp });
await expect(unreadBadge).not.toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1');
await expect(chatListItem).toContainText('The video call has ended');
debug('marking calls read in conversation via sync');
await sendCallLogEventSync({ callTimestamp, groupCall: true });
await expect(unreadBadge).not.toBeVisible();
await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible();
await expect(chatListItem).toContainText('The video call has ended');
debug('waiting for message to expire');
await delay(EXPIRE_TIMER_MS);
await expect(chatListItem).toContainText(/Timer set to \d seconds?/);
debug('opening group conversation to verify expired');
await chatListItem.click();
await expect($missedGroupCall(page)).not.toBeVisible();
});
it('does not expire an ongoing group call', async () => {
const page = await app.getWindow();
const chatListItem = $chatListItem(page, group.id);
debug('waiting for group chat list item');
await expect(chatListItem).toBeVisible();
debug('setting expire timer via group state');
await setGroupExpireTimer();
await expect(chatListItem).toContainText(/Timer set to \d seconds?/);
debug('opening group conversation');
await chatListItem.click();
debug('opening call screen');
await page.getByRole('button', { name: 'Start a video call' }).click();
debug('accepting audio permissions');
const audioPermissions = await app.waitForWindow();
await audioPermissions
.getByRole('button', { name: 'Allow Access' })
.click();
debug('accepting video permissions');
const videoPermissions = await app.waitForWindow();
await videoPermissions
.getByRole('button', { name: 'Allow Access' })
.click();
debug('starting call');
await page
.locator('.CallControls')
.getByRole('button', { name: 'Start' })
.click();
debug('minimizing call');
await page.getByRole('button', { name: 'Minimize call' }).click();
await expect($youStartedCall(page)).toBeVisible();
debug('wait to make sure message does not expire before call is over');
await delay(WAIT_FOR_SLOW_EXPIRE_TIMER_MS);
await expect($youStartedCall(page)).toBeVisible();
debug('leaving call');
await page.getByRole('button', { name: 'Leave call' }).click();
debug('waiting for message to expire');
await delay(EXPIRE_TIMER_MS);
await expect($youStartedCall(page)).not.toBeVisible();
});
});
});
+4
View File
@@ -197,6 +197,10 @@ export class App extends EventEmitter {
return this.#app.firstWindow();
}
public async waitForWindow(): Promise<Page> {
return this.#app.waitForEvent('window');
}
public async openSignalRoute(url: URL | string): Promise<void> {
const window = await this.getWindow();
await window.evaluate(
+11 -2
View File
@@ -23,7 +23,8 @@ describe('SQL/updateToSchemaVersion1100', () => {
db = createDB();
// index updated in 1170
// columns updated in 1210
updateToVersion(db, 1210);
// query now needs hasExpireTimer from 1530
updateToVersion(db, 1530);
});
afterEach(() => {
@@ -88,8 +89,16 @@ describe('SQL/updateToSchemaVersion1100', () => {
peerId: latestCallInConversation.peerId,
};
const readAt = target.timestamp + 1;
const start = performance.now();
const changes = markAllCallHistoryRead(db, target, true);
const changes = markAllCallHistoryRead(
db,
target,
readAt,
new Set(),
true
);
const end = performance.now();
assert.equal(changes, Math.ceil(COUNT / CONVERSATIONS));
assert.isBelow(end - start, 50);
+6 -2
View File
@@ -3493,7 +3493,8 @@ export default class MessageReceiver
const callEventDetails = getCallEventForProto(
callEvent,
'MessageReceiver.handleCallEvent'
'MessageReceiver.handleCallEvent',
envelope.timestamp
);
const callEventSync = new CallEventSyncEvent(
@@ -3563,7 +3564,10 @@ export default class MessageReceiver
const { receivedAtCounter } = envelope;
const callLogEventDetails = getCallLogEventForProto(callLogEvent);
const callLogEventDetails = getCallLogEventForProto(
callLogEvent,
envelope.timestamp
);
const callLogEventSync = new CallLogEventSyncEvent(
{
callLogEventDetails,
+35 -8
View File
@@ -10,6 +10,7 @@ import * as Bytes from '../Bytes.std.ts';
import { UUID_BYTE_SIZE } from './Crypto.std.ts';
import { toNumber } from '../util/toNumber.std.ts';
import { strictAssert } from '../util/assert.std.ts';
// These are strings (1) for the backup (2) for Storybook.
export enum CallMode {
@@ -36,6 +37,7 @@ export enum CallDirection {
export enum CallLogEvent {
Clear = 'Clear',
UNIMPLEMENTED_ClearInConversation = 'ClearInConversation',
MarkedAsRead = 'MarkedAsRead',
MarkedAsReadInConversation = 'MarkedAsReadInConversation',
}
@@ -143,16 +145,18 @@ export type CallLogEventTarget = Readonly<
export type CallLogEventDetails = Readonly<{
type: CallLogEvent;
timestamp: number;
targetTimestamp: number;
peerIdAsConversationId: AciString | string | null;
peerIdAsRoomId: string | null;
callId: string | null;
eventTimestamp: number;
}>;
export type CallEventDetails = CallDetails &
Readonly<{
event: CallEvent;
eventSource: string;
eventTimestamp: number;
}>;
export type CallHistoryDetails = CallDetails &
@@ -233,6 +237,7 @@ export const callDetailsSchema = z.object({
export const callEventDetailsSchema = callDetailsSchema.extend({
event: callEventSchema,
eventSource: z.string(),
eventTimestamp: z.number(),
}) satisfies z.ZodType<CallEventDetails>;
export const callHistoryDetailsSchema = callDetailsSchema.extend({
@@ -256,7 +261,7 @@ export const callHistoryGroupSchema = z.object({
const conversationPeerIdInBytesSchema = z
.instanceof(Uint8Array)
.transform(value => {
.transform((value): string | AciString => {
// direct conversationId
if (value.byteLength === UUID_BYTE_SIZE) {
const uuid = bytesToUuid(value);
@@ -301,13 +306,35 @@ export const callEventNormalizeSchema = z
])
);
const CALL_LOG_EVENT_TYPE_MAP: Record<
Proto.SyncMessage.CallLogEvent.Type,
CallLogEvent
> = {
[Proto.SyncMessage.CallLogEvent.Type.CLEAR]: CallLogEvent.Clear,
[Proto.SyncMessage.CallLogEvent.Type.CLEAR_IN_CONVERSATION]:
CallLogEvent.UNIMPLEMENTED_ClearInConversation,
[Proto.SyncMessage.CallLogEvent.Type.MARKED_AS_READ]:
CallLogEvent.MarkedAsRead,
[Proto.SyncMessage.CallLogEvent.Type.MARKED_AS_READ_IN_CONVERSATION]:
CallLogEvent.MarkedAsReadInConversation,
};
const callLogEventTypeNormalizeSchema = z
.enum(Proto.SyncMessage.CallLogEvent.Type)
.transform(type => {
const result = CALL_LOG_EVENT_TYPE_MAP[type];
strictAssert(result, `Missing CALL_LOG_EVENT_TYPE_MAP for ${type}`);
return result;
});
export const callLogEventNormalizeSchema = z.object({
type: z.nativeEnum(Proto.SyncMessage.CallLogEvent.Type),
timestamp: longToNumberSchema,
peerIdAsConversationId: conversationPeerIdInBytesSchema.optional(),
peerIdAsRoomId: roomIdInBytesSchema.optional(),
callId: longToStringSchema.optional(),
});
type: callLogEventTypeNormalizeSchema,
targetTimestamp: longToNumberSchema,
peerIdAsConversationId: conversationPeerIdInBytesSchema.nullable(),
peerIdAsRoomId: roomIdInBytesSchema.nullable(),
callId: longToStringSchema.nullable(),
eventTimestamp: z.number(),
}) satisfies z.ZodType<CallLogEventDetails>;
export function isSameCallHistoryGroup(
a: CallHistoryGroup,
+6
View File
@@ -1,6 +1,7 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isTestOrMockEnvironment } from '../environment.std.ts';
import { createLogger } from '../logging/log.std.ts';
import { missingCaseError } from './missingCaseError.std.ts';
@@ -60,6 +61,11 @@ export class Sound {
soundNode.connect(volumeNode);
volumeNode.connect(this.#context.destination);
if (isTestOrMockEnvironment()) {
// Mute sounds in tests
volumeNode.gain.setValueAtTime(0, this.#context.currentTime);
}
soundNode.loop = this.#loop;
soundNode.start(0, 0);
+118 -81
View File
@@ -34,7 +34,6 @@ import {
AdhocCallStatus,
CallStatusValue,
callLogEventNormalizeSchema,
CallLogEvent,
ClearCallHistoryResult,
} from '../types/CallDisposition.std.ts';
import type { AciString } from '../types/ServiceId.std.ts';
@@ -70,6 +69,8 @@ import { calling } from '../services/calling.preload.ts';
import { cleanupMessages } from './cleanup.preload.ts';
import { MessageModel } from '../models/messages.preload.ts';
import { itemStorage } from '../textsecure/Storage.preload.ts';
import { update as updateExpiringMessagesService } from '../services/expiringMessagesDeletion.preload.ts';
import type { DurationInSeconds } from './durations/duration-in-seconds.std.ts';
const { isEqual } = lodash;
@@ -202,7 +203,8 @@ export function convertJoinState(joinState: JoinState): GroupCallJoinState {
export function getCallEventForProto(
callEventProto: Proto.SyncMessage.CallEvent.Params,
eventSource: string
eventSource: string,
eventTimestamp: number
): CallEventDetails {
const callEvent = parseLoose(callEventNormalizeSchema, callEventProto);
const { callId, conversationId: peerId, timestamp } = callEvent;
@@ -269,44 +271,29 @@ export function getCallEventForProto(
timestamp,
event,
eventSource,
eventTimestamp,
});
}
const callLogEventFromProto: Partial<
Record<Proto.SyncMessage.CallLogEvent.Type, CallLogEvent>
> = {
[Proto.SyncMessage.CallLogEvent.Type.CLEAR]: CallLogEvent.Clear,
[Proto.SyncMessage.CallLogEvent.Type.MARKED_AS_READ]:
CallLogEvent.MarkedAsRead,
[Proto.SyncMessage.CallLogEvent.Type.MARKED_AS_READ_IN_CONVERSATION]:
CallLogEvent.MarkedAsReadInConversation,
};
export function getCallLogEventForProto(
callLogEventProto: Proto.SyncMessage.CallLogEvent.Params
callLogEventProto: Proto.SyncMessage.CallLogEvent,
eventTimestamp: number
): CallLogEventDetails {
// CallLogEvent peerId is ambiguous whether it's a conversationId (direct, or groupId)
// or roomId so handle both cases
const { conversationId: peerIdBytes } = callLogEventProto;
const {
conversationId: peerIdBytes,
timestamp: targetTimestamp,
...rest
} = callLogEventProto;
const callLogEvent = parseLoose(callLogEventNormalizeSchema, {
...callLogEventProto,
return parseLoose(callLogEventNormalizeSchema, {
...rest,
targetTimestamp,
peerIdAsConversationId: peerIdBytes,
peerIdAsRoomId: peerIdBytes,
eventTimestamp,
});
const type = callLogEventFromProto[callLogEvent.type];
if (type == null) {
throw new TypeError(`Unknown call log event ${callLogEvent.type}`);
}
return {
type,
timestamp: callLogEvent.timestamp,
peerIdAsConversationId: callLogEvent.peerIdAsConversationId ?? null,
peerIdAsRoomId: callLogEvent.peerIdAsRoomId ?? null,
callId: callLogEvent.callId ?? null,
};
}
const directionToProto = {
@@ -533,70 +520,73 @@ function getCallDirectionFromRingerId(
// Call Details
// ------------
export function getCallDetailsFromDirectCall(
peerId: AciString | string,
call: Call
): CallDetails {
const ringerId = call.isIncoming ? call.remoteUserId : null;
export function getCallDetailsFromDirectCall(params: {
peerId: AciString | string;
call: Call;
eventTimestamp: number;
}): CallDetails {
const ringerId = params.call.isIncoming ? params.call.remoteUserId : null;
return parseStrict(callDetailsSchema, {
callId: (call.callId satisfies bigint).toString(),
peerId,
callId: params.call.callId.toString(),
peerId: params.peerId,
ringerId,
startedById: ringerId,
mode: CallMode.Direct,
type: call.isVideoCall ? CallType.Video : CallType.Audio,
direction: call.isIncoming
type: params.call.isVideoCall ? CallType.Video : CallType.Audio,
direction: params.call.isIncoming
? CallDirection.Incoming
: CallDirection.Outgoing,
timestamp: Date.now(),
timestamp: params.eventTimestamp,
endedTimestamp: null,
});
}
export function getCallDetailsFromEndedDirectCall(
callId: string,
peerId: AciString | string,
ringerId: AciString | string,
wasVideoCall: boolean,
timestamp: number
): CallDetails {
export function getCallDetailsFromEndedDirectCall(params: {
callId: bigint;
peerId: AciString | string;
ringerId: AciString | string;
wasVideoCall: boolean;
eventTimestamp: number;
}): CallDetails {
return parseStrict(callDetailsSchema, {
callId,
peerId,
ringerId,
startedById: ringerId,
callId: params.callId.toString(),
peerId: params.peerId,
ringerId: params.ringerId,
startedById: params.ringerId,
mode: CallMode.Direct,
type: wasVideoCall ? CallType.Video : CallType.Audio,
direction: getCallDirectionFromRingerId(ringerId),
timestamp,
type: params.wasVideoCall ? CallType.Video : CallType.Audio,
direction: getCallDirectionFromRingerId(params.ringerId),
timestamp: params.eventTimestamp,
endedTimestamp: null,
});
}
export function getCallDetailsFromGroupCallMeta(
peerId: AciString | string,
groupCallMeta: GroupCallMeta
): CallDetails {
export function getCallDetailsFromGroupCallMeta(params: {
peerId: AciString | string;
groupCallMeta: GroupCallMeta;
eventTimestamp: number;
}): CallDetails {
return parseStrict(callDetailsSchema, {
callId: groupCallMeta.callId,
peerId,
ringerId: groupCallMeta.ringerId,
startedById: groupCallMeta.ringerId,
callId: params.groupCallMeta.callId,
peerId: params.peerId,
ringerId: params.groupCallMeta.ringerId,
startedById: params.groupCallMeta.ringerId,
mode: CallMode.Group,
type: CallType.Group,
direction: getCallDirectionFromRingerId(groupCallMeta.ringerId),
timestamp: Date.now(),
direction: getCallDirectionFromRingerId(params.groupCallMeta.ringerId),
timestamp: params.eventTimestamp,
endedTimestamp: null,
});
}
export function getCallDetailsForAdhocCall(
peerId: AciString | string,
callId: string
): CallDetails {
export function getCallDetailsForAdhocCall(params: {
peerId: AciString | string;
callId: string;
eventTimestamp: number;
}): CallDetails {
return parseStrict(callDetailsSchema, {
callId,
peerId,
callId: params.callId,
peerId: params.peerId,
ringerId: null,
startedById: null,
mode: CallMode.Adhoc,
@@ -604,7 +594,7 @@ export function getCallDetailsForAdhocCall(
// 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: params.eventTimestamp,
endedTimestamp: null,
});
}
@@ -612,15 +602,17 @@ export function getCallDetailsForAdhocCall(
// Call Event Details
// ------------------
export function getCallEventDetails(
callDetails: CallDetails,
event: LocalCallEvent,
eventSource: string
): CallEventDetails {
export function getCallEventDetails(params: {
callDetails: CallDetails;
event: LocalCallEvent;
eventSource: string;
eventTimestamp: number;
}): CallEventDetails {
return parseStrict(callEventDetailsSchema, {
...callDetails,
event,
eventSource,
...params.callDetails,
event: params.event,
eventSource: params.eventSource,
eventTimestamp: params.eventTimestamp,
});
}
@@ -1061,6 +1053,7 @@ async function updateLocalCallHistory({
receivedAtCounter,
receivedAtMS,
serverGuid,
eventTimestamp: callEvent.eventTimestamp,
});
return updatedCallHistory;
}
@@ -1167,12 +1160,14 @@ async function saveCallHistory({
receivedAtCounter,
receivedAtMS,
serverGuid,
eventTimestamp,
}: {
callHistory: CallHistoryDetails;
conversation: ConversationModel;
receivedAtCounter: number | null;
receivedAtMS: number | null;
serverGuid: string | null;
eventTimestamp: number | null;
}): Promise<CallHistoryDetails> {
log.info(
'saveCallHistory: Saving call history:',
@@ -1220,6 +1215,8 @@ async function saveCallHistory({
let seenStatus: SeenStatus;
const isCallCurrentlyActive = calling.isCallActive(conversation.id);
const isUnseenDirectCall =
callHistory.mode === CallMode.Direct &&
callHistory.direction === CallDirection.Incoming &&
@@ -1248,6 +1245,27 @@ async function saveCallHistory({
const { id: newId } = generateMessageId(counter);
let expireTimer: DurationInSeconds | undefined;
if (prevMessage != null) {
expireTimer = prevMessage.expireTimer;
} else {
expireTimer = conversation.get('expireTimer');
}
let expirationStartTimestamp: number | null | undefined =
prevMessage?.expirationStartTimestamp;
if (
expireTimer != null &&
seenStatus === SeenStatus.Seen &&
!isCallCurrentlyActive
) {
expirationStartTimestamp ??=
eventTimestamp != null
? Math.min(eventTimestamp, Date.now())
: Date.now();
}
const message = new MessageModel({
id: prevMessage?.id ?? newId,
conversationId: conversation.id,
@@ -1261,6 +1279,8 @@ async function saveCallHistory({
seenStatus,
callId: callHistory.callId,
serverGuid: prevMessage?.serverGuid ?? (serverGuid || undefined),
expireTimer,
expirationStartTimestamp,
});
const id = await window.MessageCache.saveMessage(message, {
@@ -1300,7 +1320,9 @@ async function saveCallHistory({
await DataWriter.updateConversation(conversation.attributes);
}
window.reduxActions.callHistory.updateCallHistoryUnreadCount();
window.reduxActions.callHistory.updateCallHistoryUnreadCount([
conversation.id,
]);
return callHistory;
}
@@ -1489,17 +1511,31 @@ export async function markAllCallHistoryReadAndSync(
log.info(
`markAllCallHistoryReadAndSync: Marking call history read before (${latestCall.callId}, ${latestCall.timestamp})`
);
const readAt = Date.now();
const activeCallIds = calling.getActiveCallIds();
let count: number;
if (inConversation) {
count = await DataWriter.markAllCallHistoryReadInConversation(latestCall);
count = await DataWriter.markAllCallHistoryReadInConversation(
latestCall,
readAt,
activeCallIds
);
} else {
count = await DataWriter.markAllCallHistoryRead(latestCall);
count = await DataWriter.markAllCallHistoryRead(
latestCall,
readAt,
activeCallIds
);
}
log.info(
`markAllCallHistoryReadAndSync: Marked ${count} call history messages read`
);
if (count > 0) {
updateExpiringMessagesService();
}
const ourAci = itemStorage.user.getCheckedAci();
const callLogEvent: Proto.SyncMessage.CallLogEvent.Params = {
@@ -1591,6 +1627,7 @@ export async function updateLocalGroupCallHistoryTimestamp(
receivedAtCounter: null,
receivedAtMS: null,
serverGuid: null,
eventTimestamp: null,
});
return updatedCallHistory;
+15 -1
View File
@@ -28,6 +28,7 @@ import { isAciString } from './isAciString.std.ts';
import type { MessageModel } from '../models/messages.preload.ts';
import { postSaveUpdates } from './cleanup.preload.ts';
import { itemStorage } from '../textsecure/Storage.preload.ts';
import { calling } from '../services/calling.preload.ts';
const { isNumber, pick } = lodash;
@@ -47,6 +48,7 @@ export async function markConversationRead(
const [
unreadMessages,
unreadCallMessages,
unreadEditedMessages,
unreadReactions,
unreadPollVotes,
@@ -57,6 +59,11 @@ export async function markConversationRead(
readAt: options.readAt,
includeStoryReplies: !isGroup(conversationAttrs),
}),
DataWriter.getUnreadCallMessagesAndMarkRead({
conversationId,
readMessageReceivedAt: readMessage.received_at,
activeCallIds: calling.getActiveCallIds(),
}),
DataWriter.getUnreadEditedMessagesAndMarkRead({
conversationId,
readMessageReceivedAt: readMessage.received_at,
@@ -80,12 +87,15 @@ export async function markConversationRead(
receivedAt: readMessage.received_at,
},
unreadMessages: unreadMessages.length,
unreadCallMessages: unreadCallMessages.length,
unreadEditedMessages: unreadEditedMessages.length,
unreadReactions: unreadReactions.length,
unreadPollVotes: unreadPollVotes.length,
});
if (
!unreadMessages.length &&
!unreadCallMessages.length &&
!unreadEditedMessages.length &&
!unreadReactions.length &&
!unreadPollVotes.length
@@ -138,7 +148,11 @@ export async function markConversationRead(
});
});
const allUnreadMessages = [...unreadMessages, ...unreadEditedMessages];
const allUnreadMessages = [
...unreadMessages,
...unreadCallMessages,
...unreadEditedMessages,
];
const updatedMessages: Array<MessageModel> = [];
+48 -13
View File
@@ -3,12 +3,13 @@
import type { CallLogEventSyncEvent } from '../textsecure/messageReceiverEvents.std.ts';
import { createLogger } from '../logging/log.std.ts';
import { DataWriter } from '../sql/Client.preload.ts';
import { DataReader, DataWriter } from '../sql/Client.preload.ts';
import type { CallLogEventTarget } from '../types/CallDisposition.std.ts';
import { CallLogEvent } from '../types/CallDisposition.std.ts';
import { missingCaseError } from './missingCaseError.std.ts';
import { strictAssert } from './assert.std.ts';
import { updateDeletedMessages } from './callDisposition.preload.ts';
import { update as updateExpiringMessagesService } from '../services/expiringMessagesDeletion.preload.ts';
import { calling } from '../services/calling.preload.ts';
const log = createLogger('onCallLogEventSync');
@@ -16,51 +17,85 @@ export async function onCallLogEventSync(
syncEvent: CallLogEventSyncEvent
): Promise<void> {
const { data, confirm } = syncEvent;
const { type, peerIdAsConversationId, peerIdAsRoomId, callId, timestamp } =
data.callLogEventDetails;
const {
type,
peerIdAsConversationId,
peerIdAsRoomId,
callId,
targetTimestamp,
eventTimestamp,
} = data.callLogEventDetails;
const target: CallLogEventTarget = {
peerIdAsConversationId,
peerIdAsRoomId,
callId,
timestamp,
timestamp: targetTimestamp,
};
log.info(
`Processing event (Event: ${type}, CallId: ${callId}, Timestamp: ${timestamp})`
`Processing event (Event: ${type}, CallId: ${callId}, Timestamp: ${targetTimestamp})`
);
if (type === CallLogEvent.Clear) {
log.info('Clearing call history');
let unreadConversationIds: ReadonlyArray<string> = [];
try {
unreadConversationIds =
await DataReader.getCallHistoryUnreadCallConversationIds();
const messageIds = await DataWriter.clearCallHistory(target);
updateDeletedMessages(messageIds);
} finally {
// We want to reset the call history even if the clear fails.
window.reduxActions.callHistory.resetCallHistory();
window.reduxActions.callHistory.updateCallHistoryUnreadCount(
unreadConversationIds
);
}
confirm();
} else if (type === CallLogEvent.MarkedAsRead) {
log.info('Marking call history read');
let unreadConversationIds: ReadonlyArray<string> = [];
try {
const count = await DataWriter.markAllCallHistoryRead(target);
unreadConversationIds =
await DataReader.getCallHistoryUnreadCallConversationIds();
const count = await DataWriter.markAllCallHistoryRead(
target,
Math.min(Date.now(), eventTimestamp),
calling.getActiveCallIds()
);
log.info(`Marked ${count} call history messages read`);
if (count !== 0) {
updateExpiringMessagesService();
}
} finally {
window.reduxActions.callHistory.updateCallHistoryUnreadCount();
window.reduxActions.callHistory.updateCallHistoryUnreadCount(
unreadConversationIds
);
}
confirm();
} else if (type === CallLogEvent.MarkedAsReadInConversation) {
log.info('Marking call history read in conversation');
try {
strictAssert(peerIdAsConversationId, 'Missing peerIdAsConversationId');
strictAssert(peerIdAsRoomId, 'Missing peerIdAsRoomId');
const count =
await DataWriter.markAllCallHistoryReadInConversation(target);
const count = await DataWriter.markAllCallHistoryReadInConversation(
target,
Math.min(Date.now(), eventTimestamp),
calling.getActiveCallIds()
);
log.info(`Marked ${count} call history messages read`);
if (count !== 0) {
updateExpiringMessagesService();
}
} finally {
window.reduxActions.callHistory.updateCallHistoryUnreadCount();
window.reduxActions.callHistory.updateCallHistoryUnreadCount(
peerIdAsConversationId != null ? [peerIdAsConversationId] : []
);
}
confirm();
} else if (type === CallLogEvent.UNIMPLEMENTED_ClearInConversation) {
log.warn('CallLogEvent.CLEAR_IN_CONVERSATION not supported');
confirm();
} else {
throw missingCaseError(type);
}