Introduce new conversationJobQueue
This commit is contained in:
parent
37d4776472
commit
30783c887c
40 changed files with 3111 additions and 1742 deletions
|
@ -415,20 +415,10 @@
|
||||||
"message": "The following people may have reinstalled or changed devices. Verify your safety number with them to ensure privacy.",
|
"message": "The following people may have reinstalled or changed devices. Verify your safety number with them to ensure privacy.",
|
||||||
"description": "Shown on confirmation dialog when user attempts to send a message"
|
"description": "Shown on confirmation dialog when user attempts to send a message"
|
||||||
},
|
},
|
||||||
"safetyNumberChangeDialog__pending-messages--1": {
|
"safetyNumberChangeDialog__pending-messages": {
|
||||||
"message": "Send pending message",
|
"message": "Send pending messages",
|
||||||
"description": "Shown on confirmation dialog when user attempts to send a message in the outbox"
|
"description": "Shown on confirmation dialog when user attempts to send a message in the outbox"
|
||||||
},
|
},
|
||||||
"safetyNumberChangeDialog__pending-messages--many": {
|
|
||||||
"message": "Send $count$ pending messages",
|
|
||||||
"description": "Shown on confirmation dialog when user attempts to send a message in the outbox",
|
|
||||||
"placeholders": {
|
|
||||||
"count": {
|
|
||||||
"content": "$1",
|
|
||||||
"example": "123"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"identityKeyErrorOnSend": {
|
"identityKeyErrorOnSend": {
|
||||||
"message": "Your safety number with $name1$ has changed. This could either mean that someone is trying to intercept your communication or that $name2$ has simply reinstalled Signal. You may wish to verify your safety number with this contact.",
|
"message": "Your safety number with $name1$ has changed. This could either mean that someone is trying to intercept your communication or that $name2$ has simply reinstalled Signal. You may wish to verify your safety number with this contact.",
|
||||||
"description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change",
|
"description": "Shown when user clicks on a failed recipient in the message detail view after an identity key change",
|
||||||
|
|
|
@ -9,7 +9,6 @@ import * as log from './logging/log';
|
||||||
export type ConfigKeyType =
|
export type ConfigKeyType =
|
||||||
| 'desktop.announcementGroup'
|
| 'desktop.announcementGroup'
|
||||||
| 'desktop.clientExpiration'
|
| 'desktop.clientExpiration'
|
||||||
| 'desktop.disableGV1'
|
|
||||||
| 'desktop.groupCallOutboundRing'
|
| 'desktop.groupCallOutboundRing'
|
||||||
| 'desktop.internalUser'
|
| 'desktop.internalUser'
|
||||||
| 'desktop.mandatoryProfileSharing'
|
| 'desktop.mandatoryProfileSharing'
|
||||||
|
|
|
@ -897,10 +897,12 @@ export async function startApp(): Promise<void> {
|
||||||
const ourUuid = window.textsecure.storage.user.getUuid()?.toString();
|
const ourUuid = window.textsecure.storage.user.getUuid()?.toString();
|
||||||
const ourConversationId =
|
const ourConversationId =
|
||||||
window.ConversationController.getOurConversationId();
|
window.ConversationController.getOurConversationId();
|
||||||
|
const ourDeviceId = window.textsecure.storage.user.getDeviceId();
|
||||||
|
|
||||||
const themeSetting = window.Events.getThemeSetting();
|
const themeSetting = window.Events.getThemeSetting();
|
||||||
const theme = themeSetting === 'system' ? window.systemTheme : themeSetting;
|
const theme = themeSetting === 'system' ? window.systemTheme : themeSetting;
|
||||||
|
|
||||||
|
// TODO: DESKTOP-3125
|
||||||
const initialState = {
|
const initialState = {
|
||||||
badges: initialBadgesState,
|
badges: initialBadgesState,
|
||||||
conversations: {
|
conversations: {
|
||||||
|
@ -923,7 +925,7 @@ export async function startApp(): Promise<void> {
|
||||||
),
|
),
|
||||||
messagesByConversation: {},
|
messagesByConversation: {},
|
||||||
messagesLookup: {},
|
messagesLookup: {},
|
||||||
outboundMessagesPendingConversationVerification: {},
|
verificationDataByConversation: {},
|
||||||
selectedConversationId: undefined,
|
selectedConversationId: undefined,
|
||||||
selectedMessage: undefined,
|
selectedMessage: undefined,
|
||||||
selectedMessageCounter: 0,
|
selectedMessageCounter: 0,
|
||||||
|
@ -942,6 +944,7 @@ export async function startApp(): Promise<void> {
|
||||||
tempPath: window.baseTempPath,
|
tempPath: window.baseTempPath,
|
||||||
regionCode: window.storage.get('regionCode'),
|
regionCode: window.storage.get('regionCode'),
|
||||||
ourConversationId,
|
ourConversationId,
|
||||||
|
ourDeviceId,
|
||||||
ourNumber,
|
ourNumber,
|
||||||
ourUuid,
|
ourUuid,
|
||||||
platform: window.platform,
|
platform: window.platform,
|
||||||
|
|
|
@ -30,13 +30,12 @@ type PropsType = {
|
||||||
|
|
||||||
export const App = ({
|
export const App = ({
|
||||||
appView,
|
appView,
|
||||||
cancelMessagesPendingConversationVerification,
|
cancelConversationVerification,
|
||||||
conversationsStoppingMessageSendBecauseOfVerification,
|
conversationsStoppingSend,
|
||||||
hasInitialLoadCompleted,
|
hasInitialLoadCompleted,
|
||||||
getPreferredBadge,
|
getPreferredBadge,
|
||||||
i18n,
|
i18n,
|
||||||
isCustomizingPreferredReactions,
|
isCustomizingPreferredReactions,
|
||||||
numberOfMessagesPendingBecauseOfVerification,
|
|
||||||
renderCallManager,
|
renderCallManager,
|
||||||
renderCustomizingPreferredReactionsModal,
|
renderCustomizingPreferredReactionsModal,
|
||||||
renderGlobalModalContainer,
|
renderGlobalModalContainer,
|
||||||
|
@ -45,7 +44,7 @@ export const App = ({
|
||||||
requestVerification,
|
requestVerification,
|
||||||
registerSingleDevice,
|
registerSingleDevice,
|
||||||
theme,
|
theme,
|
||||||
verifyConversationsStoppingMessageSend,
|
verifyConversationsStoppingSend,
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
let contents;
|
let contents;
|
||||||
|
|
||||||
|
@ -66,27 +65,18 @@ export const App = ({
|
||||||
} else if (appView === AppViewType.Inbox) {
|
} else if (appView === AppViewType.Inbox) {
|
||||||
contents = (
|
contents = (
|
||||||
<Inbox
|
<Inbox
|
||||||
cancelMessagesPendingConversationVerification={
|
cancelConversationVerification={cancelConversationVerification}
|
||||||
cancelMessagesPendingConversationVerification
|
conversationsStoppingSend={conversationsStoppingSend}
|
||||||
}
|
|
||||||
conversationsStoppingMessageSendBecauseOfVerification={
|
|
||||||
conversationsStoppingMessageSendBecauseOfVerification
|
|
||||||
}
|
|
||||||
hasInitialLoadCompleted={hasInitialLoadCompleted}
|
hasInitialLoadCompleted={hasInitialLoadCompleted}
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
isCustomizingPreferredReactions={isCustomizingPreferredReactions}
|
isCustomizingPreferredReactions={isCustomizingPreferredReactions}
|
||||||
numberOfMessagesPendingBecauseOfVerification={
|
|
||||||
numberOfMessagesPendingBecauseOfVerification
|
|
||||||
}
|
|
||||||
renderCustomizingPreferredReactionsModal={
|
renderCustomizingPreferredReactionsModal={
|
||||||
renderCustomizingPreferredReactionsModal
|
renderCustomizingPreferredReactionsModal
|
||||||
}
|
}
|
||||||
renderSafetyNumber={renderSafetyNumber}
|
renderSafetyNumber={renderSafetyNumber}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
verifyConversationsStoppingMessageSend={
|
verifyConversationsStoppingSend={verifyConversationsStoppingSend}
|
||||||
verifyConversationsStoppingMessageSend
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,31 +20,29 @@ type InboxViewOptionsType = Backbone.ViewOptions & {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
cancelMessagesPendingConversationVerification: () => void;
|
cancelConversationVerification: () => void;
|
||||||
conversationsStoppingMessageSendBecauseOfVerification: Array<ConversationType>;
|
conversationsStoppingSend: Array<ConversationType>;
|
||||||
hasInitialLoadCompleted: boolean;
|
hasInitialLoadCompleted: boolean;
|
||||||
getPreferredBadge: PreferredBadgeSelectorType;
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
isCustomizingPreferredReactions: boolean;
|
isCustomizingPreferredReactions: boolean;
|
||||||
numberOfMessagesPendingBecauseOfVerification: number;
|
|
||||||
renderCustomizingPreferredReactionsModal: () => JSX.Element;
|
renderCustomizingPreferredReactionsModal: () => JSX.Element;
|
||||||
renderSafetyNumber: (props: SafetyNumberProps) => JSX.Element;
|
renderSafetyNumber: (props: SafetyNumberProps) => JSX.Element;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
verifyConversationsStoppingMessageSend: () => void;
|
verifyConversationsStoppingSend: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Inbox = ({
|
export const Inbox = ({
|
||||||
cancelMessagesPendingConversationVerification,
|
cancelConversationVerification,
|
||||||
conversationsStoppingMessageSendBecauseOfVerification,
|
conversationsStoppingSend,
|
||||||
hasInitialLoadCompleted,
|
hasInitialLoadCompleted,
|
||||||
getPreferredBadge,
|
getPreferredBadge,
|
||||||
i18n,
|
i18n,
|
||||||
isCustomizingPreferredReactions,
|
isCustomizingPreferredReactions,
|
||||||
numberOfMessagesPendingBecauseOfVerification,
|
|
||||||
renderCustomizingPreferredReactionsModal,
|
renderCustomizingPreferredReactionsModal,
|
||||||
renderSafetyNumber,
|
renderSafetyNumber,
|
||||||
theme,
|
theme,
|
||||||
verifyConversationsStoppingMessageSend,
|
verifyConversationsStoppingSend,
|
||||||
}: PropsType): JSX.Element => {
|
}: PropsType): JSX.Element => {
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewRef = useRef<InboxViewType | undefined>(undefined);
|
const viewRef = useRef<InboxViewType | undefined>(undefined);
|
||||||
|
@ -76,21 +74,15 @@ export const Inbox = ({
|
||||||
}, [hasInitialLoadCompleted, viewRef]);
|
}, [hasInitialLoadCompleted, viewRef]);
|
||||||
|
|
||||||
let activeModal: ReactNode;
|
let activeModal: ReactNode;
|
||||||
if (conversationsStoppingMessageSendBecauseOfVerification.length) {
|
if (conversationsStoppingSend.length) {
|
||||||
const confirmText: string =
|
|
||||||
numberOfMessagesPendingBecauseOfVerification === 1
|
|
||||||
? i18n('safetyNumberChangeDialog__pending-messages--1')
|
|
||||||
: i18n('safetyNumberChangeDialog__pending-messages--many', [
|
|
||||||
numberOfMessagesPendingBecauseOfVerification.toString(),
|
|
||||||
]);
|
|
||||||
activeModal = (
|
activeModal = (
|
||||||
<SafetyNumberChangeDialog
|
<SafetyNumberChangeDialog
|
||||||
confirmText={confirmText}
|
confirmText={i18n('safetyNumberChangeDialog__pending-messages')}
|
||||||
contacts={conversationsStoppingMessageSendBecauseOfVerification}
|
contacts={conversationsStoppingSend}
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onCancel={cancelMessagesPendingConversationVerification}
|
onCancel={cancelConversationVerification}
|
||||||
onConfirm={verifyConversationsStoppingMessageSend}
|
onConfirm={verifyConversationsStoppingSend}
|
||||||
renderSafetyNumber={renderSafetyNumber}
|
renderSafetyNumber={renderSafetyNumber}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
|
|
238
ts/groups.ts
238
ts/groups.ts
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2020-2021 Signal Messenger, LLC
|
// Copyright 2020-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -59,24 +59,25 @@ import type {
|
||||||
GroupLogResponseType,
|
GroupLogResponseType,
|
||||||
} from './textsecure/WebAPI';
|
} from './textsecure/WebAPI';
|
||||||
import type MessageSender from './textsecure/SendMessage';
|
import type MessageSender from './textsecure/SendMessage';
|
||||||
import type { CallbackResultType } from './textsecure/Types.d';
|
|
||||||
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
|
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
|
||||||
import type { ConversationModel } from './models/conversations';
|
import type { ConversationModel } from './models/conversations';
|
||||||
import { getGroupSizeHardLimit } from './groups/limits';
|
import { getGroupSizeHardLimit } from './groups/limits';
|
||||||
import { ourProfileKeyService } from './services/ourProfileKey';
|
|
||||||
import {
|
import {
|
||||||
isGroupV1 as getIsGroupV1,
|
isGroupV1 as getIsGroupV1,
|
||||||
isGroupV2 as getIsGroupV2,
|
isGroupV2 as getIsGroupV2,
|
||||||
isMe,
|
isMe,
|
||||||
} from './util/whatTypeOfConversation';
|
} from './util/whatTypeOfConversation';
|
||||||
import type { SendTypesType } from './util/handleMessageSend';
|
|
||||||
import { handleMessageSend } from './util/handleMessageSend';
|
|
||||||
import { getSendOptions } from './util/getSendOptions';
|
|
||||||
import * as Bytes from './Bytes';
|
import * as Bytes from './Bytes';
|
||||||
import type { AvatarDataType } from './types/Avatar';
|
import type { AvatarDataType } from './types/Avatar';
|
||||||
import { UUID, isValidUuid } from './types/UUID';
|
import { UUID, isValidUuid } from './types/UUID';
|
||||||
import type { UUIDStringType } from './types/UUID';
|
import type { UUIDStringType } from './types/UUID';
|
||||||
import { SignalService as Proto } from './protobuf';
|
import { SignalService as Proto } from './protobuf';
|
||||||
|
|
||||||
|
import {
|
||||||
|
conversationJobQueue,
|
||||||
|
conversationQueueJobEnum,
|
||||||
|
} from './jobs/conversationJobQueue';
|
||||||
|
|
||||||
import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
|
||||||
|
|
||||||
export { joinViaLink } from './groups/joinViaLink';
|
export { joinViaLink } from './groups/joinViaLink';
|
||||||
|
@ -1234,11 +1235,11 @@ export async function modifyGroupV2({
|
||||||
inviteLinkPassword?: string;
|
inviteLinkPassword?: string;
|
||||||
name: string;
|
name: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const idLog = `${name}/${conversation.idForLogging()}`;
|
const logId = `${name}/${conversation.idForLogging()}`;
|
||||||
|
|
||||||
if (!getIsGroupV2(conversation.attributes)) {
|
if (!getIsGroupV2(conversation.attributes)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`modifyGroupV2/${idLog}: Called for non-GroupV2 conversation`
|
`modifyGroupV2/${logId}: Called for non-GroupV2 conversation`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1248,21 +1249,21 @@ export async function modifyGroupV2({
|
||||||
const MAX_ATTEMPTS = 5;
|
const MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
||||||
log.info(`modifyGroupV2/${idLog}: Starting attempt ${attempt}`);
|
log.info(`modifyGroupV2/${logId}: Starting attempt ${attempt}`);
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await window.waitForEmptyEventQueue();
|
await window.waitForEmptyEventQueue();
|
||||||
|
|
||||||
log.info(`modifyGroupV2/${idLog}: Queuing attempt ${attempt}`);
|
log.info(`modifyGroupV2/${logId}: Queuing attempt ${attempt}`);
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await conversation.queueJob('modifyGroupV2', async () => {
|
await conversation.queueJob('modifyGroupV2', async () => {
|
||||||
log.info(`modifyGroupV2/${idLog}: Running attempt ${attempt}`);
|
log.info(`modifyGroupV2/${logId}: Running attempt ${attempt}`);
|
||||||
|
|
||||||
const actions = await createGroupChange();
|
const actions = await createGroupChange();
|
||||||
if (!actions) {
|
if (!actions) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`modifyGroupV2/${idLog}: No change actions. Returning early.`
|
`modifyGroupV2/${logId}: No change actions. Returning early.`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1274,7 +1275,7 @@ export async function modifyGroupV2({
|
||||||
|
|
||||||
if ((currentRevision || 0) + 1 !== newRevision) {
|
if ((currentRevision || 0) + 1 !== newRevision) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`modifyGroupV2/${idLog}: Revision mismatch - ${currentRevision} to ${newRevision}.`
|
`modifyGroupV2/${logId}: Revision mismatch - ${currentRevision} to ${newRevision}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1297,76 +1298,44 @@ export async function modifyGroupV2({
|
||||||
newRevision,
|
newRevision,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send message to notify group members (including pending members) of change
|
const groupV2Info = conversation.getGroupV2Info({
|
||||||
const profileKey = conversation.get('profileSharing')
|
includePendingMembers: true,
|
||||||
? await ourProfileKeyService.get()
|
extraConversationsForSend,
|
||||||
: undefined;
|
});
|
||||||
|
strictAssert(groupV2Info, 'missing groupV2Info');
|
||||||
|
|
||||||
const sendOptions = await getSendOptions(conversation.attributes);
|
await conversationJobQueue.add({
|
||||||
const timestamp = Date.now();
|
type: conversationQueueJobEnum.enum.GroupUpdate,
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
|
||||||
|
|
||||||
const promise = handleMessageSend(
|
|
||||||
window.Signal.Util.sendToGroup({
|
|
||||||
groupSendOptions: {
|
|
||||||
groupV2: conversation.getGroupV2Info({
|
|
||||||
groupChange: groupChangeBuffer,
|
|
||||||
includePendingMembers: true,
|
|
||||||
extraConversationsForSend,
|
|
||||||
}),
|
|
||||||
timestamp,
|
|
||||||
profileKey,
|
|
||||||
},
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
messageId: undefined,
|
|
||||||
sendOptions,
|
|
||||||
sendTarget: conversation.toSenderKeyTarget(),
|
|
||||||
sendType: 'groupChange',
|
|
||||||
}),
|
|
||||||
{ messageIds: [], sendType: 'groupChange' }
|
|
||||||
);
|
|
||||||
|
|
||||||
// We don't save this message; we just use it to ensure that a sync message is
|
|
||||||
// sent to our linked devices.
|
|
||||||
const m = new window.Whisper.Message({
|
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
type: 'not-to-save',
|
groupChangeBase64: Bytes.toBase64(groupChangeBuffer),
|
||||||
sent_at: timestamp,
|
recipients: groupV2Info.members,
|
||||||
received_at: timestamp,
|
revision: groupV2Info.revision,
|
||||||
// TODO: DESKTOP-722
|
});
|
||||||
// this type does not fully implement the interface it is expected to
|
|
||||||
} as unknown as MessageAttributesType);
|
|
||||||
|
|
||||||
// This is to ensure that the functions in send() and sendSyncMessage()
|
|
||||||
// don't save anything to the database.
|
|
||||||
m.doNotSave = true;
|
|
||||||
|
|
||||||
await m.send(promise);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// If we've gotten here with no error, we exit!
|
// If we've gotten here with no error, we exit!
|
||||||
log.info(
|
log.info(
|
||||||
`modifyGroupV2/${idLog}: Update complete, with attempt ${attempt}!`
|
`modifyGroupV2/${logId}: Update complete, with attempt ${attempt}!`
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 409 && Date.now() <= timeoutTime) {
|
if (error.code === 409 && Date.now() <= timeoutTime) {
|
||||||
log.info(
|
log.info(
|
||||||
`modifyGroupV2/${idLog}: Conflict while updating. Trying again...`
|
`modifyGroupV2/${logId}: Conflict while updating. Trying again...`
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await conversation.fetchLatestGroupV2Data({ force: true });
|
await conversation.fetchLatestGroupV2Data({ force: true });
|
||||||
} else if (error.code === 409) {
|
} else if (error.code === 409) {
|
||||||
log.error(
|
log.error(
|
||||||
`modifyGroupV2/${idLog}: Conflict while updating. Timed out; not retrying.`
|
`modifyGroupV2/${logId}: Conflict while updating. Timed out; not retrying.`
|
||||||
);
|
);
|
||||||
// We don't wait here because we're breaking out of the loop immediately.
|
// We don't wait here because we're breaking out of the loop immediately.
|
||||||
conversation.fetchLatestGroupV2Data({ force: true });
|
conversation.fetchLatestGroupV2Data({ force: true });
|
||||||
throw error;
|
throw error;
|
||||||
} else {
|
} else {
|
||||||
const errorString = error && error.stack ? error.stack : error;
|
const errorString = error && error.stack ? error.stack : error;
|
||||||
log.error(`modifyGroupV2/${idLog}: Error updating: ${errorString}`);
|
log.error(`modifyGroupV2/${logId}: Error updating: ${errorString}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1673,33 +1642,16 @@ export async function createGroupV2({
|
||||||
});
|
});
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const profileKey = await ourProfileKeyService.get();
|
|
||||||
|
|
||||||
const groupV2Info = conversation.getGroupV2Info({
|
const groupV2Info = conversation.getGroupV2Info({
|
||||||
includePendingMembers: true,
|
includePendingMembers: true,
|
||||||
});
|
});
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
strictAssert(groupV2Info, 'missing groupV2Info');
|
||||||
const sendOptions = await getSendOptions(conversation.attributes);
|
|
||||||
|
|
||||||
await wrapWithSyncMessageSend({
|
await conversationJobQueue.add({
|
||||||
conversation,
|
type: conversationQueueJobEnum.enum.GroupUpdate,
|
||||||
logId: `sendToGroup/${logId}`,
|
conversationId: conversation.id,
|
||||||
messageIds: [],
|
recipients: groupV2Info.members,
|
||||||
send: async () =>
|
revision: groupV2Info.revision,
|
||||||
window.Signal.Util.sendToGroup({
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
groupSendOptions: {
|
|
||||||
groupV2: groupV2Info,
|
|
||||||
timestamp,
|
|
||||||
profileKey,
|
|
||||||
},
|
|
||||||
messageId: undefined,
|
|
||||||
sendOptions,
|
|
||||||
sendTarget: conversation.toSenderKeyTarget(),
|
|
||||||
sendType: 'groupChange',
|
|
||||||
}),
|
|
||||||
sendType: 'groupChange',
|
|
||||||
timestamp,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const createdTheGroupMessage: MessageAttributesType = {
|
const createdTheGroupMessage: MessageAttributesType = {
|
||||||
|
@ -2199,119 +2151,17 @@ export async function initiateMigrationToGroupV2(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We've migrated the group, now we need to let all other group members know about it
|
const groupV2Info = conversation.getGroupV2Info({
|
||||||
const logId = conversation.idForLogging();
|
includePendingMembers: true,
|
||||||
const timestamp = Date.now();
|
|
||||||
|
|
||||||
const ourProfileKey = await ourProfileKeyService.get();
|
|
||||||
|
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
|
||||||
const sendOptions = await getSendOptions(conversation.attributes);
|
|
||||||
|
|
||||||
await wrapWithSyncMessageSend({
|
|
||||||
conversation,
|
|
||||||
logId: `sendToGroup/${logId}`,
|
|
||||||
messageIds: [],
|
|
||||||
send: async () =>
|
|
||||||
// Minimal message to notify group members about migration
|
|
||||||
window.Signal.Util.sendToGroup({
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
groupSendOptions: {
|
|
||||||
groupV2: conversation.getGroupV2Info({
|
|
||||||
includePendingMembers: true,
|
|
||||||
}),
|
|
||||||
timestamp,
|
|
||||||
profileKey: ourProfileKey,
|
|
||||||
},
|
|
||||||
messageId: undefined,
|
|
||||||
sendOptions,
|
|
||||||
sendTarget: conversation.toSenderKeyTarget(),
|
|
||||||
sendType: 'groupChange',
|
|
||||||
}),
|
|
||||||
sendType: 'groupChange',
|
|
||||||
timestamp,
|
|
||||||
});
|
});
|
||||||
}
|
strictAssert(groupV2Info, 'missing groupV2Info');
|
||||||
|
|
||||||
export async function wrapWithSyncMessageSend({
|
await conversationJobQueue.add({
|
||||||
conversation,
|
type: conversationQueueJobEnum.enum.GroupUpdate,
|
||||||
logId,
|
conversationId: conversation.id,
|
||||||
messageIds,
|
recipients: groupV2Info.members,
|
||||||
send,
|
revision: groupV2Info.revision,
|
||||||
sendType,
|
});
|
||||||
timestamp,
|
|
||||||
}: {
|
|
||||||
conversation: ConversationModel;
|
|
||||||
logId: string;
|
|
||||||
messageIds: Array<string>;
|
|
||||||
send: (sender: MessageSender) => Promise<CallbackResultType>;
|
|
||||||
sendType: SendTypesType;
|
|
||||||
timestamp: number;
|
|
||||||
}): Promise<void> {
|
|
||||||
const sender = window.textsecure.messaging;
|
|
||||||
if (!sender) {
|
|
||||||
throw new Error(
|
|
||||||
`initiateMigrationToGroupV2/${logId}: textsecure.messaging is not available!`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: CallbackResultType | undefined;
|
|
||||||
try {
|
|
||||||
response = await handleMessageSend(send(sender), { messageIds, sendType });
|
|
||||||
} catch (error) {
|
|
||||||
if (conversation.processSendResponse(error)) {
|
|
||||||
response = error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw new Error(
|
|
||||||
`wrapWithSyncMessageSend/${logId}: message send didn't return result!!`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal implementation of sending same message to linked devices
|
|
||||||
const { dataMessage } = response;
|
|
||||||
if (!dataMessage) {
|
|
||||||
throw new Error(
|
|
||||||
`wrapWithSyncMessageSend/${logId}: dataMessage was not returned by send!`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ourConversationId =
|
|
||||||
window.ConversationController.getOurConversationId();
|
|
||||||
if (!ourConversationId) {
|
|
||||||
throw new Error(
|
|
||||||
`wrapWithSyncMessageSend/${logId}: Cannot get our conversationId!`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ourConversation = window.ConversationController.get(ourConversationId);
|
|
||||||
if (!ourConversation) {
|
|
||||||
throw new Error(
|
|
||||||
`wrapWithSyncMessageSend/${logId}: Cannot get our conversation!`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.ConversationController.areWePrimaryDevice()) {
|
|
||||||
log.warn(
|
|
||||||
`wrapWithSyncMessageSend/${logId}: We are primary device; not sync message`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = await getSendOptions(ourConversation.attributes);
|
|
||||||
await handleMessageSend(
|
|
||||||
sender.sendSyncMessage({
|
|
||||||
destination: ourConversation.get('e164'),
|
|
||||||
destinationUuid: ourConversation.get('uuid'),
|
|
||||||
encodedDataMessage: dataMessage,
|
|
||||||
expirationStartTimestamp: null,
|
|
||||||
options,
|
|
||||||
timestamp,
|
|
||||||
}),
|
|
||||||
{ messageIds, sendType }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitThenRespondToGroupV2Migration(
|
export async function waitThenRespondToGroupV2Migration(
|
||||||
|
|
347
ts/jobs/conversationJobQueue.ts
Normal file
347
ts/jobs/conversationJobQueue.ts
Normal file
|
@ -0,0 +1,347 @@
|
||||||
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type PQueue from 'p-queue';
|
||||||
|
import * as globalLogger from '../logging/log';
|
||||||
|
|
||||||
|
import * as durations from '../util/durations';
|
||||||
|
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
|
||||||
|
import { commonShouldJobContinue } from './helpers/commonShouldJobContinue';
|
||||||
|
import { InMemoryQueues } from './helpers/InMemoryQueues';
|
||||||
|
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
||||||
|
import { JobQueue } from './JobQueue';
|
||||||
|
|
||||||
|
import { sendNormalMessage } from './helpers/sendNormalMessage';
|
||||||
|
import { sendDirectExpirationTimerUpdate } from './helpers/sendDirectExpirationTimerUpdate';
|
||||||
|
import { sendGroupUpdate } from './helpers/sendGroupUpdate';
|
||||||
|
import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone';
|
||||||
|
import { sendProfileKey } from './helpers/sendProfileKey';
|
||||||
|
import { sendReaction } from './helpers/sendReaction';
|
||||||
|
|
||||||
|
import type { LoggerType } from '../types/Logging';
|
||||||
|
import { ConversationVerificationState } from '../state/ducks/conversationsEnums';
|
||||||
|
import { sleep } from '../util/sleep';
|
||||||
|
import { SECOND } from '../util/durations';
|
||||||
|
import {
|
||||||
|
OutgoingIdentityKeyError,
|
||||||
|
SendMessageProtoError,
|
||||||
|
} from '../textsecure/Errors';
|
||||||
|
import { strictAssert } from '../util/assert';
|
||||||
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
|
import { explodePromise } from '../util/explodePromise';
|
||||||
|
|
||||||
|
// Note: generally, we only want to add to this list. If you do need to change one of
|
||||||
|
// these values, you'll likely need to write a database migration.
|
||||||
|
export const conversationQueueJobEnum = z.enum([
|
||||||
|
'DeleteForEveryone',
|
||||||
|
'DirectExpirationTimerUpdate',
|
||||||
|
'GroupUpdate',
|
||||||
|
'NormalMessage',
|
||||||
|
'ProfileKey',
|
||||||
|
'Reaction',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const deleteForEveryoneJobDataSchema = z.object({
|
||||||
|
type: z.literal(conversationQueueJobEnum.enum.DeleteForEveryone),
|
||||||
|
conversationId: z.string(),
|
||||||
|
messageId: z.string(),
|
||||||
|
recipients: z.array(z.string()),
|
||||||
|
revision: z.number().optional(),
|
||||||
|
targetTimestamp: z.number(),
|
||||||
|
});
|
||||||
|
export type DeleteForEveryoneJobData = z.infer<
|
||||||
|
typeof deleteForEveryoneJobDataSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
const expirationTimerUpdateJobDataSchema = z.object({
|
||||||
|
type: z.literal(conversationQueueJobEnum.enum.DirectExpirationTimerUpdate),
|
||||||
|
conversationId: z.string(),
|
||||||
|
expireTimer: z.number().or(z.undefined()),
|
||||||
|
// Note: no recipients/revision, because this job is for 1:1 conversations only!
|
||||||
|
});
|
||||||
|
export type ExpirationTimerUpdateJobData = z.infer<
|
||||||
|
typeof expirationTimerUpdateJobDataSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
const groupUpdateJobDataSchema = z.object({
|
||||||
|
type: z.literal(conversationQueueJobEnum.enum.GroupUpdate),
|
||||||
|
conversationId: z.string(),
|
||||||
|
groupChangeBase64: z.string().optional(),
|
||||||
|
recipients: z.array(z.string()),
|
||||||
|
revision: z.number(),
|
||||||
|
});
|
||||||
|
export type GroupUpdateJobData = z.infer<typeof groupUpdateJobDataSchema>;
|
||||||
|
|
||||||
|
const normalMessageSendJobDataSchema = z.object({
|
||||||
|
type: z.literal(conversationQueueJobEnum.enum.NormalMessage),
|
||||||
|
conversationId: z.string(),
|
||||||
|
messageId: z.string(),
|
||||||
|
// Note: recipients are baked into the message itself
|
||||||
|
revision: z.number().optional(),
|
||||||
|
});
|
||||||
|
export type NormalMessageSendJobData = z.infer<
|
||||||
|
typeof normalMessageSendJobDataSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
const profileKeyJobDataSchema = z.object({
|
||||||
|
type: z.literal(conversationQueueJobEnum.enum.ProfileKey),
|
||||||
|
conversationId: z.string(),
|
||||||
|
// Note: we will use whichever recipients list is up to date when this job runs
|
||||||
|
revision: z.number().optional(),
|
||||||
|
});
|
||||||
|
export type ProfileKeyJobData = z.infer<typeof profileKeyJobDataSchema>;
|
||||||
|
|
||||||
|
const reactionJobDataSchema = z.object({
|
||||||
|
type: z.literal(conversationQueueJobEnum.enum.Reaction),
|
||||||
|
conversationId: z.string(),
|
||||||
|
messageId: z.string(),
|
||||||
|
// Note: recipients are baked into the message itself
|
||||||
|
revision: z.number().optional(),
|
||||||
|
});
|
||||||
|
export type ReactionJobData = z.infer<typeof reactionJobDataSchema>;
|
||||||
|
|
||||||
|
export const conversationQueueJobDataSchema = z.union([
|
||||||
|
deleteForEveryoneJobDataSchema,
|
||||||
|
expirationTimerUpdateJobDataSchema,
|
||||||
|
groupUpdateJobDataSchema,
|
||||||
|
normalMessageSendJobDataSchema,
|
||||||
|
profileKeyJobDataSchema,
|
||||||
|
reactionJobDataSchema,
|
||||||
|
]);
|
||||||
|
export type ConversationQueueJobData = z.infer<
|
||||||
|
typeof conversationQueueJobDataSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ConversationQueueJobBundle = {
|
||||||
|
isFinalAttempt: boolean;
|
||||||
|
shouldContinue: boolean;
|
||||||
|
timeRemaining: number;
|
||||||
|
timestamp: number;
|
||||||
|
log: LoggerType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_RETRY_TIME = durations.DAY;
|
||||||
|
const MAX_ATTEMPTS = exponentialBackoffMaxAttempts(MAX_RETRY_TIME);
|
||||||
|
|
||||||
|
export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
|
||||||
|
private readonly inMemoryQueues = new InMemoryQueues();
|
||||||
|
private readonly verificationWaitMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
resolve: (value: unknown) => unknown;
|
||||||
|
reject: (error: Error) => unknown;
|
||||||
|
promise: Promise<unknown>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
protected parseData(data: unknown): ConversationQueueJobData {
|
||||||
|
return conversationQueueJobDataSchema.parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override getInMemoryQueue({
|
||||||
|
data,
|
||||||
|
}: Readonly<{ data: ConversationQueueJobData }>): PQueue {
|
||||||
|
return this.inMemoryQueues.get(data.conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startVerificationWaiter(conversationId: string): Promise<unknown> {
|
||||||
|
const existing = this.verificationWaitMap.get(conversationId);
|
||||||
|
if (existing) {
|
||||||
|
globalLogger.info(
|
||||||
|
`startVerificationWaiter: Found existing waiter for conversation ${conversationId}. Returning it.`
|
||||||
|
);
|
||||||
|
return existing.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalLogger.info(
|
||||||
|
`startVerificationWaiter: Starting new waiter for conversation ${conversationId}.`
|
||||||
|
);
|
||||||
|
const { resolve, reject, promise } = explodePromise();
|
||||||
|
this.verificationWaitMap.set(conversationId, {
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
promise,
|
||||||
|
});
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
public resolveVerificationWaiter(conversationId: string): void {
|
||||||
|
const existing = this.verificationWaitMap.get(conversationId);
|
||||||
|
if (existing) {
|
||||||
|
globalLogger.info(
|
||||||
|
`resolveVerificationWaiter: Found waiter for conversation ${conversationId}. Resolving.`
|
||||||
|
);
|
||||||
|
existing.resolve('resolveVerificationWaiter: success');
|
||||||
|
this.verificationWaitMap.delete(conversationId);
|
||||||
|
} else {
|
||||||
|
globalLogger.warn(
|
||||||
|
`resolveVerificationWaiter: Missing waiter for conversation ${conversationId}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async run(
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
timestamp,
|
||||||
|
}: Readonly<{ data: ConversationQueueJobData; timestamp: number }>,
|
||||||
|
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
|
||||||
|
): Promise<void> {
|
||||||
|
const { type, conversationId } = data;
|
||||||
|
const isFinalAttempt = attempt >= MAX_ATTEMPTS;
|
||||||
|
|
||||||
|
await window.ConversationController.load();
|
||||||
|
|
||||||
|
const conversation = window.ConversationController.get(conversationId);
|
||||||
|
if (!conversation) {
|
||||||
|
throw new Error(`Failed to find conversation ${conversationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeRemaining: number;
|
||||||
|
let shouldContinue: boolean;
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
log.info('calculating timeRemaining and shouldContinue...');
|
||||||
|
timeRemaining = timestamp + MAX_RETRY_TIME - Date.now();
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
shouldContinue = await commonShouldJobContinue({
|
||||||
|
attempt,
|
||||||
|
log,
|
||||||
|
timeRemaining,
|
||||||
|
});
|
||||||
|
if (!shouldContinue) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationData =
|
||||||
|
window.reduxStore.getState().conversations
|
||||||
|
.verificationDataByConversation[conversationId];
|
||||||
|
|
||||||
|
if (!verificationData) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
verificationData.type ===
|
||||||
|
ConversationVerificationState.PendingVerification
|
||||||
|
) {
|
||||||
|
log.info(
|
||||||
|
'verification is pending for this conversation; waiting at most 30s...'
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await Promise.race([
|
||||||
|
this.startVerificationWaiter(conversation.id),
|
||||||
|
sleep(30 * SECOND),
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
verificationData.type ===
|
||||||
|
ConversationVerificationState.VerificationCancelled
|
||||||
|
) {
|
||||||
|
if (verificationData.canceledAt >= timestamp) {
|
||||||
|
log.info(
|
||||||
|
'cancelling job; user cancelled out of verification dialog.'
|
||||||
|
);
|
||||||
|
shouldContinue = false;
|
||||||
|
} else {
|
||||||
|
log.info(
|
||||||
|
'clearing cancellation tombstone; continuing ahead with job'
|
||||||
|
);
|
||||||
|
window.reduxActions.conversations.clearCancelledConversationVerification(
|
||||||
|
conversation.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw missingCaseError(verificationData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobBundle = {
|
||||||
|
isFinalAttempt,
|
||||||
|
shouldContinue,
|
||||||
|
timeRemaining,
|
||||||
|
timestamp,
|
||||||
|
log,
|
||||||
|
};
|
||||||
|
// Note: A six-letter variable makes below code autoformatting easier to read.
|
||||||
|
const jobSet = conversationQueueJobEnum.enum;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case jobSet.DeleteForEveryone:
|
||||||
|
await sendDeleteForEveryone(conversation, jobBundle, data);
|
||||||
|
break;
|
||||||
|
case jobSet.DirectExpirationTimerUpdate:
|
||||||
|
await sendDirectExpirationTimerUpdate(conversation, jobBundle, data);
|
||||||
|
break;
|
||||||
|
case jobSet.GroupUpdate:
|
||||||
|
await sendGroupUpdate(conversation, jobBundle, data);
|
||||||
|
break;
|
||||||
|
case jobSet.NormalMessage:
|
||||||
|
await sendNormalMessage(conversation, jobBundle, data);
|
||||||
|
break;
|
||||||
|
case jobSet.ProfileKey:
|
||||||
|
await sendProfileKey(conversation, jobBundle, data);
|
||||||
|
break;
|
||||||
|
case jobSet.Reaction:
|
||||||
|
await sendReaction(conversation, jobBundle, data);
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
// Note: This should never happen, because the zod call in parseData wouldn't
|
||||||
|
// accept data that doesn't look like our type specification.
|
||||||
|
const problem: never = type;
|
||||||
|
log.error(
|
||||||
|
`conversationJobQueue: Got job with type ${problem}; Cancelling job.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const untrustedConversationIds: Array<string> = [];
|
||||||
|
if (error instanceof OutgoingIdentityKeyError) {
|
||||||
|
const failedConversation = window.ConversationController.getOrCreate(
|
||||||
|
error.identifier,
|
||||||
|
'private'
|
||||||
|
);
|
||||||
|
strictAssert(failedConversation, 'Conversation should be created');
|
||||||
|
untrustedConversationIds.push(conversation.id);
|
||||||
|
} else if (error instanceof SendMessageProtoError) {
|
||||||
|
(error.errors || []).forEach(innerError => {
|
||||||
|
if (innerError instanceof OutgoingIdentityKeyError) {
|
||||||
|
const failedConversation =
|
||||||
|
window.ConversationController.getOrCreate(
|
||||||
|
innerError.identifier,
|
||||||
|
'private'
|
||||||
|
);
|
||||||
|
strictAssert(failedConversation, 'Conversation should be created');
|
||||||
|
untrustedConversationIds.push(conversation.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (untrustedConversationIds.length) {
|
||||||
|
log.error(
|
||||||
|
`Send failed because ${untrustedConversationIds.length} conversation(s) were untrusted. Adding to verification list.`
|
||||||
|
);
|
||||||
|
window.reduxActions.conversations.conversationStoppedByMissingVerification(
|
||||||
|
{
|
||||||
|
conversationId: conversation.id,
|
||||||
|
untrustedConversationIds,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const conversationJobQueue = new ConversationJobQueue({
|
||||||
|
store: jobQueueDatabaseStore,
|
||||||
|
queueType: 'conversation',
|
||||||
|
maxAttempts: MAX_ATTEMPTS,
|
||||||
|
});
|
20
ts/jobs/helpers/areAllErrorsUnregistered.ts
Normal file
20
ts/jobs/helpers/areAllErrorsUnregistered.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { ConversationAttributesType } from '../../model-types.d';
|
||||||
|
import {
|
||||||
|
SendMessageProtoError,
|
||||||
|
UnregisteredUserError,
|
||||||
|
} from '../../textsecure/Errors';
|
||||||
|
import { isGroup } from '../../util/whatTypeOfConversation';
|
||||||
|
|
||||||
|
export function areAllErrorsUnregistered(
|
||||||
|
conversation: ConversationAttributesType,
|
||||||
|
error: unknown
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
isGroup(conversation) &&
|
||||||
|
error instanceof SendMessageProtoError &&
|
||||||
|
error.errors?.every(item => item instanceof UnregisteredUserError)
|
||||||
|
);
|
||||||
|
}
|
14
ts/jobs/helpers/getUntrustedConversationIds.ts
Normal file
14
ts/jobs/helpers/getUntrustedConversationIds.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export function getUntrustedConversationIds(
|
||||||
|
recipients: ReadonlyArray<string>
|
||||||
|
): Array<string> {
|
||||||
|
return recipients.filter(recipient => {
|
||||||
|
const recipientConversation = window.ConversationController.getOrCreate(
|
||||||
|
recipient,
|
||||||
|
'private'
|
||||||
|
);
|
||||||
|
return recipientConversation.isUntrusted();
|
||||||
|
});
|
||||||
|
}
|
|
@ -7,19 +7,32 @@ import { sleepFor413RetryAfterTime } from './sleepFor413RetryAfterTime';
|
||||||
import { getHttpErrorCode } from './getHttpErrorCode';
|
import { getHttpErrorCode } from './getHttpErrorCode';
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
import { findRetryAfterTimeFromError } from './findRetryAfterTimeFromError';
|
import { findRetryAfterTimeFromError } from './findRetryAfterTimeFromError';
|
||||||
|
import { SendMessageProtoError } from '../../textsecure/Errors';
|
||||||
|
|
||||||
|
export function maybeExpandErrors(error: unknown): ReadonlyArray<unknown> {
|
||||||
|
if (error instanceof SendMessageProtoError) {
|
||||||
|
return error.errors || [error];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [error];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: toThrow is very important to preserve the full error for outer handlers. For
|
||||||
|
// example, the catch handler check for Safety Number Errors in conversationJobQueue.
|
||||||
export async function handleMultipleSendErrors({
|
export async function handleMultipleSendErrors({
|
||||||
errors,
|
errors,
|
||||||
isFinalAttempt,
|
isFinalAttempt,
|
||||||
log,
|
log,
|
||||||
markFailed,
|
markFailed,
|
||||||
timeRemaining,
|
timeRemaining,
|
||||||
|
toThrow,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
errors: ReadonlyArray<unknown>;
|
errors: ReadonlyArray<unknown>;
|
||||||
isFinalAttempt: boolean;
|
isFinalAttempt: boolean;
|
||||||
log: Pick<LoggerType, 'info'>;
|
log: Pick<LoggerType, 'info'>;
|
||||||
markFailed?: (() => void) | (() => Promise<void>);
|
markFailed?: (() => void) | (() => Promise<void>);
|
||||||
timeRemaining: number;
|
timeRemaining: number;
|
||||||
|
toThrow: unknown;
|
||||||
}>): Promise<void> {
|
}>): Promise<void> {
|
||||||
strictAssert(errors.length, 'Expected at least one error');
|
strictAssert(errors.length, 'Expected at least one error');
|
||||||
|
|
||||||
|
@ -66,5 +79,5 @@ export async function handleMultipleSendErrors({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
throw errors[0];
|
throw toThrow;
|
||||||
}
|
}
|
||||||
|
|
149
ts/jobs/helpers/sendDeleteForEveryone.ts
Normal file
149
ts/jobs/helpers/sendDeleteForEveryone.ts
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { isNumber } from 'lodash';
|
||||||
|
|
||||||
|
import { getSendOptions } from '../../util/getSendOptions';
|
||||||
|
import {
|
||||||
|
isDirectConversation,
|
||||||
|
isGroupV2,
|
||||||
|
} from '../../util/whatTypeOfConversation';
|
||||||
|
import { SignalService as Proto } from '../../protobuf';
|
||||||
|
import {
|
||||||
|
handleMultipleSendErrors,
|
||||||
|
maybeExpandErrors,
|
||||||
|
} from './handleMultipleSendErrors';
|
||||||
|
import { ourProfileKeyService } from '../../services/ourProfileKey';
|
||||||
|
import { wrapWithSyncMessageSend } from '../../util/wrapWithSyncMessageSend';
|
||||||
|
|
||||||
|
import type { ConversationModel } from '../../models/conversations';
|
||||||
|
import type {
|
||||||
|
ConversationQueueJobBundle,
|
||||||
|
DeleteForEveryoneJobData,
|
||||||
|
} from '../conversationJobQueue';
|
||||||
|
import { getUntrustedConversationIds } from './getUntrustedConversationIds';
|
||||||
|
|
||||||
|
// Note: because we don't have a recipient map, if some sends fail, we will resend this
|
||||||
|
// message to folks that got it on the first go-round. This is okay, because a delete
|
||||||
|
// for everyone has no effect when applied the second time on a message.
|
||||||
|
export async function sendDeleteForEveryone(
|
||||||
|
conversation: ConversationModel,
|
||||||
|
{
|
||||||
|
isFinalAttempt,
|
||||||
|
shouldContinue,
|
||||||
|
timestamp,
|
||||||
|
timeRemaining,
|
||||||
|
log,
|
||||||
|
}: ConversationQueueJobBundle,
|
||||||
|
data: DeleteForEveryoneJobData
|
||||||
|
): Promise<void> {
|
||||||
|
if (!shouldContinue) {
|
||||||
|
log.info('Ran out of time. Giving up on sending delete for everyone');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messageId, recipients, revision, targetTimestamp } = data;
|
||||||
|
const sendType = 'deleteForEveryone';
|
||||||
|
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||||
|
const contentHint = ContentHint.RESENDABLE;
|
||||||
|
const messageIds = [messageId];
|
||||||
|
|
||||||
|
const logId = `deleteForEveryone/${conversation.idForLogging()}`;
|
||||||
|
|
||||||
|
const untrustedConversationIds = getUntrustedConversationIds(recipients);
|
||||||
|
if (untrustedConversationIds.length) {
|
||||||
|
window.reduxActions.conversations.conversationStoppedByMissingVerification({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
untrustedConversationIds,
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
`Delete for everyone blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Failing this attempt.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conversation.queueJob(
|
||||||
|
'conversationQueue/sendDeleteForEveryone',
|
||||||
|
async () => {
|
||||||
|
log.info(
|
||||||
|
`Sending deleteForEveryone to conversation ${logId}`,
|
||||||
|
`with timestamp ${timestamp}`,
|
||||||
|
`for message ${targetTimestamp}`
|
||||||
|
);
|
||||||
|
|
||||||
|
let profileKey: Uint8Array | undefined;
|
||||||
|
if (conversation.get('profileSharing')) {
|
||||||
|
profileKey = await ourProfileKeyService.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendOptions = await getSendOptions(conversation.attributes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isDirectConversation(conversation.attributes)) {
|
||||||
|
await wrapWithSyncMessageSend({
|
||||||
|
conversation,
|
||||||
|
logId,
|
||||||
|
messageIds,
|
||||||
|
send: async sender =>
|
||||||
|
sender.sendMessageToIdentifier({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
identifier: conversation.getSendTarget()!,
|
||||||
|
messageText: undefined,
|
||||||
|
attachments: [],
|
||||||
|
deletedForEveryoneTimestamp: targetTimestamp,
|
||||||
|
timestamp,
|
||||||
|
expireTimer: undefined,
|
||||||
|
contentHint,
|
||||||
|
groupId: undefined,
|
||||||
|
profileKey,
|
||||||
|
options: sendOptions,
|
||||||
|
}),
|
||||||
|
sendType,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (isGroupV2(conversation.attributes) && !isNumber(revision)) {
|
||||||
|
log.error('No revision provided, but conversation is GroupV2');
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupV2Info = conversation.getGroupV2Info({
|
||||||
|
members: recipients,
|
||||||
|
});
|
||||||
|
if (groupV2Info && isNumber(revision)) {
|
||||||
|
groupV2Info.revision = revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
await wrapWithSyncMessageSend({
|
||||||
|
conversation,
|
||||||
|
logId,
|
||||||
|
messageIds,
|
||||||
|
send: async () =>
|
||||||
|
window.Signal.Util.sendToGroup({
|
||||||
|
contentHint,
|
||||||
|
groupSendOptions: {
|
||||||
|
groupV1: conversation.getGroupV1Info(recipients),
|
||||||
|
groupV2: groupV2Info,
|
||||||
|
deletedForEveryoneTimestamp: targetTimestamp,
|
||||||
|
timestamp,
|
||||||
|
profileKey,
|
||||||
|
},
|
||||||
|
messageId,
|
||||||
|
sendOptions,
|
||||||
|
sendTarget: conversation.toSenderKeyTarget(),
|
||||||
|
sendType: 'deleteForEveryone',
|
||||||
|
}),
|
||||||
|
sendType,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
await handleMultipleSendErrors({
|
||||||
|
errors: maybeExpandErrors(error),
|
||||||
|
isFinalAttempt,
|
||||||
|
log,
|
||||||
|
timeRemaining,
|
||||||
|
toThrow: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
125
ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts
Normal file
125
ts/jobs/helpers/sendDirectExpirationTimerUpdate.ts
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { getSendOptions } from '../../util/getSendOptions';
|
||||||
|
import { isDirectConversation, isMe } from '../../util/whatTypeOfConversation';
|
||||||
|
import { SignalService as Proto } from '../../protobuf';
|
||||||
|
import {
|
||||||
|
handleMultipleSendErrors,
|
||||||
|
maybeExpandErrors,
|
||||||
|
} from './handleMultipleSendErrors';
|
||||||
|
import { wrapWithSyncMessageSend } from '../../util/wrapWithSyncMessageSend';
|
||||||
|
import { ourProfileKeyService } from '../../services/ourProfileKey';
|
||||||
|
|
||||||
|
import type { ConversationModel } from '../../models/conversations';
|
||||||
|
import type {
|
||||||
|
ExpirationTimerUpdateJobData,
|
||||||
|
ConversationQueueJobBundle,
|
||||||
|
} from '../conversationJobQueue';
|
||||||
|
|
||||||
|
export async function sendDirectExpirationTimerUpdate(
|
||||||
|
conversation: ConversationModel,
|
||||||
|
{
|
||||||
|
isFinalAttempt,
|
||||||
|
shouldContinue,
|
||||||
|
timeRemaining,
|
||||||
|
timestamp,
|
||||||
|
log,
|
||||||
|
}: ConversationQueueJobBundle,
|
||||||
|
data: ExpirationTimerUpdateJobData
|
||||||
|
): Promise<void> {
|
||||||
|
if (!shouldContinue) {
|
||||||
|
log.info('Ran out of time. Giving up on sending expiration timer update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDirectConversation(conversation.attributes)) {
|
||||||
|
log.error(
|
||||||
|
`Conversation ${conversation.idForLogging()} is not a 1:1 conversation; cancelling expiration timer job.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversation.isUntrusted()) {
|
||||||
|
window.reduxActions.conversations.conversationStoppedByMissingVerification({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
untrustedConversationIds: [conversation.id],
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
'Expiration timer send blocked because conversation is untrusted. Failing this attempt.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
`Starting expiration timer update for ${conversation.idForLogging()} with timestamp ${timestamp}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const { expireTimer } = data;
|
||||||
|
|
||||||
|
const sendOptions = await getSendOptions(conversation.attributes);
|
||||||
|
let profileKey: Uint8Array | undefined;
|
||||||
|
if (conversation.get('profileSharing')) {
|
||||||
|
profileKey = await ourProfileKeyService.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||||
|
const contentHint = ContentHint.RESENDABLE;
|
||||||
|
|
||||||
|
const sendType = 'expirationTimerUpdate';
|
||||||
|
const flags = Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
|
||||||
|
const proto = await window.textsecure.messaging.getContentMessage({
|
||||||
|
expireTimer,
|
||||||
|
flags,
|
||||||
|
profileKey,
|
||||||
|
recipients: conversation.getRecipients(),
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!proto.dataMessage) {
|
||||||
|
log.error(
|
||||||
|
"ContentMessage proto didn't have a data message; cancelling job."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logId = `expirationTimerUdate/${conversation.idForLogging()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isMe(conversation.attributes)) {
|
||||||
|
await window.textsecure.messaging.sendSyncMessage({
|
||||||
|
encodedDataMessage: Proto.DataMessage.encode(
|
||||||
|
proto.dataMessage
|
||||||
|
).finish(),
|
||||||
|
destination: conversation.get('e164'),
|
||||||
|
destinationUuid: conversation.get('uuid'),
|
||||||
|
expirationStartTimestamp: null,
|
||||||
|
options: sendOptions,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
} else if (isDirectConversation(conversation.attributes)) {
|
||||||
|
await wrapWithSyncMessageSend({
|
||||||
|
conversation,
|
||||||
|
logId,
|
||||||
|
messageIds: [],
|
||||||
|
send: async sender =>
|
||||||
|
sender.sendIndividualProto({
|
||||||
|
contentHint,
|
||||||
|
identifier: conversation.getSendTarget(),
|
||||||
|
options: sendOptions,
|
||||||
|
proto,
|
||||||
|
timestamp,
|
||||||
|
}),
|
||||||
|
sendType,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
await handleMultipleSendErrors({
|
||||||
|
errors: maybeExpandErrors(error),
|
||||||
|
isFinalAttempt,
|
||||||
|
log,
|
||||||
|
timeRemaining,
|
||||||
|
toThrow: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
124
ts/jobs/helpers/sendGroupUpdate.ts
Normal file
124
ts/jobs/helpers/sendGroupUpdate.ts
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { getSendOptionsForRecipients } from '../../util/getSendOptions';
|
||||||
|
import { isGroupV2 } from '../../util/whatTypeOfConversation';
|
||||||
|
import { SignalService as Proto } from '../../protobuf';
|
||||||
|
import {
|
||||||
|
handleMultipleSendErrors,
|
||||||
|
maybeExpandErrors,
|
||||||
|
} from './handleMultipleSendErrors';
|
||||||
|
import { wrapWithSyncMessageSend } from '../../util/wrapWithSyncMessageSend';
|
||||||
|
import * as Bytes from '../../Bytes';
|
||||||
|
import { strictAssert } from '../../util/assert';
|
||||||
|
import { ourProfileKeyService } from '../../services/ourProfileKey';
|
||||||
|
|
||||||
|
import type { ConversationModel } from '../../models/conversations';
|
||||||
|
import type { GroupV2InfoType } from '../../textsecure/SendMessage';
|
||||||
|
import type {
|
||||||
|
GroupUpdateJobData,
|
||||||
|
ConversationQueueJobBundle,
|
||||||
|
} from '../conversationJobQueue';
|
||||||
|
import { getUntrustedConversationIds } from './getUntrustedConversationIds';
|
||||||
|
|
||||||
|
// Note: because we don't have a recipient map, if some sends fail, we will resend this
|
||||||
|
// message to folks that got it on the first go-round. This is okay, because receivers
|
||||||
|
// will drop this as an empty message if they already know about its revision.
|
||||||
|
export async function sendGroupUpdate(
|
||||||
|
conversation: ConversationModel,
|
||||||
|
{
|
||||||
|
isFinalAttempt,
|
||||||
|
shouldContinue,
|
||||||
|
timeRemaining,
|
||||||
|
timestamp,
|
||||||
|
log,
|
||||||
|
}: ConversationQueueJobBundle,
|
||||||
|
data: GroupUpdateJobData
|
||||||
|
): Promise<void> {
|
||||||
|
if (!shouldContinue) {
|
||||||
|
log.info('Ran out of time. Giving up on sending group update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGroupV2(conversation.attributes)) {
|
||||||
|
log.error(
|
||||||
|
`Conversation ${conversation.idForLogging()} is not GroupV2, cannot send group update!`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
`Starting group update for ${conversation.idForLogging()} with timestamp ${timestamp}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const { groupChangeBase64, recipients, revision } = data;
|
||||||
|
|
||||||
|
const untrustedConversationIds = getUntrustedConversationIds(recipients);
|
||||||
|
if (untrustedConversationIds.length) {
|
||||||
|
window.reduxActions.conversations.conversationStoppedByMissingVerification({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
untrustedConversationIds,
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
`Delete for everyone blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Failing this attempt.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendOptions = await getSendOptionsForRecipients(recipients);
|
||||||
|
|
||||||
|
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||||
|
const contentHint = ContentHint.RESENDABLE;
|
||||||
|
const sendType = 'groupChange';
|
||||||
|
const logId = `sendGroupUpdate/${conversation.idForLogging()}`;
|
||||||
|
|
||||||
|
const groupChange = groupChangeBase64
|
||||||
|
? Bytes.fromBase64(groupChangeBase64)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let profileKey: Uint8Array | undefined;
|
||||||
|
if (conversation.get('profileSharing')) {
|
||||||
|
profileKey = await ourProfileKeyService.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupV2Info = conversation.getGroupV2Info();
|
||||||
|
strictAssert(groupV2Info, 'groupV2Info missing');
|
||||||
|
const groupV2: GroupV2InfoType = {
|
||||||
|
...groupV2Info,
|
||||||
|
revision,
|
||||||
|
members: recipients,
|
||||||
|
groupChange,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await conversation.queueJob('conversationQueue/sendGroupUpdate', async () =>
|
||||||
|
wrapWithSyncMessageSend({
|
||||||
|
conversation,
|
||||||
|
logId,
|
||||||
|
messageIds: [],
|
||||||
|
send: async () =>
|
||||||
|
window.Signal.Util.sendToGroup({
|
||||||
|
groupSendOptions: {
|
||||||
|
groupV2,
|
||||||
|
timestamp,
|
||||||
|
profileKey,
|
||||||
|
},
|
||||||
|
contentHint,
|
||||||
|
messageId: undefined,
|
||||||
|
sendOptions,
|
||||||
|
sendTarget: conversation.toSenderKeyTarget(),
|
||||||
|
sendType,
|
||||||
|
}),
|
||||||
|
sendType,
|
||||||
|
timestamp,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
await handleMultipleSendErrors({
|
||||||
|
errors: maybeExpandErrors(error),
|
||||||
|
isFinalAttempt,
|
||||||
|
log,
|
||||||
|
timeRemaining,
|
||||||
|
toThrow: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
446
ts/jobs/helpers/sendNormalMessage.ts
Normal file
446
ts/jobs/helpers/sendNormalMessage.ts
Normal file
|
@ -0,0 +1,446 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { isNumber } from 'lodash';
|
||||||
|
|
||||||
|
import * as Errors from '../../types/errors';
|
||||||
|
import type { MessageModel } from '../../models/messages';
|
||||||
|
import { getMessageById } from '../../messages/getMessageById';
|
||||||
|
import type { ConversationModel } from '../../models/conversations';
|
||||||
|
import { isGroupV2, isMe } from '../../util/whatTypeOfConversation';
|
||||||
|
import { getSendOptions } from '../../util/getSendOptions';
|
||||||
|
import { SignalService as Proto } from '../../protobuf';
|
||||||
|
import { handleMessageSend } from '../../util/handleMessageSend';
|
||||||
|
import type { CallbackResultType } from '../../textsecure/Types.d';
|
||||||
|
import { isSent } from '../../messages/MessageSendState';
|
||||||
|
import {
|
||||||
|
getLastChallengeError,
|
||||||
|
isOutgoing,
|
||||||
|
} from '../../state/selectors/message';
|
||||||
|
import type { AttachmentType } from '../../textsecure/SendMessage';
|
||||||
|
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
||||||
|
import type { BodyRangesType } from '../../types/Util';
|
||||||
|
import type { WhatIsThis } from '../../window.d';
|
||||||
|
import type { LoggerType } from '../../types/Logging';
|
||||||
|
import type {
|
||||||
|
ConversationQueueJobBundle,
|
||||||
|
NormalMessageSendJobData,
|
||||||
|
} from '../conversationJobQueue';
|
||||||
|
|
||||||
|
import { handleMultipleSendErrors } from './handleMultipleSendErrors';
|
||||||
|
import { ourProfileKeyService } from '../../services/ourProfileKey';
|
||||||
|
|
||||||
|
export async function sendNormalMessage(
|
||||||
|
conversation: ConversationModel,
|
||||||
|
{
|
||||||
|
isFinalAttempt,
|
||||||
|
shouldContinue,
|
||||||
|
timeRemaining,
|
||||||
|
log,
|
||||||
|
}: ConversationQueueJobBundle,
|
||||||
|
data: NormalMessageSendJobData
|
||||||
|
): Promise<void> {
|
||||||
|
const { Message } = window.Signal.Types;
|
||||||
|
|
||||||
|
const { messageId, revision } = data;
|
||||||
|
const message = await getMessageById(messageId);
|
||||||
|
if (!message) {
|
||||||
|
log.info(
|
||||||
|
`message ${messageId} was not found, maybe because it was deleted. Giving up on sending it`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageConversation = message.getConversation();
|
||||||
|
if (messageConversation !== conversation) {
|
||||||
|
log.error(
|
||||||
|
`Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOutgoing(message.attributes)) {
|
||||||
|
log.error(
|
||||||
|
`message ${messageId} was not an outgoing message to begin with. This is probably a bogus job. Giving up on sending it`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.isErased() || message.get('deletedForEveryone')) {
|
||||||
|
log.info(`message ${messageId} was erased. Giving up on sending it`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageSendErrors: Array<Error> = [];
|
||||||
|
|
||||||
|
// We don't want to save errors on messages unless we're giving up. If it's our
|
||||||
|
// final attempt, we know upfront that we want to give up. However, we might also
|
||||||
|
// want to give up if (1) we get a 508 from the server, asking us to please stop
|
||||||
|
// (2) we get a 428 from the server, flagging the message for spam (3) some other
|
||||||
|
// reason not known at the time of this writing.
|
||||||
|
//
|
||||||
|
// This awkward callback lets us hold onto errors we might want to save, so we can
|
||||||
|
// decide whether to save them later on.
|
||||||
|
const saveErrors = isFinalAttempt
|
||||||
|
? undefined
|
||||||
|
: (errors: Array<Error>) => {
|
||||||
|
messageSendErrors = errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!shouldContinue) {
|
||||||
|
log.info(`message ${messageId} ran out of time. Giving up on sending it`);
|
||||||
|
await markMessageFailed(message, messageSendErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let profileKey: Uint8Array | undefined;
|
||||||
|
if (conversation.get('profileSharing')) {
|
||||||
|
profileKey = await ourProfileKeyService.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
let originalError: Error | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
allRecipientIdentifiers,
|
||||||
|
recipientIdentifiersWithoutMe,
|
||||||
|
untrustedConversationIds,
|
||||||
|
} = getMessageRecipients({
|
||||||
|
message,
|
||||||
|
conversation,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (untrustedConversationIds.length) {
|
||||||
|
window.reduxActions.conversations.conversationStoppedByMissingVerification(
|
||||||
|
{
|
||||||
|
conversationId: conversation.id,
|
||||||
|
untrustedConversationIds,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Message ${messageId} sending blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Failing this attempt.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allRecipientIdentifiers.length) {
|
||||||
|
log.warn(
|
||||||
|
`trying to send message ${messageId} but it looks like it was already sent to everyone. This is unexpected, but we're giving up`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
attachments,
|
||||||
|
body,
|
||||||
|
deletedForEveryoneTimestamp,
|
||||||
|
expireTimer,
|
||||||
|
mentions,
|
||||||
|
messageTimestamp,
|
||||||
|
preview,
|
||||||
|
quote,
|
||||||
|
sticker,
|
||||||
|
} = await getMessageSendData({ log, message });
|
||||||
|
|
||||||
|
let messageSendPromise: Promise<CallbackResultType | void>;
|
||||||
|
|
||||||
|
if (recipientIdentifiersWithoutMe.length === 0) {
|
||||||
|
log.info('sending sync message only');
|
||||||
|
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
||||||
|
attachments,
|
||||||
|
body,
|
||||||
|
groupV2: conversation.getGroupV2Info({
|
||||||
|
members: recipientIdentifiersWithoutMe,
|
||||||
|
}),
|
||||||
|
deletedForEveryoneTimestamp,
|
||||||
|
expireTimer,
|
||||||
|
preview,
|
||||||
|
profileKey,
|
||||||
|
quote,
|
||||||
|
recipients: allRecipientIdentifiers,
|
||||||
|
sticker,
|
||||||
|
timestamp: messageTimestamp,
|
||||||
|
});
|
||||||
|
messageSendPromise = message.sendSyncMessageOnly(dataMessage, saveErrors);
|
||||||
|
} else {
|
||||||
|
const conversationType = conversation.get('type');
|
||||||
|
const sendOptions = await getSendOptions(conversation.attributes);
|
||||||
|
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||||
|
|
||||||
|
let innerPromise: Promise<CallbackResultType>;
|
||||||
|
if (conversationType === Message.GROUP) {
|
||||||
|
// Note: this will happen for all old jobs queued beore 5.32.x
|
||||||
|
if (isGroupV2(conversation.attributes) && !isNumber(revision)) {
|
||||||
|
log.error('No revision provided, but conversation is GroupV2');
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupV2Info = conversation.getGroupV2Info({
|
||||||
|
members: recipientIdentifiersWithoutMe,
|
||||||
|
});
|
||||||
|
if (groupV2Info && isNumber(revision)) {
|
||||||
|
groupV2Info.revision = revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('sending group message');
|
||||||
|
innerPromise = conversation.queueJob(
|
||||||
|
'conversationQueue/sendNormalMessage',
|
||||||
|
() =>
|
||||||
|
window.Signal.Util.sendToGroup({
|
||||||
|
contentHint: ContentHint.RESENDABLE,
|
||||||
|
groupSendOptions: {
|
||||||
|
attachments,
|
||||||
|
deletedForEveryoneTimestamp,
|
||||||
|
expireTimer,
|
||||||
|
groupV1: conversation.getGroupV1Info(
|
||||||
|
recipientIdentifiersWithoutMe
|
||||||
|
),
|
||||||
|
groupV2: groupV2Info,
|
||||||
|
messageText: body,
|
||||||
|
preview,
|
||||||
|
profileKey,
|
||||||
|
quote,
|
||||||
|
sticker,
|
||||||
|
timestamp: messageTimestamp,
|
||||||
|
mentions,
|
||||||
|
},
|
||||||
|
messageId,
|
||||||
|
sendOptions,
|
||||||
|
sendTarget: conversation.toSenderKeyTarget(),
|
||||||
|
sendType: 'message',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.info('sending direct message');
|
||||||
|
innerPromise = window.textsecure.messaging.sendMessageToIdentifier({
|
||||||
|
identifier: recipientIdentifiersWithoutMe[0],
|
||||||
|
messageText: body,
|
||||||
|
attachments,
|
||||||
|
quote,
|
||||||
|
preview,
|
||||||
|
sticker,
|
||||||
|
reaction: undefined,
|
||||||
|
deletedForEveryoneTimestamp,
|
||||||
|
timestamp: messageTimestamp,
|
||||||
|
expireTimer,
|
||||||
|
contentHint: ContentHint.RESENDABLE,
|
||||||
|
groupId: undefined,
|
||||||
|
profileKey,
|
||||||
|
options: sendOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
messageSendPromise = message.send(
|
||||||
|
handleMessageSend(innerPromise, {
|
||||||
|
messageIds: [messageId],
|
||||||
|
sendType: 'message',
|
||||||
|
}),
|
||||||
|
saveErrors
|
||||||
|
);
|
||||||
|
|
||||||
|
// Because message.send swallows and processes errors, we'll await the inner promise
|
||||||
|
// to get the SendMessageProtoError, which gives us information upstream
|
||||||
|
// processors need to detect certain kinds of situations.
|
||||||
|
try {
|
||||||
|
await innerPromise;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
originalError = error;
|
||||||
|
} else {
|
||||||
|
log.error(
|
||||||
|
`promiseForError threw something other than an error: ${Errors.toLogFormat(
|
||||||
|
error
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await messageSendPromise;
|
||||||
|
|
||||||
|
if (
|
||||||
|
getLastChallengeError({
|
||||||
|
errors: messageSendErrors,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
log.info(
|
||||||
|
`message ${messageId} hit a spam challenge. Not retrying any more`
|
||||||
|
);
|
||||||
|
await message.saveErrors(messageSendErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const didFullySend =
|
||||||
|
!messageSendErrors.length || didSendToEveryone(message);
|
||||||
|
if (!didFullySend) {
|
||||||
|
throw new Error('message did not fully send');
|
||||||
|
}
|
||||||
|
} catch (thrownError: unknown) {
|
||||||
|
const errors = [thrownError, ...messageSendErrors];
|
||||||
|
await handleMultipleSendErrors({
|
||||||
|
errors,
|
||||||
|
isFinalAttempt,
|
||||||
|
log,
|
||||||
|
markFailed: () => markMessageFailed(message, messageSendErrors),
|
||||||
|
timeRemaining,
|
||||||
|
// In the case of a failed group send thrownError will not be SentMessageProtoError,
|
||||||
|
// but we should have been able to harvest the original error. In the Note to Self
|
||||||
|
// send case, thrownError will be the error we care about, and we won't have an
|
||||||
|
// originalError.
|
||||||
|
toThrow: originalError || thrownError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageRecipients({
|
||||||
|
conversation,
|
||||||
|
message,
|
||||||
|
}: Readonly<{
|
||||||
|
conversation: ConversationModel;
|
||||||
|
message: MessageModel;
|
||||||
|
}>): {
|
||||||
|
allRecipientIdentifiers: Array<string>;
|
||||||
|
recipientIdentifiersWithoutMe: Array<string>;
|
||||||
|
untrustedConversationIds: Array<string>;
|
||||||
|
} {
|
||||||
|
const allRecipientIdentifiers: Array<string> = [];
|
||||||
|
const recipientIdentifiersWithoutMe: Array<string> = [];
|
||||||
|
const untrustedConversationIds: Array<string> = [];
|
||||||
|
|
||||||
|
const currentConversationRecipients =
|
||||||
|
conversation.getRecipientConversationIds();
|
||||||
|
|
||||||
|
Object.entries(message.get('sendStateByConversationId') || {}).forEach(
|
||||||
|
([recipientConversationId, sendState]) => {
|
||||||
|
if (isSent(sendState.status)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipient = window.ConversationController.get(
|
||||||
|
recipientConversationId
|
||||||
|
);
|
||||||
|
if (!recipient) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecipientMe = isMe(recipient.attributes);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!currentConversationRecipients.has(recipientConversationId) &&
|
||||||
|
!isRecipientMe
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.isUntrusted()) {
|
||||||
|
untrustedConversationIds.push(recipientConversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientIdentifier = recipient.getSendTarget();
|
||||||
|
if (!recipientIdentifier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
allRecipientIdentifiers.push(recipientIdentifier);
|
||||||
|
if (!isRecipientMe) {
|
||||||
|
recipientIdentifiersWithoutMe.push(recipientIdentifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allRecipientIdentifiers,
|
||||||
|
recipientIdentifiersWithoutMe,
|
||||||
|
untrustedConversationIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMessageSendData({
|
||||||
|
log,
|
||||||
|
message,
|
||||||
|
}: Readonly<{
|
||||||
|
log: LoggerType;
|
||||||
|
message: MessageModel;
|
||||||
|
}>): Promise<{
|
||||||
|
attachments: Array<AttachmentType>;
|
||||||
|
body: undefined | string;
|
||||||
|
deletedForEveryoneTimestamp: undefined | number;
|
||||||
|
expireTimer: undefined | number;
|
||||||
|
mentions: undefined | BodyRangesType;
|
||||||
|
messageTimestamp: number;
|
||||||
|
preview: Array<LinkPreviewType>;
|
||||||
|
quote: WhatIsThis;
|
||||||
|
sticker: WhatIsThis;
|
||||||
|
}> {
|
||||||
|
const {
|
||||||
|
loadAttachmentData,
|
||||||
|
loadPreviewData,
|
||||||
|
loadQuoteData,
|
||||||
|
loadStickerData,
|
||||||
|
} = window.Signal.Migrations;
|
||||||
|
|
||||||
|
let messageTimestamp: number;
|
||||||
|
const sentAt = message.get('sent_at');
|
||||||
|
const timestamp = message.get('timestamp');
|
||||||
|
if (sentAt) {
|
||||||
|
messageTimestamp = sentAt;
|
||||||
|
} else if (timestamp) {
|
||||||
|
log.error('message lacked sent_at. Falling back to timestamp');
|
||||||
|
messageTimestamp = timestamp;
|
||||||
|
} else {
|
||||||
|
log.error(
|
||||||
|
'message lacked sent_at and timestamp. Falling back to current time'
|
||||||
|
);
|
||||||
|
messageTimestamp = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [attachmentsWithData, preview, quote, sticker] = await Promise.all([
|
||||||
|
// We don't update the caches here because (1) we expect the caches to be populated
|
||||||
|
// on initial send, so they should be there in the 99% case (2) if you're retrying
|
||||||
|
// a failed message across restarts, we don't touch the cache for simplicity. If
|
||||||
|
// sends are failing, let's not add the complication of a cache.
|
||||||
|
Promise.all((message.get('attachments') ?? []).map(loadAttachmentData)),
|
||||||
|
message.cachedOutgoingPreviewData ||
|
||||||
|
loadPreviewData(message.get('preview')),
|
||||||
|
message.cachedOutgoingQuoteData || loadQuoteData(message.get('quote')),
|
||||||
|
message.cachedOutgoingStickerData ||
|
||||||
|
loadStickerData(message.get('sticker')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { body, attachments } = window.Whisper.Message.getLongMessageAttachment(
|
||||||
|
{
|
||||||
|
body: message.get('body'),
|
||||||
|
attachments: attachmentsWithData,
|
||||||
|
now: messageTimestamp,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachments,
|
||||||
|
body,
|
||||||
|
deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'),
|
||||||
|
expireTimer: message.get('expireTimer'),
|
||||||
|
mentions: message.get('bodyRanges'),
|
||||||
|
messageTimestamp,
|
||||||
|
preview,
|
||||||
|
quote,
|
||||||
|
sticker,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markMessageFailed(
|
||||||
|
message: MessageModel,
|
||||||
|
errors: Array<Error>
|
||||||
|
): Promise<void> {
|
||||||
|
message.markFailed();
|
||||||
|
message.saveErrors(errors, { skipSave: true });
|
||||||
|
await window.Signal.Data.saveMessage(message.attributes, {
|
||||||
|
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function didSendToEveryone(message: Readonly<MessageModel>): boolean {
|
||||||
|
const sendStateByConversationId =
|
||||||
|
message.get('sendStateByConversationId') || {};
|
||||||
|
return Object.values(sendStateByConversationId).every(sendState =>
|
||||||
|
isSent(sendState.status)
|
||||||
|
);
|
||||||
|
}
|
146
ts/jobs/helpers/sendProfileKey.ts
Normal file
146
ts/jobs/helpers/sendProfileKey.ts
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { isNumber } from 'lodash';
|
||||||
|
|
||||||
|
import { handleMessageSend } from '../../util/handleMessageSend';
|
||||||
|
import { getSendOptions } from '../../util/getSendOptions';
|
||||||
|
import {
|
||||||
|
isDirectConversation,
|
||||||
|
isGroupV2,
|
||||||
|
} from '../../util/whatTypeOfConversation';
|
||||||
|
import { SignalService as Proto } from '../../protobuf';
|
||||||
|
import {
|
||||||
|
handleMultipleSendErrors,
|
||||||
|
maybeExpandErrors,
|
||||||
|
} from './handleMultipleSendErrors';
|
||||||
|
import { ourProfileKeyService } from '../../services/ourProfileKey';
|
||||||
|
|
||||||
|
import type { ConversationModel } from '../../models/conversations';
|
||||||
|
import type {
|
||||||
|
ConversationQueueJobBundle,
|
||||||
|
ProfileKeyJobData,
|
||||||
|
} from '../conversationJobQueue';
|
||||||
|
import type { CallbackResultType } from '../../textsecure/Types.d';
|
||||||
|
import { getUntrustedConversationIds } from './getUntrustedConversationIds';
|
||||||
|
import { areAllErrorsUnregistered } from './areAllErrorsUnregistered';
|
||||||
|
|
||||||
|
// Note: because we don't have a recipient map, we will resend this message to folks that
|
||||||
|
// got it on the first go-round, if some sends fail. This is okay, because a recipient
|
||||||
|
// getting your profileKey again is just fine.
|
||||||
|
export async function sendProfileKey(
|
||||||
|
conversation: ConversationModel,
|
||||||
|
{
|
||||||
|
isFinalAttempt,
|
||||||
|
shouldContinue,
|
||||||
|
timestamp,
|
||||||
|
timeRemaining,
|
||||||
|
log,
|
||||||
|
}: ConversationQueueJobBundle,
|
||||||
|
data: ProfileKeyJobData
|
||||||
|
): Promise<void> {
|
||||||
|
if (!shouldContinue) {
|
||||||
|
log.info('Ran out of time. Giving up on sending profile key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conversation.get('profileSharing')) {
|
||||||
|
log.info('No longer sharing profile. Cancelling job.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileKey = await ourProfileKeyService.get();
|
||||||
|
if (!profileKey) {
|
||||||
|
log.info('Unable to fetch profile. Cancelling job.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
`starting profile key share to ${conversation.idForLogging()} with timestamp ${timestamp}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const { revision } = data;
|
||||||
|
const sendOptions = await getSendOptions(conversation.attributes);
|
||||||
|
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||||
|
const contentHint = ContentHint.RESENDABLE;
|
||||||
|
const sendType = 'profileKeyUpdate';
|
||||||
|
|
||||||
|
let sendPromise: Promise<CallbackResultType>;
|
||||||
|
|
||||||
|
// Note: flags and the profileKey itself are all that matter in the proto.
|
||||||
|
|
||||||
|
const untrustedConversationIds = getUntrustedConversationIds(
|
||||||
|
conversation.getRecipients()
|
||||||
|
);
|
||||||
|
if (untrustedConversationIds.length) {
|
||||||
|
window.reduxActions.conversations.conversationStoppedByMissingVerification({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
untrustedConversationIds,
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
`Profile key send blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Failing this attempt.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectConversation(conversation.attributes)) {
|
||||||
|
const proto = await window.textsecure.messaging.getContentMessage({
|
||||||
|
flags: Proto.DataMessage.Flags.PROFILE_KEY_UPDATE,
|
||||||
|
profileKey,
|
||||||
|
recipients: conversation.getRecipients(),
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
sendPromise = window.textsecure.messaging.sendIndividualProto({
|
||||||
|
contentHint,
|
||||||
|
identifier: conversation.getSendTarget(),
|
||||||
|
options: sendOptions,
|
||||||
|
proto,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (isGroupV2(conversation.attributes) && !isNumber(revision)) {
|
||||||
|
log.error('No revision provided, but conversation is GroupV2');
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupV2Info = conversation.getGroupV2Info();
|
||||||
|
if (groupV2Info && isNumber(revision)) {
|
||||||
|
groupV2Info.revision = revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPromise = window.Signal.Util.sendToGroup({
|
||||||
|
contentHint,
|
||||||
|
groupSendOptions: {
|
||||||
|
flags: Proto.DataMessage.Flags.PROFILE_KEY_UPDATE,
|
||||||
|
groupV1: conversation.getGroupV1Info(),
|
||||||
|
groupV2: groupV2Info,
|
||||||
|
profileKey,
|
||||||
|
timestamp,
|
||||||
|
},
|
||||||
|
messageId: undefined,
|
||||||
|
sendOptions,
|
||||||
|
sendTarget: conversation.toSenderKeyTarget(),
|
||||||
|
sendType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleMessageSend(sendPromise, {
|
||||||
|
messageIds: [],
|
||||||
|
sendType,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (areAllErrorsUnregistered(conversation.attributes, error)) {
|
||||||
|
log.info(
|
||||||
|
'Group send failures were all UnregisteredUserError, returning succcessfully.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMultipleSendErrors({
|
||||||
|
errors: maybeExpandErrors(error),
|
||||||
|
isFinalAttempt,
|
||||||
|
log,
|
||||||
|
timeRemaining,
|
||||||
|
toThrow: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
377
ts/jobs/helpers/sendReaction.ts
Normal file
377
ts/jobs/helpers/sendReaction.ts
Normal file
|
@ -0,0 +1,377 @@
|
||||||
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { isNumber } from 'lodash';
|
||||||
|
|
||||||
|
import * as Errors from '../../types/errors';
|
||||||
|
import { repeat, zipObject } from '../../util/iterables';
|
||||||
|
import type { CallbackResultType } from '../../textsecure/Types.d';
|
||||||
|
import type { MessageModel } from '../../models/messages';
|
||||||
|
import type { MessageReactionType } from '../../model-types.d';
|
||||||
|
import type { ConversationModel } from '../../models/conversations';
|
||||||
|
|
||||||
|
import * as reactionUtil from '../../reactions/util';
|
||||||
|
import { isSent, SendStatus } from '../../messages/MessageSendState';
|
||||||
|
import { getMessageById } from '../../messages/getMessageById';
|
||||||
|
import {
|
||||||
|
isMe,
|
||||||
|
isDirectConversation,
|
||||||
|
isGroupV2,
|
||||||
|
} from '../../util/whatTypeOfConversation';
|
||||||
|
import { getSendOptions } from '../../util/getSendOptions';
|
||||||
|
import { SignalService as Proto } from '../../protobuf';
|
||||||
|
import { handleMessageSend } from '../../util/handleMessageSend';
|
||||||
|
import { ourProfileKeyService } from '../../services/ourProfileKey';
|
||||||
|
import { canReact } from '../../state/selectors/message';
|
||||||
|
import { findAndFormatContact } from '../../util/findAndFormatContact';
|
||||||
|
import { UUID } from '../../types/UUID';
|
||||||
|
import { handleMultipleSendErrors } from './handleMultipleSendErrors';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ConversationQueueJobBundle,
|
||||||
|
ReactionJobData,
|
||||||
|
} from '../conversationJobQueue';
|
||||||
|
|
||||||
|
export async function sendReaction(
|
||||||
|
conversation: ConversationModel,
|
||||||
|
{
|
||||||
|
isFinalAttempt,
|
||||||
|
shouldContinue,
|
||||||
|
timeRemaining,
|
||||||
|
log,
|
||||||
|
}: ConversationQueueJobBundle,
|
||||||
|
data: ReactionJobData
|
||||||
|
): Promise<void> {
|
||||||
|
const { messageId, revision } = data;
|
||||||
|
const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
|
||||||
|
|
||||||
|
await window.ConversationController.load();
|
||||||
|
|
||||||
|
const ourConversationId =
|
||||||
|
window.ConversationController.getOurConversationIdOrThrow();
|
||||||
|
|
||||||
|
const message = await getMessageById(messageId);
|
||||||
|
if (!message) {
|
||||||
|
log.info(
|
||||||
|
`message ${messageId} was not found, maybe because it was deleted. Giving up on sending its reactions`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pendingReaction, emojiToRemove } =
|
||||||
|
reactionUtil.getNewestPendingOutgoingReaction(
|
||||||
|
getReactions(message),
|
||||||
|
ourConversationId
|
||||||
|
);
|
||||||
|
if (!pendingReaction) {
|
||||||
|
log.info(`no pending reaction for ${messageId}. Doing nothing`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canReact(message.attributes, ourConversationId, findAndFormatContact)) {
|
||||||
|
log.info(`could not react to ${messageId}. Removing this pending reaction`);
|
||||||
|
markReactionFailed(message, pendingReaction);
|
||||||
|
await window.Signal.Data.saveMessage(message.attributes, { ourUuid });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldContinue) {
|
||||||
|
log.info(
|
||||||
|
`reacting to message ${messageId} ran out of time. Giving up on sending it`
|
||||||
|
);
|
||||||
|
markReactionFailed(message, pendingReaction);
|
||||||
|
await window.Signal.Data.saveMessage(message.attributes, { ourUuid });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sendErrors: Array<Error> = [];
|
||||||
|
const saveErrors = (errors: Array<Error>): void => {
|
||||||
|
sendErrors = errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
let originalError: Error | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messageConversation = message.getConversation();
|
||||||
|
if (messageConversation !== conversation) {
|
||||||
|
log.error(
|
||||||
|
`message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
allRecipientIdentifiers,
|
||||||
|
recipientIdentifiersWithoutMe,
|
||||||
|
untrustedConversationIds,
|
||||||
|
} = getRecipients(pendingReaction, conversation);
|
||||||
|
|
||||||
|
if (untrustedConversationIds.length) {
|
||||||
|
window.reduxActions.conversations.conversationStoppedByMissingVerification(
|
||||||
|
{
|
||||||
|
conversationId: conversation.id,
|
||||||
|
untrustedConversationIds,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Reaction for message ${messageId} sending blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Failing this attempt.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expireTimer = message.get('expireTimer');
|
||||||
|
const profileKey = conversation.get('profileSharing')
|
||||||
|
? await ourProfileKeyService.get()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const reactionForSend = pendingReaction.emoji
|
||||||
|
? pendingReaction
|
||||||
|
: {
|
||||||
|
...pendingReaction,
|
||||||
|
emoji: emojiToRemove,
|
||||||
|
remove: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ephemeralMessageForReactionSend = new window.Whisper.Message({
|
||||||
|
id: UUID.generate.toString(),
|
||||||
|
type: 'outgoing',
|
||||||
|
conversationId: conversation.get('id'),
|
||||||
|
sent_at: pendingReaction.timestamp,
|
||||||
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||||
|
received_at_ms: pendingReaction.timestamp,
|
||||||
|
reaction: reactionForSend,
|
||||||
|
timestamp: pendingReaction.timestamp,
|
||||||
|
sendStateByConversationId: zipObject(
|
||||||
|
Object.keys(pendingReaction.isSentByConversationId || {}),
|
||||||
|
repeat({
|
||||||
|
status: SendStatus.Pending,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
ephemeralMessageForReactionSend.doNotSave = true;
|
||||||
|
|
||||||
|
let didFullySend: boolean;
|
||||||
|
const successfulConversationIds = new Set<string>();
|
||||||
|
|
||||||
|
if (recipientIdentifiersWithoutMe.length === 0) {
|
||||||
|
log.info('sending sync reaction message only');
|
||||||
|
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
||||||
|
attachments: [],
|
||||||
|
expireTimer,
|
||||||
|
groupV2: conversation.getGroupV2Info({
|
||||||
|
members: recipientIdentifiersWithoutMe,
|
||||||
|
}),
|
||||||
|
preview: [],
|
||||||
|
profileKey,
|
||||||
|
reaction: reactionForSend,
|
||||||
|
recipients: allRecipientIdentifiers,
|
||||||
|
timestamp: pendingReaction.timestamp,
|
||||||
|
});
|
||||||
|
await ephemeralMessageForReactionSend.sendSyncMessageOnly(
|
||||||
|
dataMessage,
|
||||||
|
saveErrors
|
||||||
|
);
|
||||||
|
|
||||||
|
didFullySend = true;
|
||||||
|
successfulConversationIds.add(ourConversationId);
|
||||||
|
} else {
|
||||||
|
const sendOptions = await getSendOptions(conversation.attributes);
|
||||||
|
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||||
|
|
||||||
|
let promise: Promise<CallbackResultType>;
|
||||||
|
if (isDirectConversation(conversation.attributes)) {
|
||||||
|
log.info('sending direct reaction message');
|
||||||
|
promise = window.textsecure.messaging.sendMessageToIdentifier({
|
||||||
|
identifier: recipientIdentifiersWithoutMe[0],
|
||||||
|
messageText: undefined,
|
||||||
|
attachments: [],
|
||||||
|
quote: undefined,
|
||||||
|
preview: [],
|
||||||
|
sticker: undefined,
|
||||||
|
reaction: reactionForSend,
|
||||||
|
deletedForEveryoneTimestamp: undefined,
|
||||||
|
timestamp: pendingReaction.timestamp,
|
||||||
|
expireTimer,
|
||||||
|
contentHint: ContentHint.RESENDABLE,
|
||||||
|
groupId: undefined,
|
||||||
|
profileKey,
|
||||||
|
options: sendOptions,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log.info('sending group reaction message');
|
||||||
|
promise = conversation.queueJob(
|
||||||
|
'conversationQueue/sendReaction',
|
||||||
|
() => {
|
||||||
|
// Note: this will happen for all old jobs queued before 5.32.x
|
||||||
|
if (isGroupV2(conversation.attributes) && !isNumber(revision)) {
|
||||||
|
log.error('No revision provided, but conversation is GroupV2');
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupV2Info = conversation.getGroupV2Info({
|
||||||
|
members: recipientIdentifiersWithoutMe,
|
||||||
|
});
|
||||||
|
if (groupV2Info && isNumber(revision)) {
|
||||||
|
groupV2Info.revision = revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.Signal.Util.sendToGroup({
|
||||||
|
contentHint: ContentHint.RESENDABLE,
|
||||||
|
groupSendOptions: {
|
||||||
|
groupV1: conversation.getGroupV1Info(
|
||||||
|
recipientIdentifiersWithoutMe
|
||||||
|
),
|
||||||
|
groupV2: groupV2Info,
|
||||||
|
reaction: reactionForSend,
|
||||||
|
timestamp: pendingReaction.timestamp,
|
||||||
|
expireTimer,
|
||||||
|
profileKey,
|
||||||
|
},
|
||||||
|
messageId,
|
||||||
|
sendOptions,
|
||||||
|
sendTarget: conversation.toSenderKeyTarget(),
|
||||||
|
sendType: 'reaction',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ephemeralMessageForReactionSend.send(
|
||||||
|
handleMessageSend(promise, {
|
||||||
|
messageIds: [messageId],
|
||||||
|
sendType: 'reaction',
|
||||||
|
}),
|
||||||
|
saveErrors
|
||||||
|
);
|
||||||
|
|
||||||
|
// Because message.send swallows and processes errors, we'll await the inner promise
|
||||||
|
// to get the SendMessageProtoError, which gives us information upstream
|
||||||
|
/// processors need to detect certain kinds of errors.
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
originalError = error;
|
||||||
|
} else {
|
||||||
|
log.error(
|
||||||
|
`promise threw something other than an error: ${Errors.toLogFormat(
|
||||||
|
error
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
didFullySend = true;
|
||||||
|
const reactionSendStateByConversationId =
|
||||||
|
ephemeralMessageForReactionSend.get('sendStateByConversationId') || {};
|
||||||
|
for (const [conversationId, sendState] of Object.entries(
|
||||||
|
reactionSendStateByConversationId
|
||||||
|
)) {
|
||||||
|
if (isSent(sendState.status)) {
|
||||||
|
successfulConversationIds.add(conversationId);
|
||||||
|
} else {
|
||||||
|
didFullySend = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newReactions = reactionUtil.markOutgoingReactionSent(
|
||||||
|
getReactions(message),
|
||||||
|
pendingReaction,
|
||||||
|
successfulConversationIds
|
||||||
|
);
|
||||||
|
setReactions(message, newReactions);
|
||||||
|
|
||||||
|
if (!didFullySend) {
|
||||||
|
throw new Error('reaction did not fully send');
|
||||||
|
}
|
||||||
|
} catch (thrownError: unknown) {
|
||||||
|
await handleMultipleSendErrors({
|
||||||
|
errors: [thrownError, ...sendErrors],
|
||||||
|
isFinalAttempt,
|
||||||
|
log,
|
||||||
|
markFailed: () => markReactionFailed(message, pendingReaction),
|
||||||
|
timeRemaining,
|
||||||
|
// In the case of a failed group send thrownError will not be SentMessageProtoError,
|
||||||
|
// but we should have been able to harvest the original error. In the Note to Self
|
||||||
|
// send case, thrownError will be the error we care about, and we won't have an
|
||||||
|
// originalError.
|
||||||
|
toThrow: originalError || thrownError,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await window.Signal.Data.saveMessage(message.attributes, { ourUuid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getReactions = (message: MessageModel): Array<MessageReactionType> =>
|
||||||
|
message.get('reactions') || [];
|
||||||
|
|
||||||
|
const setReactions = (
|
||||||
|
message: MessageModel,
|
||||||
|
reactions: Array<MessageReactionType>
|
||||||
|
): void => {
|
||||||
|
if (reactions.length) {
|
||||||
|
message.set('reactions', reactions);
|
||||||
|
} else {
|
||||||
|
message.unset('reactions');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRecipients(
|
||||||
|
reaction: Readonly<MessageReactionType>,
|
||||||
|
conversation: ConversationModel
|
||||||
|
): {
|
||||||
|
allRecipientIdentifiers: Array<string>;
|
||||||
|
recipientIdentifiersWithoutMe: Array<string>;
|
||||||
|
untrustedConversationIds: Array<string>;
|
||||||
|
} {
|
||||||
|
const allRecipientIdentifiers: Array<string> = [];
|
||||||
|
const recipientIdentifiersWithoutMe: Array<string> = [];
|
||||||
|
const untrustedConversationIds: Array<string> = [];
|
||||||
|
|
||||||
|
const currentConversationRecipients =
|
||||||
|
conversation.getRecipientConversationIds();
|
||||||
|
|
||||||
|
for (const id of reactionUtil.getUnsentConversationIds(reaction)) {
|
||||||
|
const recipient = window.ConversationController.get(id);
|
||||||
|
if (!recipient) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientIdentifier = recipient.getSendTarget();
|
||||||
|
const isRecipientMe = isMe(recipient.attributes);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!recipientIdentifier ||
|
||||||
|
(!currentConversationRecipients.has(id) && !isRecipientMe)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.isUntrusted()) {
|
||||||
|
untrustedConversationIds.push(recipientIdentifier);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
allRecipientIdentifiers.push(recipientIdentifier);
|
||||||
|
if (!isRecipientMe) {
|
||||||
|
recipientIdentifiersWithoutMe.push(recipientIdentifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allRecipientIdentifiers,
|
||||||
|
recipientIdentifiersWithoutMe,
|
||||||
|
untrustedConversationIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function markReactionFailed(
|
||||||
|
message: MessageModel,
|
||||||
|
pendingReaction: MessageReactionType
|
||||||
|
): void {
|
||||||
|
const newReactions = reactionUtil.markOutgoingReactionFailed(
|
||||||
|
getReactions(message),
|
||||||
|
pendingReaction
|
||||||
|
);
|
||||||
|
setReactions(message, newReactions);
|
||||||
|
}
|
|
@ -3,9 +3,8 @@
|
||||||
|
|
||||||
import type { WebAPIType } from '../textsecure/WebAPI';
|
import type { WebAPIType } from '../textsecure/WebAPI';
|
||||||
|
|
||||||
|
import { conversationJobQueue } from './conversationJobQueue';
|
||||||
import { deliveryReceiptsJobQueue } from './deliveryReceiptsJobQueue';
|
import { deliveryReceiptsJobQueue } from './deliveryReceiptsJobQueue';
|
||||||
import { normalMessageSendJobQueue } from './normalMessageSendJobQueue';
|
|
||||||
import { reactionJobQueue } from './reactionJobQueue';
|
|
||||||
import { readReceiptsJobQueue } from './readReceiptsJobQueue';
|
import { readReceiptsJobQueue } from './readReceiptsJobQueue';
|
||||||
import { readSyncJobQueue } from './readSyncJobQueue';
|
import { readSyncJobQueue } from './readSyncJobQueue';
|
||||||
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
|
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
|
||||||
|
@ -26,8 +25,7 @@ export function initializeAllJobQueues({
|
||||||
reportSpamJobQueue.initialize({ server });
|
reportSpamJobQueue.initialize({ server });
|
||||||
|
|
||||||
// General conversation send queue
|
// General conversation send queue
|
||||||
normalMessageSendJobQueue.streamJobs();
|
conversationJobQueue.streamJobs();
|
||||||
reactionJobQueue.streamJobs();
|
|
||||||
|
|
||||||
// Single proto send queue, used for a variety of one-off simple messages
|
// Single proto send queue, used for a variety of one-off simple messages
|
||||||
singleProtoJobQueue.streamJobs();
|
singleProtoJobQueue.streamJobs();
|
||||||
|
|
|
@ -1,461 +0,0 @@
|
||||||
// Copyright 2021-2022 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import type PQueue from 'p-queue';
|
|
||||||
import type { LoggerType } from '../types/Logging';
|
|
||||||
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
|
|
||||||
import { commonShouldJobContinue } from './helpers/commonShouldJobContinue';
|
|
||||||
import { InMemoryQueues } from './helpers/InMemoryQueues';
|
|
||||||
import type { MessageModel } from '../models/messages';
|
|
||||||
import { getMessageById } from '../messages/getMessageById';
|
|
||||||
import type { ConversationModel } from '../models/conversations';
|
|
||||||
import { ourProfileKeyService } from '../services/ourProfileKey';
|
|
||||||
import { strictAssert } from '../util/assert';
|
|
||||||
import { isRecord } from '../util/isRecord';
|
|
||||||
import * as durations from '../util/durations';
|
|
||||||
import { isMe } from '../util/whatTypeOfConversation';
|
|
||||||
import { getSendOptions } from '../util/getSendOptions';
|
|
||||||
import { SignalService as Proto } from '../protobuf';
|
|
||||||
import { handleMessageSend } from '../util/handleMessageSend';
|
|
||||||
import type { CallbackResultType } from '../textsecure/Types.d';
|
|
||||||
import { isSent } from '../messages/MessageSendState';
|
|
||||||
import { getLastChallengeError, isOutgoing } from '../state/selectors/message';
|
|
||||||
import type { AttachmentType } from '../textsecure/SendMessage';
|
|
||||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
|
||||||
import type { BodyRangesType } from '../types/Util';
|
|
||||||
import type { WhatIsThis } from '../window.d';
|
|
||||||
|
|
||||||
import { JobQueue } from './JobQueue';
|
|
||||||
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
|
||||||
import { handleMultipleSendErrors } from './helpers/handleMultipleSendErrors';
|
|
||||||
|
|
||||||
const { loadAttachmentData, loadPreviewData, loadQuoteData, loadStickerData } =
|
|
||||||
window.Signal.Migrations;
|
|
||||||
const { Message } = window.Signal.Types;
|
|
||||||
|
|
||||||
const MAX_RETRY_TIME = durations.DAY;
|
|
||||||
const MAX_ATTEMPTS = exponentialBackoffMaxAttempts(MAX_RETRY_TIME);
|
|
||||||
|
|
||||||
type NormalMessageSendJobData = {
|
|
||||||
messageId: string;
|
|
||||||
conversationId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class NormalMessageSendJobQueue extends JobQueue<NormalMessageSendJobData> {
|
|
||||||
private readonly inMemoryQueues = new InMemoryQueues();
|
|
||||||
|
|
||||||
protected parseData(data: unknown): NormalMessageSendJobData {
|
|
||||||
// Because we do this so often and Zod is a bit slower, we do "manual" parsing here.
|
|
||||||
strictAssert(isRecord(data), 'Job data is not an object');
|
|
||||||
const { messageId, conversationId } = data;
|
|
||||||
strictAssert(
|
|
||||||
typeof messageId === 'string',
|
|
||||||
'Job data had a non-string message ID'
|
|
||||||
);
|
|
||||||
strictAssert(
|
|
||||||
typeof conversationId === 'string',
|
|
||||||
'Job data had a non-string conversation ID'
|
|
||||||
);
|
|
||||||
return { messageId, conversationId };
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override getInMemoryQueue({
|
|
||||||
data,
|
|
||||||
}: Readonly<{ data: NormalMessageSendJobData }>): PQueue {
|
|
||||||
return this.inMemoryQueues.get(data.conversationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async run(
|
|
||||||
{
|
|
||||||
data,
|
|
||||||
timestamp,
|
|
||||||
}: Readonly<{ data: NormalMessageSendJobData; timestamp: number }>,
|
|
||||||
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
|
|
||||||
): Promise<void> {
|
|
||||||
const { messageId } = data;
|
|
||||||
|
|
||||||
const timeRemaining = timestamp + MAX_RETRY_TIME - Date.now();
|
|
||||||
const isFinalAttempt = attempt >= MAX_ATTEMPTS;
|
|
||||||
|
|
||||||
// We don't immediately use this value because we may want to mark the message
|
|
||||||
// failed before doing so.
|
|
||||||
const shouldContinue = await commonShouldJobContinue({
|
|
||||||
attempt,
|
|
||||||
log,
|
|
||||||
timeRemaining,
|
|
||||||
});
|
|
||||||
|
|
||||||
await window.ConversationController.load();
|
|
||||||
|
|
||||||
const message = await getMessageById(messageId);
|
|
||||||
if (!message) {
|
|
||||||
log.info(
|
|
||||||
`message ${messageId} was not found, maybe because it was deleted. Giving up on sending it`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOutgoing(message.attributes)) {
|
|
||||||
log.error(
|
|
||||||
`message ${messageId} was not an outgoing message to begin with. This is probably a bogus job. Giving up on sending it`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.isErased() || message.get('deletedForEveryone')) {
|
|
||||||
log.info(`message ${messageId} was erased. Giving up on sending it`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let messageSendErrors: Array<Error> = [];
|
|
||||||
|
|
||||||
// We don't want to save errors on messages unless we're giving up. If it's our
|
|
||||||
// final attempt, we know upfront that we want to give up. However, we might also
|
|
||||||
// want to give up if (1) we get a 508 from the server, asking us to please stop
|
|
||||||
// (2) we get a 428 from the server, flagging the message for spam (3) some other
|
|
||||||
// reason not known at the time of this writing.
|
|
||||||
//
|
|
||||||
// This awkward callback lets us hold onto errors we might want to save, so we can
|
|
||||||
// decide whether to save them later on.
|
|
||||||
const saveErrors = isFinalAttempt
|
|
||||||
? undefined
|
|
||||||
: (errors: Array<Error>) => {
|
|
||||||
messageSendErrors = errors;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!shouldContinue) {
|
|
||||||
log.info(`message ${messageId} ran out of time. Giving up on sending it`);
|
|
||||||
await markMessageFailed(message, messageSendErrors);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const conversation = message.getConversation();
|
|
||||||
if (!conversation) {
|
|
||||||
throw new Error(
|
|
||||||
`could not find conversation for message with ID ${messageId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
allRecipientIdentifiers,
|
|
||||||
recipientIdentifiersWithoutMe,
|
|
||||||
untrustedConversationIds,
|
|
||||||
} = getMessageRecipients({
|
|
||||||
message,
|
|
||||||
conversation,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (untrustedConversationIds.length) {
|
|
||||||
log.info(
|
|
||||||
`message ${messageId} sending blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Giving up on the job, but it may be reborn later`
|
|
||||||
);
|
|
||||||
window.reduxActions.conversations.messageStoppedByMissingVerification(
|
|
||||||
messageId,
|
|
||||||
untrustedConversationIds
|
|
||||||
);
|
|
||||||
await markMessageFailed(message, messageSendErrors);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allRecipientIdentifiers.length) {
|
|
||||||
log.warn(
|
|
||||||
`trying to send message ${messageId} but it looks like it was already sent to everyone. This is unexpected, but we're giving up`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
attachments,
|
|
||||||
body,
|
|
||||||
deletedForEveryoneTimestamp,
|
|
||||||
expireTimer,
|
|
||||||
mentions,
|
|
||||||
messageTimestamp,
|
|
||||||
preview,
|
|
||||||
profileKey,
|
|
||||||
quote,
|
|
||||||
sticker,
|
|
||||||
} = await getMessageSendData({ conversation, log, message });
|
|
||||||
|
|
||||||
let messageSendPromise: Promise<unknown>;
|
|
||||||
|
|
||||||
if (recipientIdentifiersWithoutMe.length === 0) {
|
|
||||||
log.info('sending sync message only');
|
|
||||||
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
|
||||||
attachments,
|
|
||||||
body,
|
|
||||||
groupV2: conversation.getGroupV2Info({
|
|
||||||
members: recipientIdentifiersWithoutMe,
|
|
||||||
}),
|
|
||||||
deletedForEveryoneTimestamp,
|
|
||||||
expireTimer,
|
|
||||||
preview,
|
|
||||||
profileKey,
|
|
||||||
quote,
|
|
||||||
recipients: allRecipientIdentifiers,
|
|
||||||
sticker,
|
|
||||||
timestamp: messageTimestamp,
|
|
||||||
});
|
|
||||||
messageSendPromise = message.sendSyncMessageOnly(
|
|
||||||
dataMessage,
|
|
||||||
saveErrors
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const conversationType = conversation.get('type');
|
|
||||||
const sendOptions = await getSendOptions(conversation.attributes);
|
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
|
||||||
|
|
||||||
let innerPromise: Promise<CallbackResultType>;
|
|
||||||
if (conversationType === Message.GROUP) {
|
|
||||||
log.info('sending group message');
|
|
||||||
innerPromise = conversation.queueJob(
|
|
||||||
'normalMessageSendJobQueue',
|
|
||||||
() =>
|
|
||||||
window.Signal.Util.sendToGroup({
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
groupSendOptions: {
|
|
||||||
attachments,
|
|
||||||
deletedForEveryoneTimestamp,
|
|
||||||
expireTimer,
|
|
||||||
groupV1: conversation.getGroupV1Info(
|
|
||||||
recipientIdentifiersWithoutMe
|
|
||||||
),
|
|
||||||
groupV2: conversation.getGroupV2Info({
|
|
||||||
members: recipientIdentifiersWithoutMe,
|
|
||||||
}),
|
|
||||||
messageText: body,
|
|
||||||
preview,
|
|
||||||
profileKey,
|
|
||||||
quote,
|
|
||||||
sticker,
|
|
||||||
timestamp: messageTimestamp,
|
|
||||||
mentions,
|
|
||||||
},
|
|
||||||
messageId,
|
|
||||||
sendOptions,
|
|
||||||
sendTarget: conversation.toSenderKeyTarget(),
|
|
||||||
sendType: 'message',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log.info('sending direct message');
|
|
||||||
innerPromise = window.textsecure.messaging.sendMessageToIdentifier({
|
|
||||||
identifier: recipientIdentifiersWithoutMe[0],
|
|
||||||
messageText: body,
|
|
||||||
attachments,
|
|
||||||
quote,
|
|
||||||
preview,
|
|
||||||
sticker,
|
|
||||||
reaction: undefined,
|
|
||||||
deletedForEveryoneTimestamp,
|
|
||||||
timestamp: messageTimestamp,
|
|
||||||
expireTimer,
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
groupId: undefined,
|
|
||||||
profileKey,
|
|
||||||
options: sendOptions,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
messageSendPromise = message.send(
|
|
||||||
handleMessageSend(innerPromise, {
|
|
||||||
messageIds: [messageId],
|
|
||||||
sendType: 'message',
|
|
||||||
}),
|
|
||||||
saveErrors
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await messageSendPromise;
|
|
||||||
|
|
||||||
if (
|
|
||||||
getLastChallengeError({
|
|
||||||
errors: messageSendErrors,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
log.info(
|
|
||||||
`message ${messageId} hit a spam challenge. Not retrying any more`
|
|
||||||
);
|
|
||||||
await message.saveErrors(messageSendErrors);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const didFullySend =
|
|
||||||
!messageSendErrors.length || didSendToEveryone(message);
|
|
||||||
if (!didFullySend) {
|
|
||||||
throw new Error('message did not fully send');
|
|
||||||
}
|
|
||||||
} catch (thrownError: unknown) {
|
|
||||||
await handleMultipleSendErrors({
|
|
||||||
errors: [thrownError, ...messageSendErrors],
|
|
||||||
isFinalAttempt,
|
|
||||||
log,
|
|
||||||
markFailed: () => markMessageFailed(message, messageSendErrors),
|
|
||||||
timeRemaining,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const normalMessageSendJobQueue = new NormalMessageSendJobQueue({
|
|
||||||
store: jobQueueDatabaseStore,
|
|
||||||
queueType: 'normal message send',
|
|
||||||
maxAttempts: MAX_ATTEMPTS,
|
|
||||||
});
|
|
||||||
|
|
||||||
function getMessageRecipients({
|
|
||||||
conversation,
|
|
||||||
message,
|
|
||||||
}: Readonly<{
|
|
||||||
conversation: ConversationModel;
|
|
||||||
message: MessageModel;
|
|
||||||
}>): {
|
|
||||||
allRecipientIdentifiers: Array<string>;
|
|
||||||
recipientIdentifiersWithoutMe: Array<string>;
|
|
||||||
untrustedConversationIds: Array<string>;
|
|
||||||
} {
|
|
||||||
const allRecipientIdentifiers: Array<string> = [];
|
|
||||||
const recipientIdentifiersWithoutMe: Array<string> = [];
|
|
||||||
const untrustedConversationIds: Array<string> = [];
|
|
||||||
|
|
||||||
const currentConversationRecipients =
|
|
||||||
conversation.getRecipientConversationIds();
|
|
||||||
|
|
||||||
Object.entries(message.get('sendStateByConversationId') || {}).forEach(
|
|
||||||
([recipientConversationId, sendState]) => {
|
|
||||||
if (isSent(sendState.status)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipient = window.ConversationController.get(
|
|
||||||
recipientConversationId
|
|
||||||
);
|
|
||||||
if (!recipient) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRecipientMe = isMe(recipient.attributes);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!currentConversationRecipients.has(recipientConversationId) &&
|
|
||||||
!isRecipientMe
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipient.isUntrusted()) {
|
|
||||||
untrustedConversationIds.push(recipientConversationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipientIdentifier = recipient.getSendTarget();
|
|
||||||
if (!recipientIdentifier) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
allRecipientIdentifiers.push(recipientIdentifier);
|
|
||||||
if (!isRecipientMe) {
|
|
||||||
recipientIdentifiersWithoutMe.push(recipientIdentifier);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
allRecipientIdentifiers,
|
|
||||||
recipientIdentifiersWithoutMe,
|
|
||||||
untrustedConversationIds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMessageSendData({
|
|
||||||
conversation,
|
|
||||||
log,
|
|
||||||
message,
|
|
||||||
}: Readonly<{
|
|
||||||
conversation: ConversationModel;
|
|
||||||
log: LoggerType;
|
|
||||||
message: MessageModel;
|
|
||||||
}>): Promise<{
|
|
||||||
attachments: Array<AttachmentType>;
|
|
||||||
body: undefined | string;
|
|
||||||
deletedForEveryoneTimestamp: undefined | number;
|
|
||||||
expireTimer: undefined | number;
|
|
||||||
mentions: undefined | BodyRangesType;
|
|
||||||
messageTimestamp: number;
|
|
||||||
preview: Array<LinkPreviewType>;
|
|
||||||
profileKey: undefined | Uint8Array;
|
|
||||||
quote: WhatIsThis;
|
|
||||||
sticker: WhatIsThis;
|
|
||||||
}> {
|
|
||||||
let messageTimestamp: number;
|
|
||||||
const sentAt = message.get('sent_at');
|
|
||||||
const timestamp = message.get('timestamp');
|
|
||||||
if (sentAt) {
|
|
||||||
messageTimestamp = sentAt;
|
|
||||||
} else if (timestamp) {
|
|
||||||
log.error('message lacked sent_at. Falling back to timestamp');
|
|
||||||
messageTimestamp = timestamp;
|
|
||||||
} else {
|
|
||||||
log.error(
|
|
||||||
'message lacked sent_at and timestamp. Falling back to current time'
|
|
||||||
);
|
|
||||||
messageTimestamp = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
const [attachmentsWithData, preview, quote, sticker, profileKey] =
|
|
||||||
await Promise.all([
|
|
||||||
// We don't update the caches here because (1) we expect the caches to be populated
|
|
||||||
// on initial send, so they should be there in the 99% case (2) if you're retrying
|
|
||||||
// a failed message across restarts, we don't touch the cache for simplicity. If
|
|
||||||
// sends are failing, let's not add the complication of a cache.
|
|
||||||
Promise.all((message.get('attachments') ?? []).map(loadAttachmentData)),
|
|
||||||
message.cachedOutgoingPreviewData ||
|
|
||||||
loadPreviewData(message.get('preview')),
|
|
||||||
message.cachedOutgoingQuoteData || loadQuoteData(message.get('quote')),
|
|
||||||
message.cachedOutgoingStickerData ||
|
|
||||||
loadStickerData(message.get('sticker')),
|
|
||||||
conversation.get('profileSharing')
|
|
||||||
? ourProfileKeyService.get()
|
|
||||||
: undefined,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { body, attachments } = window.Whisper.Message.getLongMessageAttachment(
|
|
||||||
{
|
|
||||||
body: message.get('body'),
|
|
||||||
attachments: attachmentsWithData,
|
|
||||||
now: messageTimestamp,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
attachments,
|
|
||||||
body,
|
|
||||||
deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'),
|
|
||||||
expireTimer: message.get('expireTimer'),
|
|
||||||
mentions: message.get('bodyRanges'),
|
|
||||||
messageTimestamp,
|
|
||||||
preview,
|
|
||||||
profileKey,
|
|
||||||
quote,
|
|
||||||
sticker,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markMessageFailed(
|
|
||||||
message: MessageModel,
|
|
||||||
errors: Array<Error>
|
|
||||||
): Promise<void> {
|
|
||||||
message.markFailed();
|
|
||||||
message.saveErrors(errors, { skipSave: true });
|
|
||||||
await window.Signal.Data.saveMessage(message.attributes, {
|
|
||||||
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function didSendToEveryone(message: Readonly<MessageModel>): boolean {
|
|
||||||
const sendStateByConversationId =
|
|
||||||
message.get('sendStateByConversationId') || {};
|
|
||||||
return Object.values(sendStateByConversationId).every(sendState =>
|
|
||||||
isSent(sendState.status)
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,350 +0,0 @@
|
||||||
// Copyright 2021-2022 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import * as z from 'zod';
|
|
||||||
import type PQueue from 'p-queue';
|
|
||||||
import { repeat, zipObject } from '../util/iterables';
|
|
||||||
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
|
|
||||||
import * as durations from '../util/durations';
|
|
||||||
|
|
||||||
import type { LoggerType } from '../types/Logging';
|
|
||||||
import type { CallbackResultType } from '../textsecure/Types.d';
|
|
||||||
import type { MessageModel } from '../models/messages';
|
|
||||||
import type { MessageReactionType } from '../model-types.d';
|
|
||||||
import type { ConversationModel } from '../models/conversations';
|
|
||||||
|
|
||||||
import * as reactionUtil from '../reactions/util';
|
|
||||||
import { isSent, SendStatus } from '../messages/MessageSendState';
|
|
||||||
import { getMessageById } from '../messages/getMessageById';
|
|
||||||
import { isMe, isDirectConversation } from '../util/whatTypeOfConversation';
|
|
||||||
import { getSendOptions } from '../util/getSendOptions';
|
|
||||||
import { SignalService as Proto } from '../protobuf';
|
|
||||||
import { handleMessageSend } from '../util/handleMessageSend';
|
|
||||||
import { ourProfileKeyService } from '../services/ourProfileKey';
|
|
||||||
import { canReact } from '../state/selectors/message';
|
|
||||||
import { findAndFormatContact } from '../util/findAndFormatContact';
|
|
||||||
import { UUID } from '../types/UUID';
|
|
||||||
|
|
||||||
import { JobQueue } from './JobQueue';
|
|
||||||
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
|
||||||
import { commonShouldJobContinue } from './helpers/commonShouldJobContinue';
|
|
||||||
import { handleMultipleSendErrors } from './helpers/handleMultipleSendErrors';
|
|
||||||
import { InMemoryQueues } from './helpers/InMemoryQueues';
|
|
||||||
|
|
||||||
const MAX_RETRY_TIME = durations.DAY;
|
|
||||||
const MAX_ATTEMPTS = exponentialBackoffMaxAttempts(MAX_RETRY_TIME);
|
|
||||||
|
|
||||||
const reactionJobData = z.object({
|
|
||||||
messageId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ReactionJobData = z.infer<typeof reactionJobData>;
|
|
||||||
|
|
||||||
export class ReactionJobQueue extends JobQueue<ReactionJobData> {
|
|
||||||
private readonly inMemoryQueues = new InMemoryQueues();
|
|
||||||
|
|
||||||
protected parseData(data: unknown): ReactionJobData {
|
|
||||||
return reactionJobData.parse(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override getInMemoryQueue({
|
|
||||||
data,
|
|
||||||
}: Readonly<{ data: Pick<ReactionJobData, 'messageId'> }>): PQueue {
|
|
||||||
return this.inMemoryQueues.get(data.messageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async run(
|
|
||||||
{ data, timestamp }: Readonly<{ data: ReactionJobData; timestamp: number }>,
|
|
||||||
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
|
|
||||||
): Promise<void> {
|
|
||||||
const { messageId } = data;
|
|
||||||
const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
|
|
||||||
|
|
||||||
const timeRemaining = timestamp + MAX_RETRY_TIME - Date.now();
|
|
||||||
const isFinalAttempt = attempt >= MAX_ATTEMPTS;
|
|
||||||
|
|
||||||
// We don't immediately use this value because we may want to mark the reaction
|
|
||||||
// failed before doing so.
|
|
||||||
const shouldContinue = await commonShouldJobContinue({
|
|
||||||
attempt,
|
|
||||||
log,
|
|
||||||
timeRemaining,
|
|
||||||
});
|
|
||||||
|
|
||||||
await window.ConversationController.load();
|
|
||||||
|
|
||||||
const ourConversationId =
|
|
||||||
window.ConversationController.getOurConversationIdOrThrow();
|
|
||||||
|
|
||||||
const message = await getMessageById(messageId);
|
|
||||||
if (!message) {
|
|
||||||
log.info(
|
|
||||||
`message ${messageId} was not found, maybe because it was deleted. Giving up on sending its reactions`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pendingReaction, emojiToRemove } =
|
|
||||||
reactionUtil.getNewestPendingOutgoingReaction(
|
|
||||||
getReactions(message),
|
|
||||||
ourConversationId
|
|
||||||
);
|
|
||||||
if (!pendingReaction) {
|
|
||||||
log.info(`no pending reaction for ${messageId}. Doing nothing`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!canReact(message.attributes, ourConversationId, findAndFormatContact)
|
|
||||||
) {
|
|
||||||
log.info(
|
|
||||||
`could not react to ${messageId}. Removing this pending reaction`
|
|
||||||
);
|
|
||||||
markReactionFailed(message, pendingReaction);
|
|
||||||
await window.Signal.Data.saveMessage(message.attributes, { ourUuid });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldContinue) {
|
|
||||||
log.info(
|
|
||||||
`reacting to message ${messageId} ran out of time. Giving up on sending it`
|
|
||||||
);
|
|
||||||
markReactionFailed(message, pendingReaction);
|
|
||||||
await window.Signal.Data.saveMessage(message.attributes, { ourUuid });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sendErrors: Array<Error> = [];
|
|
||||||
const saveErrors = (errors: Array<Error>): void => {
|
|
||||||
sendErrors = errors;
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const conversation = message.getConversation();
|
|
||||||
if (!conversation) {
|
|
||||||
throw new Error(
|
|
||||||
`could not find conversation for message with ID ${messageId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { allRecipientIdentifiers, recipientIdentifiersWithoutMe } =
|
|
||||||
getRecipients(pendingReaction, conversation);
|
|
||||||
|
|
||||||
const expireTimer = message.get('expireTimer');
|
|
||||||
const profileKey = conversation.get('profileSharing')
|
|
||||||
? await ourProfileKeyService.get()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const reactionForSend = pendingReaction.emoji
|
|
||||||
? pendingReaction
|
|
||||||
: {
|
|
||||||
...pendingReaction,
|
|
||||||
emoji: emojiToRemove,
|
|
||||||
remove: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ephemeralMessageForReactionSend = new window.Whisper.Message({
|
|
||||||
id: UUID.generate.toString(),
|
|
||||||
type: 'outgoing',
|
|
||||||
conversationId: conversation.get('id'),
|
|
||||||
sent_at: pendingReaction.timestamp,
|
|
||||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
||||||
received_at_ms: pendingReaction.timestamp,
|
|
||||||
reaction: reactionForSend,
|
|
||||||
timestamp: pendingReaction.timestamp,
|
|
||||||
sendStateByConversationId: zipObject(
|
|
||||||
Object.keys(pendingReaction.isSentByConversationId || {}),
|
|
||||||
repeat({
|
|
||||||
status: SendStatus.Pending,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
});
|
|
||||||
ephemeralMessageForReactionSend.doNotSave = true;
|
|
||||||
|
|
||||||
let didFullySend: boolean;
|
|
||||||
const successfulConversationIds = new Set<string>();
|
|
||||||
|
|
||||||
if (recipientIdentifiersWithoutMe.length === 0) {
|
|
||||||
log.info('sending sync reaction message only');
|
|
||||||
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
|
||||||
attachments: [],
|
|
||||||
expireTimer,
|
|
||||||
groupV2: conversation.getGroupV2Info({
|
|
||||||
members: recipientIdentifiersWithoutMe,
|
|
||||||
}),
|
|
||||||
preview: [],
|
|
||||||
profileKey,
|
|
||||||
reaction: reactionForSend,
|
|
||||||
recipients: allRecipientIdentifiers,
|
|
||||||
timestamp: pendingReaction.timestamp,
|
|
||||||
});
|
|
||||||
await ephemeralMessageForReactionSend.sendSyncMessageOnly(
|
|
||||||
dataMessage,
|
|
||||||
saveErrors
|
|
||||||
);
|
|
||||||
|
|
||||||
didFullySend = true;
|
|
||||||
successfulConversationIds.add(ourConversationId);
|
|
||||||
} else {
|
|
||||||
const sendOptions = await getSendOptions(conversation.attributes);
|
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
|
||||||
|
|
||||||
let promise: Promise<CallbackResultType>;
|
|
||||||
if (isDirectConversation(conversation.attributes)) {
|
|
||||||
log.info('sending direct reaction message');
|
|
||||||
promise = window.textsecure.messaging.sendMessageToIdentifier({
|
|
||||||
identifier: recipientIdentifiersWithoutMe[0],
|
|
||||||
messageText: undefined,
|
|
||||||
attachments: [],
|
|
||||||
quote: undefined,
|
|
||||||
preview: [],
|
|
||||||
sticker: undefined,
|
|
||||||
reaction: reactionForSend,
|
|
||||||
deletedForEveryoneTimestamp: undefined,
|
|
||||||
timestamp: pendingReaction.timestamp,
|
|
||||||
expireTimer,
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
groupId: undefined,
|
|
||||||
profileKey,
|
|
||||||
options: sendOptions,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
log.info('sending group reaction message');
|
|
||||||
promise = window.Signal.Util.sendToGroup({
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
groupSendOptions: {
|
|
||||||
groupV1: conversation.getGroupV1Info(
|
|
||||||
recipientIdentifiersWithoutMe
|
|
||||||
),
|
|
||||||
groupV2: conversation.getGroupV2Info({
|
|
||||||
members: recipientIdentifiersWithoutMe,
|
|
||||||
}),
|
|
||||||
reaction: reactionForSend,
|
|
||||||
timestamp: pendingReaction.timestamp,
|
|
||||||
expireTimer,
|
|
||||||
profileKey,
|
|
||||||
},
|
|
||||||
messageId,
|
|
||||||
sendOptions,
|
|
||||||
sendTarget: conversation.toSenderKeyTarget(),
|
|
||||||
sendType: 'reaction',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await ephemeralMessageForReactionSend.send(
|
|
||||||
handleMessageSend(promise, {
|
|
||||||
messageIds: [messageId],
|
|
||||||
sendType: 'reaction',
|
|
||||||
}),
|
|
||||||
saveErrors
|
|
||||||
);
|
|
||||||
|
|
||||||
didFullySend = true;
|
|
||||||
const reactionSendStateByConversationId =
|
|
||||||
ephemeralMessageForReactionSend.get('sendStateByConversationId') ||
|
|
||||||
{};
|
|
||||||
for (const [conversationId, sendState] of Object.entries(
|
|
||||||
reactionSendStateByConversationId
|
|
||||||
)) {
|
|
||||||
if (isSent(sendState.status)) {
|
|
||||||
successfulConversationIds.add(conversationId);
|
|
||||||
} else {
|
|
||||||
didFullySend = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newReactions = reactionUtil.markOutgoingReactionSent(
|
|
||||||
getReactions(message),
|
|
||||||
pendingReaction,
|
|
||||||
successfulConversationIds
|
|
||||||
);
|
|
||||||
setReactions(message, newReactions);
|
|
||||||
|
|
||||||
if (!didFullySend) {
|
|
||||||
throw new Error('reaction did not fully send');
|
|
||||||
}
|
|
||||||
} catch (thrownError: unknown) {
|
|
||||||
await handleMultipleSendErrors({
|
|
||||||
errors: [thrownError, ...sendErrors],
|
|
||||||
isFinalAttempt,
|
|
||||||
log,
|
|
||||||
markFailed: () => markReactionFailed(message, pendingReaction),
|
|
||||||
timeRemaining,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await window.Signal.Data.saveMessage(message.attributes, { ourUuid });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const reactionJobQueue = new ReactionJobQueue({
|
|
||||||
store: jobQueueDatabaseStore,
|
|
||||||
queueType: 'reactions',
|
|
||||||
maxAttempts: MAX_ATTEMPTS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const getReactions = (message: MessageModel): Array<MessageReactionType> =>
|
|
||||||
message.get('reactions') || [];
|
|
||||||
|
|
||||||
const setReactions = (
|
|
||||||
message: MessageModel,
|
|
||||||
reactions: Array<MessageReactionType>
|
|
||||||
): void => {
|
|
||||||
if (reactions.length) {
|
|
||||||
message.set('reactions', reactions);
|
|
||||||
} else {
|
|
||||||
message.unset('reactions');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function getRecipients(
|
|
||||||
reaction: Readonly<MessageReactionType>,
|
|
||||||
conversation: ConversationModel
|
|
||||||
): {
|
|
||||||
allRecipientIdentifiers: Array<string>;
|
|
||||||
recipientIdentifiersWithoutMe: Array<string>;
|
|
||||||
} {
|
|
||||||
const allRecipientIdentifiers: Array<string> = [];
|
|
||||||
const recipientIdentifiersWithoutMe: Array<string> = [];
|
|
||||||
|
|
||||||
const currentConversationRecipients =
|
|
||||||
conversation.getRecipientConversationIds();
|
|
||||||
|
|
||||||
for (const id of reactionUtil.getUnsentConversationIds(reaction)) {
|
|
||||||
const recipient = window.ConversationController.get(id);
|
|
||||||
if (!recipient) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipientIdentifier = recipient.getSendTarget();
|
|
||||||
const isRecipientMe = isMe(recipient.attributes);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!recipientIdentifier ||
|
|
||||||
recipient.isUntrusted() ||
|
|
||||||
(!currentConversationRecipients.has(id) && !isRecipientMe)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
allRecipientIdentifiers.push(recipientIdentifier);
|
|
||||||
if (!isRecipientMe) {
|
|
||||||
recipientIdentifiersWithoutMe.push(recipientIdentifier);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { allRecipientIdentifiers, recipientIdentifiersWithoutMe };
|
|
||||||
}
|
|
||||||
|
|
||||||
function markReactionFailed(
|
|
||||||
message: MessageModel,
|
|
||||||
pendingReaction: MessageReactionType
|
|
||||||
): void {
|
|
||||||
const newReactions = reactionUtil.markOutgoingReactionFailed(
|
|
||||||
getReactions(message),
|
|
||||||
pendingReaction
|
|
||||||
);
|
|
||||||
setReactions(message, newReactions);
|
|
||||||
}
|
|
|
@ -16,8 +16,10 @@ import { handleMessageSend } from '../util/handleMessageSend';
|
||||||
import { getSendOptions } from '../util/getSendOptions';
|
import { getSendOptions } from '../util/getSendOptions';
|
||||||
import type { SingleProtoJobData } from '../textsecure/SendMessage';
|
import type { SingleProtoJobData } from '../textsecure/SendMessage';
|
||||||
import { singleProtoJobDataSchema } from '../textsecure/SendMessage';
|
import { singleProtoJobDataSchema } from '../textsecure/SendMessage';
|
||||||
import { handleMultipleSendErrors } from './helpers/handleMultipleSendErrors';
|
import {
|
||||||
import { SendMessageProtoError } from '../textsecure/Errors';
|
handleMultipleSendErrors,
|
||||||
|
maybeExpandErrors,
|
||||||
|
} from './helpers/handleMultipleSendErrors';
|
||||||
|
|
||||||
const MAX_RETRY_TIME = DAY;
|
const MAX_RETRY_TIME = DAY;
|
||||||
const MAX_PARALLEL_JOBS = 5;
|
const MAX_PARALLEL_JOBS = 5;
|
||||||
|
@ -91,16 +93,12 @@ export class SingleProtoJobQueue extends JobQueue<SingleProtoJobData> {
|
||||||
{ messageIds, sendType: type }
|
{ messageIds, sendType: type }
|
||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const errors =
|
|
||||||
error instanceof SendMessageProtoError
|
|
||||||
? error.errors || [error]
|
|
||||||
: [error];
|
|
||||||
|
|
||||||
await handleMultipleSendErrors({
|
await handleMultipleSendErrors({
|
||||||
errors,
|
errors: maybeExpandErrors(error),
|
||||||
isFinalAttempt,
|
isFinalAttempt,
|
||||||
log,
|
log,
|
||||||
timeRemaining,
|
timeRemaining,
|
||||||
|
toThrow: error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,6 @@ import { getTextWithMentions } from '../util/getTextWithMentions';
|
||||||
import { migrateColor } from '../util/migrateColor';
|
import { migrateColor } from '../util/migrateColor';
|
||||||
import { isNotNil } from '../util/isNotNil';
|
import { isNotNil } from '../util/isNotNil';
|
||||||
import { dropNull } from '../util/dropNull';
|
import { dropNull } from '../util/dropNull';
|
||||||
import { ourProfileKeyService } from '../services/ourProfileKey';
|
|
||||||
import { notificationService } from '../services/notifications';
|
import { notificationService } from '../services/notifications';
|
||||||
import { getSendOptions } from '../util/getSendOptions';
|
import { getSendOptions } from '../util/getSendOptions';
|
||||||
import { isConversationAccepted } from '../util/isConversationAccepted';
|
import { isConversationAccepted } from '../util/isConversationAccepted';
|
||||||
|
@ -94,7 +93,10 @@ import {
|
||||||
isTapToView,
|
isTapToView,
|
||||||
getMessagePropStatus,
|
getMessagePropStatus,
|
||||||
} from '../state/selectors/message';
|
} from '../state/selectors/message';
|
||||||
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
|
import {
|
||||||
|
conversationJobQueue,
|
||||||
|
conversationQueueJobEnum,
|
||||||
|
} from '../jobs/conversationJobQueue';
|
||||||
import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue';
|
import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue';
|
||||||
import { Deletes } from '../messageModifiers/Deletes';
|
import { Deletes } from '../messageModifiers/Deletes';
|
||||||
import type { ReactionModel } from '../messageModifiers/Reactions';
|
import type { ReactionModel } from '../messageModifiers/Reactions';
|
||||||
|
@ -867,10 +869,7 @@ export class ConversationModel extends window.Backbone
|
||||||
}
|
}
|
||||||
|
|
||||||
isGroupV1AndDisabled(): boolean {
|
isGroupV1AndDisabled(): boolean {
|
||||||
return (
|
return isGroupV1(this.attributes);
|
||||||
isGroupV1(this.attributes) &&
|
|
||||||
window.Signal.RemoteConfig.isEnabled('desktop.disableGV1')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isBlocked(): boolean {
|
isBlocked(): boolean {
|
||||||
|
@ -2453,29 +2452,6 @@ export class ConversationModel extends window.Backbone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We only want to throw if there's a 'real' error contained with this information
|
|
||||||
// coming back from our low-level send infrastructure.
|
|
||||||
processSendResponse(
|
|
||||||
result: Error | CallbackResultType
|
|
||||||
): result is CallbackResultType {
|
|
||||||
if (result instanceof Error) {
|
|
||||||
throw result;
|
|
||||||
} else if (result && result.errors) {
|
|
||||||
// We filter out unregistered user errors, because we ignore those in groups
|
|
||||||
const wasThereARealError = window._.some(
|
|
||||||
result.errors,
|
|
||||||
error => error.name !== 'UnregisteredUserError'
|
|
||||||
);
|
|
||||||
if (wasThereARealError) {
|
|
||||||
throw result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async safeGetVerified(): Promise<number> {
|
async safeGetVerified(): Promise<number> {
|
||||||
const uuid = this.getUuid();
|
const uuid = this.getUuid();
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
|
@ -3529,6 +3505,11 @@ export class ConversationModel extends window.Backbone
|
||||||
includePendingMembers?: boolean;
|
includePendingMembers?: boolean;
|
||||||
extraConversationsForSend?: Array<string>;
|
extraConversationsForSend?: Array<string>;
|
||||||
} = {}): Array<string> {
|
} = {}): Array<string> {
|
||||||
|
if (isDirectConversation(this.attributes)) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
return [this.getSendTarget()!];
|
||||||
|
}
|
||||||
|
|
||||||
const members = this.getMembers({ includePendingMembers });
|
const members = this.getMembers({ includePendingMembers });
|
||||||
|
|
||||||
// There are cases where we need to send to someone we just removed from the group, to
|
// There are cases where we need to send to someone we just removed from the group, to
|
||||||
|
@ -3723,145 +3704,55 @@ export class ConversationModel extends window.Backbone
|
||||||
throw new Error('Cannot send DOE for a message older than three hours');
|
throw new Error('Cannot send DOE for a message older than three hours');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await conversationJobQueue.add({
|
||||||
|
type: conversationQueueJobEnum.enum.DeleteForEveryone,
|
||||||
|
conversationId: this.id,
|
||||||
|
messageId,
|
||||||
|
recipients: this.getRecipients(),
|
||||||
|
revision: this.get('revision'),
|
||||||
|
targetTimestamp,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
'sendDeleteForEveryoneMessage: Failed to queue delete for everyone',
|
||||||
|
Errors.toLogFormat(error)
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const deleteModel = Deletes.getSingleton().add({
|
const deleteModel = Deletes.getSingleton().add({
|
||||||
targetSentTimestamp: targetTimestamp,
|
targetSentTimestamp: targetTimestamp,
|
||||||
fromId: window.ConversationController.getOurConversationId(),
|
fromId: window.ConversationController.getOurConversationId(),
|
||||||
});
|
});
|
||||||
|
Deletes.getSingleton().onDelete(deleteModel);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const destination = this.getSendTarget()!;
|
|
||||||
|
|
||||||
await this.queueJob('sendDeleteForEveryone', async () => {
|
|
||||||
log.info(
|
|
||||||
'Sending deleteForEveryone to conversation',
|
|
||||||
this.idForLogging(),
|
|
||||||
'with timestamp',
|
|
||||||
timestamp
|
|
||||||
);
|
|
||||||
|
|
||||||
// We are only creating this model so we can use its sync message
|
|
||||||
// sending functionality. It will not be saved to the database.
|
|
||||||
const message = new window.Whisper.Message({
|
|
||||||
id: UUID.generate().toString(),
|
|
||||||
type: 'outgoing',
|
|
||||||
conversationId: this.get('id'),
|
|
||||||
sent_at: timestamp,
|
|
||||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
||||||
received_at_ms: timestamp,
|
|
||||||
deletedForEveryoneTimestamp: targetTimestamp,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
// We're offline!
|
|
||||||
if (!window.textsecure.messaging) {
|
|
||||||
throw new Error('Cannot send DOE while offline!');
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendOptions = await getSendOptions(this.attributes);
|
|
||||||
|
|
||||||
const promise = (async () => {
|
|
||||||
let profileKey: Uint8Array | undefined;
|
|
||||||
if (this.get('profileSharing')) {
|
|
||||||
profileKey = await ourProfileKeyService.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
|
||||||
|
|
||||||
if (isDirectConversation(this.attributes)) {
|
|
||||||
return window.textsecure.messaging.sendMessageToIdentifier({
|
|
||||||
identifier: destination,
|
|
||||||
messageText: undefined,
|
|
||||||
attachments: [],
|
|
||||||
quote: undefined,
|
|
||||||
preview: [],
|
|
||||||
sticker: undefined,
|
|
||||||
reaction: undefined,
|
|
||||||
deletedForEveryoneTimestamp: targetTimestamp,
|
|
||||||
timestamp,
|
|
||||||
expireTimer: undefined,
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
groupId: undefined,
|
|
||||||
profileKey,
|
|
||||||
options: sendOptions,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return window.Signal.Util.sendToGroup({
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
groupSendOptions: {
|
|
||||||
groupV1: this.getGroupV1Info(),
|
|
||||||
groupV2: this.getGroupV2Info(),
|
|
||||||
deletedForEveryoneTimestamp: targetTimestamp,
|
|
||||||
timestamp,
|
|
||||||
profileKey,
|
|
||||||
},
|
|
||||||
messageId,
|
|
||||||
sendOptions,
|
|
||||||
sendTarget: this.toSenderKeyTarget(),
|
|
||||||
sendType: 'deleteForEveryone',
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
// This is to ensure that the functions in send() and sendSyncMessage() don't save
|
|
||||||
// anything to the database.
|
|
||||||
message.doNotSave = true;
|
|
||||||
|
|
||||||
const result = await message.send(
|
|
||||||
handleMessageSend(promise, {
|
|
||||||
messageIds: [messageId],
|
|
||||||
sendType: 'deleteForEveryone',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!message.hasSuccessfulDelivery()) {
|
|
||||||
// This is handled by `conversation_view` which displays a toast on
|
|
||||||
// send error.
|
|
||||||
throw new Error('No successful delivery for delete for everyone');
|
|
||||||
}
|
|
||||||
Deletes.getSingleton().onDelete(deleteModel);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}).catch(error => {
|
|
||||||
log.error(
|
|
||||||
'Error sending deleteForEveryone',
|
|
||||||
deleteModel,
|
|
||||||
targetTimestamp,
|
|
||||||
error && error.stack
|
|
||||||
);
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendProfileKeyUpdate(): Promise<void> {
|
async sendProfileKeyUpdate(): Promise<void> {
|
||||||
const id = this.get('id');
|
if (isMe(this.attributes)) {
|
||||||
const recipients = this.getRecipients();
|
|
||||||
if (!this.get('profileSharing')) {
|
|
||||||
log.error(
|
|
||||||
'Attempted to send profileKeyUpdate to conversation without profileSharing enabled',
|
|
||||||
id,
|
|
||||||
recipients
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.info('Sending profileKeyUpdate to conversation', id, recipients);
|
|
||||||
const profileKey = await ourProfileKeyService.get();
|
if (!this.get('profileSharing')) {
|
||||||
if (!profileKey) {
|
|
||||||
log.error(
|
log.error(
|
||||||
'Attempted to send profileKeyUpdate but our profile key was not found'
|
'sendProfileKeyUpdate: profileSharing not enabled for conversation',
|
||||||
|
this.idForLogging()
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await handleMessageSend(
|
try {
|
||||||
window.textsecure.messaging.sendProfileKeyUpdate(
|
await conversationJobQueue.add({
|
||||||
profileKey,
|
type: conversationQueueJobEnum.enum.ProfileKey,
|
||||||
recipients,
|
conversationId: this.id,
|
||||||
await getSendOptions(this.attributes),
|
revision: this.get('revision'),
|
||||||
this.get('groupId')
|
});
|
||||||
),
|
} catch (error) {
|
||||||
{ messageIds: [], sendType: 'profileKeyUpdate' }
|
log.error(
|
||||||
);
|
'sendProfileKeyUpdate: Failed to queue profile share',
|
||||||
|
Errors.toLogFormat(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async enqueueMessageForSend(
|
async enqueueMessageForSend(
|
||||||
|
@ -3979,8 +3870,13 @@ export class ConversationModel extends window.Backbone
|
||||||
'Expected a timestamp'
|
'Expected a timestamp'
|
||||||
);
|
);
|
||||||
|
|
||||||
await normalMessageSendJobQueue.add(
|
await conversationJobQueue.add(
|
||||||
{ messageId: message.id, conversationId: this.id },
|
{
|
||||||
|
type: conversationQueueJobEnum.enum.NormalMessage,
|
||||||
|
conversationId: this.id,
|
||||||
|
messageId: message.id,
|
||||||
|
revision: this.get('revision'),
|
||||||
|
},
|
||||||
async jobToInsert => {
|
async jobToInsert => {
|
||||||
log.info(
|
log.info(
|
||||||
`enqueueMessageForSend: saving message ${message.id} and job ${jobToInsert.id}`
|
`enqueueMessageForSend: saving message ${message.id} and job ${jobToInsert.id}`
|
||||||
|
@ -4374,6 +4270,12 @@ export class ConversationModel extends window.Backbone
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isGroupV1AndDisabled()) {
|
||||||
|
throw new Error(
|
||||||
|
'updateExpirationTimer: GroupV1 is deprecated; cannot update expiration timer'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let expireTimer: number | undefined = providedExpireTimer;
|
let expireTimer: number | undefined = providedExpireTimer;
|
||||||
let source = providedSource;
|
let source = providedSource;
|
||||||
if (this.get('left')) {
|
if (this.get('left')) {
|
||||||
|
@ -4398,6 +4300,23 @@ export class ConversationModel extends window.Backbone
|
||||||
source,
|
source,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// if change wasn't made remotely, send it to the number/group
|
||||||
|
if (!receivedAt) {
|
||||||
|
try {
|
||||||
|
await conversationJobQueue.add({
|
||||||
|
type: conversationQueueJobEnum.enum.DirectExpirationTimerUpdate,
|
||||||
|
conversationId: this.id,
|
||||||
|
expireTimer,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
'updateExpirationTimer: Failed to queue expiration timer update',
|
||||||
|
Errors.toLogFormat(error)
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
source = source || window.ConversationController.getOurConversationId();
|
source = source || window.ConversationController.getOurConversationId();
|
||||||
|
|
||||||
// When we add a disappearing messages notification to the conversation, we want it
|
// When we add a disappearing messages notification to the conversation, we want it
|
||||||
|
@ -4440,69 +4359,6 @@ export class ConversationModel extends window.Backbone
|
||||||
const message = window.MessageController.register(id, model);
|
const message = window.MessageController.register(id, model);
|
||||||
this.addSingleMessage(message);
|
this.addSingleMessage(message);
|
||||||
|
|
||||||
// if change was made remotely, don't send it to the number/group
|
|
||||||
if (receivedAt) {
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendOptions = await getSendOptions(this.attributes);
|
|
||||||
|
|
||||||
let profileKey;
|
|
||||||
if (this.get('profileSharing')) {
|
|
||||||
profileKey = await ourProfileKeyService.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
let promise;
|
|
||||||
|
|
||||||
if (isMe(this.attributes)) {
|
|
||||||
const flags = Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
|
|
||||||
const dataMessage = await window.textsecure.messaging.getDataMessage({
|
|
||||||
attachments: [],
|
|
||||||
// body
|
|
||||||
// deletedForEveryoneTimestamp
|
|
||||||
expireTimer,
|
|
||||||
flags,
|
|
||||||
preview: [],
|
|
||||||
profileKey,
|
|
||||||
// quote
|
|
||||||
// reaction
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
recipients: [this.getSendTarget()!],
|
|
||||||
// sticker
|
|
||||||
timestamp: message.get('sent_at'),
|
|
||||||
});
|
|
||||||
return message.sendSyncMessageOnly(dataMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDirectConversation(this.attributes)) {
|
|
||||||
promise =
|
|
||||||
window.textsecure.messaging.sendExpirationTimerUpdateToIdentifier(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
this.getSendTarget()!,
|
|
||||||
expireTimer,
|
|
||||||
message.get('sent_at'),
|
|
||||||
profileKey,
|
|
||||||
sendOptions
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
promise = window.textsecure.messaging.sendExpirationTimerUpdateToGroup(
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
this.get('groupId')!,
|
|
||||||
this.getRecipients(),
|
|
||||||
expireTimer,
|
|
||||||
message.get('sent_at'),
|
|
||||||
profileKey,
|
|
||||||
sendOptions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await message.send(
|
|
||||||
handleMessageSend(promise, {
|
|
||||||
messageIds: [],
|
|
||||||
sendType: 'expirationTimerUpdate',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4543,49 +4399,54 @@ export class ConversationModel extends window.Backbone
|
||||||
return !this.get('left');
|
return !this.get('left');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deprecated: only applies to GroupV1
|
||||||
async leaveGroup(): Promise<void> {
|
async leaveGroup(): Promise<void> {
|
||||||
const now = Date.now();
|
if (!isGroupV1(this.attributes)) {
|
||||||
if (this.get('type') === 'group') {
|
throw new Error(
|
||||||
const groupId = this.get('groupId');
|
`leaveGroup: Group ${this.idForLogging()} is not GroupV1!`
|
||||||
|
|
||||||
if (!groupId) {
|
|
||||||
throw new Error(`leaveGroup/${this.idForLogging()}: No groupId!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupIdentifiers = this.getRecipients();
|
|
||||||
this.set({ left: true });
|
|
||||||
window.Signal.Data.updateConversation(this.attributes);
|
|
||||||
|
|
||||||
const model = new window.Whisper.Message({
|
|
||||||
group_update: { left: 'You' },
|
|
||||||
conversationId: this.id,
|
|
||||||
type: 'outgoing',
|
|
||||||
sent_at: now,
|
|
||||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
||||||
received_at_ms: now,
|
|
||||||
// TODO: DESKTOP-722
|
|
||||||
} as unknown as MessageAttributesType);
|
|
||||||
|
|
||||||
const id = await window.Signal.Data.saveMessage(model.attributes, {
|
|
||||||
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
|
||||||
});
|
|
||||||
model.set({ id });
|
|
||||||
|
|
||||||
const message = window.MessageController.register(model.id, model);
|
|
||||||
this.addSingleMessage(message);
|
|
||||||
|
|
||||||
const options = await getSendOptions(this.attributes);
|
|
||||||
message.send(
|
|
||||||
handleMessageSend(
|
|
||||||
window.textsecure.messaging.leaveGroup(
|
|
||||||
groupId,
|
|
||||||
groupIdentifiers,
|
|
||||||
options
|
|
||||||
),
|
|
||||||
{ messageIds: [], sendType: 'legacyGroupChange' }
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const groupId = this.get('groupId');
|
||||||
|
|
||||||
|
if (!groupId) {
|
||||||
|
throw new Error(`leaveGroup/${this.idForLogging()}: No groupId!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupIdentifiers = this.getRecipients();
|
||||||
|
this.set({ left: true });
|
||||||
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
|
|
||||||
|
const model = new window.Whisper.Message({
|
||||||
|
group_update: { left: 'You' },
|
||||||
|
conversationId: this.id,
|
||||||
|
type: 'outgoing',
|
||||||
|
sent_at: now,
|
||||||
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||||
|
received_at_ms: now,
|
||||||
|
// TODO: DESKTOP-722
|
||||||
|
} as unknown as MessageAttributesType);
|
||||||
|
|
||||||
|
const id = await window.Signal.Data.saveMessage(model.attributes, {
|
||||||
|
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||||
|
});
|
||||||
|
model.set({ id });
|
||||||
|
|
||||||
|
const message = window.MessageController.register(model.id, model);
|
||||||
|
this.addSingleMessage(message);
|
||||||
|
|
||||||
|
const options = await getSendOptions(this.attributes);
|
||||||
|
message.send(
|
||||||
|
handleMessageSend(
|
||||||
|
window.textsecure.messaging.leaveGroup(
|
||||||
|
groupId,
|
||||||
|
groupIdentifiers,
|
||||||
|
options
|
||||||
|
),
|
||||||
|
{ messageIds: [], sendType: 'legacyGroupChange' }
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async markRead(
|
async markRead(
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2020-2021 Signal Messenger, LLC
|
// Copyright 2020-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { isEmpty, isEqual, mapValues, maxBy, noop, omit, union } from 'lodash';
|
import { isEmpty, isEqual, mapValues, maxBy, noop, omit, union } from 'lodash';
|
||||||
|
@ -125,8 +125,10 @@ import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
|
||||||
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
|
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
|
||||||
import * as LinkPreview from '../types/LinkPreview';
|
import * as LinkPreview from '../types/LinkPreview';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
|
import {
|
||||||
import { reactionJobQueue } from '../jobs/reactionJobQueue';
|
conversationJobQueue,
|
||||||
|
conversationQueueJobEnum,
|
||||||
|
} from '../jobs/conversationJobQueue';
|
||||||
import { notificationService } from '../services/notifications';
|
import { notificationService } from '../services/notifications';
|
||||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
|
@ -144,6 +146,7 @@ import {
|
||||||
} from '../messages/helpers';
|
} from '../messages/helpers';
|
||||||
import type { ReplacementValuesType } from '../types/I18N';
|
import type { ReplacementValuesType } from '../types/I18N';
|
||||||
import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue';
|
import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue';
|
||||||
|
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
|
||||||
|
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
|
@ -1164,8 +1167,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
|
|
||||||
this.set('sendStateByConversationId', newSendStateByConversationId);
|
this.set('sendStateByConversationId', newSendStateByConversationId);
|
||||||
|
|
||||||
await normalMessageSendJobQueue.add(
|
await conversationJobQueue.add(
|
||||||
{ messageId: this.id, conversationId: conversation.id },
|
{
|
||||||
|
type: conversationQueueJobEnum.enum.NormalMessage,
|
||||||
|
conversationId: conversation.id,
|
||||||
|
messageId: this.id,
|
||||||
|
revision: conversation.get('revision'),
|
||||||
|
},
|
||||||
async jobToInsert => {
|
async jobToInsert => {
|
||||||
await window.Signal.Data.saveMessage(this.attributes, {
|
await window.Signal.Data.saveMessage(this.attributes, {
|
||||||
jobToInsert,
|
jobToInsert,
|
||||||
|
@ -1441,7 +1449,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
async sendSyncMessageOnly(
|
async sendSyncMessageOnly(
|
||||||
dataMessage: Uint8Array,
|
dataMessage: Uint8Array,
|
||||||
saveErrors?: (errors: Array<Error>) => void
|
saveErrors?: (errors: Array<Error>) => void
|
||||||
): Promise<void> {
|
): Promise<CallbackResultType | void> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const conv = this.getConversation()!;
|
const conv = this.getConversation()!;
|
||||||
this.set({ dataMessage });
|
this.set({ dataMessage });
|
||||||
|
@ -1461,8 +1469,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
? result.unidentifiedDeliveries
|
? result.unidentifiedDeliveries
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
} catch (result) {
|
return result;
|
||||||
const resultErrors = result?.errors;
|
} catch (error) {
|
||||||
|
const resultErrors = error?.errors;
|
||||||
const errors = Array.isArray(resultErrors)
|
const errors = Array.isArray(resultErrors)
|
||||||
? resultErrors
|
? resultErrors
|
||||||
: [new Error('Unknown error')];
|
: [new Error('Unknown error')];
|
||||||
|
@ -1472,6 +1481,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
// We don't save because we're about to save below.
|
// We don't save because we're about to save below.
|
||||||
this.saveErrors(errors, { skipSave: true });
|
this.saveErrors(errors, { skipSave: true });
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await window.Signal.Data.saveMessage(this.attributes, {
|
await window.Signal.Data.saveMessage(this.attributes, {
|
||||||
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||||
|
@ -3180,9 +3190,14 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (reaction.get('source') === ReactionSource.FromThisDevice) {
|
if (reaction.get('source') === ReactionSource.FromThisDevice) {
|
||||||
const jobData = { messageId: this.id };
|
const jobData: ConversationQueueJobData = {
|
||||||
|
type: conversationQueueJobEnum.enum.Reaction,
|
||||||
|
conversationId: conversation.id,
|
||||||
|
messageId: this.id,
|
||||||
|
revision: conversation.get('revision'),
|
||||||
|
};
|
||||||
if (shouldPersist) {
|
if (shouldPersist) {
|
||||||
await reactionJobQueue.add(jobData, async jobToInsert => {
|
await conversationJobQueue.add(jobData, async jobToInsert => {
|
||||||
log.info(
|
log.info(
|
||||||
`enqueueReactionForSend: saving message ${this.idForLogging()} and job ${
|
`enqueueReactionForSend: saving message ${this.idForLogging()} and job ${
|
||||||
jobToInsert.id
|
jobToInsert.id
|
||||||
|
@ -3194,7 +3209,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await reactionJobQueue.add(jobData);
|
await conversationJobQueue.add(jobData);
|
||||||
}
|
}
|
||||||
} else if (shouldPersist) {
|
} else if (shouldPersist) {
|
||||||
await window.Signal.Data.saveMessage(this.attributes, {
|
await window.Signal.Data.saveMessage(this.attributes, {
|
||||||
|
|
|
@ -76,11 +76,8 @@ import { getOwn } from '../util/getOwn';
|
||||||
import { isNormalNumber } from '../util/isNormalNumber';
|
import { isNormalNumber } from '../util/isNormalNumber';
|
||||||
import * as durations from '../util/durations';
|
import * as durations from '../util/durations';
|
||||||
import { handleMessageSend } from '../util/handleMessageSend';
|
import { handleMessageSend } from '../util/handleMessageSend';
|
||||||
import {
|
import { fetchMembershipProof, getMembershipList } from '../groups';
|
||||||
fetchMembershipProof,
|
import { wrapWithSyncMessageSend } from '../util/wrapWithSyncMessageSend';
|
||||||
getMembershipList,
|
|
||||||
wrapWithSyncMessageSend,
|
|
||||||
} from '../groups';
|
|
||||||
import type { ProcessedEnvelope } from '../textsecure/Types.d';
|
import type { ProcessedEnvelope } from '../textsecure/Types.d';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
import { normalizeGroupCallTimestamp } from '../util/ringrtc/normalizeGroupCallTimestamp';
|
import { normalizeGroupCallTimestamp } from '../util/ringrtc/normalizeGroupCallTimestamp';
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2020-2021 Signal Messenger, LLC
|
// Copyright 2020-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
|
@ -1948,6 +1948,13 @@ async function removeMessages(ids: Array<string>): Promise<void> {
|
||||||
|
|
||||||
async function getMessageById(id: string): Promise<MessageType | undefined> {
|
async function getMessageById(id: string): Promise<MessageType | undefined> {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
|
return getMessageByIdSync(db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageByIdSync(
|
||||||
|
db: Database,
|
||||||
|
id: string
|
||||||
|
): MessageType | undefined {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare<Query>('SELECT json FROM messages WHERE id = $id;')
|
.prepare<Query>('SELECT json FROM messages WHERE id = $id;')
|
||||||
.get({
|
.get({
|
||||||
|
@ -4549,7 +4556,13 @@ async function removeKnownDraftAttachments(
|
||||||
|
|
||||||
async function getJobsInQueue(queueType: string): Promise<Array<StoredJob>> {
|
async function getJobsInQueue(queueType: string): Promise<Array<StoredJob>> {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
|
return getJobsInQueueSync(db, queueType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJobsInQueueSync(
|
||||||
|
db: Database,
|
||||||
|
queueType: string
|
||||||
|
): Array<StoredJob> {
|
||||||
return db
|
return db
|
||||||
.prepare<Query>(
|
.prepare<Query>(
|
||||||
`
|
`
|
||||||
|
@ -4568,7 +4581,7 @@ async function getJobsInQueue(queueType: string): Promise<Array<StoredJob>> {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertJobSync(db: Database, job: Readonly<StoredJob>): void {
|
export function insertJobSync(db: Database, job: Readonly<StoredJob>): void {
|
||||||
db.prepare<Query>(
|
db.prepare<Query>(
|
||||||
`
|
`
|
||||||
INSERT INTO jobs
|
INSERT INTO jobs
|
||||||
|
|
109
ts/sql/migrations/51-centralize-conversation-jobs.ts
Normal file
109
ts/sql/migrations/51-centralize-conversation-jobs.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { Database } from 'better-sqlite3';
|
||||||
|
|
||||||
|
import type { LoggerType } from '../../types/Logging';
|
||||||
|
import { isRecord } from '../../util/isRecord';
|
||||||
|
import {
|
||||||
|
getJobsInQueueSync,
|
||||||
|
getMessageByIdSync,
|
||||||
|
insertJobSync,
|
||||||
|
} from '../Server';
|
||||||
|
|
||||||
|
export default function updateToSchemaVersion51(
|
||||||
|
currentVersion: number,
|
||||||
|
db: Database,
|
||||||
|
logger: LoggerType
|
||||||
|
): void {
|
||||||
|
if (currentVersion >= 51) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
const deleteJobsInQueue = db.prepare(
|
||||||
|
'DELETE FROM jobs WHERE queueType = $queueType'
|
||||||
|
);
|
||||||
|
|
||||||
|
// First, make sure that reactions job data has a type and conversationId
|
||||||
|
const reactionsJobs = getJobsInQueueSync(db, 'reactions');
|
||||||
|
deleteJobsInQueue.run({ queueType: 'reactions' });
|
||||||
|
|
||||||
|
reactionsJobs.forEach(job => {
|
||||||
|
const { data, id } = job;
|
||||||
|
|
||||||
|
if (!isRecord(data)) {
|
||||||
|
logger.warn(
|
||||||
|
`updateToSchemaVersion51: reactions queue job ${id} was missing valid data`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messageId } = data;
|
||||||
|
if (typeof messageId !== 'string') {
|
||||||
|
logger.warn(
|
||||||
|
`updateToSchemaVersion51: reactions queue job ${id} had a non-string messageId`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = getMessageByIdSync(db, messageId);
|
||||||
|
if (!message) {
|
||||||
|
logger.warn(
|
||||||
|
`updateToSchemaVersion51: Unable to find message for reaction job ${id}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { conversationId } = message;
|
||||||
|
if (typeof conversationId !== 'string') {
|
||||||
|
logger.warn(
|
||||||
|
`updateToSchemaVersion51: reactions queue job ${id} had a non-string conversationId`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newJob = {
|
||||||
|
...job,
|
||||||
|
queueType: 'conversation',
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
type: 'Reaction',
|
||||||
|
conversationId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
insertJobSync(db, newJob);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then make sure all normal send job data has a type
|
||||||
|
const normalSendJobs = getJobsInQueueSync(db, 'normal send');
|
||||||
|
deleteJobsInQueue.run({ queueType: 'normal send' });
|
||||||
|
|
||||||
|
normalSendJobs.forEach(job => {
|
||||||
|
const { data, id } = job;
|
||||||
|
|
||||||
|
if (!isRecord(data)) {
|
||||||
|
logger.warn(
|
||||||
|
`updateToSchemaVersion51: normal send queue job ${id} was missing valid data`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newJob = {
|
||||||
|
...job,
|
||||||
|
queueType: 'conversation',
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
type: 'NormalMessage',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
insertJobSync(db, newJob);
|
||||||
|
});
|
||||||
|
|
||||||
|
db.pragma('user_version = 51');
|
||||||
|
})();
|
||||||
|
|
||||||
|
logger.info('updateToSchemaVersion51: success!');
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { Database } from 'better-sqlite3';
|
import type { Database } from 'better-sqlite3';
|
||||||
|
@ -26,6 +26,7 @@ import updateToSchemaVersion47 from './47-further-optimize';
|
||||||
import updateToSchemaVersion48 from './48-fix-user-initiated-index';
|
import updateToSchemaVersion48 from './48-fix-user-initiated-index';
|
||||||
import updateToSchemaVersion49 from './49-fix-preview-index';
|
import updateToSchemaVersion49 from './49-fix-preview-index';
|
||||||
import updateToSchemaVersion50 from './50-fix-messages-unread-index';
|
import updateToSchemaVersion50 from './50-fix-messages-unread-index';
|
||||||
|
import updateToSchemaVersion51 from './51-centralize-conversation-jobs';
|
||||||
|
|
||||||
function updateToSchemaVersion1(
|
function updateToSchemaVersion1(
|
||||||
currentVersion: number,
|
currentVersion: number,
|
||||||
|
@ -1915,6 +1916,7 @@ export const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion48,
|
updateToSchemaVersion48,
|
||||||
updateToSchemaVersion49,
|
updateToSchemaVersion49,
|
||||||
updateToSchemaVersion50,
|
updateToSchemaVersion50,
|
||||||
|
updateToSchemaVersion51,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function updateSchema(db: Database, logger: LoggerType): void {
|
export function updateSchema(db: Database, logger: LoggerType): void {
|
||||||
|
|
|
@ -52,7 +52,6 @@ import {
|
||||||
getGroupSizeRecommendedLimit,
|
getGroupSizeRecommendedLimit,
|
||||||
getGroupSizeHardLimit,
|
getGroupSizeHardLimit,
|
||||||
} from '../../groups/limits';
|
} from '../../groups/limits';
|
||||||
import { getMessagesById } from '../../messages/getMessagesById';
|
|
||||||
import { isMessageUnread } from '../../util/isMessageUnread';
|
import { isMessageUnread } from '../../util/isMessageUnread';
|
||||||
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
|
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
|
||||||
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
||||||
|
@ -61,8 +60,9 @@ import { writeProfile } from '../../services/writeProfile';
|
||||||
import { writeUsername } from '../../services/writeUsername';
|
import { writeUsername } from '../../services/writeUsername';
|
||||||
import {
|
import {
|
||||||
getConversationsByUsername,
|
getConversationsByUsername,
|
||||||
|
getConversationIdsStoppingSend,
|
||||||
|
getConversationIdsStoppedForVerification,
|
||||||
getMe,
|
getMe,
|
||||||
getMessageIdsPendingBecauseOfVerification,
|
|
||||||
getUsernameSaveState,
|
getUsernameSaveState,
|
||||||
} from '../selectors/conversations';
|
} from '../selectors/conversations';
|
||||||
import type { AvatarDataType } from '../../types/Avatar';
|
import type { AvatarDataType } from '../../types/Avatar';
|
||||||
|
@ -71,9 +71,10 @@ import { getAvatarData } from '../../util/getAvatarData';
|
||||||
import { isSameAvatarData } from '../../util/isSameAvatarData';
|
import { isSameAvatarData } from '../../util/isSameAvatarData';
|
||||||
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
|
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
|
||||||
import {
|
import {
|
||||||
UsernameSaveState,
|
|
||||||
ComposerStep,
|
ComposerStep,
|
||||||
|
ConversationVerificationState,
|
||||||
OneTimeModalState,
|
OneTimeModalState,
|
||||||
|
UsernameSaveState,
|
||||||
} from './conversationsEnums';
|
} from './conversationsEnums';
|
||||||
import { showToast } from '../../util/showToast';
|
import { showToast } from '../../util/showToast';
|
||||||
import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername';
|
import { ToastFailedToDeleteUsername } from '../../components/ToastFailedToDeleteUsername';
|
||||||
|
@ -81,6 +82,7 @@ import { ToastFailedToFetchUsername } from '../../components/ToastFailedToFetchU
|
||||||
import { isValidUsername } from '../../types/Username';
|
import { isValidUsername } from '../../types/Username';
|
||||||
|
|
||||||
import type { NoopActionType } from './noop';
|
import type { NoopActionType } from './noop';
|
||||||
|
import { conversationJobQueue } from '../../jobs/conversationJobQueue';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
|
@ -277,6 +279,16 @@ type ComposerGroupCreationState = {
|
||||||
userAvatarData: Array<AvatarDataType>;
|
userAvatarData: Array<AvatarDataType>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConversationVerificationData =
|
||||||
|
| {
|
||||||
|
type: ConversationVerificationState.PendingVerification;
|
||||||
|
conversationsNeedingVerification: ReadonlyArray<string>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ConversationVerificationState.VerificationCancelled;
|
||||||
|
canceledAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type FoundUsernameType = {
|
export type FoundUsernameType = {
|
||||||
uuid: UUIDStringType;
|
uuid: UUIDStringType;
|
||||||
username: string;
|
username: string;
|
||||||
|
@ -331,13 +343,11 @@ export type ConversationsStateType = {
|
||||||
usernameSaveState: UsernameSaveState;
|
usernameSaveState: UsernameSaveState;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Each key is a conversation ID. Each value is an array of message IDs stopped by that
|
* Each key is a conversation ID. Each value is a value representing the state of
|
||||||
* conversation being unverified.
|
* verification: either a set of pending conversationIds to be approved, or a tombstone
|
||||||
|
* telling jobs to cancel themselves up to that timestamp.
|
||||||
*/
|
*/
|
||||||
outboundMessagesPendingConversationVerification: Record<
|
verificationDataByConversation: Record<string, ConversationVerificationData>;
|
||||||
string,
|
|
||||||
Array<string>
|
|
||||||
>;
|
|
||||||
|
|
||||||
// Note: it's very important that both of these locations are always kept up to date
|
// Note: it's very important that both of these locations are always kept up to date
|
||||||
messagesLookup: MessageLookupType;
|
messagesLookup: MessageLookupType;
|
||||||
|
@ -369,15 +379,14 @@ export const getConversationCallMode = (
|
||||||
return CallMode.None;
|
return CallMode.None;
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryMessages = async (messageIds: Iterable<string>): Promise<void> => {
|
|
||||||
const messages = await getMessagesById(messageIds);
|
|
||||||
await Promise.all(messages.map(message => message.retrySend()));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
const CLEAR_MESSAGES_PENDING_CONVERSATION_VERIFICATION =
|
const CANCEL_CONVERSATION_PENDING_VERIFICATION =
|
||||||
'conversations/CLEAR_MESSAGES_PENDING_CONVERSATION_VERIFICATION';
|
'conversations/CANCEL_CONVERSATION_PENDING_VERIFICATION';
|
||||||
|
const CLEAR_CANCELLED_VERIFICATION =
|
||||||
|
'conversations/CLEAR_CANCELLED_VERIFICATION';
|
||||||
|
const CLEAR_CONVERSATIONS_PENDING_VERIFICATION =
|
||||||
|
'conversations/CLEAR_CONVERSATIONS_PENDING_VERIFICATION';
|
||||||
export const COLORS_CHANGED = 'conversations/COLORS_CHANGED';
|
export const COLORS_CHANGED = 'conversations/COLORS_CHANGED';
|
||||||
export const COLOR_SELECTED = 'conversations/COLOR_SELECTED';
|
export const COLOR_SELECTED = 'conversations/COLOR_SELECTED';
|
||||||
const COMPOSE_TOGGLE_EDITING_AVATAR =
|
const COMPOSE_TOGGLE_EDITING_AVATAR =
|
||||||
|
@ -386,11 +395,17 @@ const COMPOSE_ADD_AVATAR = 'conversations/compose/ADD_AVATAR';
|
||||||
const COMPOSE_REMOVE_AVATAR = 'conversations/compose/REMOVE_AVATAR';
|
const COMPOSE_REMOVE_AVATAR = 'conversations/compose/REMOVE_AVATAR';
|
||||||
const COMPOSE_REPLACE_AVATAR = 'conversations/compose/REPLACE_AVATAR';
|
const COMPOSE_REPLACE_AVATAR = 'conversations/compose/REPLACE_AVATAR';
|
||||||
const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED';
|
const CUSTOM_COLOR_REMOVED = 'conversations/CUSTOM_COLOR_REMOVED';
|
||||||
const MESSAGE_STOPPED_BY_MISSING_VERIFICATION =
|
const CONVERSATION_STOPPED_BY_MISSING_VERIFICATION =
|
||||||
'conversations/MESSAGE_STOPPED_BY_MISSING_VERIFICATION';
|
'conversations/CONVERSATION_STOPPED_BY_MISSING_VERIFICATION';
|
||||||
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
|
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
|
||||||
const UPDATE_USERNAME_SAVE_STATE = 'conversations/UPDATE_USERNAME_SAVE_STATE';
|
const UPDATE_USERNAME_SAVE_STATE = 'conversations/UPDATE_USERNAME_SAVE_STATE';
|
||||||
|
|
||||||
|
export type CancelVerificationDataByConversationActionType = {
|
||||||
|
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
|
||||||
|
payload: {
|
||||||
|
canceledAt: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
type CantAddContactToGroupActionType = {
|
type CantAddContactToGroupActionType = {
|
||||||
type: 'CANT_ADD_CONTACT_TO_GROUP';
|
type: 'CANT_ADD_CONTACT_TO_GROUP';
|
||||||
payload: {
|
payload: {
|
||||||
|
@ -401,8 +416,14 @@ type ClearGroupCreationErrorActionType = { type: 'CLEAR_GROUP_CREATION_ERROR' };
|
||||||
type ClearInvitedUuidsForNewlyCreatedGroupActionType = {
|
type ClearInvitedUuidsForNewlyCreatedGroupActionType = {
|
||||||
type: 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP';
|
type: 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP';
|
||||||
};
|
};
|
||||||
type ClearMessagesPendingConversationVerificationActionType = {
|
type ClearVerificationDataByConversationActionType = {
|
||||||
type: typeof CLEAR_MESSAGES_PENDING_CONVERSATION_VERIFICATION;
|
type: typeof CLEAR_CONVERSATIONS_PENDING_VERIFICATION;
|
||||||
|
};
|
||||||
|
type ClearCancelledVerificationActionType = {
|
||||||
|
type: typeof CLEAR_CANCELLED_VERIFICATION;
|
||||||
|
payload: {
|
||||||
|
conversationId: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
type CloseCantAddContactToGroupModalActionType = {
|
type CloseCantAddContactToGroupModalActionType = {
|
||||||
type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL';
|
type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL';
|
||||||
|
@ -515,10 +536,10 @@ export type MessageSelectedActionType = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
type MessageStoppedByMissingVerificationActionType = {
|
type ConversationStoppedByMissingVerificationActionType = {
|
||||||
type: typeof MESSAGE_STOPPED_BY_MISSING_VERIFICATION;
|
type: typeof CONVERSATION_STOPPED_BY_MISSING_VERIFICATION;
|
||||||
payload: {
|
payload: {
|
||||||
messageId: string;
|
conversationId: string;
|
||||||
untrustedConversationIds: ReadonlyArray<string>;
|
untrustedConversationIds: ReadonlyArray<string>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -735,11 +756,13 @@ type ReplaceAvatarsActionType = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export type ConversationActionType =
|
export type ConversationActionType =
|
||||||
|
| CancelVerificationDataByConversationActionType
|
||||||
| CantAddContactToGroupActionType
|
| CantAddContactToGroupActionType
|
||||||
|
| ClearCancelledVerificationActionType
|
||||||
| ClearChangedMessagesActionType
|
| ClearChangedMessagesActionType
|
||||||
|
| ClearVerificationDataByConversationActionType
|
||||||
| ClearGroupCreationErrorActionType
|
| ClearGroupCreationErrorActionType
|
||||||
| ClearInvitedUuidsForNewlyCreatedGroupActionType
|
| ClearInvitedUuidsForNewlyCreatedGroupActionType
|
||||||
| ClearMessagesPendingConversationVerificationActionType
|
|
||||||
| ClearSelectedMessageActionType
|
| ClearSelectedMessageActionType
|
||||||
| ClearUnreadMetricsActionType
|
| ClearUnreadMetricsActionType
|
||||||
| CloseCantAddContactToGroupModalActionType
|
| CloseCantAddContactToGroupModalActionType
|
||||||
|
@ -754,12 +777,12 @@ export type ConversationActionType =
|
||||||
| ConversationAddedActionType
|
| ConversationAddedActionType
|
||||||
| ConversationChangedActionType
|
| ConversationChangedActionType
|
||||||
| ConversationRemovedActionType
|
| ConversationRemovedActionType
|
||||||
|
| ConversationStoppedByMissingVerificationActionType
|
||||||
| ConversationUnloadedActionType
|
| ConversationUnloadedActionType
|
||||||
| CreateGroupFulfilledActionType
|
| CreateGroupFulfilledActionType
|
||||||
| CreateGroupPendingActionType
|
| CreateGroupPendingActionType
|
||||||
| CreateGroupRejectedActionType
|
| CreateGroupRejectedActionType
|
||||||
| CustomColorRemovedActionType
|
| CustomColorRemovedActionType
|
||||||
| MessageStoppedByMissingVerificationActionType
|
|
||||||
| MessageChangedActionType
|
| MessageChangedActionType
|
||||||
| MessageDeletedActionType
|
| MessageDeletedActionType
|
||||||
| MessageExpandedActionType
|
| MessageExpandedActionType
|
||||||
|
@ -800,8 +823,9 @@ export type ConversationActionType =
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
cancelMessagesPendingConversationVerification,
|
cancelConversationVerification,
|
||||||
cantAddContactToGroup,
|
cantAddContactToGroup,
|
||||||
|
clearCancelledConversationVerification,
|
||||||
clearChangedMessages,
|
clearChangedMessages,
|
||||||
clearGroupCreationError,
|
clearGroupCreationError,
|
||||||
clearInvitedUuidsForNewlyCreatedGroup,
|
clearInvitedUuidsForNewlyCreatedGroup,
|
||||||
|
@ -819,11 +843,11 @@ export const actions = {
|
||||||
conversationAdded,
|
conversationAdded,
|
||||||
conversationChanged,
|
conversationChanged,
|
||||||
conversationRemoved,
|
conversationRemoved,
|
||||||
|
conversationStoppedByMissingVerification,
|
||||||
conversationUnloaded,
|
conversationUnloaded,
|
||||||
createGroup,
|
createGroup,
|
||||||
deleteAvatarFromDisk,
|
deleteAvatarFromDisk,
|
||||||
doubleCheckMissingQuoteReference,
|
doubleCheckMissingQuoteReference,
|
||||||
messageStoppedByMissingVerification,
|
|
||||||
messageChanged,
|
messageChanged,
|
||||||
messageDeleted,
|
messageDeleted,
|
||||||
messageExpanded,
|
messageExpanded,
|
||||||
|
@ -868,7 +892,7 @@ export const actions = {
|
||||||
toggleConversationInChooseMembers,
|
toggleConversationInChooseMembers,
|
||||||
toggleComposeEditingAvatar,
|
toggleComposeEditingAvatar,
|
||||||
updateConversationModelSharedGroups,
|
updateConversationModelSharedGroups,
|
||||||
verifyConversationsStoppingMessageSend,
|
verifyConversationsStoppingSend,
|
||||||
};
|
};
|
||||||
|
|
||||||
function filterAvatarData(
|
function filterAvatarData(
|
||||||
|
@ -1244,43 +1268,79 @@ function toggleComposeEditingAvatar(): ToggleComposeEditingAvatarActionType {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyConversationsStoppingMessageSend(): ThunkAction<
|
export function cancelConversationVerification(
|
||||||
|
canceledAt?: number
|
||||||
|
): ThunkAction<
|
||||||
void,
|
void,
|
||||||
RootStateType,
|
RootStateType,
|
||||||
unknown,
|
unknown,
|
||||||
ClearMessagesPendingConversationVerificationActionType
|
CancelVerificationDataByConversationActionType
|
||||||
> {
|
> {
|
||||||
return async (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const { outboundMessagesPendingConversationVerification } =
|
const state = getState();
|
||||||
getState().conversations;
|
const conversationIdsBlocked =
|
||||||
|
getConversationIdsStoppedForVerification(state);
|
||||||
const allMessageIds = new Set<string>();
|
|
||||||
const promises: Array<Promise<unknown>> = [];
|
|
||||||
|
|
||||||
Object.entries(outboundMessagesPendingConversationVerification).forEach(
|
|
||||||
([conversationId, messageIds]) => {
|
|
||||||
for (const messageId of messageIds) {
|
|
||||||
allMessageIds.add(messageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversation = window.ConversationController.get(conversationId);
|
|
||||||
if (!conversation) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (conversation.isUnverified()) {
|
|
||||||
promises.push(conversation.setVerifiedDefault());
|
|
||||||
}
|
|
||||||
promises.push(conversation.setApproved());
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
promises.push(retryMessages(allMessageIds));
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CLEAR_MESSAGES_PENDING_CONVERSATION_VERIFICATION,
|
type: CANCEL_CONVERSATION_PENDING_VERIFICATION,
|
||||||
|
payload: {
|
||||||
|
canceledAt: canceledAt ?? Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the blocked conversation queues up again
|
||||||
|
conversationIdsBlocked.forEach(conversationId => {
|
||||||
|
conversationJobQueue.resolveVerificationWaiter(conversationId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyConversationsStoppingSend(): ThunkAction<
|
||||||
|
void,
|
||||||
|
RootStateType,
|
||||||
|
unknown,
|
||||||
|
ClearVerificationDataByConversationActionType
|
||||||
|
> {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const conversationIdsStoppingSend = getConversationIdsStoppingSend(state);
|
||||||
|
const conversationIdsBlocked =
|
||||||
|
getConversationIdsStoppedForVerification(state);
|
||||||
|
|
||||||
|
// Mark conversations as approved/verified as appropriate
|
||||||
|
const promises: Array<Promise<unknown>> = [];
|
||||||
|
conversationIdsStoppingSend.forEach(async conversationId => {
|
||||||
|
const conversation = window.ConversationController.get(conversationId);
|
||||||
|
if (!conversation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (conversation.isUnverified()) {
|
||||||
|
promises.push(conversation.setVerifiedDefault());
|
||||||
|
}
|
||||||
|
promises.push(conversation.setApproved());
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: CLEAR_CONVERSATIONS_PENDING_VERIFICATION,
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
// Start the blocked conversation queues up again
|
||||||
|
conversationIdsBlocked.forEach(conversationId => {
|
||||||
|
conversationJobQueue.resolveVerificationWaiter(conversationId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCancelledConversationVerification(
|
||||||
|
conversationId: string
|
||||||
|
): ClearCancelledVerificationActionType {
|
||||||
|
return {
|
||||||
|
type: CLEAR_CANCELLED_VERIFICATION,
|
||||||
|
payload: {
|
||||||
|
conversationId,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1338,32 +1398,6 @@ function composeReplaceAvatar(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelMessagesPendingConversationVerification(): ThunkAction<
|
|
||||||
void,
|
|
||||||
RootStateType,
|
|
||||||
unknown,
|
|
||||||
ClearMessagesPendingConversationVerificationActionType
|
|
||||||
> {
|
|
||||||
return async (dispatch, getState) => {
|
|
||||||
const messageIdsPending = getMessageIdsPendingBecauseOfVerification(
|
|
||||||
getState()
|
|
||||||
);
|
|
||||||
const messagesStopped = await getMessagesById([...messageIdsPending]);
|
|
||||||
messagesStopped.forEach(message => {
|
|
||||||
message.markFailed();
|
|
||||||
});
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: CLEAR_MESSAGES_PENDING_CONVERSATION_VERIFICATION,
|
|
||||||
});
|
|
||||||
|
|
||||||
await window.Signal.Data.saveMessages(
|
|
||||||
messagesStopped.map(message => message.attributes),
|
|
||||||
{ ourUuid: window.textsecure.storage.user.getCheckedUuid().toString() }
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function cantAddContactToGroup(
|
function cantAddContactToGroup(
|
||||||
conversationId: string
|
conversationId: string
|
||||||
): CantAddContactToGroupActionType {
|
): CantAddContactToGroupActionType {
|
||||||
|
@ -1398,21 +1432,9 @@ function conversationChanged(
|
||||||
id: string,
|
id: string,
|
||||||
data: ConversationType
|
data: ConversationType
|
||||||
): ThunkAction<void, RootStateType, unknown, ConversationChangedActionType> {
|
): ThunkAction<void, RootStateType, unknown, ConversationChangedActionType> {
|
||||||
return async (dispatch, getState) => {
|
return dispatch => {
|
||||||
calling.groupMembersChanged(id);
|
calling.groupMembersChanged(id);
|
||||||
|
|
||||||
if (!data.isUntrusted) {
|
|
||||||
const messageIdsPending =
|
|
||||||
getOwn(
|
|
||||||
getState().conversations
|
|
||||||
.outboundMessagesPendingConversationVerification,
|
|
||||||
id
|
|
||||||
) ?? [];
|
|
||||||
if (messageIdsPending.length) {
|
|
||||||
retryMessages(messageIdsPending);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'CONVERSATION_CHANGED',
|
type: 'CONVERSATION_CHANGED',
|
||||||
payload: {
|
payload: {
|
||||||
|
@ -1511,16 +1533,13 @@ function selectMessage(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageStoppedByMissingVerification(
|
function conversationStoppedByMissingVerification(payload: {
|
||||||
messageId: string,
|
conversationId: string;
|
||||||
untrustedConversationIds: ReadonlyArray<string>
|
untrustedConversationIds: ReadonlyArray<string>;
|
||||||
): MessageStoppedByMissingVerificationActionType {
|
}): ConversationStoppedByMissingVerificationActionType {
|
||||||
return {
|
return {
|
||||||
type: MESSAGE_STOPPED_BY_MISSING_VERIFICATION,
|
type: CONVERSATION_STOPPED_BY_MISSING_VERIFICATION,
|
||||||
payload: {
|
payload,
|
||||||
messageId,
|
|
||||||
untrustedConversationIds,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2095,7 +2114,7 @@ export function getEmptyState(): ConversationsStateType {
|
||||||
conversationsByUuid: {},
|
conversationsByUuid: {},
|
||||||
conversationsByGroupId: {},
|
conversationsByGroupId: {},
|
||||||
conversationsByUsername: {},
|
conversationsByUsername: {},
|
||||||
outboundMessagesPendingConversationVerification: {},
|
verificationDataByConversation: {},
|
||||||
messagesByConversation: {},
|
messagesByConversation: {},
|
||||||
messagesLookup: {},
|
messagesLookup: {},
|
||||||
selectedMessageCounter: 0,
|
selectedMessageCounter: 0,
|
||||||
|
@ -2261,10 +2280,73 @@ export function reducer(
|
||||||
state: Readonly<ConversationsStateType> = getEmptyState(),
|
state: Readonly<ConversationsStateType> = getEmptyState(),
|
||||||
action: Readonly<ConversationActionType>
|
action: Readonly<ConversationActionType>
|
||||||
): ConversationsStateType {
|
): ConversationsStateType {
|
||||||
if (action.type === CLEAR_MESSAGES_PENDING_CONVERSATION_VERIFICATION) {
|
if (action.type === CLEAR_CONVERSATIONS_PENDING_VERIFICATION) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
outboundMessagesPendingConversationVerification: {},
|
verificationDataByConversation: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === CLEAR_CANCELLED_VERIFICATION) {
|
||||||
|
const { conversationId } = action.payload;
|
||||||
|
const { verificationDataByConversation } = state;
|
||||||
|
|
||||||
|
const existingPendingState = getOwn(
|
||||||
|
verificationDataByConversation,
|
||||||
|
conversationId
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there are active verifications required, this will do nothing.
|
||||||
|
if (
|
||||||
|
existingPendingState &&
|
||||||
|
existingPendingState.type ===
|
||||||
|
ConversationVerificationState.PendingVerification
|
||||||
|
) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
verificationDataByConversation: omit(
|
||||||
|
verificationDataByConversation,
|
||||||
|
conversationId
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === CANCEL_CONVERSATION_PENDING_VERIFICATION) {
|
||||||
|
const { canceledAt } = action.payload;
|
||||||
|
const { verificationDataByConversation } = state;
|
||||||
|
const newverificationDataByConversation: Record<
|
||||||
|
string,
|
||||||
|
ConversationVerificationData
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
const entries = Object.entries(verificationDataByConversation);
|
||||||
|
if (!entries.length) {
|
||||||
|
log.warn(
|
||||||
|
'CANCEL_CONVERSATION_PENDING_VERIFICATION: No conversations pending verification'
|
||||||
|
);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [conversationId, data] of entries) {
|
||||||
|
if (
|
||||||
|
data.type === ConversationVerificationState.VerificationCancelled &&
|
||||||
|
data.canceledAt > canceledAt
|
||||||
|
) {
|
||||||
|
newverificationDataByConversation[conversationId] = data;
|
||||||
|
} else {
|
||||||
|
newverificationDataByConversation[conversationId] = {
|
||||||
|
type: ConversationVerificationState.VerificationCancelled,
|
||||||
|
canceledAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
verificationDataByConversation: newverificationDataByConversation,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2356,9 +2438,6 @@ export function reducer(
|
||||||
[id]: data,
|
[id]: data,
|
||||||
},
|
},
|
||||||
...updateConversationLookups(data, undefined, state),
|
...updateConversationLookups(data, undefined, state),
|
||||||
outboundMessagesPendingConversationVerification: data.isUntrusted
|
|
||||||
? state.outboundMessagesPendingConversationVerification
|
|
||||||
: omit(state.outboundMessagesPendingConversationVerification, id),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === 'CONVERSATION_CHANGED') {
|
if (action.type === 'CONVERSATION_CHANGED') {
|
||||||
|
@ -2384,7 +2463,7 @@ export function reducer(
|
||||||
showArchived = false;
|
showArchived = false;
|
||||||
}
|
}
|
||||||
// Inbox -> Archived: no conversation is selected
|
// Inbox -> Archived: no conversation is selected
|
||||||
// Note: With today's stacked converastions architecture, this can result in weird
|
// Note: With today's stacked conversations architecture, this can result in weird
|
||||||
// behavior - no selected conversation in the left pane, but a conversation show
|
// behavior - no selected conversation in the left pane, but a conversation show
|
||||||
// in the right pane.
|
// in the right pane.
|
||||||
if (!existing.isArchived && data.isArchived) {
|
if (!existing.isArchived && data.isArchived) {
|
||||||
|
@ -2405,9 +2484,6 @@ export function reducer(
|
||||||
[id]: data,
|
[id]: data,
|
||||||
},
|
},
|
||||||
...updateConversationLookups(data, existing, state),
|
...updateConversationLookups(data, existing, state),
|
||||||
outboundMessagesPendingConversationVerification: data.isUntrusted
|
|
||||||
? state.outboundMessagesPendingConversationVerification
|
|
||||||
: omit(state.outboundMessagesPendingConversationVerification, id),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === 'CONVERSATION_REMOVED') {
|
if (action.type === 'CONVERSATION_REMOVED') {
|
||||||
|
@ -2511,30 +2587,48 @@ export function reducer(
|
||||||
selectedMessageCounter: state.selectedMessageCounter + 1,
|
selectedMessageCounter: state.selectedMessageCounter + 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === MESSAGE_STOPPED_BY_MISSING_VERIFICATION) {
|
if (action.type === CONVERSATION_STOPPED_BY_MISSING_VERIFICATION) {
|
||||||
const { messageId, untrustedConversationIds } = action.payload;
|
const { conversationId, untrustedConversationIds } = action.payload;
|
||||||
|
|
||||||
const newOutboundMessagesPendingConversationVerification = {
|
const { verificationDataByConversation } = state;
|
||||||
...state.outboundMessagesPendingConversationVerification,
|
const existingPendingState = getOwn(
|
||||||
};
|
verificationDataByConversation,
|
||||||
untrustedConversationIds.forEach(conversationId => {
|
conversationId
|
||||||
const existingPendingMessageIds =
|
);
|
||||||
getOwn(
|
|
||||||
newOutboundMessagesPendingConversationVerification,
|
if (
|
||||||
conversationId
|
!existingPendingState ||
|
||||||
) ?? [];
|
existingPendingState.type ===
|
||||||
if (!existingPendingMessageIds.includes(messageId)) {
|
ConversationVerificationState.VerificationCancelled
|
||||||
newOutboundMessagesPendingConversationVerification[conversationId] = [
|
) {
|
||||||
...existingPendingMessageIds,
|
return {
|
||||||
messageId,
|
...state,
|
||||||
];
|
verificationDataByConversation: {
|
||||||
}
|
...verificationDataByConversation,
|
||||||
});
|
[conversationId]: {
|
||||||
|
type: ConversationVerificationState.PendingVerification as const,
|
||||||
|
conversationsNeedingVerification: untrustedConversationIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationsNeedingVerification: ReadonlyArray<string> = Array.from(
|
||||||
|
new Set([
|
||||||
|
...existingPendingState.conversationsNeedingVerification,
|
||||||
|
...untrustedConversationIds,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
outboundMessagesPendingConversationVerification:
|
verificationDataByConversation: {
|
||||||
newOutboundMessagesPendingConversationVerification,
|
...verificationDataByConversation,
|
||||||
|
[conversationId]: {
|
||||||
|
type: ConversationVerificationState.PendingVerification as const,
|
||||||
|
conversationsNeedingVerification,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === 'MESSAGE_CHANGED') {
|
if (action.type === 'MESSAGE_CHANGED') {
|
||||||
|
|
|
@ -28,3 +28,8 @@ export enum OneTimeModalState {
|
||||||
Showing,
|
Showing,
|
||||||
Shown,
|
Shown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ConversationVerificationState {
|
||||||
|
PendingVerification = 'PendingVerification',
|
||||||
|
VerificationCancelled = 'VerificationCancelled',
|
||||||
|
}
|
||||||
|
|
|
@ -12,12 +12,17 @@ import type {
|
||||||
ConversationMessageType,
|
ConversationMessageType,
|
||||||
ConversationsStateType,
|
ConversationsStateType,
|
||||||
ConversationType,
|
ConversationType,
|
||||||
|
ConversationVerificationData,
|
||||||
MessageLookupType,
|
MessageLookupType,
|
||||||
MessagesByConversationType,
|
MessagesByConversationType,
|
||||||
PreJoinConversationType,
|
PreJoinConversationType,
|
||||||
} from '../ducks/conversations';
|
} from '../ducks/conversations';
|
||||||
import type { UsernameSaveState } from '../ducks/conversationsEnums';
|
import type { UsernameSaveState } from '../ducks/conversationsEnums';
|
||||||
import { ComposerStep, OneTimeModalState } from '../ducks/conversationsEnums';
|
import {
|
||||||
|
ComposerStep,
|
||||||
|
OneTimeModalState,
|
||||||
|
ConversationVerificationState,
|
||||||
|
} from '../ducks/conversationsEnums';
|
||||||
import { getOwn } from '../../util/getOwn';
|
import { getOwn } from '../../util/getOwn';
|
||||||
import { isNotNil } from '../../util/isNotNil';
|
import { isNotNil } from '../../util/isNotNil';
|
||||||
import { deconstructLookup } from '../../util/deconstructLookup';
|
import { deconstructLookup } from '../../util/deconstructLookup';
|
||||||
|
@ -995,52 +1000,59 @@ export const getGroupAdminsSelector = createSelector(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const getOutboundMessagesPendingConversationVerification = createSelector(
|
const getConversationVerificationData = createSelector(
|
||||||
getConversations,
|
getConversations,
|
||||||
(
|
(
|
||||||
conversations: Readonly<ConversationsStateType>
|
conversations: Readonly<ConversationsStateType>
|
||||||
): Record<string, Array<string>> =>
|
): Record<string, ConversationVerificationData> =>
|
||||||
conversations.outboundMessagesPendingConversationVerification
|
conversations.verificationDataByConversation
|
||||||
);
|
);
|
||||||
|
|
||||||
const getConversationIdsStoppingMessageSendBecauseOfVerification =
|
export const getConversationIdsStoppedForVerification = createSelector(
|
||||||
createSelector(
|
getConversationVerificationData,
|
||||||
getOutboundMessagesPendingConversationVerification,
|
(verificationDataByConversation): Array<string> =>
|
||||||
(outboundMessagesPendingConversationVerification): Array<string> =>
|
Object.keys(verificationDataByConversation)
|
||||||
Object.keys(outboundMessagesPendingConversationVerification)
|
);
|
||||||
);
|
|
||||||
|
|
||||||
export const getConversationsStoppingMessageSendBecauseOfVerification =
|
export const getConversationsStoppedForVerification = createSelector(
|
||||||
createSelector(
|
getConversationByIdSelector,
|
||||||
getConversationByIdSelector,
|
getConversationIdsStoppedForVerification,
|
||||||
getConversationIdsStoppingMessageSendBecauseOfVerification,
|
(
|
||||||
(
|
conversationSelector: (id: string) => undefined | ConversationType,
|
||||||
conversationSelector: (id: string) => undefined | ConversationType,
|
conversationIds: ReadonlyArray<string>
|
||||||
conversationIds: ReadonlyArray<string>
|
): Array<ConversationType> => {
|
||||||
): Array<ConversationType> => {
|
const conversations = conversationIds
|
||||||
const conversations = conversationIds
|
.map(conversationId => conversationSelector(conversationId))
|
||||||
.map(conversationId => conversationSelector(conversationId))
|
.filter(isNotNil);
|
||||||
.filter(isNotNil);
|
return sortByTitle(conversations);
|
||||||
return sortByTitle(conversations);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getMessageIdsPendingBecauseOfVerification = createSelector(
|
|
||||||
getOutboundMessagesPendingConversationVerification,
|
|
||||||
(outboundMessagesPendingConversationVerification): Set<string> => {
|
|
||||||
const result = new Set<string>();
|
|
||||||
Object.values(outboundMessagesPendingConversationVerification).forEach(
|
|
||||||
messageGroup => {
|
|
||||||
messageGroup.forEach(messageId => {
|
|
||||||
result.add(messageId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getNumberOfMessagesPendingBecauseOfVerification = createSelector(
|
export const getConversationIdsStoppingSend = createSelector(
|
||||||
getMessageIdsPendingBecauseOfVerification,
|
getConversationVerificationData,
|
||||||
(messageIds: Readonly<Set<string>>): number => messageIds.size
|
(pendingData): Array<string> => {
|
||||||
|
const result = new Set<string>();
|
||||||
|
Object.values(pendingData).forEach(item => {
|
||||||
|
if (item.type === ConversationVerificationState.PendingVerification) {
|
||||||
|
item.conversationsNeedingVerification.forEach(conversationId => {
|
||||||
|
result.add(conversationId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(result);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getConversationsStoppingSend = createSelector(
|
||||||
|
getConversationByIdSelector,
|
||||||
|
getConversationIdsStoppingSend,
|
||||||
|
(
|
||||||
|
conversationSelector: (id: string) => undefined | ConversationType,
|
||||||
|
conversationIds: ReadonlyArray<string>
|
||||||
|
): Array<ConversationType> => {
|
||||||
|
const conversations = conversationIds
|
||||||
|
.map(conversationId => conversationSelector(conversationId))
|
||||||
|
.filter(isNotNil);
|
||||||
|
return sortByTitle(conversations);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -12,10 +12,7 @@ import { SmartSafetyNumberViewer } from './SafetyNumberViewer';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||||
import { getIntl, getTheme } from '../selectors/user';
|
import { getIntl, getTheme } from '../selectors/user';
|
||||||
import {
|
import { getConversationsStoppingSend } from '../selectors/conversations';
|
||||||
getConversationsStoppingMessageSendBecauseOfVerification,
|
|
||||||
getNumberOfMessagesPendingBecauseOfVerification,
|
|
||||||
} from '../selectors/conversations';
|
|
||||||
import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions';
|
import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions';
|
||||||
import { mapDispatchToProps } from '../actions';
|
import { mapDispatchToProps } from '../actions';
|
||||||
import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialog';
|
import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialog';
|
||||||
|
@ -23,13 +20,10 @@ import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialo
|
||||||
const mapStateToProps = (state: StateType) => {
|
const mapStateToProps = (state: StateType) => {
|
||||||
return {
|
return {
|
||||||
...state.app,
|
...state.app,
|
||||||
conversationsStoppingMessageSendBecauseOfVerification:
|
conversationsStoppingSend: getConversationsStoppingSend(state),
|
||||||
getConversationsStoppingMessageSendBecauseOfVerification(state),
|
|
||||||
getPreferredBadge: getPreferredBadgeSelector(state),
|
getPreferredBadge: getPreferredBadgeSelector(state),
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
isCustomizingPreferredReactions: getIsCustomizingPreferredReactions(state),
|
isCustomizingPreferredReactions: getIsCustomizingPreferredReactions(state),
|
||||||
numberOfMessagesPendingBecauseOfVerification:
|
|
||||||
getNumberOfMessagesPendingBecauseOfVerification(state),
|
|
||||||
renderCallManager: () => <SmartCallManager />,
|
renderCallManager: () => <SmartCallManager />,
|
||||||
renderCustomizingPreferredReactionsModal: () => (
|
renderCustomizingPreferredReactionsModal: () => (
|
||||||
<SmartCustomizingPreferredReactionsModal />
|
<SmartCustomizingPreferredReactionsModal />
|
||||||
|
|
|
@ -4,8 +4,9 @@
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
OneTimeModalState,
|
|
||||||
ComposerStep,
|
ComposerStep,
|
||||||
|
ConversationVerificationState,
|
||||||
|
OneTimeModalState,
|
||||||
} from '../../../state/ducks/conversationsEnums';
|
} from '../../../state/ducks/conversationsEnums';
|
||||||
import type {
|
import type {
|
||||||
ConversationLookupType,
|
ConversationLookupType,
|
||||||
|
@ -27,16 +28,17 @@ import {
|
||||||
getComposeSelectedContacts,
|
getComposeSelectedContacts,
|
||||||
getContactNameColorSelector,
|
getContactNameColorSelector,
|
||||||
getConversationByIdSelector,
|
getConversationByIdSelector,
|
||||||
|
getConversationIdsStoppingSend,
|
||||||
|
getConversationIdsStoppedForVerification,
|
||||||
getConversationsByTitleSelector,
|
getConversationsByTitleSelector,
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
getConversationsStoppingMessageSendBecauseOfVerification,
|
getConversationsStoppingSend,
|
||||||
|
getConversationsStoppedForVerification,
|
||||||
getFilteredCandidateContactsForNewGroup,
|
getFilteredCandidateContactsForNewGroup,
|
||||||
getFilteredComposeContacts,
|
getFilteredComposeContacts,
|
||||||
getFilteredComposeGroups,
|
getFilteredComposeGroups,
|
||||||
getInvitedContactsForNewlyCreatedGroup,
|
getInvitedContactsForNewlyCreatedGroup,
|
||||||
getMaximumGroupSizeModalState,
|
getMaximumGroupSizeModalState,
|
||||||
getMessageIdsPendingBecauseOfVerification,
|
|
||||||
getNumberOfMessagesPendingBecauseOfVerification,
|
|
||||||
getPlaceholderContact,
|
getPlaceholderContact,
|
||||||
getRecommendedGroupSizeModalState,
|
getRecommendedGroupSizeModalState,
|
||||||
getSelectedConversationId,
|
getSelectedConversationId,
|
||||||
|
@ -289,19 +291,17 @@ describe('both/state/selectors/conversations', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#getConversationsStoppingMessageSendBecauseOfVerification', () => {
|
describe('#getConversationsStoppingSend', () => {
|
||||||
it('returns an empty array if there are no conversations stopping send', () => {
|
it('returns an empty array if there are no conversations stopping send', () => {
|
||||||
const state = getEmptyRootState();
|
const state = getEmptyRootState();
|
||||||
|
|
||||||
assert.isEmpty(
|
assert.isEmpty(getConversationsStoppingSend(state));
|
||||||
getConversationsStoppingMessageSendBecauseOfVerification(state)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns all conversations stopping message send', () => {
|
it('returns all conversations stopping send', () => {
|
||||||
const convo1 = makeConversation('abc');
|
const convo1 = makeConversation('abc');
|
||||||
const convo2 = makeConversation('def');
|
const convo2 = makeConversation('def');
|
||||||
const state = {
|
const state: StateType = {
|
||||||
...getEmptyRootState(),
|
...getEmptyRootState(),
|
||||||
conversations: {
|
conversations: {
|
||||||
...getEmptyState(),
|
...getEmptyState(),
|
||||||
|
@ -309,77 +309,71 @@ describe('both/state/selectors/conversations', () => {
|
||||||
def: convo2,
|
def: convo2,
|
||||||
abc: convo1,
|
abc: convo1,
|
||||||
},
|
},
|
||||||
outboundMessagesPendingConversationVerification: {
|
verificationDataByConversation: {
|
||||||
def: ['message 2', 'message 3'],
|
'convo a': {
|
||||||
abc: ['message 1', 'message 2'],
|
type: ConversationVerificationState.PendingVerification as const,
|
||||||
|
conversationsNeedingVerification: ['abc'],
|
||||||
|
},
|
||||||
|
'convo b': {
|
||||||
|
type: ConversationVerificationState.PendingVerification as const,
|
||||||
|
conversationsNeedingVerification: ['def', 'abc'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.sameDeepMembers(getConversationIdsStoppingSend(state), [
|
||||||
getConversationsStoppingMessageSendBecauseOfVerification(state),
|
'abc',
|
||||||
[convo1, convo2]
|
'def',
|
||||||
);
|
]);
|
||||||
|
|
||||||
|
assert.sameDeepMembers(getConversationsStoppingSend(state), [
|
||||||
|
convo1,
|
||||||
|
convo2,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#getMessageIdsPendingBecauseOfVerification', () => {
|
describe('#getConversationStoppedForVerification', () => {
|
||||||
it('returns an empty set if there are no conversations stopping send', () => {
|
it('returns an empty array if there are no conversations stopping send', () => {
|
||||||
const state = getEmptyRootState();
|
const state = getEmptyRootState();
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.isEmpty(getConversationsStoppingSend(state));
|
||||||
getMessageIdsPendingBecauseOfVerification(state),
|
|
||||||
new Set()
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns a set of unique pending messages', () => {
|
it('returns all conversations stopping send', () => {
|
||||||
const state = {
|
const convoA = makeConversation('convo a');
|
||||||
|
const convoB = makeConversation('convo b');
|
||||||
|
const state: StateType = {
|
||||||
...getEmptyRootState(),
|
...getEmptyRootState(),
|
||||||
conversations: {
|
conversations: {
|
||||||
...getEmptyState(),
|
...getEmptyState(),
|
||||||
outboundMessagesPendingConversationVerification: {
|
conversationLookup: {
|
||||||
abc: ['message 2', 'message 3'],
|
'convo a': convoA,
|
||||||
def: ['message 1', 'message 2'],
|
'convo b': convoB,
|
||||||
ghi: ['message 4'],
|
},
|
||||||
|
verificationDataByConversation: {
|
||||||
|
'convo a': {
|
||||||
|
type: ConversationVerificationState.PendingVerification as const,
|
||||||
|
conversationsNeedingVerification: ['abc'],
|
||||||
|
},
|
||||||
|
'convo b': {
|
||||||
|
type: ConversationVerificationState.PendingVerification as const,
|
||||||
|
conversationsNeedingVerification: ['def', 'abc'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.sameDeepMembers(getConversationIdsStoppedForVerification(state), [
|
||||||
getMessageIdsPendingBecauseOfVerification(state),
|
'convo a',
|
||||||
new Set(['message 1', 'message 2', 'message 3', 'message 4'])
|
'convo b',
|
||||||
);
|
]);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#getNumberOfMessagesPendingBecauseOfVerification', () => {
|
assert.sameDeepMembers(getConversationsStoppedForVerification(state), [
|
||||||
it('returns 0 if there are no conversations stopping send', () => {
|
convoA,
|
||||||
const state = getEmptyRootState();
|
convoB,
|
||||||
|
]);
|
||||||
assert.strictEqual(
|
|
||||||
getNumberOfMessagesPendingBecauseOfVerification(state),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a count of unique pending messages', () => {
|
|
||||||
const state = {
|
|
||||||
...getEmptyRootState(),
|
|
||||||
conversations: {
|
|
||||||
...getEmptyState(),
|
|
||||||
outboundMessagesPendingConversationVerification: {
|
|
||||||
abc: ['message 2', 'message 3'],
|
|
||||||
def: ['message 1', 'message 2'],
|
|
||||||
ghi: ['message 4'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.strictEqual(
|
|
||||||
getNumberOfMessagesPendingBecauseOfVerification(state),
|
|
||||||
4
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -9,19 +9,23 @@ import { set } from 'lodash/fp';
|
||||||
import { reducer as rootReducer } from '../../../state/reducer';
|
import { reducer as rootReducer } from '../../../state/reducer';
|
||||||
import { noopAction } from '../../../state/ducks/noop';
|
import { noopAction } from '../../../state/ducks/noop';
|
||||||
import {
|
import {
|
||||||
OneTimeModalState,
|
|
||||||
ComposerStep,
|
ComposerStep,
|
||||||
|
ConversationVerificationState,
|
||||||
|
OneTimeModalState,
|
||||||
} from '../../../state/ducks/conversationsEnums';
|
} from '../../../state/ducks/conversationsEnums';
|
||||||
import type {
|
import type {
|
||||||
|
CancelVerificationDataByConversationActionType,
|
||||||
ConversationMessageType,
|
ConversationMessageType,
|
||||||
ConversationType,
|
|
||||||
ConversationsStateType,
|
ConversationsStateType,
|
||||||
|
ConversationType,
|
||||||
MessageType,
|
MessageType,
|
||||||
SwitchToAssociatedViewActionType,
|
SwitchToAssociatedViewActionType,
|
||||||
ToggleConversationInChooseMembersActionType,
|
ToggleConversationInChooseMembersActionType,
|
||||||
} from '../../../state/ducks/conversations';
|
} from '../../../state/ducks/conversations';
|
||||||
import {
|
import {
|
||||||
actions,
|
actions,
|
||||||
|
cancelConversationVerification,
|
||||||
|
clearCancelledConversationVerification,
|
||||||
getConversationCallMode,
|
getConversationCallMode,
|
||||||
getEmptyState,
|
getEmptyState,
|
||||||
reducer,
|
reducer,
|
||||||
|
@ -53,7 +57,7 @@ const {
|
||||||
closeRecommendedGroupSizeModal,
|
closeRecommendedGroupSizeModal,
|
||||||
createGroup,
|
createGroup,
|
||||||
messageSizeChanged,
|
messageSizeChanged,
|
||||||
messageStoppedByMissingVerification,
|
conversationStoppedByMissingVerification,
|
||||||
openConversationInternal,
|
openConversationInternal,
|
||||||
repairNewestMessage,
|
repairNewestMessage,
|
||||||
repairOldestMessage,
|
repairOldestMessage,
|
||||||
|
@ -898,32 +902,205 @@ describe('both/state/ducks/conversations', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MESSAGE_STOPPED_BY_MISSING_VERIFICATION', () => {
|
describe('CONVERSATION_STOPPED_BY_MISSING_VERIFICATION', () => {
|
||||||
it('adds messages that need conversation verification, removing duplicates', () => {
|
it('adds to state, removing duplicates', () => {
|
||||||
const first = reducer(
|
const first = reducer(
|
||||||
getEmptyState(),
|
getEmptyState(),
|
||||||
messageStoppedByMissingVerification('message 1', ['convo 1'])
|
conversationStoppedByMissingVerification({
|
||||||
|
conversationId: 'convo A',
|
||||||
|
untrustedConversationIds: ['convo 1'],
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const second = reducer(
|
const second = reducer(
|
||||||
first,
|
first,
|
||||||
messageStoppedByMissingVerification('message 1', ['convo 2'])
|
conversationStoppedByMissingVerification({
|
||||||
|
conversationId: 'convo A',
|
||||||
|
untrustedConversationIds: ['convo 2'],
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const third = reducer(
|
const third = reducer(
|
||||||
second,
|
second,
|
||||||
messageStoppedByMissingVerification('message 2', [
|
conversationStoppedByMissingVerification({
|
||||||
'convo 1',
|
conversationId: 'convo A',
|
||||||
'convo 3',
|
untrustedConversationIds: ['convo 1', 'convo 3'],
|
||||||
])
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepStrictEqual(
|
assert.deepStrictEqual(third.verificationDataByConversation, {
|
||||||
third.outboundMessagesPendingConversationVerification,
|
'convo A': {
|
||||||
{
|
type: ConversationVerificationState.PendingVerification,
|
||||||
'convo 1': ['message 1', 'message 2'],
|
conversationsNeedingVerification: ['convo 1', 'convo 2', 'convo 3'],
|
||||||
'convo 2': ['message 1'],
|
},
|
||||||
'convo 3': ['message 2'],
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
|
it('stomps on VerificationCancelled state', () => {
|
||||||
|
const state: ConversationsStateType = {
|
||||||
|
...getEmptyState(),
|
||||||
|
verificationDataByConversation: {
|
||||||
|
'convo A': {
|
||||||
|
type: ConversationVerificationState.VerificationCancelled,
|
||||||
|
canceledAt: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const actual = reducer(
|
||||||
|
state,
|
||||||
|
conversationStoppedByMissingVerification({
|
||||||
|
conversationId: 'convo A',
|
||||||
|
untrustedConversationIds: ['convo 1', 'convo 2'],
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(actual.verificationDataByConversation, {
|
||||||
|
'convo A': {
|
||||||
|
type: ConversationVerificationState.PendingVerification,
|
||||||
|
conversationsNeedingVerification: ['convo 1', 'convo 2'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CANCEL_CONVERSATION_PENDING_VERIFICATION', () => {
|
||||||
|
function getAction(
|
||||||
|
timestamp: number,
|
||||||
|
conversationsState: ConversationsStateType
|
||||||
|
): CancelVerificationDataByConversationActionType {
|
||||||
|
const dispatch = sinon.spy();
|
||||||
|
|
||||||
|
cancelConversationVerification(timestamp)(
|
||||||
|
dispatch,
|
||||||
|
() => ({
|
||||||
|
...getEmptyRootState(),
|
||||||
|
conversations: conversationsState,
|
||||||
|
}),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
return dispatch.getCall(0).args[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('replaces existing PendingVerification state', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const state: ConversationsStateType = {
|
||||||
|
...getEmptyState(),
|
||||||
|
verificationDataByConversation: {
|
||||||
|
'convo A': {
|
||||||
|
type: ConversationVerificationState.PendingVerification,
|
||||||
|
conversationsNeedingVerification: ['convo 1', 'convo 2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const action = getAction(now, state);
|
||||||
|
const actual = reducer(state, action);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(actual.verificationDataByConversation, {
|
||||||
|
'convo A': {
|
||||||
|
type: ConversationVerificationState.VerificationCancelled,
|
||||||
|
canceledAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates timestamp for existing VerificationCancelled state', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const state: ConversationsStateType = {
|
||||||
|
...getEmptyState(),
|
||||||
|
verificationDataByConversation: {
|
||||||
|
'convo A': {
|
||||||
|
type: ConversationVerificationState.VerificationCancelled,
|
||||||
|
canceledAt: now - 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const action = getAction(now, state);
|
||||||
|
const actual = reducer(state, action);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(actual.verificationDataByConversation, {
|
||||||
|
'convo A': {
|
||||||
|
type: ConversationVerificationState.VerificationCancelled,
|
||||||
|
canceledAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses newest timestamp when updating existing VerificationCancelled state', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const state: ConversationsStateType = {
|
||||||
|
...getEmptyState(),
|
||||||
|
verificationDataByConversation: {
|
||||||
|
'convo A': {
|
||||||
|
type: ConversationVerificationState.VerificationCancelled,
|
||||||
|
canceledAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const action = getAction(now, state);
|
||||||
|
const actual = reducer(state, action);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(actual.verificationDataByConversation, {
|
||||||
|
'convo A': {
|
||||||
|
type: ConversationVerificationState.VerificationCancelled,
|
||||||
|
canceledAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing if no existing state', () => {
|
||||||
|
const state: ConversationsStateType = getEmptyState();
|
||||||
|
const action = getAction(Date.now(), state);
|
||||||
|
const actual = reducer(state, action);
|
||||||
|
|
||||||
|
assert.strictEqual(actual, state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CANCEL_CONVERSATION_PENDING_VERIFICATION', () => {
|
||||||
|
it('removes existing VerificationCancelled state', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const state: ConversationsStateType = {
|
||||||
|
...getEmptyState(),
|
||||||
|
verificationDataByConversation: {
|
||||||
|
'convo A': {
|
||||||
|
type: ConversationVerificationState.VerificationCancelled,
|
||||||
|
canceledAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const actual = reducer(
|
||||||
|
state,
|
||||||
|
clearCancelledConversationVerification('convo A')
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(actual.verificationDataByConversation, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves existing PendingVerification state', () => {
|
||||||
|
const state: ConversationsStateType = {
|
||||||
|
...getEmptyState(),
|
||||||
|
verificationDataByConversation: {
|
||||||
|
'convo A': {
|
||||||
|
type: ConversationVerificationState.PendingVerification,
|
||||||
|
conversationsNeedingVerification: ['convo 1', 'convo 2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const actual = reducer(
|
||||||
|
state,
|
||||||
|
clearCancelledConversationVerification('convo A')
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(actual, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing with empty state', () => {
|
||||||
|
const state: ConversationsStateType = getEmptyState();
|
||||||
|
const actual = reducer(
|
||||||
|
state,
|
||||||
|
clearCancelledConversationVerification('convo A')
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(actual, state);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -425,7 +425,7 @@ describe('LeftPaneInboxHelper', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getConversationAndMessageAtIndex', () => {
|
describe('getConversationAndMessageAtIndex', () => {
|
||||||
it('returns pinned converastions, then non-pinned conversations', () => {
|
it('returns pinned conversations, then non-pinned conversations', () => {
|
||||||
const conversations = [
|
const conversations = [
|
||||||
getDefaultConversation(),
|
getDefaultConversation(),
|
||||||
getDefaultConversation(),
|
getDefaultConversation(),
|
||||||
|
|
|
@ -4,10 +4,34 @@
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
import * as sinon from 'sinon';
|
import * as sinon from 'sinon';
|
||||||
import { noop, omit } from 'lodash';
|
import { noop, omit } from 'lodash';
|
||||||
import { HTTPError } from '../../../textsecure/Errors';
|
import { HTTPError, SendMessageProtoError } from '../../../textsecure/Errors';
|
||||||
import { SECOND } from '../../../util/durations';
|
import { SECOND } from '../../../util/durations';
|
||||||
|
|
||||||
import { handleMultipleSendErrors } from '../../../jobs/helpers/handleMultipleSendErrors';
|
import {
|
||||||
|
handleMultipleSendErrors,
|
||||||
|
maybeExpandErrors,
|
||||||
|
} from '../../../jobs/helpers/handleMultipleSendErrors';
|
||||||
|
|
||||||
|
describe('maybeExpandErrors', () => {
|
||||||
|
// This returns a readonly array, but Chai wants a mutable one.
|
||||||
|
const expand = (input: unknown) => maybeExpandErrors(input) as Array<unknown>;
|
||||||
|
|
||||||
|
it("wraps the provided value if it's not a SendMessageProtoError with errors", () => {
|
||||||
|
const input = { foo: 123 };
|
||||||
|
assert.sameMembers(expand(input), [input]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps the provided value if a SendMessageProtoError with no errors', () => {
|
||||||
|
const input = new SendMessageProtoError({});
|
||||||
|
assert.sameMembers(expand(input), [input]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a SendMessageProtoError's errors", () => {
|
||||||
|
const errors = [new Error('one'), new Error('two')];
|
||||||
|
const input = new SendMessageProtoError({ errors });
|
||||||
|
assert.strictEqual(expand(input), errors);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('handleMultipleSendErrors', () => {
|
describe('handleMultipleSendErrors', () => {
|
||||||
const make413 = (retryAfter: number): HTTPError =>
|
const make413 = (retryAfter: number): HTTPError =>
|
||||||
|
@ -43,8 +67,9 @@ describe('handleMultipleSendErrors', () => {
|
||||||
handleMultipleSendErrors({
|
handleMultipleSendErrors({
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
errors: [new Error('first'), new Error('second')],
|
errors: [new Error('first'), new Error('second')],
|
||||||
|
toThrow: new Error('to throw'),
|
||||||
}),
|
}),
|
||||||
'first'
|
'to throw'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -57,6 +82,7 @@ describe('handleMultipleSendErrors', () => {
|
||||||
errors: [new Error('uh oh')],
|
errors: [new Error('uh oh')],
|
||||||
markFailed,
|
markFailed,
|
||||||
isFinalAttempt: true,
|
isFinalAttempt: true,
|
||||||
|
toThrow: new Error('to throw'),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -69,8 +95,9 @@ describe('handleMultipleSendErrors', () => {
|
||||||
...omit(defaultOptions, 'markFailed'),
|
...omit(defaultOptions, 'markFailed'),
|
||||||
errors: [new Error('Test message')],
|
errors: [new Error('Test message')],
|
||||||
isFinalAttempt: true,
|
isFinalAttempt: true,
|
||||||
|
toThrow: new Error('to throw'),
|
||||||
}),
|
}),
|
||||||
'Test message'
|
'to throw'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -89,6 +116,7 @@ describe('handleMultipleSendErrors', () => {
|
||||||
make413(20),
|
make413(20),
|
||||||
],
|
],
|
||||||
timeRemaining: 99999999,
|
timeRemaining: 99999999,
|
||||||
|
toThrow: new Error('to throw'),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// No-op
|
// No-op
|
||||||
|
@ -112,6 +140,7 @@ describe('handleMultipleSendErrors', () => {
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
errors: [make413(9999)],
|
errors: [make413(9999)],
|
||||||
timeRemaining: 99,
|
timeRemaining: 99,
|
||||||
|
toThrow: new Error('to throw'),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// No-op
|
// No-op
|
||||||
|
@ -130,6 +159,7 @@ describe('handleMultipleSendErrors', () => {
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
errors: [new Error('uh oh')],
|
errors: [new Error('uh oh')],
|
||||||
isFinalAttempt: true,
|
isFinalAttempt: true,
|
||||||
|
toThrow: new Error('to throw'),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -142,6 +172,7 @@ describe('handleMultipleSendErrors', () => {
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
errors: [new Error('uh oh'), { code: 508 }, make413(99999)],
|
errors: [new Error('uh oh'), { code: 508 }, make413(99999)],
|
||||||
markFailed: noop,
|
markFailed: noop,
|
||||||
|
toThrow: new Error('to throw'),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -153,6 +184,7 @@ describe('handleMultipleSendErrors', () => {
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
errors: [{ code: 508 }],
|
errors: [{ code: 508 }],
|
||||||
markFailed,
|
markFailed,
|
||||||
|
toThrow: new Error('to throw'),
|
||||||
});
|
});
|
||||||
|
|
||||||
sinon.assert.calledOnceWithExactly(markFailed);
|
sinon.assert.calledOnceWithExactly(markFailed);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
|
@ -8,6 +8,7 @@ import { v4 as generateGuid } from 'uuid';
|
||||||
|
|
||||||
import { SCHEMA_VERSIONS } from '../sql/migrations';
|
import { SCHEMA_VERSIONS } from '../sql/migrations';
|
||||||
import { consoleLogger } from '../util/consoleLogger';
|
import { consoleLogger } from '../util/consoleLogger';
|
||||||
|
import { getJobsInQueueSync, insertJobSync } from '../sql/Server';
|
||||||
|
|
||||||
const OUR_UUID = generateGuid();
|
const OUR_UUID = generateGuid();
|
||||||
|
|
||||||
|
@ -1325,7 +1326,7 @@ describe('SQL migrations test', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateToSchemaVersion49', () => {
|
describe('updateToSchemaVersion50', () => {
|
||||||
it('creates usable index for messages_unread', () => {
|
it('creates usable index for messages_unread', () => {
|
||||||
updateToVersion(50);
|
updateToVersion(50);
|
||||||
|
|
||||||
|
@ -1351,4 +1352,252 @@ describe('SQL migrations test', () => {
|
||||||
assert.notInclude(details, 'SCAN');
|
assert.notInclude(details, 'SCAN');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updateToSchemaVersion51', () => {
|
||||||
|
it('moves reactions/normal send jobs over to conversation queue', () => {
|
||||||
|
updateToVersion(50);
|
||||||
|
|
||||||
|
const MESSAGE_ID_1 = generateGuid();
|
||||||
|
const CONVERSATION_ID_1 = generateGuid();
|
||||||
|
|
||||||
|
db.exec(
|
||||||
|
`
|
||||||
|
INSERT INTO messages
|
||||||
|
(id, json)
|
||||||
|
VALUES ('${MESSAGE_ID_1}', '${JSON.stringify({
|
||||||
|
conversationId: CONVERSATION_ID_1,
|
||||||
|
})}')
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
db.exec(
|
||||||
|
`
|
||||||
|
INSERT INTO jobs
|
||||||
|
(id, timestamp, queueType, data)
|
||||||
|
VALUES
|
||||||
|
('id-1', 1, 'random job', '{}'),
|
||||||
|
('id-2', 2, 'normal send', '{}'),
|
||||||
|
('id-3', 3, 'reactions', '{"messageId":"${MESSAGE_ID_1}"}'),
|
||||||
|
('id-4', 4, 'conversation', '{}');
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalJobs = db.prepare('SELECT COUNT(*) FROM jobs;').pluck();
|
||||||
|
const normalSendJobs = db
|
||||||
|
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'normal send';")
|
||||||
|
.pluck();
|
||||||
|
const conversationJobs = db
|
||||||
|
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'conversation';")
|
||||||
|
.pluck();
|
||||||
|
const reactionJobs = db
|
||||||
|
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'reactions';")
|
||||||
|
.pluck();
|
||||||
|
|
||||||
|
assert.strictEqual(totalJobs.get(), 4, 'before total');
|
||||||
|
assert.strictEqual(normalSendJobs.get(), 1, 'before normal');
|
||||||
|
assert.strictEqual(conversationJobs.get(), 1, 'before conversation');
|
||||||
|
assert.strictEqual(reactionJobs.get(), 1, 'before reaction');
|
||||||
|
|
||||||
|
updateToVersion(51);
|
||||||
|
|
||||||
|
assert.strictEqual(totalJobs.get(), 4, 'after total');
|
||||||
|
assert.strictEqual(normalSendJobs.get(), 0, 'after normal');
|
||||||
|
assert.strictEqual(conversationJobs.get(), 3, 'after conversation');
|
||||||
|
assert.strictEqual(reactionJobs.get(), 0, 'after reaction');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates reactions jobs with their conversationId', () => {
|
||||||
|
updateToVersion(50);
|
||||||
|
|
||||||
|
const MESSAGE_ID_1 = generateGuid();
|
||||||
|
const MESSAGE_ID_2 = generateGuid();
|
||||||
|
const MESSAGE_ID_3 = generateGuid();
|
||||||
|
|
||||||
|
const CONVERSATION_ID_1 = generateGuid();
|
||||||
|
const CONVERSATION_ID_2 = generateGuid();
|
||||||
|
|
||||||
|
insertJobSync(db, {
|
||||||
|
id: 'id-1',
|
||||||
|
timestamp: 1,
|
||||||
|
queueType: 'reactions',
|
||||||
|
data: {
|
||||||
|
messageId: MESSAGE_ID_1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
insertJobSync(db, {
|
||||||
|
id: 'id-2',
|
||||||
|
timestamp: 2,
|
||||||
|
queueType: 'reactions',
|
||||||
|
data: {
|
||||||
|
messageId: MESSAGE_ID_2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
insertJobSync(db, {
|
||||||
|
id: 'id-3-missing-data',
|
||||||
|
timestamp: 3,
|
||||||
|
queueType: 'reactions',
|
||||||
|
});
|
||||||
|
insertJobSync(db, {
|
||||||
|
id: 'id-4-non-string-messageId',
|
||||||
|
timestamp: 1,
|
||||||
|
queueType: 'reactions',
|
||||||
|
data: {
|
||||||
|
messageId: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
insertJobSync(db, {
|
||||||
|
id: 'id-5-missing-message',
|
||||||
|
timestamp: 5,
|
||||||
|
queueType: 'reactions',
|
||||||
|
data: {
|
||||||
|
messageId: 'missing',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
insertJobSync(db, {
|
||||||
|
id: 'id-6-missing-conversation',
|
||||||
|
timestamp: 6,
|
||||||
|
queueType: 'reactions',
|
||||||
|
data: {
|
||||||
|
messageId: MESSAGE_ID_3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageJson1 = JSON.stringify({
|
||||||
|
conversationId: CONVERSATION_ID_1,
|
||||||
|
});
|
||||||
|
const messageJson2 = JSON.stringify({
|
||||||
|
conversationId: CONVERSATION_ID_2,
|
||||||
|
});
|
||||||
|
db.exec(
|
||||||
|
`
|
||||||
|
INSERT INTO messages
|
||||||
|
(id, conversationId, json)
|
||||||
|
VALUES
|
||||||
|
('${MESSAGE_ID_1}', '${CONVERSATION_ID_1}', '${messageJson1}'),
|
||||||
|
('${MESSAGE_ID_2}', '${CONVERSATION_ID_2}', '${messageJson2}'),
|
||||||
|
('${MESSAGE_ID_3}', null, '{}');
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalJobs = db.prepare('SELECT COUNT(*) FROM jobs;').pluck();
|
||||||
|
const reactionJobs = db
|
||||||
|
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'reactions';")
|
||||||
|
.pluck();
|
||||||
|
const conversationJobs = db
|
||||||
|
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'conversation';")
|
||||||
|
.pluck();
|
||||||
|
|
||||||
|
assert.strictEqual(totalJobs.get(), 6, 'total jobs before');
|
||||||
|
assert.strictEqual(reactionJobs.get(), 6, 'reaction jobs before');
|
||||||
|
assert.strictEqual(conversationJobs.get(), 0, 'conversation jobs before');
|
||||||
|
|
||||||
|
updateToVersion(51);
|
||||||
|
|
||||||
|
assert.strictEqual(totalJobs.get(), 2, 'total jobs after');
|
||||||
|
assert.strictEqual(reactionJobs.get(), 0, 'reaction jobs after');
|
||||||
|
assert.strictEqual(conversationJobs.get(), 2, 'conversation jobs after');
|
||||||
|
|
||||||
|
const jobs = getJobsInQueueSync(db, 'conversation');
|
||||||
|
|
||||||
|
assert.deepEqual(jobs, [
|
||||||
|
{
|
||||||
|
id: 'id-1',
|
||||||
|
timestamp: 1,
|
||||||
|
queueType: 'conversation',
|
||||||
|
data: {
|
||||||
|
type: 'Reaction',
|
||||||
|
conversationId: CONVERSATION_ID_1,
|
||||||
|
messageId: MESSAGE_ID_1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'id-2',
|
||||||
|
timestamp: 2,
|
||||||
|
queueType: 'conversation',
|
||||||
|
data: {
|
||||||
|
type: 'Reaction',
|
||||||
|
conversationId: CONVERSATION_ID_2,
|
||||||
|
messageId: MESSAGE_ID_2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates normal send jobs with their conversationId', () => {
|
||||||
|
updateToVersion(50);
|
||||||
|
|
||||||
|
const MESSAGE_ID_1 = generateGuid();
|
||||||
|
const MESSAGE_ID_2 = generateGuid();
|
||||||
|
|
||||||
|
const CONVERSATION_ID_1 = generateGuid();
|
||||||
|
const CONVERSATION_ID_2 = generateGuid();
|
||||||
|
|
||||||
|
insertJobSync(db, {
|
||||||
|
id: 'id-1',
|
||||||
|
timestamp: 1,
|
||||||
|
queueType: 'normal send',
|
||||||
|
data: {
|
||||||
|
conversationId: CONVERSATION_ID_1,
|
||||||
|
messageId: MESSAGE_ID_1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
insertJobSync(db, {
|
||||||
|
id: 'id-2',
|
||||||
|
timestamp: 2,
|
||||||
|
queueType: 'normal send',
|
||||||
|
data: {
|
||||||
|
conversationId: CONVERSATION_ID_2,
|
||||||
|
messageId: MESSAGE_ID_2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
insertJobSync(db, {
|
||||||
|
id: 'id-3-missing-data',
|
||||||
|
timestamp: 3,
|
||||||
|
queueType: 'normal send',
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalJobs = db.prepare('SELECT COUNT(*) FROM jobs;').pluck();
|
||||||
|
const normalSend = db
|
||||||
|
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'normal send';")
|
||||||
|
.pluck();
|
||||||
|
const conversationJobs = db
|
||||||
|
.prepare("SELECT COUNT(*) FROM jobs WHERE queueType = 'conversation';")
|
||||||
|
.pluck();
|
||||||
|
|
||||||
|
assert.strictEqual(totalJobs.get(), 3, 'total jobs before');
|
||||||
|
assert.strictEqual(normalSend.get(), 3, 'normal send jobs before');
|
||||||
|
assert.strictEqual(conversationJobs.get(), 0, 'conversation jobs before');
|
||||||
|
|
||||||
|
updateToVersion(51);
|
||||||
|
|
||||||
|
assert.strictEqual(totalJobs.get(), 2, 'total jobs after');
|
||||||
|
assert.strictEqual(normalSend.get(), 0, 'normal send jobs after');
|
||||||
|
assert.strictEqual(conversationJobs.get(), 2, 'conversation jobs after');
|
||||||
|
|
||||||
|
const jobs = getJobsInQueueSync(db, 'conversation');
|
||||||
|
|
||||||
|
assert.deepEqual(jobs, [
|
||||||
|
{
|
||||||
|
id: 'id-1',
|
||||||
|
timestamp: 1,
|
||||||
|
queueType: 'conversation',
|
||||||
|
data: {
|
||||||
|
type: 'NormalMessage',
|
||||||
|
conversationId: CONVERSATION_ID_1,
|
||||||
|
messageId: MESSAGE_ID_1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'id-2',
|
||||||
|
timestamp: 2,
|
||||||
|
queueType: 'conversation',
|
||||||
|
data: {
|
||||||
|
type: 'NormalMessage',
|
||||||
|
conversationId: CONVERSATION_ID_2,
|
||||||
|
messageId: MESSAGE_ID_2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -195,6 +195,7 @@ export type MessageOptionsType = {
|
||||||
export type GroupSendOptionsType = {
|
export type GroupSendOptionsType = {
|
||||||
attachments?: Array<AttachmentType>;
|
attachments?: Array<AttachmentType>;
|
||||||
expireTimer?: number;
|
expireTimer?: number;
|
||||||
|
flags?: number;
|
||||||
groupV2?: GroupV2InfoType;
|
groupV2?: GroupV2InfoType;
|
||||||
groupV1?: GroupV1InfoType;
|
groupV1?: GroupV1InfoType;
|
||||||
messageText?: string;
|
messageText?: string;
|
||||||
|
@ -764,20 +765,21 @@ export default class MessageSender {
|
||||||
options: Readonly<GroupSendOptionsType>
|
options: Readonly<GroupSendOptionsType>
|
||||||
): MessageOptionsType {
|
): MessageOptionsType {
|
||||||
const {
|
const {
|
||||||
messageText,
|
|
||||||
timestamp,
|
|
||||||
attachments,
|
attachments,
|
||||||
quote,
|
|
||||||
preview,
|
|
||||||
sticker,
|
|
||||||
reaction,
|
|
||||||
expireTimer,
|
|
||||||
profileKey,
|
|
||||||
deletedForEveryoneTimestamp,
|
deletedForEveryoneTimestamp,
|
||||||
groupV2,
|
expireTimer,
|
||||||
groupV1,
|
flags,
|
||||||
mentions,
|
|
||||||
groupCallUpdate,
|
groupCallUpdate,
|
||||||
|
groupV1,
|
||||||
|
groupV2,
|
||||||
|
mentions,
|
||||||
|
messageText,
|
||||||
|
preview,
|
||||||
|
profileKey,
|
||||||
|
quote,
|
||||||
|
reaction,
|
||||||
|
sticker,
|
||||||
|
timestamp,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (!groupV1 && !groupV2) {
|
if (!groupV1 && !groupV2) {
|
||||||
|
@ -815,6 +817,7 @@ export default class MessageSender {
|
||||||
body: messageText,
|
body: messageText,
|
||||||
deletedForEveryoneTimestamp,
|
deletedForEveryoneTimestamp,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
|
flags,
|
||||||
groupCallUpdate,
|
groupCallUpdate,
|
||||||
groupV2,
|
groupV2,
|
||||||
group: groupV1
|
group: groupV1
|
||||||
|
@ -970,12 +973,14 @@ export default class MessageSender {
|
||||||
|
|
||||||
async sendIndividualProto({
|
async sendIndividualProto({
|
||||||
contentHint,
|
contentHint,
|
||||||
|
groupId,
|
||||||
identifier,
|
identifier,
|
||||||
options,
|
options,
|
||||||
proto,
|
proto,
|
||||||
timestamp,
|
timestamp,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
contentHint: number;
|
contentHint: number;
|
||||||
|
groupId?: string;
|
||||||
identifier: string | undefined;
|
identifier: string | undefined;
|
||||||
options?: SendOptionsType;
|
options?: SendOptionsType;
|
||||||
proto: Proto.DataMessage | Proto.Content | PlaintextContent;
|
proto: Proto.DataMessage | Proto.Content | PlaintextContent;
|
||||||
|
@ -993,7 +998,7 @@ export default class MessageSender {
|
||||||
this.sendMessageProto({
|
this.sendMessageProto({
|
||||||
callback,
|
callback,
|
||||||
contentHint,
|
contentHint,
|
||||||
groupId: undefined,
|
groupId,
|
||||||
options,
|
options,
|
||||||
proto,
|
proto,
|
||||||
recipients: [identifier],
|
recipients: [identifier],
|
||||||
|
@ -1534,35 +1539,6 @@ export default class MessageSender {
|
||||||
|
|
||||||
// Sending messages to contacts
|
// Sending messages to contacts
|
||||||
|
|
||||||
async sendProfileKeyUpdate(
|
|
||||||
profileKey: Readonly<Uint8Array>,
|
|
||||||
recipients: ReadonlyArray<string>,
|
|
||||||
options: Readonly<SendOptionsType>,
|
|
||||||
groupId?: string
|
|
||||||
): Promise<CallbackResultType> {
|
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
|
||||||
|
|
||||||
return this.sendMessage({
|
|
||||||
messageOptions: {
|
|
||||||
recipients,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
profileKey,
|
|
||||||
flags: Proto.DataMessage.Flags.PROFILE_KEY_UPDATE,
|
|
||||||
...(groupId
|
|
||||||
? {
|
|
||||||
group: {
|
|
||||||
id: groupId,
|
|
||||||
type: Proto.GroupContext.Type.DELIVER,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
groupId: undefined,
|
|
||||||
options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendCallingMessage(
|
async sendCallingMessage(
|
||||||
recipientId: string,
|
recipientId: string,
|
||||||
callingMessage: Readonly<Proto.ICallingMessage>,
|
callingMessage: Readonly<Proto.ICallingMessage>,
|
||||||
|
@ -1699,29 +1675,6 @@ export default class MessageSender {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendExpirationTimerUpdateToIdentifier(
|
|
||||||
identifier: string,
|
|
||||||
expireTimer: number | undefined,
|
|
||||||
timestamp: number,
|
|
||||||
profileKey?: Readonly<Uint8Array>,
|
|
||||||
options?: Readonly<SendOptionsType>
|
|
||||||
): Promise<CallbackResultType> {
|
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
|
||||||
|
|
||||||
return this.sendMessage({
|
|
||||||
messageOptions: {
|
|
||||||
recipients: [identifier],
|
|
||||||
timestamp,
|
|
||||||
expireTimer,
|
|
||||||
profileKey,
|
|
||||||
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
|
||||||
},
|
|
||||||
contentHint: ContentHint.RESENDABLE,
|
|
||||||
groupId: undefined,
|
|
||||||
options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendRetryRequest({
|
async sendRetryRequest({
|
||||||
groupId,
|
groupId,
|
||||||
options,
|
options,
|
||||||
|
@ -2020,65 +1973,6 @@ export default class MessageSender {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendExpirationTimerUpdateToGroup(
|
|
||||||
groupId: string,
|
|
||||||
groupIdentifiers: ReadonlyArray<string>,
|
|
||||||
expireTimer: number | undefined,
|
|
||||||
timestamp: number,
|
|
||||||
profileKey?: Readonly<Uint8Array>,
|
|
||||||
options?: Readonly<SendOptionsType>
|
|
||||||
): Promise<CallbackResultType> {
|
|
||||||
const myNumber = window.textsecure.storage.user.getNumber();
|
|
||||||
const myUuid = window.textsecure.storage.user.getUuid()?.toString();
|
|
||||||
const recipients = groupIdentifiers.filter(
|
|
||||||
identifier => identifier !== myNumber && identifier !== myUuid
|
|
||||||
);
|
|
||||||
const messageOptions = {
|
|
||||||
recipients,
|
|
||||||
timestamp,
|
|
||||||
expireTimer,
|
|
||||||
profileKey,
|
|
||||||
flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
|
||||||
group: {
|
|
||||||
id: groupId,
|
|
||||||
type: Proto.GroupContext.Type.DELIVER,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const proto = await this.getContentMessage(messageOptions);
|
|
||||||
|
|
||||||
if (recipients.length === 0) {
|
|
||||||
return Promise.resolve({
|
|
||||||
successfulIdentifiers: [],
|
|
||||||
failoverIdentifiers: [],
|
|
||||||
errors: [],
|
|
||||||
unidentifiedDeliveries: [],
|
|
||||||
dataMessage: await this.getDataMessage(messageOptions),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
|
||||||
const contentHint = ContentHint.RESENDABLE;
|
|
||||||
const sendLogCallback =
|
|
||||||
groupIdentifiers.length > 1
|
|
||||||
? this.makeSendLogCallback({
|
|
||||||
contentHint,
|
|
||||||
proto: Buffer.from(Proto.Content.encode(proto).finish()),
|
|
||||||
sendType: 'expirationTimerUpdate',
|
|
||||||
timestamp,
|
|
||||||
})
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return this.sendGroupProto({
|
|
||||||
contentHint,
|
|
||||||
groupId: undefined, // only for GV2 ids
|
|
||||||
options,
|
|
||||||
proto,
|
|
||||||
recipients,
|
|
||||||
sendLogCallback,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple pass-throughs
|
// Simple pass-throughs
|
||||||
|
|
||||||
async getProfile(
|
async getProfile(
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ConversationAttributesType } from '../model-types.d';
|
import type { ConversationAttributesType } from '../model-types.d';
|
||||||
|
@ -19,6 +19,7 @@ import {
|
||||||
} from './phoneNumberSharingMode';
|
} from './phoneNumberSharingMode';
|
||||||
import type { SerializedCertificateType } from '../textsecure/OutgoingMessage';
|
import type { SerializedCertificateType } from '../textsecure/OutgoingMessage';
|
||||||
import { SenderCertificateMode } from '../textsecure/OutgoingMessage';
|
import { SenderCertificateMode } from '../textsecure/OutgoingMessage';
|
||||||
|
import { isNotNil } from './isNotNil';
|
||||||
|
|
||||||
const SEALED_SENDER = {
|
const SEALED_SENDER = {
|
||||||
UNKNOWN: 0,
|
UNKNOWN: 0,
|
||||||
|
@ -27,6 +28,39 @@ const SEALED_SENDER = {
|
||||||
UNRESTRICTED: 3,
|
UNRESTRICTED: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function getSendOptionsForRecipients(
|
||||||
|
recipients: ReadonlyArray<string>
|
||||||
|
): Promise<SendOptionsType> {
|
||||||
|
const conversations = recipients
|
||||||
|
.map(identifier => window.ConversationController.get(identifier))
|
||||||
|
.filter(isNotNil);
|
||||||
|
|
||||||
|
const metadataList = await Promise.all(
|
||||||
|
conversations.map(conversation => getSendOptions(conversation.attributes))
|
||||||
|
);
|
||||||
|
|
||||||
|
return metadataList.reduce(
|
||||||
|
(acc, current): SendOptionsType => {
|
||||||
|
const { sendMetadata: accMetadata } = acc;
|
||||||
|
const { sendMetadata: currentMetadata } = current;
|
||||||
|
|
||||||
|
if (!currentMetadata) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
if (!accMetadata) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(accMetadata, currentMetadata);
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sendMetadata: {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSendOptions(
|
export async function getSendOptions(
|
||||||
conversationAttrs: ConversationAttributesType,
|
conversationAttrs: ConversationAttributesType,
|
||||||
options: { syncMessage?: boolean } = {}
|
options: { syncMessage?: boolean } = {}
|
||||||
|
|
|
@ -7826,15 +7826,15 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rule": "jQuery-load(",
|
"rule": "jQuery-load(",
|
||||||
"path": "ts/jobs/normalMessageSendJobQueue.ts",
|
"path": "ts/jobs/conversationJobQueue.ts",
|
||||||
"line": " await window.ConversationController.load();",
|
"line": " await window.ConversationController.load();",
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2021-12-15T19:58:28.089Z"
|
"updated": "2021-12-15T19:58:28.089Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rule": "jQuery-load(",
|
"rule": "jQuery-load(",
|
||||||
"path": "ts/jobs/reactionJobQueue.ts",
|
"path": "ts/jobs/helpers/sendReaction.ts",
|
||||||
"line": " await window.ConversationController.load();",
|
"line": " await window.ConversationController.load();",
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2021-11-04T16:14:03.477Z"
|
"updated": "2021-11-04T16:14:03.477Z"
|
||||||
},
|
},
|
||||||
|
@ -8080,4 +8080,4 @@
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-09-17T21:02:59.414Z"
|
"updated": "2021-09-17T21:02:59.414Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -30,6 +30,15 @@ export function isMe(conversationAttrs: ConversationAttributesType): boolean {
|
||||||
return Boolean((e164 && e164 === ourNumber) || (uuid && uuid === ourUuid));
|
return Boolean((e164 && e164 === ourNumber) || (uuid && uuid === ourUuid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isGroup(
|
||||||
|
conversationAttrs: Pick<
|
||||||
|
ConversationAttributesType,
|
||||||
|
'groupId' | 'groupVersion'
|
||||||
|
>
|
||||||
|
): boolean {
|
||||||
|
return isGroupV2(conversationAttrs) || isGroupV1(conversationAttrs);
|
||||||
|
}
|
||||||
|
|
||||||
export function isGroupV1(
|
export function isGroupV1(
|
||||||
conversationAttrs: Pick<ConversationAttributesType, 'groupId'>
|
conversationAttrs: Pick<ConversationAttributesType, 'groupId'>
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|
108
ts/util/wrapWithSyncMessageSend.ts
Normal file
108
ts/util/wrapWithSyncMessageSend.ts
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as log from '../logging/log';
|
||||||
|
|
||||||
|
import { SendMessageProtoError } from '../textsecure/Errors';
|
||||||
|
import { getSendOptions } from './getSendOptions';
|
||||||
|
import { handleMessageSend } from './handleMessageSend';
|
||||||
|
|
||||||
|
import type { CallbackResultType } from '../textsecure/Types.d';
|
||||||
|
import type { ConversationModel } from '../models/conversations';
|
||||||
|
import type { SendTypesType } from './handleMessageSend';
|
||||||
|
import type MessageSender from '../textsecure/SendMessage';
|
||||||
|
import { areAllErrorsUnregistered } from '../jobs/helpers/areAllErrorsUnregistered';
|
||||||
|
|
||||||
|
export async function wrapWithSyncMessageSend({
|
||||||
|
conversation,
|
||||||
|
logId,
|
||||||
|
messageIds,
|
||||||
|
send,
|
||||||
|
sendType,
|
||||||
|
timestamp,
|
||||||
|
}: {
|
||||||
|
conversation: ConversationModel;
|
||||||
|
logId: string;
|
||||||
|
messageIds: Array<string>;
|
||||||
|
send: (sender: MessageSender) => Promise<CallbackResultType>;
|
||||||
|
sendType: SendTypesType;
|
||||||
|
timestamp: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const sender = window.textsecure.messaging;
|
||||||
|
if (!sender) {
|
||||||
|
throw new Error(
|
||||||
|
`wrapWithSyncMessageSend/${logId}: textsecure.messaging is not available!`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: CallbackResultType | undefined;
|
||||||
|
let error: Error | undefined;
|
||||||
|
let didSuccessfullySendOne = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await handleMessageSend(send(sender), { messageIds, sendType });
|
||||||
|
didSuccessfullySendOne = true;
|
||||||
|
} catch (thrown) {
|
||||||
|
if (thrown instanceof SendMessageProtoError) {
|
||||||
|
didSuccessfullySendOne = Boolean(
|
||||||
|
thrown.successfulIdentifiers && thrown.successfulIdentifiers.length > 0
|
||||||
|
);
|
||||||
|
error = thrown;
|
||||||
|
}
|
||||||
|
if (thrown instanceof Error) {
|
||||||
|
error = thrown;
|
||||||
|
} else {
|
||||||
|
log.error(
|
||||||
|
`wrapWithSyncMessageSend/${logId}: Thrown value was not an Error, returning early`
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response && !error) {
|
||||||
|
throw new Error(
|
||||||
|
`wrapWithSyncMessageSend/${logId}: message send didn't return result or error!`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataMessage =
|
||||||
|
response?.dataMessage ||
|
||||||
|
(error instanceof SendMessageProtoError ? error.dataMessage : undefined);
|
||||||
|
|
||||||
|
if (didSuccessfullySendOne) {
|
||||||
|
if (!dataMessage) {
|
||||||
|
log.error(
|
||||||
|
`wrapWithSyncMessageSend/${logId}: dataMessage was not returned by send!`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.error(`wrapWithSyncMessageSend/${logId}: Sending sync message...`);
|
||||||
|
const ourConversation =
|
||||||
|
window.ConversationController.getOurConversationOrThrow();
|
||||||
|
const options = await getSendOptions(ourConversation.attributes, {
|
||||||
|
syncMessage: true,
|
||||||
|
});
|
||||||
|
await handleMessageSend(
|
||||||
|
sender.sendSyncMessage({
|
||||||
|
destination: ourConversation.get('e164'),
|
||||||
|
destinationUuid: ourConversation.get('uuid'),
|
||||||
|
encodedDataMessage: dataMessage,
|
||||||
|
expirationStartTimestamp: null,
|
||||||
|
options,
|
||||||
|
timestamp,
|
||||||
|
}),
|
||||||
|
{ messageIds, sendType: sendType === 'message' ? 'sentSync' : sendType }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (areAllErrorsUnregistered(conversation.attributes, error)) {
|
||||||
|
log.info(
|
||||||
|
`wrapWithSyncMessageSend/${logId}: Group send failures were all UnregisteredUserError, returning succcessfully.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue