Refactor sender key sends to allow distribution lists
This commit is contained in:
parent
61a6f1b4cf
commit
161b0e5379
10 changed files with 210 additions and 110 deletions
10
ts/groups.ts
10
ts/groups.ts
|
@ -1316,10 +1316,10 @@ export async function modifyGroupV2({
|
||||||
timestamp,
|
timestamp,
|
||||||
profileKey,
|
profileKey,
|
||||||
},
|
},
|
||||||
conversation,
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
contentHint: ContentHint.RESENDABLE,
|
||||||
messageId: undefined,
|
messageId: undefined,
|
||||||
sendOptions,
|
sendOptions,
|
||||||
|
sendTarget: conversation.toSenderKeyTarget(),
|
||||||
sendType: 'groupChange',
|
sendType: 'groupChange',
|
||||||
}),
|
}),
|
||||||
{ messageIds: [], sendType: 'groupChange' }
|
{ messageIds: [], sendType: 'groupChange' }
|
||||||
|
@ -1686,15 +1686,15 @@ export async function createGroupV2({
|
||||||
messageIds: [],
|
messageIds: [],
|
||||||
send: async () =>
|
send: async () =>
|
||||||
window.Signal.Util.sendToGroup({
|
window.Signal.Util.sendToGroup({
|
||||||
|
contentHint: ContentHint.RESENDABLE,
|
||||||
groupSendOptions: {
|
groupSendOptions: {
|
||||||
groupV2: groupV2Info,
|
groupV2: groupV2Info,
|
||||||
timestamp,
|
timestamp,
|
||||||
profileKey,
|
profileKey,
|
||||||
},
|
},
|
||||||
conversation,
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
messageId: undefined,
|
messageId: undefined,
|
||||||
sendOptions,
|
sendOptions,
|
||||||
|
sendTarget: conversation.toSenderKeyTarget(),
|
||||||
sendType: 'groupChange',
|
sendType: 'groupChange',
|
||||||
}),
|
}),
|
||||||
sendType: 'groupChange',
|
sendType: 'groupChange',
|
||||||
|
@ -2213,6 +2213,7 @@ export async function initiateMigrationToGroupV2(
|
||||||
send: async () =>
|
send: async () =>
|
||||||
// Minimal message to notify group members about migration
|
// Minimal message to notify group members about migration
|
||||||
window.Signal.Util.sendToGroup({
|
window.Signal.Util.sendToGroup({
|
||||||
|
contentHint: ContentHint.RESENDABLE,
|
||||||
groupSendOptions: {
|
groupSendOptions: {
|
||||||
groupV2: conversation.getGroupV2Info({
|
groupV2: conversation.getGroupV2Info({
|
||||||
includePendingMembers: true,
|
includePendingMembers: true,
|
||||||
|
@ -2220,10 +2221,9 @@ export async function initiateMigrationToGroupV2(
|
||||||
timestamp,
|
timestamp,
|
||||||
profileKey: ourProfileKey,
|
profileKey: ourProfileKey,
|
||||||
},
|
},
|
||||||
conversation,
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
messageId: undefined,
|
messageId: undefined,
|
||||||
sendOptions,
|
sendOptions,
|
||||||
|
sendTarget: conversation.toSenderKeyTarget(),
|
||||||
sendType: 'groupChange',
|
sendType: 'groupChange',
|
||||||
}),
|
}),
|
||||||
sendType: 'groupChange',
|
sendType: 'groupChange',
|
||||||
|
|
|
@ -214,6 +214,7 @@ export class NormalMessageSendJobQueue extends JobQueue<NormalMessageSendJobData
|
||||||
'normalMessageSendJobQueue',
|
'normalMessageSendJobQueue',
|
||||||
() =>
|
() =>
|
||||||
window.Signal.Util.sendToGroup({
|
window.Signal.Util.sendToGroup({
|
||||||
|
contentHint: ContentHint.RESENDABLE,
|
||||||
groupSendOptions: {
|
groupSendOptions: {
|
||||||
attachments,
|
attachments,
|
||||||
deletedForEveryoneTimestamp,
|
deletedForEveryoneTimestamp,
|
||||||
|
@ -232,10 +233,9 @@ export class NormalMessageSendJobQueue extends JobQueue<NormalMessageSendJobData
|
||||||
timestamp: messageTimestamp,
|
timestamp: messageTimestamp,
|
||||||
mentions,
|
mentions,
|
||||||
},
|
},
|
||||||
conversation,
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
messageId,
|
messageId,
|
||||||
sendOptions,
|
sendOptions,
|
||||||
|
sendTarget: conversation.toSenderKeyTarget(),
|
||||||
sendType: 'message',
|
sendType: 'message',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
@ -201,6 +201,7 @@ export class ReactionJobQueue extends JobQueue<ReactionJobData> {
|
||||||
} else {
|
} else {
|
||||||
log.info('sending group reaction message');
|
log.info('sending group reaction message');
|
||||||
promise = window.Signal.Util.sendToGroup({
|
promise = window.Signal.Util.sendToGroup({
|
||||||
|
contentHint: ContentHint.RESENDABLE,
|
||||||
groupSendOptions: {
|
groupSendOptions: {
|
||||||
groupV1: conversation.getGroupV1Info(
|
groupV1: conversation.getGroupV1Info(
|
||||||
recipientIdentifiersWithoutMe
|
recipientIdentifiersWithoutMe
|
||||||
|
@ -213,10 +214,9 @@ export class ReactionJobQueue extends JobQueue<ReactionJobData> {
|
||||||
expireTimer,
|
expireTimer,
|
||||||
profileKey,
|
profileKey,
|
||||||
},
|
},
|
||||||
conversation,
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
messageId,
|
messageId,
|
||||||
sendOptions,
|
sendOptions,
|
||||||
|
sendTarget: conversation.toSenderKeyTarget(),
|
||||||
sendType: 'reaction',
|
sendType: 'reaction',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
MessageModelCollectionType,
|
MessageModelCollectionType,
|
||||||
QuotedMessageType,
|
QuotedMessageType,
|
||||||
|
SenderKeyInfoType,
|
||||||
VerificationOptions,
|
VerificationOptions,
|
||||||
WhatIsThis,
|
WhatIsThis,
|
||||||
} from '../model-types.d';
|
} from '../model-types.d';
|
||||||
|
@ -102,6 +103,7 @@ import { createIdenticon } from '../util/createIdenticon';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
import { isMessageUnread } from '../util/isMessageUnread';
|
import { isMessageUnread } from '../util/isMessageUnread';
|
||||||
|
import type { SenderKeyTargetType } from '../util/sendToGroup';
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
window.Whisper = window.Whisper || {};
|
window.Whisper = window.Whisper || {};
|
||||||
|
@ -356,6 +358,23 @@ export class ConversationModel extends window.Backbone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toSenderKeyTarget(): SenderKeyTargetType {
|
||||||
|
return {
|
||||||
|
getGroupId: () => this.get('groupId'),
|
||||||
|
getMembers: () => this.getMembers(),
|
||||||
|
hasMember: (id: string) => this.hasMember(id),
|
||||||
|
idForLogging: () => this.idForLogging(),
|
||||||
|
isGroupV2: () => isGroupV2(this.attributes),
|
||||||
|
isValid: () => isGroupV2(this.attributes),
|
||||||
|
|
||||||
|
getSenderKeyInfo: () => this.get('senderKeyInfo'),
|
||||||
|
saveSenderKeyInfo: async (senderKeyInfo: SenderKeyInfoType) => {
|
||||||
|
this.set({ senderKeyInfo });
|
||||||
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
isMemberRequestingToJoin(id: string): boolean {
|
isMemberRequestingToJoin(id: string): boolean {
|
||||||
if (!isGroupV2(this.attributes)) {
|
if (!isGroupV2(this.attributes)) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -1272,11 +1291,11 @@ export class ConversationModel extends window.Backbone
|
||||||
window.Signal.Util.sendContentMessageToGroup({
|
window.Signal.Util.sendContentMessageToGroup({
|
||||||
contentHint: ContentHint.IMPLICIT,
|
contentHint: ContentHint.IMPLICIT,
|
||||||
contentMessage,
|
contentMessage,
|
||||||
conversation: this,
|
|
||||||
messageId: undefined,
|
messageId: undefined,
|
||||||
online: true,
|
online: true,
|
||||||
recipients: groupMembers,
|
recipients: groupMembers,
|
||||||
sendOptions,
|
sendOptions,
|
||||||
|
sendTarget: this.toSenderKeyTarget(),
|
||||||
sendType: 'typing',
|
sendType: 'typing',
|
||||||
timestamp,
|
timestamp,
|
||||||
}),
|
}),
|
||||||
|
@ -3759,6 +3778,7 @@ export class ConversationModel extends window.Backbone
|
||||||
}
|
}
|
||||||
|
|
||||||
return window.Signal.Util.sendToGroup({
|
return window.Signal.Util.sendToGroup({
|
||||||
|
contentHint: ContentHint.RESENDABLE,
|
||||||
groupSendOptions: {
|
groupSendOptions: {
|
||||||
groupV1: this.getGroupV1Info(),
|
groupV1: this.getGroupV1Info(),
|
||||||
groupV2: this.getGroupV2Info(),
|
groupV2: this.getGroupV2Info(),
|
||||||
|
@ -3766,10 +3786,9 @@ export class ConversationModel extends window.Backbone
|
||||||
timestamp,
|
timestamp,
|
||||||
profileKey,
|
profileKey,
|
||||||
},
|
},
|
||||||
conversation: this,
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
messageId,
|
messageId,
|
||||||
sendOptions,
|
sendOptions,
|
||||||
|
sendTarget: this.toSenderKeyTarget(),
|
||||||
sendType: 'deleteForEveryone',
|
sendType: 'deleteForEveryone',
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -949,15 +949,15 @@ export class CallingClass {
|
||||||
send: () =>
|
send: () =>
|
||||||
conversation.queueJob('sendGroupCallUpdateMessage', () =>
|
conversation.queueJob('sendGroupCallUpdateMessage', () =>
|
||||||
window.Signal.Util.sendToGroup({
|
window.Signal.Util.sendToGroup({
|
||||||
|
contentHint: ContentHint.DEFAULT,
|
||||||
groupSendOptions: {
|
groupSendOptions: {
|
||||||
groupCallUpdate: { eraId },
|
groupCallUpdate: { eraId },
|
||||||
groupV2,
|
groupV2,
|
||||||
timestamp,
|
timestamp,
|
||||||
},
|
},
|
||||||
conversation,
|
|
||||||
contentHint: ContentHint.DEFAULT,
|
|
||||||
messageId: undefined,
|
messageId: undefined,
|
||||||
sendOptions,
|
sendOptions,
|
||||||
|
sendTarget: conversation.toSenderKeyTarget(),
|
||||||
sendType: 'callingMessage',
|
sendType: 'callingMessage',
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
@ -1567,11 +1567,11 @@ export class CallingClass {
|
||||||
window.Signal.Util.sendContentMessageToGroup({
|
window.Signal.Util.sendContentMessageToGroup({
|
||||||
contentHint: ContentHint.DEFAULT,
|
contentHint: ContentHint.DEFAULT,
|
||||||
contentMessage,
|
contentMessage,
|
||||||
conversation,
|
|
||||||
isPartialSend: false,
|
isPartialSend: false,
|
||||||
messageId: undefined,
|
messageId: undefined,
|
||||||
recipients: conversation.getRecipients(),
|
recipients: conversation.getRecipients(),
|
||||||
sendOptions: await getSendOptions(conversation.attributes),
|
sendOptions: await getSendOptions(conversation.attributes),
|
||||||
|
sendTarget: conversation.toSenderKeyTarget(),
|
||||||
sendType: 'callingMessage',
|
sendType: 'callingMessage',
|
||||||
timestamp,
|
timestamp,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -288,6 +288,7 @@ const dataInterface: ClientInterface = {
|
||||||
_deleteAllStoryDistributions,
|
_deleteAllStoryDistributions,
|
||||||
createNewStoryDistribution,
|
createNewStoryDistribution,
|
||||||
getAllStoryDistributionsWithMembers,
|
getAllStoryDistributionsWithMembers,
|
||||||
|
modifyStoryDistribution,
|
||||||
modifyStoryDistributionMembers,
|
modifyStoryDistributionMembers,
|
||||||
deleteStoryDistribution,
|
deleteStoryDistribution,
|
||||||
|
|
||||||
|
@ -1660,15 +1661,20 @@ async function _deleteAllStoryDistributions(): Promise<void> {
|
||||||
await channels._deleteAllStoryDistributions();
|
await channels._deleteAllStoryDistributions();
|
||||||
}
|
}
|
||||||
async function createNewStoryDistribution(
|
async function createNewStoryDistribution(
|
||||||
story: StoryDistributionWithMembersType
|
distribution: StoryDistributionWithMembersType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await channels.createNewStoryDistribution(story);
|
await channels.createNewStoryDistribution(distribution);
|
||||||
}
|
}
|
||||||
async function getAllStoryDistributionsWithMembers(): Promise<
|
async function getAllStoryDistributionsWithMembers(): Promise<
|
||||||
Array<StoryDistributionWithMembersType>
|
Array<StoryDistributionWithMembersType>
|
||||||
> {
|
> {
|
||||||
return channels.getAllStoryDistributionsWithMembers();
|
return channels.getAllStoryDistributionsWithMembers();
|
||||||
}
|
}
|
||||||
|
async function modifyStoryDistribution(
|
||||||
|
distribution: StoryDistributionType
|
||||||
|
): Promise<void> {
|
||||||
|
await channels.modifyStoryDistribution(distribution);
|
||||||
|
}
|
||||||
async function modifyStoryDistributionMembers(
|
async function modifyStoryDistributionMembers(
|
||||||
id: string,
|
id: string,
|
||||||
options: {
|
options: {
|
||||||
|
|
|
@ -499,11 +499,12 @@ export type DataInterface = {
|
||||||
>;
|
>;
|
||||||
_deleteAllStoryDistributions(): Promise<void>;
|
_deleteAllStoryDistributions(): Promise<void>;
|
||||||
createNewStoryDistribution(
|
createNewStoryDistribution(
|
||||||
story: StoryDistributionWithMembersType
|
distribution: StoryDistributionWithMembersType
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
getAllStoryDistributionsWithMembers(): Promise<
|
getAllStoryDistributionsWithMembers(): Promise<
|
||||||
Array<StoryDistributionWithMembersType>
|
Array<StoryDistributionWithMembersType>
|
||||||
>;
|
>;
|
||||||
|
modifyStoryDistribution(distribution: StoryDistributionType): Promise<void>;
|
||||||
modifyStoryDistributionMembers(
|
modifyStoryDistributionMembers(
|
||||||
id: string,
|
id: string,
|
||||||
options: {
|
options: {
|
||||||
|
|
|
@ -280,6 +280,7 @@ const dataInterface: ServerInterface = {
|
||||||
_deleteAllStoryDistributions,
|
_deleteAllStoryDistributions,
|
||||||
createNewStoryDistribution,
|
createNewStoryDistribution,
|
||||||
getAllStoryDistributionsWithMembers,
|
getAllStoryDistributionsWithMembers,
|
||||||
|
modifyStoryDistribution,
|
||||||
modifyStoryDistributionMembers,
|
modifyStoryDistributionMembers,
|
||||||
deleteStoryDistribution,
|
deleteStoryDistribution,
|
||||||
|
|
||||||
|
@ -3848,12 +3849,12 @@ async function _deleteAllStoryDistributions(): Promise<void> {
|
||||||
db.prepare<EmptyQuery>('DELETE FROM storyDistributions;').run();
|
db.prepare<EmptyQuery>('DELETE FROM storyDistributions;').run();
|
||||||
}
|
}
|
||||||
async function createNewStoryDistribution(
|
async function createNewStoryDistribution(
|
||||||
story: StoryDistributionWithMembersType
|
distribution: StoryDistributionWithMembersType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
|
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
const payload = freezeStoryDistribution(story);
|
const payload = freezeStoryDistribution(distribution);
|
||||||
|
|
||||||
prepare(
|
prepare(
|
||||||
db,
|
db,
|
||||||
|
@ -3874,7 +3875,7 @@ async function createNewStoryDistribution(
|
||||||
`
|
`
|
||||||
).run(payload);
|
).run(payload);
|
||||||
|
|
||||||
const { id: listId, members } = story;
|
const { id: listId, members } = distribution;
|
||||||
|
|
||||||
const memberInsertStatement = prepare(
|
const memberInsertStatement = prepare(
|
||||||
db,
|
db,
|
||||||
|
@ -3910,6 +3911,24 @@ async function getAllStoryDistributionsWithMembers(): Promise<
|
||||||
members: (byListId[list.id] || []).map(member => member.uuid),
|
members: (byListId[list.id] || []).map(member => member.uuid),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
async function modifyStoryDistribution(
|
||||||
|
distribution: StoryDistributionType
|
||||||
|
): Promise<void> {
|
||||||
|
const payload = freezeStoryDistribution(distribution);
|
||||||
|
const db = getInstance();
|
||||||
|
prepare(
|
||||||
|
db,
|
||||||
|
`
|
||||||
|
UPDATE storyDistributions
|
||||||
|
SET
|
||||||
|
name = $name,
|
||||||
|
avatarUrlPath = $avatarUrlPath,
|
||||||
|
avatarKey = $avatarKey,
|
||||||
|
senderKeyInfoJson = $senderKeyInfoJson
|
||||||
|
WHERE id = $id
|
||||||
|
`
|
||||||
|
).run(payload);
|
||||||
|
}
|
||||||
async function modifyStoryDistributionMembers(
|
async function modifyStoryDistributionMembers(
|
||||||
listId: string,
|
listId: string,
|
||||||
{
|
{
|
||||||
|
|
|
@ -17,6 +17,7 @@ const {
|
||||||
createNewStoryDistribution,
|
createNewStoryDistribution,
|
||||||
deleteStoryDistribution,
|
deleteStoryDistribution,
|
||||||
getAllStoryDistributionsWithMembers,
|
getAllStoryDistributionsWithMembers,
|
||||||
|
modifyStoryDistribution,
|
||||||
modifyStoryDistributionMembers,
|
modifyStoryDistributionMembers,
|
||||||
} = dataInterface;
|
} = dataInterface;
|
||||||
|
|
||||||
|
@ -59,6 +60,55 @@ describe('sql/storyDistribution', () => {
|
||||||
assert.lengthOf(await getAllStoryDistributionsWithMembers(), 0);
|
assert.lengthOf(await getAllStoryDistributionsWithMembers(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates core fields with modifyStoryDistribution', async () => {
|
||||||
|
const UUID_1 = getUuid();
|
||||||
|
const UUID_2 = getUuid();
|
||||||
|
const list: StoryDistributionWithMembersType = {
|
||||||
|
id: getUuid(),
|
||||||
|
name: 'My Story',
|
||||||
|
avatarUrlPath: getUuid(),
|
||||||
|
avatarKey: getRandomBytes(128),
|
||||||
|
members: [UUID_1, UUID_2],
|
||||||
|
senderKeyInfo: {
|
||||||
|
createdAtDate: Date.now(),
|
||||||
|
distributionId: getUuid(),
|
||||||
|
memberDevices: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await createNewStoryDistribution(list);
|
||||||
|
|
||||||
|
assert.lengthOf(await _getAllStoryDistributions(), 1);
|
||||||
|
assert.lengthOf(await _getAllStoryDistributionMembers(), 2);
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...list,
|
||||||
|
name: 'Updated story',
|
||||||
|
avatarKey: getRandomBytes(128),
|
||||||
|
avatarUrlPath: getUuid(),
|
||||||
|
senderKeyInfo: {
|
||||||
|
createdAtDate: Date.now() + 10,
|
||||||
|
distributionId: getUuid(),
|
||||||
|
memberDevices: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
identifier: UUID_1,
|
||||||
|
registrationId: 232,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await modifyStoryDistribution(updated);
|
||||||
|
|
||||||
|
assert.lengthOf(await _getAllStoryDistributions(), 1);
|
||||||
|
assert.lengthOf(await _getAllStoryDistributionMembers(), 2);
|
||||||
|
|
||||||
|
const allHydratedLists = await getAllStoryDistributionsWithMembers();
|
||||||
|
assert.lengthOf(allHydratedLists, 1);
|
||||||
|
assert.deepEqual(allHydratedLists[0], updated);
|
||||||
|
});
|
||||||
|
|
||||||
it('adds and removes with modifyStoryDistributionMembers', async () => {
|
it('adds and removes with modifyStoryDistributionMembers', async () => {
|
||||||
const UUID_1 = getUuid();
|
const UUID_1 = getUuid();
|
||||||
const UUID_2 = getUuid();
|
const UUID_2 = getUuid();
|
||||||
|
|
|
@ -34,7 +34,10 @@ import { IdentityKeys, SenderKeys, Sessions } from '../LibSignalStores';
|
||||||
import type { ConversationModel } from '../models/conversations';
|
import type { ConversationModel } from '../models/conversations';
|
||||||
import type { DeviceType, CallbackResultType } from '../textsecure/Types.d';
|
import type { DeviceType, CallbackResultType } from '../textsecure/Types.d';
|
||||||
import { getKeysForIdentifier } from '../textsecure/getKeysForIdentifier';
|
import { getKeysForIdentifier } from '../textsecure/getKeysForIdentifier';
|
||||||
import type { ConversationAttributesType } from '../model-types.d';
|
import type {
|
||||||
|
ConversationAttributesType,
|
||||||
|
SenderKeyInfoType,
|
||||||
|
} from '../model-types.d';
|
||||||
import type { SendTypesType } from './handleMessageSend';
|
import type { SendTypesType } from './handleMessageSend';
|
||||||
import {
|
import {
|
||||||
handleMessageSend,
|
handleMessageSend,
|
||||||
|
@ -51,7 +54,6 @@ import { SignalService as Proto } from '../protobuf';
|
||||||
import * as RemoteConfig from '../RemoteConfig';
|
import * as RemoteConfig from '../RemoteConfig';
|
||||||
|
|
||||||
import { strictAssert } from './assert';
|
import { strictAssert } from './assert';
|
||||||
import { isGroupV2 } from './whatTypeOfConversation';
|
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
|
|
||||||
const ERROR_EXPIRED_OR_MISSING_DEVICES = 409;
|
const ERROR_EXPIRED_OR_MISSING_DEVICES = 409;
|
||||||
|
@ -70,21 +72,33 @@ const ZERO_ACCESS_KEY = Bytes.toBase64(new Uint8Array(ACCESS_KEY_LENGTH));
|
||||||
|
|
||||||
// Public API:
|
// Public API:
|
||||||
|
|
||||||
|
export type SenderKeyTargetType = {
|
||||||
|
getGroupId: () => string | undefined;
|
||||||
|
getMembers: () => Array<ConversationModel>;
|
||||||
|
hasMember: (id: string) => boolean;
|
||||||
|
idForLogging: () => string;
|
||||||
|
isGroupV2: () => boolean;
|
||||||
|
isValid: () => boolean;
|
||||||
|
|
||||||
|
getSenderKeyInfo: () => SenderKeyInfoType | undefined;
|
||||||
|
saveSenderKeyInfo: (senderKeyInfo: SenderKeyInfoType) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export async function sendToGroup({
|
export async function sendToGroup({
|
||||||
contentHint,
|
contentHint,
|
||||||
conversation,
|
|
||||||
groupSendOptions,
|
groupSendOptions,
|
||||||
messageId,
|
|
||||||
isPartialSend,
|
isPartialSend,
|
||||||
|
messageId,
|
||||||
sendOptions,
|
sendOptions,
|
||||||
|
sendTarget,
|
||||||
sendType,
|
sendType,
|
||||||
}: {
|
}: {
|
||||||
contentHint: number;
|
contentHint: number;
|
||||||
conversation: ConversationModel;
|
|
||||||
groupSendOptions: GroupSendOptionsType;
|
groupSendOptions: GroupSendOptionsType;
|
||||||
isPartialSend?: boolean;
|
isPartialSend?: boolean;
|
||||||
messageId: string | undefined;
|
messageId: string | undefined;
|
||||||
sendOptions?: SendOptionsType;
|
sendOptions?: SendOptionsType;
|
||||||
|
sendTarget: SenderKeyTargetType;
|
||||||
sendType: SendTypesType;
|
sendType: SendTypesType;
|
||||||
}): Promise<CallbackResultType> {
|
}): Promise<CallbackResultType> {
|
||||||
strictAssert(
|
strictAssert(
|
||||||
|
@ -105,11 +119,11 @@ export async function sendToGroup({
|
||||||
return sendContentMessageToGroup({
|
return sendContentMessageToGroup({
|
||||||
contentHint,
|
contentHint,
|
||||||
contentMessage,
|
contentMessage,
|
||||||
conversation,
|
|
||||||
isPartialSend,
|
isPartialSend,
|
||||||
messageId,
|
messageId,
|
||||||
recipients,
|
recipients,
|
||||||
sendOptions,
|
sendOptions,
|
||||||
|
sendTarget,
|
||||||
sendType,
|
sendType,
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
|
@ -118,27 +132,27 @@ export async function sendToGroup({
|
||||||
export async function sendContentMessageToGroup({
|
export async function sendContentMessageToGroup({
|
||||||
contentHint,
|
contentHint,
|
||||||
contentMessage,
|
contentMessage,
|
||||||
conversation,
|
|
||||||
isPartialSend,
|
isPartialSend,
|
||||||
messageId,
|
messageId,
|
||||||
online,
|
online,
|
||||||
recipients,
|
recipients,
|
||||||
sendOptions,
|
sendOptions,
|
||||||
|
sendTarget,
|
||||||
sendType,
|
sendType,
|
||||||
timestamp,
|
timestamp,
|
||||||
}: {
|
}: {
|
||||||
contentHint: number;
|
contentHint: number;
|
||||||
contentMessage: Proto.Content;
|
contentMessage: Proto.Content;
|
||||||
conversation: ConversationModel;
|
|
||||||
isPartialSend?: boolean;
|
isPartialSend?: boolean;
|
||||||
messageId: string | undefined;
|
messageId: string | undefined;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
recipients: Array<string>;
|
recipients: Array<string>;
|
||||||
sendOptions?: SendOptionsType;
|
sendOptions?: SendOptionsType;
|
||||||
|
sendTarget: SenderKeyTargetType;
|
||||||
sendType: SendTypesType;
|
sendType: SendTypesType;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}): Promise<CallbackResultType> {
|
}): Promise<CallbackResultType> {
|
||||||
const logId = conversation.idForLogging();
|
const logId = sendTarget.idForLogging();
|
||||||
strictAssert(
|
strictAssert(
|
||||||
window.textsecure.messaging,
|
window.textsecure.messaging,
|
||||||
'sendContentMessageToGroup: textsecure.messaging not available!'
|
'sendContentMessageToGroup: textsecure.messaging not available!'
|
||||||
|
@ -152,19 +166,19 @@ export async function sendContentMessageToGroup({
|
||||||
isEnabled('desktop.sendSenderKey3') &&
|
isEnabled('desktop.sendSenderKey3') &&
|
||||||
ourConversation?.get('capabilities')?.senderKey &&
|
ourConversation?.get('capabilities')?.senderKey &&
|
||||||
RemoteConfig.isEnabled('desktop.senderKey.send') &&
|
RemoteConfig.isEnabled('desktop.senderKey.send') &&
|
||||||
isGroupV2(conversation.attributes)
|
sendTarget.isValid()
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
return await sendToGroupViaSenderKey({
|
return await sendToGroupViaSenderKey({
|
||||||
contentHint,
|
contentHint,
|
||||||
contentMessage,
|
contentMessage,
|
||||||
conversation,
|
|
||||||
isPartialSend,
|
isPartialSend,
|
||||||
messageId,
|
messageId,
|
||||||
online,
|
online,
|
||||||
recipients,
|
recipients,
|
||||||
recursionCount: 0,
|
recursionCount: 0,
|
||||||
sendOptions,
|
sendOptions,
|
||||||
|
sendTarget,
|
||||||
sendType,
|
sendType,
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
|
@ -194,9 +208,7 @@ export async function sendContentMessageToGroup({
|
||||||
sendType,
|
sendType,
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
const groupId = isGroupV2(conversation.attributes)
|
const groupId = sendTarget.isGroupV2() ? sendTarget.getGroupId() : undefined;
|
||||||
? conversation.get('groupId')
|
|
||||||
: undefined;
|
|
||||||
return window.textsecure.messaging.sendGroupProto({
|
return window.textsecure.messaging.sendGroupProto({
|
||||||
contentHint,
|
contentHint,
|
||||||
groupId,
|
groupId,
|
||||||
|
@ -213,32 +225,32 @@ export async function sendContentMessageToGroup({
|
||||||
export async function sendToGroupViaSenderKey(options: {
|
export async function sendToGroupViaSenderKey(options: {
|
||||||
contentHint: number;
|
contentHint: number;
|
||||||
contentMessage: Proto.Content;
|
contentMessage: Proto.Content;
|
||||||
conversation: ConversationModel;
|
|
||||||
isPartialSend?: boolean;
|
isPartialSend?: boolean;
|
||||||
messageId: string | undefined;
|
messageId: string | undefined;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
recipients: Array<string>;
|
recipients: Array<string>;
|
||||||
recursionCount: number;
|
recursionCount: number;
|
||||||
sendOptions?: SendOptionsType;
|
sendOptions?: SendOptionsType;
|
||||||
|
sendTarget: SenderKeyTargetType;
|
||||||
sendType: SendTypesType;
|
sendType: SendTypesType;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}): Promise<CallbackResultType> {
|
}): Promise<CallbackResultType> {
|
||||||
const {
|
const {
|
||||||
contentHint,
|
contentHint,
|
||||||
contentMessage,
|
contentMessage,
|
||||||
conversation,
|
|
||||||
isPartialSend,
|
isPartialSend,
|
||||||
messageId,
|
messageId,
|
||||||
online,
|
online,
|
||||||
recursionCount,
|
|
||||||
recipients,
|
recipients,
|
||||||
|
recursionCount,
|
||||||
sendOptions,
|
sendOptions,
|
||||||
|
sendTarget,
|
||||||
sendType,
|
sendType,
|
||||||
timestamp,
|
timestamp,
|
||||||
} = options;
|
} = options;
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||||
|
|
||||||
const logId = conversation.idForLogging();
|
const logId = sendTarget.idForLogging();
|
||||||
log.info(
|
log.info(
|
||||||
`sendToGroupViaSenderKey/${logId}: Starting ${timestamp}, recursion count ${recursionCount}...`
|
`sendToGroupViaSenderKey/${logId}: Starting ${timestamp}, recursion count ${recursionCount}...`
|
||||||
);
|
);
|
||||||
|
@ -249,10 +261,10 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupId = conversation.get('groupId');
|
const groupId = sendTarget.getGroupId();
|
||||||
if (!groupId || !isGroupV2(conversation.attributes)) {
|
if (!sendTarget.isValid()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`sendToGroupViaSenderKey/${logId}: Missing groupId or group is not GV2`
|
`sendToGroupViaSenderKey/${logId}: sendTarget is not valid!`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -271,29 +283,40 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
'sendToGroupViaSenderKey: textsecure.messaging not available!'
|
'sendToGroupViaSenderKey: textsecure.messaging not available!'
|
||||||
);
|
);
|
||||||
|
|
||||||
const { attributes }: { attributes: ConversationAttributesType } =
|
|
||||||
conversation;
|
|
||||||
|
|
||||||
// 1. Add sender key info if we have none, or clear out if it's too old
|
// 1. Add sender key info if we have none, or clear out if it's too old
|
||||||
const THIRTY_DAYS = 30 * DAY;
|
const THIRTY_DAYS = 30 * DAY;
|
||||||
if (!attributes.senderKeyInfo) {
|
|
||||||
|
// Note: From here on, generally need to recurse if we change senderKeyInfo
|
||||||
|
const senderKeyInfo = sendTarget.getSenderKeyInfo();
|
||||||
|
|
||||||
|
if (!senderKeyInfo) {
|
||||||
log.info(
|
log.info(
|
||||||
`sendToGroupViaSenderKey/${logId}: Adding initial sender key info`
|
`sendToGroupViaSenderKey/${logId}: Adding initial sender key info`
|
||||||
);
|
);
|
||||||
conversation.set({
|
await sendTarget.saveSenderKeyInfo({
|
||||||
senderKeyInfo: {
|
createdAtDate: Date.now(),
|
||||||
createdAtDate: Date.now(),
|
distributionId: UUID.generate().toString(),
|
||||||
distributionId: UUID.generate().toString(),
|
memberDevices: [],
|
||||||
memberDevices: [],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
window.Signal.Data.updateConversation(attributes);
|
|
||||||
} else if (isOlderThan(attributes.senderKeyInfo.createdAtDate, THIRTY_DAYS)) {
|
// Restart here because we updated senderKeyInfo
|
||||||
const { createdAtDate } = attributes.senderKeyInfo;
|
return sendToGroupViaSenderKey({
|
||||||
|
...options,
|
||||||
|
recursionCount: recursionCount + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isOlderThan(senderKeyInfo.createdAtDate, THIRTY_DAYS)) {
|
||||||
|
const { createdAtDate } = senderKeyInfo;
|
||||||
log.info(
|
log.info(
|
||||||
`sendToGroupViaSenderKey/${logId}: Resetting sender key; ${createdAtDate} is too old`
|
`sendToGroupViaSenderKey/${logId}: Resetting sender key; ${createdAtDate} is too old`
|
||||||
);
|
);
|
||||||
await resetSenderKey(conversation);
|
await resetSenderKey(sendTarget);
|
||||||
|
|
||||||
|
// Restart here because we updated senderKeyInfo
|
||||||
|
return sendToGroupViaSenderKey({
|
||||||
|
...options,
|
||||||
|
recursionCount: recursionCount + 1,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch all devices we believe we'll be sending to
|
// 2. Fetch all devices we believe we'll be sending to
|
||||||
|
@ -320,15 +343,8 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
strictAssert(
|
const { memberDevices, distributionId, createdAtDate } = senderKeyInfo;
|
||||||
attributes.senderKeyInfo,
|
const memberSet = new Set(sendTarget.getMembers());
|
||||||
`sendToGroupViaSenderKey/${logId}: expect senderKeyInfo`
|
|
||||||
);
|
|
||||||
// Note: From here on, we will need to recurse if we change senderKeyInfo
|
|
||||||
const { memberDevices, distributionId, createdAtDate } =
|
|
||||||
attributes.senderKeyInfo;
|
|
||||||
|
|
||||||
const memberSet = new Set(conversation.getMembers());
|
|
||||||
|
|
||||||
// 4. Partition devices into sender key and non-sender key groups
|
// 4. Partition devices into sender key and non-sender key groups
|
||||||
const [devicesForSenderKey, devicesForNormalSend] = partition(
|
const [devicesForSenderKey, devicesForNormalSend] = partition(
|
||||||
|
@ -366,10 +382,10 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
// 7. If members have been removed from the group, we need to reset our sender key, then
|
// 7. If members have been removed from the group, we need to reset our sender key, then
|
||||||
// start over to get a fresh set of target devices.
|
// start over to get a fresh set of target devices.
|
||||||
const keyNeedsReset = Array.from(removedFromMemberUuids).some(
|
const keyNeedsReset = Array.from(removedFromMemberUuids).some(
|
||||||
uuid => !conversation.hasMember(uuid)
|
uuid => !sendTarget.hasMember(uuid)
|
||||||
);
|
);
|
||||||
if (keyNeedsReset) {
|
if (keyNeedsReset) {
|
||||||
await resetSenderKey(conversation);
|
await resetSenderKey(sendTarget);
|
||||||
|
|
||||||
// Restart here to start over; empty memberDevices means we'll send distribution
|
// Restart here to start over; empty memberDevices means we'll send distribution
|
||||||
// message to everyone.
|
// message to everyone.
|
||||||
|
@ -403,14 +419,11 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
// Update memberDevices with new devices
|
// Update memberDevices with new devices
|
||||||
const updatedMemberDevices = [...memberDevices, ...newToMemberDevices];
|
const updatedMemberDevices = [...memberDevices, ...newToMemberDevices];
|
||||||
|
|
||||||
conversation.set({
|
await sendTarget.saveSenderKeyInfo({
|
||||||
senderKeyInfo: {
|
createdAtDate,
|
||||||
createdAtDate,
|
distributionId,
|
||||||
distributionId,
|
memberDevices: updatedMemberDevices,
|
||||||
memberDevices: updatedMemberDevices,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
window.Signal.Data.updateConversation(conversation.attributes);
|
|
||||||
|
|
||||||
// Restart here because we might have discovered new or dropped devices as part of
|
// Restart here because we might have discovered new or dropped devices as part of
|
||||||
// distributing our sender key.
|
// distributing our sender key.
|
||||||
|
@ -430,14 +443,14 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
conversation.set({
|
await sendTarget.saveSenderKeyInfo({
|
||||||
senderKeyInfo: {
|
createdAtDate,
|
||||||
createdAtDate,
|
distributionId,
|
||||||
distributionId,
|
memberDevices: updatedMemberDevices,
|
||||||
memberDevices: updatedMemberDevices,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
window.Signal.Data.updateConversation(conversation.attributes);
|
|
||||||
|
// Note, we do not need to restart here because we don't refer back to senderKeyInfo
|
||||||
|
// after this point.
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Send the Sender Key message!
|
// 10. Send the Sender Key message!
|
||||||
|
@ -513,7 +526,7 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (error.code === ERROR_STALE_DEVICES) {
|
if (error.code === ERROR_STALE_DEVICES) {
|
||||||
await handle410Response(conversation, error);
|
await handle410Response(sendTarget, error);
|
||||||
|
|
||||||
// Restart here to use the right registrationIds for devices we already knew about,
|
// Restart here to use the right registrationIds for devices we already knew about,
|
||||||
// as well as send our sender key to these re-registered or re-linked devices.
|
// as well as send our sender key to these re-registered or re-linked devices.
|
||||||
|
@ -591,7 +604,7 @@ export async function sendToGroupViaSenderKey(options: {
|
||||||
const recipientUuid = sentToConversation.get('uuid');
|
const recipientUuid = sentToConversation.get('uuid');
|
||||||
if (!recipientUuid) {
|
if (!recipientUuid) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`sendToGroupViaSenderKey/callback: Conversation ${conversation.idForLogging()} had no UUID`
|
`sendToGroupViaSenderKey/callback: Conversation ${sentToConversation.idForLogging()} had no UUID`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -730,10 +743,10 @@ async function handle409Response(logId: string, error: HTTPError) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handle410Response(
|
async function handle410Response(
|
||||||
conversation: ConversationModel,
|
sendTarget: SenderKeyTargetType,
|
||||||
error: HTTPError
|
error: HTTPError
|
||||||
) {
|
) {
|
||||||
const logId = conversation.idForLogging();
|
const logId = sendTarget.idForLogging();
|
||||||
|
|
||||||
const parsed = multiRecipient410ResponseSchema.safeParse(error.response);
|
const parsed = multiRecipient410ResponseSchema.safeParse(error.response);
|
||||||
if (parsed.success) {
|
if (parsed.success) {
|
||||||
|
@ -757,21 +770,18 @@ async function handle410Response(
|
||||||
|
|
||||||
// Forget that we've sent our sender key to these devices, since they've
|
// Forget that we've sent our sender key to these devices, since they've
|
||||||
// been re-registered or re-linked.
|
// been re-registered or re-linked.
|
||||||
const senderKeyInfo = conversation.get('senderKeyInfo');
|
const senderKeyInfo = sendTarget.getSenderKeyInfo();
|
||||||
if (senderKeyInfo) {
|
if (senderKeyInfo) {
|
||||||
const devicesToRemove: Array<PartialDeviceType> =
|
const devicesToRemove: Array<PartialDeviceType> =
|
||||||
devices.staleDevices.map(id => ({ id, identifier: uuid }));
|
devices.staleDevices.map(id => ({ id, identifier: uuid }));
|
||||||
conversation.set({
|
await sendTarget.saveSenderKeyInfo({
|
||||||
senderKeyInfo: {
|
...senderKeyInfo,
|
||||||
...senderKeyInfo,
|
memberDevices: differenceWith(
|
||||||
memberDevices: differenceWith(
|
senderKeyInfo.memberDevices,
|
||||||
senderKeyInfo.memberDevices,
|
devicesToRemove,
|
||||||
devicesToRemove,
|
partialDeviceComparator
|
||||||
partialDeviceComparator
|
),
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
window.Signal.Data.updateConversation(conversation.attributes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
@ -836,7 +846,7 @@ async function encryptForSenderKey({
|
||||||
contentMessage: Uint8Array;
|
contentMessage: Uint8Array;
|
||||||
devices: Array<DeviceType>;
|
devices: Array<DeviceType>;
|
||||||
distributionId: string;
|
distributionId: string;
|
||||||
groupId: string;
|
groupId?: string;
|
||||||
}): Promise<Buffer> {
|
}): Promise<Buffer> {
|
||||||
const ourUuid = window.textsecure.storage.user.getCheckedUuid();
|
const ourUuid = window.textsecure.storage.user.getCheckedUuid();
|
||||||
const ourDeviceId = window.textsecure.storage.user.getDeviceId();
|
const ourDeviceId = window.textsecure.storage.user.getDeviceId();
|
||||||
|
@ -860,7 +870,7 @@ async function encryptForSenderKey({
|
||||||
() => groupEncrypt(sender, distributionId, senderKeyStore, message)
|
() => groupEncrypt(sender, distributionId, senderKeyStore, message)
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupIdBuffer = Buffer.from(groupId, 'base64');
|
const groupIdBuffer = groupId ? Buffer.from(groupId, 'base64') : null;
|
||||||
const senderCertificateObject = await senderCertificateService.get(
|
const senderCertificateObject = await senderCertificateService.get(
|
||||||
SenderCertificateMode.WithoutE164
|
SenderCertificateMode.WithoutE164
|
||||||
);
|
);
|
||||||
|
@ -1027,13 +1037,11 @@ function getOurAddress(): Address {
|
||||||
return new Address(ourUuid, ourDeviceId);
|
return new Address(ourUuid, ourDeviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetSenderKey(conversation: ConversationModel): Promise<void> {
|
async function resetSenderKey(sendTarget: SenderKeyTargetType): Promise<void> {
|
||||||
const logId = conversation.idForLogging();
|
const logId = sendTarget.idForLogging();
|
||||||
|
|
||||||
log.info(`resetSenderKey/${logId}: Sender key needs reset. Clearing data...`);
|
log.info(`resetSenderKey/${logId}: Sender key needs reset. Clearing data...`);
|
||||||
const { attributes }: { attributes: ConversationAttributesType } =
|
const senderKeyInfo = sendTarget.getSenderKeyInfo();
|
||||||
conversation;
|
|
||||||
const { senderKeyInfo } = attributes;
|
|
||||||
if (!senderKeyInfo) {
|
if (!senderKeyInfo) {
|
||||||
log.warn(`resetSenderKey/${logId}: No sender key info`);
|
log.warn(`resetSenderKey/${logId}: No sender key info`);
|
||||||
return;
|
return;
|
||||||
|
@ -1043,14 +1051,11 @@ async function resetSenderKey(conversation: ConversationModel): Promise<void> {
|
||||||
const ourAddress = getOurAddress();
|
const ourAddress = getOurAddress();
|
||||||
|
|
||||||
// Note: We preserve existing distributionId to minimize space for sender key storage
|
// Note: We preserve existing distributionId to minimize space for sender key storage
|
||||||
conversation.set({
|
await sendTarget.saveSenderKeyInfo({
|
||||||
senderKeyInfo: {
|
createdAtDate: Date.now(),
|
||||||
createdAtDate: Date.now(),
|
distributionId,
|
||||||
distributionId,
|
memberDevices: [],
|
||||||
memberDevices: [],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
window.Signal.Data.updateConversation(conversation.attributes);
|
|
||||||
|
|
||||||
const ourUuid = window.storage.user.getCheckedUuid();
|
const ourUuid = window.storage.user.getCheckedUuid();
|
||||||
await window.textsecure.storage.protocol.removeSenderKey(
|
await window.textsecure.storage.protocol.removeSenderKey(
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue