Call link call history

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

View file

@ -7408,6 +7408,10 @@
"messageformat": "Group call",
"description": "Calls Tab > Calls List > Call Item > Call Status > When group call is in its default state"
},
"icu:CallsList__ItemCallInfo--CallLink": {
"messageformat": "Call link",
"description": "On the Calls Tab, the subtitle text for a Call Link entry."
},
"icu:CallsNewCall__EmptyState--noQuery": {
"messageformat": "No recent conversations.",
"description": "Calls Tab > New Call > Conversations List > When no results found > With no search query"

View file

@ -187,6 +187,10 @@ import {
getCallsHistoryUnreadCountForRedux,
loadCallsHistory,
} from './services/callHistoryLoader';
import {
getCallLinksForRedux,
loadCallLinks,
} from './services/callLinksLoader';
import {
getCallIdFromEra,
updateLocalGroupCallHistoryTimestamp,
@ -1105,6 +1109,7 @@ export async function startApp(): Promise<void> {
loadStories(),
loadDistributionLists(),
loadCallsHistory(),
loadCallLinks(),
window.textsecure.storage.protocol.hydrateCaches(),
(async () => {
mainWindowStats = await window.SignalContext.getMainWindowStats();
@ -1155,6 +1160,7 @@ export async function startApp(): Promise<void> {
theme: ThemeType;
}) {
initializeRedux({
callLinks: getCallLinksForRedux(),
callsHistory: getCallsHistoryForRedux(),
callsHistoryUnreadCount: getCallsHistoryUnreadCountForRedux(),
initialBadgesState,

View file

@ -49,8 +49,10 @@ function getCallLink(overrideProps: Partial<CallLinkType> = {}): CallLinkType {
return {
roomId: 'abcd1234abcd1234abcd1234abcd1234abcd1234',
rootKey: 'abcd-abcd-abcd-abcd-abcd-abcd-abcd-abcd',
adminKey: null,
name: 'Axolotl Discuss',
restrictions: CallLinkRestrictions.None,
revoked: false,
expiration: Date.now() + 30 * 24 * 60 * 60 * 1000,
...overrideProps,
};

View file

@ -206,7 +206,7 @@ export function CallingLobby({
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.CallIsFull;
} else if (isCallConnecting) {
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Loading;
} else if (peekedParticipants.length) {
} else if (peekedParticipants.length || callMode === CallMode.Adhoc) {
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Join;
} else {
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Start;

View file

@ -43,6 +43,10 @@ import { formatCallHistoryGroup } from '../util/callDisposition';
import { CallsNewCallButton } from './CallsNewCall';
import { Tooltip, TooltipPlacement } from './Tooltip';
import { Theme } from '../util/theme';
import type { CallingConversationType } from '../types/Calling';
import { CallMode } from '../types/Calling';
import type { CallLinkType } from '../types/CallLink';
import { callLinkToConversation } from '../util/callLinks';
function Timestamp({
i18n,
@ -112,6 +116,7 @@ type CallsListProps = Readonly<{
pagination: CallHistoryPagination
) => Promise<Array<CallHistoryGroup>>;
callHistoryEdition: number;
getCallLink: (id: string) => CallLinkType | undefined;
getConversation: (id: string) => ConversationType | void;
i18n: LocalizerType;
selectedCallHistoryGroup: CallHistoryGroup | null;
@ -121,6 +126,7 @@ type CallsListProps = Readonly<{
conversationId: string,
selectedCallHistoryGroup: CallHistoryGroup
) => void;
startCallLinkLobbyByRoomId: (roomId: string) => void;
}>;
const CALL_LIST_ITEM_ROW_HEIGHT = 62;
@ -141,12 +147,14 @@ export function CallsList({
getCallHistoryGroupsCount,
getCallHistoryGroups,
callHistoryEdition,
getCallLink,
getConversation,
i18n,
selectedCallHistoryGroup,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
onSelectCallHistoryGroup,
startCallLinkLobbyByRoomId,
}: CallsListProps): JSX.Element {
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const listRef = useRef<List>(null);
@ -301,7 +309,16 @@ export function CallsList({
const rowRenderer = useCallback(
({ key, index, style }: ListRowProps) => {
const item = searchState.results?.items.at(index) ?? null;
const conversation = item != null ? getConversation(item.peerId) : null;
const isAdhoc = item?.mode === CallMode.Adhoc;
const callLink = isAdhoc ? getCallLink(item.peerId) : null;
const conversation: CallingConversationType | null =
// eslint-disable-next-line no-nested-ternary
item
? callLink
? callLinkToConversation(callLink, i18n)
: getConversation(item.peerId) ?? null
: null;
if (
searchState.state === 'pending' ||
@ -337,6 +354,8 @@ export function CallsList({
let statusText;
if (wasMissed) {
statusText = i18n('icu:CallsList__ItemCallInfo--Missed');
} else if (callLink) {
statusText = i18n('icu:CallsList__ItemCallInfo--CallLink');
} else if (item.type === CallType.Group) {
statusText = i18n('icu:CallsList__ItemCallInfo--GroupCall');
} else if (item.direction === CallDirection.Outgoing) {
@ -363,7 +382,7 @@ export function CallsList({
<Avatar
acceptedMessageRequest
avatarPath={conversation.avatarPath}
conversationType="group"
conversationType={conversation.type}
i18n={i18n}
isMe={false}
title={conversation.title}
@ -378,10 +397,14 @@ export function CallsList({
callType={item.type}
hasActiveCall={hasActiveCall}
onClick={() => {
if (item.type === CallType.Audio) {
onOutgoingAudioCallInConversation(conversation.id);
} else {
onOutgoingVideoCallInConversation(conversation.id);
if (callLink) {
startCallLinkLobbyByRoomId(callLink.roomId);
} else if (conversation) {
if (item.type === CallType.Audio) {
onOutgoingAudioCallInConversation(conversation.id);
} else {
onOutgoingVideoCallInConversation(conversation.id);
}
}
}}
/>
@ -403,6 +426,10 @@ export function CallsList({
</span>
}
onClick={() => {
if (callLink) {
return;
}
onSelectCallHistoryGroup(conversation.id, item);
}}
/>
@ -412,11 +439,13 @@ export function CallsList({
[
hasActiveCall,
searchState,
getCallLink,
getConversation,
selectedCallHistoryGroup,
onSelectCallHistoryGroup,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
startCallLinkLobbyByRoomId,
i18n,
]
);

View file

@ -18,6 +18,7 @@ import { ContextMenu } from './ContextMenu';
import { ConfirmationDialog } from './ConfirmationDialog';
import type { UnreadStats } from '../util/countUnreadStats';
import type { WidthBreakpoint } from './_util';
import type { CallLinkType } from '../types/CallLink';
enum CallsTabSidebarView {
CallsListView,
@ -36,6 +37,7 @@ type CallsTabProps = Readonly<{
pagination: CallHistoryPagination
) => Promise<Array<CallHistoryGroup>>;
callHistoryEdition: number;
getCallLink: (id: string) => CallLinkType | undefined;
getConversation: (id: string) => ConversationType | void;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
@ -56,6 +58,7 @@ type CallsTabProps = Readonly<{
}) => JSX.Element;
regionCode: string | undefined;
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
startCallLinkLobbyByRoomId: (roomId: string) => void;
}>;
export function CallsTab({
@ -65,6 +68,7 @@ export function CallsTab({
getCallHistoryGroupsCount,
getCallHistoryGroups,
callHistoryEdition,
getCallLink,
getConversation,
hasFailedStorySends,
hasPendingUpdate,
@ -80,6 +84,7 @@ export function CallsTab({
renderToastManager,
regionCode,
savePreferredLeftPaneWidth,
startCallLinkLobbyByRoomId,
}: CallsTabProps): JSX.Element {
const [sidebarView, setSidebarView] = useState(
CallsTabSidebarView.CallsListView
@ -230,6 +235,7 @@ export function CallsTab({
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
getCallHistoryGroups={getCallHistoryGroups}
callHistoryEdition={callHistoryEdition}
getCallLink={getCallLink}
getConversation={getConversation}
i18n={i18n}
selectedCallHistoryGroup={selected?.callHistoryGroup ?? null}
@ -240,6 +246,7 @@ export function CallsTab({
onOutgoingVideoCallInConversation={
handleOutgoingVideoCallInConversation
}
startCallLinkLobbyByRoomId={startCallLinkLobbyByRoomId}
/>
)}
{sidebarView === CallsTabSidebarView.NewCallView && (

View file

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

View file

@ -0,0 +1,18 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import dataInterface from '../sql/Client';
import type { CallLinkType } from '../types/CallLink';
import { strictAssert } from '../util/assert';
let callLinksData: ReadonlyArray<CallLinkType>;
export async function loadCallLinks(): Promise<void> {
await dataInterface.cleanupCallHistoryMessages();
callLinksData = await dataInterface.getAllCallLinks();
}
export function getCallLinksForRedux(): ReadonlyArray<CallLinkType> {
strictAssert(callLinksData != null, 'callLinks has not been loaded');
return callLinksData;
}

View file

@ -124,6 +124,9 @@ import {
getCallIdFromRing,
getLocalCallEventFromRingUpdate,
convertJoinState,
updateLocalAdhocCallHistory,
getCallIdFromEra,
getCallDetailsForAdhocCall,
} from '../util/callDisposition';
import { isNormalNumber } from '../util/isNormalNumber';
import { LocalCallEvent } from '../types/CallDisposition';
@ -181,6 +184,7 @@ type CallingReduxInterface = Pick<
| 'setPresenting'
| 'startCallingLobby'
| 'startCallLinkLobby'
| 'startCallLinkLobbyByRoomId'
| 'peekNotConnectedGroupCall'
> & {
areAnyCallsActiveOrRinging(): boolean;
@ -535,25 +539,25 @@ export class CallingClass {
callLinkRootKey: CallLinkRootKey;
}>): Promise<CallLinkState | undefined> {
if (!this._sfuUrl) {
throw new Error('Missing SFU URL; not handling call link');
throw new Error('readCallLink() missing SFU URL; not handling call link');
}
const roomId = getRoomIdFromRootKey(callLinkRootKey);
const logId = `readCallLink(${roomId})`;
const authCredentialPresentation =
await getCallLinkAuthCredentialPresentation(callLinkRootKey);
log.info(`readCallLink: roomId ${roomId}`);
const result = await RingRTC.readCallLink(
this._sfuUrl,
authCredentialPresentation.serialize(),
callLinkRootKey
);
if (!result.success) {
log.warn(`readCallLink: failed ${roomId}`);
log.warn(`${logId}: failed`);
return;
}
log.info('readCallLink: success', result);
log.info(`${logId}: success`);
return result.value;
}
@ -980,11 +984,20 @@ export class CallingClass {
callMode: CallMode.Group | CallMode.Adhoc
): GroupCallObserver {
let updateMessageState = GroupCallUpdateMessageState.SentNothing;
const updateCallHistoryOnLocalChanged =
callMode === CallMode.Group
? this.updateCallHistoryForGroupCallOnLocalChanged
: this.updateCallHistoryForAdhocCall;
const updateCallHistoryOnPeek =
callMode === CallMode.Group
? this.updateCallHistoryForGroupCallOnPeek
: this.updateCallHistoryForAdhocCall;
return {
onLocalDeviceStateChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState();
const peekInfo = groupCall.getPeekInfo() ?? null;
const { eraId } = peekInfo ?? {};
log.info(
'GroupCall#onLocalDeviceStateChanged',
@ -992,43 +1005,14 @@ export class CallingClass {
peekInfo != null ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
const groupCallMeta = getGroupCallMeta(peekInfo);
// TODO: Handle call history for adhoc calls
if (groupCallMeta != null && callMode === CallMode.Group) {
try {
const localCallEvent = getLocalCallEventFromJoinState(
convertJoinState(localDeviceState.joinState),
groupCallMeta
);
if (localCallEvent != null && peekInfo != null) {
const conversation =
window.ConversationController.get(conversationId);
strictAssert(
conversation != null,
'GroupCall#onLocalDeviceStateChanged: Missing conversation'
);
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromGroupCallMeta(
peerId,
groupCallMeta
);
const callEvent = getCallEventDetails(
callDetails,
localCallEvent,
'RingRTC.onLocalDeviceStateChanged'
);
drop(updateCallHistoryFromLocalEvent(callEvent, null));
}
} catch (error) {
log.error(
'GroupCall#onLocalDeviceStateChanged: Error updating state',
Errors.toLogFormat(error)
);
}
}
// For adhoc calls, conversationId will be a roomId
drop(
updateCallHistoryOnLocalChanged(
conversationId,
convertJoinState(localDeviceState.joinState),
peekInfo
)
);
if (localDeviceState.connectionState === ConnectionState.NotConnected) {
// NOTE: This assumes that only one call is active at a time. For example, if
@ -1040,14 +1024,11 @@ export class CallingClass {
if (
updateMessageState === GroupCallUpdateMessageState.SentJoin &&
peekInfo?.eraId != null
eraId
) {
updateMessageState = GroupCallUpdateMessageState.SentLeft;
if (callMode === CallMode.Group) {
void this.sendGroupCallUpdateMessage(
conversationId,
peekInfo?.eraId
);
drop(this.sendGroupCallUpdateMessage(conversationId, eraId));
}
}
} else {
@ -1060,17 +1041,16 @@ export class CallingClass {
this.videoCapturer.enableCaptureAndSend(groupCall);
}
// Call enters the Joined state, once per call.
// This can also happen in onPeekChanged.
if (
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
localDeviceState.joinState === JoinState.Joined &&
peekInfo?.eraId != null
eraId
) {
updateMessageState = GroupCallUpdateMessageState.SentJoin;
if (callMode === CallMode.Group) {
void this.sendGroupCallUpdateMessage(
conversationId,
peekInfo?.eraId
);
drop(this.sendGroupCallUpdateMessage(conversationId, eraId));
}
}
}
@ -1128,6 +1108,7 @@ export class CallingClass {
onPeekChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState();
const peekInfo = groupCall.getPeekInfo() ?? null;
const { eraId } = peekInfo ?? {};
log.info(
'GroupCall#onPeekChanged',
@ -1135,25 +1116,29 @@ export class CallingClass {
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
if (callMode === CallMode.Group) {
const { eraId } = peekInfo ?? {};
if (
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
localDeviceState.connectionState !== ConnectionState.NotConnected &&
localDeviceState.joinState === JoinState.Joined &&
eraId
) {
updateMessageState = GroupCallUpdateMessageState.SentJoin;
void this.sendGroupCallUpdateMessage(conversationId, eraId);
}
// Call enters the Joined state, once per call.
// This can also happen in onLocalDeviceStateChanged.
if (
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
localDeviceState.connectionState !== ConnectionState.NotConnected &&
localDeviceState.joinState === JoinState.Joined &&
eraId
) {
updateMessageState = GroupCallUpdateMessageState.SentJoin;
void this.updateCallHistoryForGroupCall(
if (callMode === CallMode.Group) {
drop(this.sendGroupCallUpdateMessage(conversationId, eraId));
}
}
// For adhoc calls, conversationId will be a roomId
drop(
updateCallHistoryOnPeek(
conversationId,
convertJoinState(localDeviceState.joinState),
peekInfo
);
}
// TODO: Call history for adhoc calls
)
);
this.syncGroupCallToRedux(conversationId, groupCall, callMode);
},
@ -1381,11 +1366,12 @@ export class CallingClass {
public formatCallLinkStateForRedux(
callLinkState: CallLinkState
): CallLinkStateType {
const { name, restrictions, expiration } = callLinkState;
const { name, restrictions, expiration, revoked } = callLinkState;
return {
name,
restrictions,
expiration: expiration.getTime(),
revoked,
};
}
@ -2641,7 +2627,84 @@ export class CallingClass {
return true;
}
public async updateCallHistoryForGroupCall(
public async updateCallHistoryForAdhocCall(
roomId: string,
joinState: GroupCallJoinState | null,
peekInfo: PeekInfo | null
): Promise<void> {
if (!peekInfo?.eraId) {
return;
}
const callId = getCallIdFromEra(peekInfo.eraId);
try {
// We only log events confirmed joined. If admin approval is required, then
// the call begins in the Pending state and we don't want history for that.
if (joinState !== GroupCallJoinState.Joined) {
return;
}
const callDetails = getCallDetailsForAdhocCall(roomId, callId);
const callEvent = getCallEventDetails(
callDetails,
LocalCallEvent.Accepted,
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged'
);
await updateLocalAdhocCallHistory(callEvent);
} catch (error) {
log.error(
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged: Error updating state',
Errors.toLogFormat(error)
);
}
}
public async updateCallHistoryForGroupCallOnLocalChanged(
conversationId: string,
joinState: GroupCallJoinState | null,
peekInfo: PeekInfo | null
): Promise<void> {
const groupCallMeta = getGroupCallMeta(peekInfo);
if (!groupCallMeta) {
return;
}
try {
const localCallEvent = getLocalCallEventFromJoinState(
joinState,
groupCallMeta
);
if (!localCallEvent) {
return;
}
const conversation = window.ConversationController.get(conversationId);
strictAssert(
conversation != null,
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged: Missing conversation'
);
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromGroupCallMeta(
peerId,
groupCallMeta
);
const callEvent = getCallEventDetails(
callDetails,
localCallEvent,
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged'
);
await updateCallHistoryFromLocalEvent(callEvent, null);
} catch (error) {
log.error(
'CallingClass.updateCallHistoryForGroupCallOnLocalChanged: Error updating state',
Errors.toLogFormat(error)
);
}
}
public async updateCallHistoryForGroupCallOnPeek(
conversationId: string,
joinState: GroupCallJoinState | null,
peekInfo: PeekInfo | null
@ -2658,7 +2721,9 @@ export class CallingClass {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
log.error('maybeNotifyGroupCall(): could not find conversation');
log.error(
'updateCallHistoryForGroupCallOnPeek(): could not find conversation'
);
return;
}
@ -2684,7 +2749,7 @@ export class CallingClass {
const callEvent = getCallEventDetails(
callDetails,
localCallEvent,
'CallingClass.updateCallHistoryForGroupCall'
'CallingClass.updateCallHistoryForGroupCallOnPeek'
);
await updateCallHistoryFromLocalEvent(callEvent, null);
}

View file

@ -30,6 +30,7 @@ import type {
CallHistoryGroup,
CallHistoryPagination,
} from '../types/CallDisposition';
import type { CallLinkType, CallLinkRestrictions } from '../types/CallLink';
export type AdjacentMessagesByConversationOptionsType = Readonly<{
conversationId: string;
@ -696,6 +697,16 @@ export type DataInterface = {
getRecentStaleRingsAndMarkOlderMissed(): Promise<
ReadonlyArray<MaybeStaleCallHistory>
>;
callLinkExists(roomId: string): Promise<boolean>;
getAllCallLinks: () => Promise<ReadonlyArray<CallLinkType>>;
insertCallLink(callLink: CallLinkType): Promise<void>;
updateCallLinkState(
roomId: string,
name: string,
restrictions: CallLinkRestrictions,
expiration: number | null,
revoked: boolean
): Promise<void>;
migrateConversationMessages: (
obsoleteId: string,
currentId: string

View file

@ -166,6 +166,13 @@ import {
GroupCallStatus,
CallType,
} from '../types/CallDisposition';
import {
callLinkExists,
getAllCallLinks,
insertCallLink,
updateCallLinkState,
} from './server/callLinks';
import { CallMode } from '../types/Calling';
type ConversationRow = Readonly<{
json: string;
@ -328,6 +335,10 @@ const dataInterface: ServerInterface = {
hasGroupCallHistoryMessage,
markCallHistoryMissed,
getRecentStaleRingsAndMarkOlderMissed,
callLinkExists,
getAllCallLinks,
insertCallLink,
updateCallLinkState,
migrateConversationMessages,
getMessagesBetween,
getNearbyMessageFromDeletedSet,
@ -439,7 +450,7 @@ type DatabaseQueryCache = Map<string, Statement<Array<unknown>>>;
const statementCache = new WeakMap<Database, DatabaseQueryCache>();
function prepare<T extends Array<unknown> | Record<string, unknown>>(
export function prepare<T extends Array<unknown> | Record<string, unknown>>(
db: Database,
query: string,
{ pluck = false }: { pluck?: boolean } = {}
@ -732,7 +743,7 @@ async function removeIndexedDBFiles(): Promise<void> {
indexedDBPath = undefined;
}
function getReadonlyInstance(): Database {
export function getReadonlyInstance(): Database {
if (!globalReadonlyInstance) {
throw new Error('getReadonlyInstance: globalReadonlyInstance not set!');
}
@ -742,7 +753,7 @@ function getReadonlyInstance(): Database {
const WRITABLE_INSTANCE_MAX_WAIT = 5 * durations.MINUTE;
async function getWritableInstance(): Promise<Database> {
export async function getWritableInstance(): Promise<Database> {
if (pausedWriteQueue) {
const { promise, resolve } = explodePromise<void>();
pausedWriteQueue.push(resolve);
@ -3499,6 +3510,7 @@ const SEEN_STATUS_SEEN = sqlConstant(SeenStatus.Seen);
const CALL_STATUS_MISSED = sqlConstant(DirectCallStatus.Missed);
const CALL_STATUS_DELETED = sqlConstant(DirectCallStatus.Deleted);
const CALL_STATUS_INCOMING = sqlConstant(CallDirection.Incoming);
const CALL_MODE_ADHOC = sqlConstant(CallMode.Adhoc);
const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000);
async function getCallHistoryUnreadCount(): Promise<number> {
@ -3581,6 +3593,7 @@ function getCallHistoryGroupDataSync(
const { limit, offset } = pagination;
const { status, conversationIds } = filter;
// TODO: DESKTOP-6827 Search Calls Tab for adhoc calls
if (conversationIds != null) {
strictAssert(conversationIds.length > 0, "can't filter by empty array");
@ -3632,8 +3645,15 @@ function getCallHistoryGroupDataSync(
const offsetLimit =
limit > 0 ? sqlFragment`LIMIT ${limit} OFFSET ${offset}` : sqlFragment``;
// COUNT(*) OVER(): As a result of GROUP BY in the query (to limit adhoc call history
// to the single latest call), COUNT(*) changes to counting each group's counts rather
// than the total number of rows. Example: Say we have 2 group calls (A and B) and
// 10 adhoc calls on a single link. COUNT(*) ... GROUP BY returns [1, 1, 10]
// corresponding with callId A, callId B, adhoc peerId (the GROUP conditions).
// However we want COUNT(*) to do the normal thing and return total rows
// (so in the example above we want 3). COUNT(*) OVER achieves this.
const projection = isCount
? sqlFragment`COUNT(*) AS count`
? sqlFragment`COUNT(*) OVER() AS count`
: sqlFragment`peerId, ringerId, mode, type, direction, status, timestamp, possibleChildren, inPeriod`;
const [query, params] = sql`
@ -3697,6 +3717,8 @@ function getCallHistoryGroupDataSync(
-- Desktop Constraints:
AND callsHistory.status IS c.status
AND ${filterClause}
-- Skip grouping logic for adhoc calls
AND callsHistory.mode IS NOT ${CALL_MODE_ADHOC}
ORDER BY timestamp DESC
) as possibleChildren,
@ -3731,6 +3753,12 @@ function getCallHistoryGroupDataSync(
FROM callAndGroupInfo
) AS parentCallAndGroupInfo
WHERE parent = parentCallAndGroupInfo.callId
GROUP BY
CASE
-- By spec, limit adhoc call history to the most recent call
WHEN mode IS ${CALL_MODE_ADHOC} THEN peerId
ELSE callId
END
ORDER BY parentCallAndGroupInfo.timestamp DESC
${offsetLimit};
`;

View file

@ -0,0 +1,40 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from '@signalapp/better-sqlite3';
import type { LoggerType } from '../../types/Logging';
import { sql } from '../util';
export const version = 1010;
export function updateToSchemaVersion1010(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 1010) {
return;
}
db.transaction(() => {
const [createTable] = sql`
CREATE TABLE callLinks (
roomId TEXT NOT NULL PRIMARY KEY,
rootKey BLOB NOT NULL,
adminKey BLOB,
name TEXT NOT NULL,
-- Enum which stores CallLinkRestrictions from ringrtc
restrictions INTEGER NOT NULL,
revoked INTEGER NOT NULL,
expiration INTEGER
) STRICT;
`;
db.exec(createTable);
db.pragma('user_version = 1010');
})();
logger.info('updateToSchemaVersion1010: success!');
}

View file

@ -75,10 +75,11 @@ import { updateToSchemaVersion960 } from './960-untag-pni';
import { updateToSchemaVersion970 } from './970-fts5-optimize';
import { updateToSchemaVersion980 } from './980-reaction-timestamp';
import { updateToSchemaVersion990 } from './990-phone-number-sharing';
import { updateToSchemaVersion1000 } from './1000-mark-unread-call-history-messages-as-unseen';
import {
version as MAX_VERSION,
updateToSchemaVersion1000,
} from './1000-mark-unread-call-history-messages-as-unseen';
updateToSchemaVersion1010,
} from './1010-call-links-table';
function updateToSchemaVersion1(
currentVersion: number,
@ -2021,6 +2022,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion980,
updateToSchemaVersion990,
updateToSchemaVersion1000,
updateToSchemaVersion1010,
];
export class DBVersionFromFutureError extends Error {

100
ts/sql/server/callLinks.ts Normal file
View file

@ -0,0 +1,100 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from '@signalapp/better-sqlite3';
import { CallLinkRootKey } from '@signalapp/ringrtc';
import type { CallLinkRestrictions, CallLinkType } from '../../types/CallLink';
import {
callLinkRestrictionsSchema,
callLinkRecordSchema,
} from '../../types/CallLink';
import { callLinkToRecord, callLinkFromRecord } from '../../util/callLinks';
import { getReadonlyInstance, getWritableInstance, prepare } from '../Server';
import { sql } from '../util';
import { strictAssert } from '../../util/assert';
export async function callLinkExists(roomId: string): Promise<boolean> {
const db = getReadonlyInstance();
const [query, params] = sql`
SELECT 1
FROM callLinks
WHERE roomId = ${roomId};
`;
return db.prepare(query).pluck(true).get(params) === 1;
}
export async function getAllCallLinks(): Promise<ReadonlyArray<CallLinkType>> {
const db = getReadonlyInstance();
const [query] = sql`
SELECT * FROM callLinks;
`;
return db
.prepare(query)
.all()
.map(item => callLinkFromRecord(callLinkRecordSchema.parse(item)));
}
function _insertCallLink(db: Database, callLink: CallLinkType): void {
const { roomId, rootKey } = callLink;
assertRoomIdMatchesRootKey(roomId, rootKey);
const data = callLinkToRecord(callLink);
prepare(
db,
`
INSERT INTO callLinks (
roomId,
rootKey,
adminKey,
name,
restrictions,
revoked,
expiration
) VALUES (
$roomId,
$rootKey,
$adminKey,
$name,
$restrictions,
$revoked,
$expiration
)
`
).run(data);
}
export async function insertCallLink(callLink: CallLinkType): Promise<void> {
const db = await getWritableInstance();
_insertCallLink(db, callLink);
}
export async function updateCallLinkState(
roomId: string,
name: string,
restrictions: CallLinkRestrictions,
expiration: number,
revoked: boolean
): Promise<void> {
const db = await getWritableInstance();
const restrictionsValue = callLinkRestrictionsSchema.parse(restrictions);
const [query, params] = sql`
UPDATE callLinks
SET
name = ${name},
restrictions = ${restrictionsValue},
expiration = ${expiration},
revoked = ${revoked ? 1 : 0}
WHERE roomId = ${roomId};
`;
db.prepare(query).run(params);
}
function assertRoomIdMatchesRootKey(roomId: string, rootKey: string): void {
const derivedRoomId = CallLinkRootKey.parse(rootKey)
.deriveRoomId()
.toString('hex');
strictAssert(
roomId === derivedRoomId,
'passed roomId must match roomId derived from root key'
);
}

View file

@ -76,6 +76,7 @@ import type { ShowErrorModalActionType } from './globalModals';
import { SHOW_ERROR_MODAL } from './globalModals';
import { ButtonVariant } from '../../components/Button';
import { getConversationIdForLogging } from '../../util/idForLogging';
import dataInterface from '../../sql/Client';
// State
@ -171,12 +172,14 @@ export type AdhocCallsType = {
export type CallLinkStateType = ReadonlyDeep<{
name: string;
restrictions: CallLinkRestrictions;
expiration: number;
expiration: number | null;
revoked: boolean;
}>;
export type CallLinksByRoomIdStateType = ReadonlyDeep<
CallLinkStateType & {
rootKey: string;
adminKey: string | null;
}
>;
@ -331,6 +334,19 @@ export type StartCallLinkLobbyType = ReadonlyDeep<{
rootKey: string;
}>;
export type StartCallLinkLobbyByRoomIdType = ReadonlyDeep<{
roomId: string;
}>;
type StartCallLinkLobbyThunkActionType = ReadonlyDeep<
ThunkAction<
void,
RootStateType,
unknown,
StartCallLinkLobbyActionType | ShowErrorModalActionType
>
>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
type StartCallingLobbyPayloadType =
| {
@ -513,7 +529,7 @@ const doGroupCallPeek = ({
: null;
try {
await calling.updateCallHistoryForGroupCall(
await calling.updateCallHistoryForGroupCallOnPeek(
conversationId,
joinState,
peekInfo
@ -1717,86 +1733,147 @@ function onOutgoingAudioCallInConversation(
};
}
function startCallLinkLobby({
rootKey,
}: StartCallLinkLobbyType): ThunkAction<
void,
RootStateType,
unknown,
StartCallLinkLobbyActionType | ShowErrorModalActionType
> {
function startCallLinkLobbyByRoomId(
roomId: string
): StartCallLinkLobbyThunkActionType {
return async (dispatch, getState) => {
const state = getState();
const callLink = getOwn(state.calling.callLinks, roomId);
if (state.calling.activeCallState) {
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: i18n('icu:calling__cant-join'),
description: i18n('icu:calling__dialog-already-in-call'),
buttonVariant: ButtonVariant.Primary,
},
});
return;
}
strictAssert(
callLink,
`startCallLinkLobbyByRoomId(${roomId}): call link not found`
);
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
const callLinkState = await calling.readCallLink({ callLinkRootKey });
if (!callLinkState) {
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: i18n('icu:calling__cant-join'),
description: i18n('icu:calling__call-link-connection-issues'),
buttonVariant: ButtonVariant.Primary,
},
});
return;
}
if (callLinkState.revoked || callLinkState.expiration < new Date()) {
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: i18n('icu:calling__cant-join'),
description: i18n('icu:calling__call-link-no-longer-valid'),
buttonVariant: ButtonVariant.Primary,
},
});
return;
}
const roomId = getRoomIdFromRootKey(callLinkRootKey);
const groupCall = getGroupCall(roomId, state.calling, CallMode.Adhoc);
const groupCallDeviceCount =
groupCall?.peekInfo?.deviceCount ||
groupCall?.remoteParticipants.length ||
0;
const callLobbyData = await calling.startCallLinkLobby({
callLinkRootKey,
hasLocalAudio: groupCallDeviceCount < 8,
});
if (!callLobbyData) {
return;
}
dispatch({
type: START_CALL_LINK_LOBBY,
payload: {
...callLobbyData,
callLinkState: calling.formatCallLinkStateForRedux(callLinkState),
callLinkRootKey: rootKey,
conversationId: roomId,
isConversationTooBigToRing: false,
},
});
const { rootKey } = callLink;
await _startCallLinkLobby({ rootKey, dispatch, getState });
};
}
function startCallLinkLobby({
rootKey,
}: StartCallLinkLobbyType): StartCallLinkLobbyThunkActionType {
return async (dispatch, getState) => {
await _startCallLinkLobby({ rootKey, dispatch, getState });
};
}
const _startCallLinkLobby = async ({
rootKey,
dispatch,
getState,
}: {
rootKey: string;
dispatch: ThunkDispatch<
RootStateType,
unknown,
StartCallLinkLobbyActionType | ShowErrorModalActionType
>;
getState: () => RootStateType;
}) => {
const state = getState();
if (state.calling.activeCallState) {
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: i18n('icu:calling__cant-join'),
description: i18n('icu:calling__dialog-already-in-call'),
buttonVariant: ButtonVariant.Primary,
},
});
return;
}
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
const callLinkState = await calling.readCallLink({ callLinkRootKey });
if (!callLinkState) {
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: i18n('icu:calling__cant-join'),
description: i18n('icu:calling__call-link-connection-issues'),
buttonVariant: ButtonVariant.Primary,
},
});
return;
}
if (callLinkState.revoked || callLinkState.expiration < new Date()) {
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: i18n('icu:calling__cant-join'),
description: i18n('icu:calling__call-link-no-longer-valid'),
buttonVariant: ButtonVariant.Primary,
},
});
return;
}
const roomId = getRoomIdFromRootKey(callLinkRootKey);
const formattedCallLinkState =
calling.formatCallLinkStateForRedux(callLinkState);
try {
const { name, restrictions, expiration, revoked } = formattedCallLinkState;
const callLinkExists = await dataInterface.callLinkExists(roomId);
if (callLinkExists) {
await dataInterface.updateCallLinkState(
roomId,
name,
restrictions,
expiration,
revoked
);
log.info('startCallLinkLobby: Updated existing call link', roomId);
} else {
await dataInterface.insertCallLink({
roomId,
rootKey,
adminKey: null,
name,
restrictions,
revoked,
expiration,
});
log.info('startCallLinkLobby: Saved new call link', roomId);
}
} catch (err) {
log.error(
'startCallLinkLobby: Call link DB error',
Errors.toLogFormat(err)
);
}
const groupCall = getGroupCall(roomId, state.calling, CallMode.Adhoc);
const groupCallDeviceCount =
groupCall?.peekInfo?.deviceCount ||
groupCall?.remoteParticipants.length ||
0;
const callLobbyData = await calling.startCallLinkLobby({
callLinkRootKey,
hasLocalAudio: groupCallDeviceCount < 8,
});
if (!callLobbyData) {
return;
}
dispatch({
type: START_CALL_LINK_LOBBY,
payload: {
...callLobbyData,
callLinkState: formattedCallLinkState,
callLinkRootKey: rootKey,
conversationId: roomId,
isConversationTooBigToRing: false,
},
});
};
function startCallingLobby({
conversationId,
isVideoCall,
@ -2010,6 +2087,7 @@ export const actions = {
setRendererCanvas,
startCall,
startCallLinkLobby,
startCallLinkLobbyByRoomId,
startCallingLobby,
switchToPresentationView,
switchFromPresentationView,
@ -2194,6 +2272,7 @@ export function reducer(
[conversationId]: {
...action.payload.callLinkState,
rootKey: action.payload.callLinkRootKey,
adminKey: null,
},
}
: callLinks,
@ -2287,18 +2366,8 @@ export function reducer(
case CallMode.Direct:
return removeConversationFromState(state, activeCall.conversationId);
case CallMode.Group:
case CallMode.Adhoc:
return omit(state, 'activeCallState');
case CallMode.Adhoc: {
// TODO: When call links persist in the DB, we can remove the removal logic here.
log.info(
`Removing active adhoc call with roomId ${activeCall.conversationId}`
);
const { callLinks } = state;
return {
...omit(state, 'activeCallState'),
callLinks: omit(callLinks, activeCall.conversationId),
};
}
default:
throw missingCaseError(activeCall);
}

View file

@ -41,9 +41,11 @@ import { getInteractionMode } from '../services/InteractionMode';
import { makeLookup } from '../util/makeLookup';
import type { CallHistoryDetails } from '../types/CallDisposition';
import type { ThemeType } from '../types/Util';
import type { CallLinkType } from '../types/CallLink';
export function getInitialState({
badges,
callLinks,
callsHistory,
callsHistoryUnreadCount,
stories,
@ -53,6 +55,7 @@ export function getInitialState({
theme,
}: {
badges: BadgesStateType;
callLinks: ReadonlyArray<CallLinkType>;
callsHistory: ReadonlyArray<CallHistoryDetails>;
callsHistoryUnreadCount: number;
stories: Array<StoryDataType>;
@ -95,7 +98,10 @@ export function getInitialState({
callHistoryByCallId: makeLookup(callsHistory, 'callId'),
unreadCount: callsHistoryUnreadCount,
},
calling: calling(),
calling: {
...calling(),
callLinks: makeLookup(callLinks, 'roomId'),
},
composer: composer(),
conversations: {
...conversations(),

View file

@ -12,8 +12,10 @@ import { actionCreators } from './actions';
import { createStore } from './createStore';
import { getInitialState } from './getInitialState';
import type { ThemeType } from '../types/Util';
import type { CallLinkType } from '../types/CallLink';
export function initializeRedux({
callLinks,
callsHistory,
callsHistoryUnreadCount,
initialBadgesState,
@ -23,6 +25,7 @@ export function initializeRedux({
storyDistributionLists,
theme,
}: {
callLinks: ReadonlyArray<CallLinkType>;
callsHistory: ReadonlyArray<CallHistoryDetails>;
callsHistoryUnreadCount: number;
initialBadgesState: BadgesStateType;
@ -34,6 +37,7 @@ export function initializeRedux({
}): void {
const initialState = getInitialState({
badges: initialBadgesState,
callLinks,
callsHistory,
callsHistoryUnreadCount,
mainWindowStats,

View file

@ -85,13 +85,9 @@ export const getCallLinkSelector = createSelector(
return;
}
const { name, restrictions, rootKey, expiration } = callLinkState;
return {
roomId,
name,
restrictions,
rootKey,
expiration,
...callLinkState,
};
}
);

View file

@ -25,7 +25,7 @@ import type { ConversationType } from '../ducks/conversations';
import { SmartConversationDetails } from './ConversationDetails';
import { SmartToastManager } from './ToastManager';
import { useCallingActions } from '../ducks/calling';
import { getActiveCallState } from '../selectors/calling';
import { getActiveCallState, getCallLinkSelector } from '../selectors/calling';
import { useCallHistoryActions } from '../ducks/callHistory';
import { getCallHistoryEdition } from '../selectors/callHistory';
import { getHasPendingUpdate } from '../selectors/updates';
@ -97,6 +97,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
const allConversations = useSelector(getAllConversations);
const regionCode = useSelector(getRegionCode);
const getConversation = useSelector(getConversationSelector);
const getCallLink = useSelector(getCallLinkSelector);
const activeCall = useSelector(getActiveCallState);
const callHistoryEdition = useSelector(getCallHistoryEdition);
@ -108,6 +109,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
const {
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
startCallLinkLobbyByRoomId,
} = useCallingActions();
const {
clearAllCallHistory: clearCallHistory,
@ -167,6 +169,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
getConversation={getConversation}
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
getCallHistoryGroups={getCallHistoryGroups}
getCallLink={getCallLink}
callHistoryEdition={callHistoryEdition}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
@ -182,6 +185,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
renderToastManager={renderToastManager}
regionCode={regionCode}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
startCallLinkLobbyByRoomId={startCallLinkLobbyByRoomId}
/>
);
});

View file

@ -0,0 +1,39 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { callLinkToRecord, callLinkFromRecord } from '../../util/callLinks';
import type { CallLinkType } from '../../types/CallLink';
import { CallLinkRestrictions } from '../../types/CallLink';
import { MONTH } from '../../util/durations/constants';
const CALL_LINK: CallLinkType = {
adminKey: null,
expiration: Date.now() + MONTH,
name: 'Fun Link',
restrictions: CallLinkRestrictions.None,
revoked: false,
roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4',
rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg',
};
const CALL_LINK_WITH_ADMIN_KEY: CallLinkType = {
adminKey: 'xXPI77e6MoVHYREW8iKYmQ==',
expiration: Date.now() + MONTH,
name: 'Fun Link',
restrictions: CallLinkRestrictions.None,
revoked: false,
roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4',
rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg',
};
describe('callLinks', () => {
it('callLinkToRecord() and callLinkFromRecord() can convert to record and back', () => {
[CALL_LINK, CALL_LINK_WITH_ADMIN_KEY].forEach(callLink => {
const record = callLinkToRecord(callLink);
const returnedCallLink = callLinkFromRecord(record);
assert.deepEqual(returnedCallLink, callLink);
});
});
});

View file

@ -12,8 +12,10 @@ import type { ServiceIdString } from '../../types/ServiceId';
import type {
CallHistoryDetails,
CallHistoryGroup,
CallStatus,
} from '../../types/CallDisposition';
import {
AdhocCallStatus,
CallDirection,
CallHistoryFilterStatus,
CallType,
@ -22,8 +24,13 @@ import {
import { strictAssert } from '../../util/assert';
import type { ConversationAttributesType } from '../../model-types';
const { removeAll, getCallHistoryGroups, saveCallHistory, saveConversation } =
dataInterface;
const {
removeAll,
getCallHistoryGroups,
getCallHistoryGroupsCount,
saveCallHistory,
saveConversation,
} = dataInterface;
function toGroup(calls: Array<CallHistoryDetails>): CallHistoryGroup {
const firstCall = calls.at(0);
@ -41,6 +48,19 @@ function toGroup(calls: Array<CallHistoryDetails>): CallHistoryGroup {
};
}
function toAdhocGroup(call: CallHistoryDetails): CallHistoryGroup {
strictAssert(call != null, 'needs call');
return {
peerId: call.peerId,
mode: call.mode,
type: call.type,
direction: call.direction,
timestamp: call.timestamp,
status: call.status,
children: [],
};
}
describe('sql/getCallHistoryGroups', () => {
beforeEach(async () => {
await removeAll();
@ -255,4 +275,158 @@ describe('sql/getCallHistoryGroups', () => {
assert.deepEqual(groups, [toGroup([call])]);
});
it('should support Missed status filter', async () => {
const now = Date.now();
const conversationId = generateUuid();
function toCall(
callId: string,
timestamp: number,
type: CallType,
status: CallStatus
) {
return {
callId,
peerId: conversationId,
ringerId: generateAci(),
mode: CallMode.Direct,
type,
direction: CallDirection.Incoming,
timestamp,
status,
};
}
const call1 = toCall(
'1',
now - 10,
CallType.Video,
DirectCallStatus.Accepted
);
const call2 = toCall('2', now, CallType.Audio, DirectCallStatus.Missed);
await saveCallHistory(call1);
await saveCallHistory(call2);
const groups = await getCallHistoryGroups(
{ status: CallHistoryFilterStatus.Missed, conversationIds: null },
{ offset: 0, limit: 0 }
);
assert.deepEqual(groups, [toGroup([call2])]);
});
it('should only return the newest call for an adhoc call roomId', async () => {
const now = Date.now();
const roomId = generateUuid();
function toCall(callId: string, timestamp: number, type: CallType) {
return {
callId,
peerId: roomId,
ringerId: null,
mode: CallMode.Adhoc,
type,
direction: CallDirection.Outgoing,
timestamp,
status: AdhocCallStatus.Joined,
};
}
const call1 = toCall('1', now - 10, CallType.Group);
const call2 = toCall('2', now, CallType.Group);
await saveCallHistory(call1);
await saveCallHistory(call2);
const groups = await getCallHistoryGroups(
{ status: CallHistoryFilterStatus.All, conversationIds: null },
{ offset: 0, limit: 0 }
);
assert.deepEqual(groups, [toAdhocGroup(call2)]);
});
});
describe('sql/getCallHistoryGroupsCount', () => {
beforeEach(async () => {
await removeAll();
});
it('counts', async () => {
const now = Date.now();
const conversationId = generateUuid();
function toCall(callId: string, timestamp: number, type: CallType) {
return {
callId,
peerId: conversationId,
ringerId: generateAci(),
mode: CallMode.Direct,
type,
direction: CallDirection.Incoming,
timestamp,
status: DirectCallStatus.Accepted,
};
}
const call1 = toCall('1', now - 30, CallType.Video);
const call2 = toCall('2', now - 20, CallType.Video);
const call3 = toCall('3', now - 10, CallType.Audio);
const call4 = toCall('4', now, CallType.Video);
await saveCallHistory(call1);
await saveCallHistory(call2);
await saveCallHistory(call3);
await saveCallHistory(call4);
const result = await getCallHistoryGroupsCount({
status: CallHistoryFilterStatus.All,
conversationIds: null,
});
assert.equal(result, 3);
});
it('should only count each call link roomId once if it had multiple calls', async () => {
const now = Date.now();
const roomId1 = generateUuid();
const roomId2 = generateUuid();
function toCall(
callId: string,
roomId: string,
timestamp: number,
type: CallType
) {
return {
callId,
peerId: roomId,
ringerId: null,
mode: CallMode.Adhoc,
type,
direction: CallDirection.Outgoing,
timestamp,
status: AdhocCallStatus.Joined,
};
}
const call1 = toCall('1', roomId1, now - 20, CallType.Group);
const call2 = toCall('2', roomId1, now - 10, CallType.Group);
const call3 = toCall('3', roomId1, now, CallType.Group);
const call4 = toCall('4', roomId2, now, CallType.Group);
await saveCallHistory(call1);
await saveCallHistory(call2);
await saveCallHistory(call3);
await saveCallHistory(call4);
const result = await getCallHistoryGroupsCount({
status: CallHistoryFilterStatus.All,
conversationIds: null,
});
assert.equal(result, 2);
});
});

View file

@ -1314,10 +1314,11 @@ describe('calling duck', () => {
callingService,
'peekGroupCall'
);
this.callingServiceUpdateCallHistoryForGroupCall = this.sandbox.stub(
callingService,
'updateCallHistoryForGroupCall'
);
this.callingServiceUpdateCallHistoryForGroupCallOnPeek =
this.sandbox.stub(
callingService,
'updateCallHistoryForGroupCallOnPeek'
);
this.clock = this.sandbox.useFakeTimers();
});

View file

@ -0,0 +1,80 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import type { PeekInfo } from '@signalapp/ringrtc';
import uuid from 'uuid';
import {
getPeerIdFromConversation,
getCallIdFromEra,
getGroupCallMeta,
} from '../../util/callDisposition';
import {
getDefaultConversation,
getDefaultGroup,
} from '../../test-both/helpers/getDefaultConversation';
import { uuidToBytes } from '../../util/uuidToBytes';
const MOCK_ERA = 'abc';
const MOCK_CALL_ID = '16919744041952114874';
const MOCK_PEEK_INFO_BASE: PeekInfo = {
devices: [],
deviceCount: 0,
deviceCountIncludingPendingDevices: 0,
deviceCountExcludingPendingDevices: 0,
pendingUsers: [],
};
describe('utils/callDisposition', () => {
describe('getCallIdFromEra', () => {
it('returns callId from era', () => {
// just to ensure the mock is correct
assert.strictEqual(getCallIdFromEra(MOCK_ERA), MOCK_CALL_ID);
});
});
describe('getGroupCallMeta', () => {
it('returns null if missing eraId or creator', () => {
assert.isNull(getGroupCallMeta({ ...MOCK_PEEK_INFO_BASE }));
assert.isNull(
getGroupCallMeta({ ...MOCK_PEEK_INFO_BASE, eraId: MOCK_ERA })
);
assert.isNull(
getGroupCallMeta({
...MOCK_PEEK_INFO_BASE,
creator: Buffer.from(uuidToBytes(uuid())),
})
);
});
it('returns group call meta when all fields are provided', () => {
const id = uuid();
assert.deepStrictEqual(
getGroupCallMeta({
...MOCK_PEEK_INFO_BASE,
eraId: MOCK_ERA,
creator: Buffer.from(uuidToBytes(id)),
}),
{ callId: MOCK_CALL_ID, ringerId: id }
);
});
});
describe('getPeerIdFromConversation', () => {
it('returns serviceId for direct conversation', () => {
const conversation = getDefaultConversation();
assert.strictEqual(
getPeerIdFromConversation(conversation),
conversation.serviceId
);
});
it('returns groupId for group conversation', () => {
const conversation = getDefaultGroup();
assert.strictEqual(
getPeerIdFromConversation(conversation),
conversation.groupId
);
});
});
});

View file

@ -65,7 +65,13 @@ export enum GroupCallStatus {
Deleted = DirectCallStatus.Deleted,
}
export type CallStatus = DirectCallStatus | GroupCallStatus;
export enum AdhocCallStatus {
Pending = DirectCallStatus.Pending,
Joined = GroupCallStatus.Joined,
Deleted = DirectCallStatus.Deleted,
}
export type CallStatus = DirectCallStatus | GroupCallStatus | AdhocCallStatus;
export type CallDetails = Readonly<{
callId: string;
@ -133,6 +139,7 @@ const callEventSchema = z.union([
const callStatusSchema = z.union([
z.nativeEnum(DirectCallStatus),
z.nativeEnum(GroupCallStatus),
z.nativeEnum(AdhocCallStatus),
]);
export const callDetailsSchema = z.object({

View file

@ -2,16 +2,11 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReadonlyDeep } from 'type-fest';
import { z } from 'zod';
import type { CallLinkRestrictions as RingRTCCallLinkRestrictions } from '@signalapp/ringrtc';
import type { ConversationType } from '../state/ducks/conversations';
export type CallLinkConversationType = ReadonlyDeep<
Omit<ConversationType, 'type'> & {
type: 'callLink';
storySendMode?: undefined;
acknowledgedGroupNameCollisions?: undefined;
}
>;
/**
* Restrictions
*/
// Must match `CallLinkRestrictions` in @signalapp/ringrtc
export enum CallLinkRestrictions {
@ -20,14 +15,56 @@ export enum CallLinkRestrictions {
Unknown = 2,
}
export const callLinkRestrictionsSchema = z.nativeEnum(
CallLinkRestrictions
) satisfies z.ZodType<RingRTCCallLinkRestrictions>;
export const callLinkRestrictionsSchema = z.nativeEnum(CallLinkRestrictions);
export function toCallLinkRestrictions(
restrictions: number
): CallLinkRestrictions {
return callLinkRestrictionsSchema.parse(restrictions);
}
/**
* Link
*/
export type CallLinkType = Readonly<{
roomId: string;
rootKey: string;
adminKey: string | null;
name: string;
restrictions: CallLinkRestrictions;
expiration: number;
revoked: boolean;
expiration: number | null;
}>;
// Ephemeral conversation-like type to satisfy components
export type CallLinkConversationType = ReadonlyDeep<
Omit<ConversationType, 'type'> & {
type: 'callLink';
storySendMode?: undefined;
acknowledgedGroupNameCollisions?: undefined;
}
>;
// DB Record
export type CallLinkRecord = Readonly<{
roomId: string;
rootKey: Uint8Array | null;
adminKey: Uint8Array | null;
name: string;
restrictions: number;
expiration: number | null;
revoked: 1 | 0; // sqlite's version of boolean
}>;
export const callLinkRecordSchema = z.object({
roomId: z.string(),
// credentials
rootKey: z.instanceof(Uint8Array).nullable(),
adminKey: z.instanceof(Uint8Array).nullable(),
// state
name: z.string(),
restrictions: callLinkRestrictionsSchema,
expiration: z.number().int(),
revoked: z.union([z.literal(1), z.literal(0)]),
}) satisfies z.ZodType<CallLinkRecord>;

View file

@ -12,6 +12,7 @@ import {
RingUpdate,
} from '@signalapp/ringrtc';
import { v4 as generateGuid } from 'uuid';
import { isEqual } from 'lodash';
import { strictAssert } from './assert';
import { SignalService as Proto } from '../protobuf';
import { bytesToUuid, uuidToBytes } from './uuidToBytes';
@ -57,6 +58,7 @@ import {
RemoteCallEvent,
callHistoryDetailsSchema,
callDetailsSchema,
AdhocCallStatus,
} from '../types/CallDisposition';
import type { ConversationType } from '../state/ducks/conversations';
import type { ConversationModel } from '../models/conversations';
@ -467,6 +469,21 @@ export function getCallDetailsFromGroupCallMeta(
});
}
export function getCallDetailsForAdhocCall(
peerId: AciString | string,
callId: string
): CallDetails {
return callDetailsSchema.parse({
callId,
peerId,
ringerId: null,
mode: CallMode.Adhoc,
type: CallType.Group,
direction: CallDirection.Outgoing,
timestamp: Date.now(),
});
}
// Call Event Details
// ------------------
@ -500,7 +517,7 @@ export function transitionCallHistory(
}
const prevStatus = callHistory?.status ?? null;
let status: DirectCallStatus | GroupCallStatus;
let status: CallStatus;
if (mode === CallMode.Direct) {
status = transitionDirectCallStatus(
@ -515,8 +532,11 @@ export function transitionCallHistory(
direction
);
} else if (mode === CallMode.Adhoc) {
// TODO: DESKTOP-6653
strictAssert(false, 'cannot transitionCallHistory for adhoc calls yet');
status = transitionAdhocCallStatus(
prevStatus as AdhocCallStatus | null,
event,
direction
);
} else {
throw missingCaseError(mode);
}
@ -557,7 +577,8 @@ function transitionTimestamp(
// Deleted call history should never be changed
if (
callHistory.status === DirectCallStatus.Deleted ||
callHistory.status === GroupCallStatus.Deleted
callHistory.status === GroupCallStatus.Deleted ||
callHistory.status === AdhocCallStatus.Deleted
) {
return callHistory.timestamp;
}
@ -567,7 +588,8 @@ function transitionTimestamp(
if (
callHistory.status === DirectCallStatus.Accepted ||
callHistory.status === GroupCallStatus.Accepted ||
callHistory.status === GroupCallStatus.Joined
callHistory.status === GroupCallStatus.Joined ||
callHistory.status === AdhocCallStatus.Joined
) {
if (callEvent.event === RemoteCallEvent.Accepted) {
return latestTimestamp;
@ -595,7 +617,8 @@ function transitionTimestamp(
callHistory.status === GroupCallStatus.OutgoingRing ||
callHistory.status === GroupCallStatus.Ringing ||
callHistory.status === DirectCallStatus.Missed ||
callHistory.status === GroupCallStatus.Missed
callHistory.status === GroupCallStatus.Missed ||
callHistory.status === AdhocCallStatus.Pending
) {
return latestTimestamp;
}
@ -750,6 +773,57 @@ function transitionGroupCallStatus(
throw missingCaseError(event);
}
function transitionAdhocCallStatus(
status: AdhocCallStatus | null,
callEvent: CallEvent,
direction: CallDirection
): AdhocCallStatus {
log.info(
`transitionAdhocCallStatus: status=${status} callEvent=${callEvent} direction=${direction}`
);
// In all cases if we get a delete event, we need to delete the call, and never
// transition from deleted.
if (
callEvent === RemoteCallEvent.Delete ||
callEvent === LocalCallEvent.Delete ||
status === AdhocCallStatus.Deleted
) {
return AdhocCallStatus.Deleted;
}
// The Accepted event is used to indicate we have fully joined an adhoc call.
// If admin approval was required for a call link, this event indicates approval
// was granted.
if (
callEvent === RemoteCallEvent.Accepted ||
callEvent === LocalCallEvent.Accepted
) {
return AdhocCallStatus.Joined;
}
if (status === AdhocCallStatus.Joined) {
return status;
}
// For Adhoc calls, ringing and corresponding events are not supported currently.
// However we handle those events here to be exhaustive.
if (
callEvent === RemoteCallEvent.NotAccepted ||
callEvent === LocalCallEvent.Missed ||
callEvent === LocalCallEvent.Declined ||
callEvent === LocalCallEvent.Hangup ||
callEvent === LocalCallEvent.RemoteHangup ||
callEvent === LocalCallEvent.Started ||
// never actually happens, but need for exhaustive check
callEvent === LocalCallEvent.Ringing
) {
return AdhocCallStatus.Pending;
}
throw missingCaseError(callEvent);
}
// actions
// -------
@ -809,6 +883,70 @@ async function updateLocalCallHistory(
);
}
export async function updateLocalAdhocCallHistory(
callEvent: CallEventDetails
): Promise<CallHistoryDetails | null> {
log.info(
'updateLocalAdhocCallHistory: Processing call event:',
formatCallEvent(callEvent)
);
const prevCallHistory =
(await window.Signal.Data.getCallHistory(
callEvent.callId,
callEvent.peerId
)) ?? null;
if (prevCallHistory != null) {
log.info(
'updateLocalAdhocCallHistory: Found previous call history:',
formatCallHistory(prevCallHistory)
);
} else {
log.info('updateLocalAdhocCallHistory: No previous call history');
}
let callHistory: CallHistoryDetails;
try {
callHistory = transitionCallHistory(prevCallHistory, callEvent);
} catch (error) {
log.error(
"updateLocalAdhocCallHistory: Couldn't transition call history:",
formatCallEvent(callEvent),
Errors.toLogFormat(error)
);
return null;
}
strictAssert(
callHistory.status === AdhocCallStatus.Pending ||
callHistory.status === AdhocCallStatus.Joined ||
callHistory.status === AdhocCallStatus.Deleted,
`updateLocalAdhocCallHistory: callHistory must have adhoc status (was ${callHistory.status})`
);
const isDeleted = callHistory.status === AdhocCallStatus.Deleted;
if (prevCallHistory != null && isEqual(prevCallHistory, callHistory)) {
log.info(
'updateLocalAdhocCallHistory: Next call history is identical, skipping save'
);
} else {
log.info(
'updateLocalAdhocCallHistory: Saving call history:',
formatCallHistory(callHistory)
);
await window.Signal.Data.saveCallHistory(callHistory);
}
if (isDeleted) {
window.reduxActions.callHistory.removeCallHistory(callHistory.callId);
} else {
window.reduxActions.callHistory.addCallHistory(callHistory);
}
return callHistory;
}
async function saveCallHistory(
callHistory: CallHistoryDetails,
conversation: ConversationModel,

View file

@ -10,7 +10,16 @@ import {
} from './zkgroup';
import { getCheckedCallLinkAuthCredentialsForToday } from '../services/groupCredentialFetcher';
import * as durations from './durations';
import type { CallLinkConversationType, CallLinkType } from '../types/CallLink';
import * as Bytes from '../Bytes';
import type {
CallLinkConversationType,
CallLinkType,
CallLinkRecord,
} from '../types/CallLink';
import {
callLinkRecordSchema,
toCallLinkRestrictions,
} from '../types/CallLink';
import type { LocalizerType } from '../types/Util';
export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string {
@ -71,3 +80,47 @@ export function callLinkToConversation(
badges: [],
};
}
/**
* DB record conversions
*/
export function callLinkToRecord(callLink: CallLinkType): CallLinkRecord {
if (callLink.rootKey == null) {
throw new Error('CallLink.callLinkToRecord: rootKey is null');
}
// rootKey has a RingRTC parsing function, adminKey is just bytes
const rootKey = callLink.rootKey
? CallLinkRootKey.parse(callLink.rootKey).bytes
: null;
const adminKey = callLink.adminKey
? Bytes.fromBase64(callLink.adminKey)
: null;
return callLinkRecordSchema.parse({
roomId: callLink.roomId,
rootKey,
adminKey,
name: callLink.name,
restrictions: callLink.restrictions,
revoked: callLink.revoked ? 1 : 0,
expiration: callLink.expiration,
});
}
export function callLinkFromRecord(record: CallLinkRecord): CallLinkType {
// root keys in memory are strings for simplicity
const rootKey = CallLinkRootKey.fromBytes(
record.rootKey as Buffer
).toString();
const adminKey = record.adminKey ? Bytes.toBase64(record.adminKey) : null;
return {
roomId: record.roomId,
rootKey,
adminKey,
name: record.name,
restrictions: toCallLinkRestrictions(record.restrictions),
revoked: record.revoked === 1,
expiration: record.expiration,
};
}

View file

@ -129,7 +129,6 @@ export function getCallingNotificationText(
return getGroupCallNotificationText(groupCallEnded, callCreator, i18n);
}
if (callHistory.mode === CallMode.Adhoc) {
// TODO: DESKTOP-6653
return null;
}
throw missingCaseError(callHistory.mode);

View file

@ -36,6 +36,7 @@ window.testUtilities = {
await Stickers.load();
initializeRedux({
callLinks: [],
callsHistory: [],
callsHistoryUnreadCount: 0,
initialBadgesState: { byId: {} },