mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-07-08 14:24:14 +01:00
Support disappearing message timers for call history
Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com>
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
Generated
+5
-5
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = [];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user