Message Requests: Add new "Report spam and block" button
This commit is contained in:
parent
20e501d9f1
commit
d4dc9b8e39
33 changed files with 630 additions and 92 deletions
|
@ -2956,9 +2956,13 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"MessageRequests--block-and-delete": {
|
||||
"message": "Block and Delete",
|
||||
"description": "Shown as a button to let the user block and delete a message request"
|
||||
"MessageRequests--block-and-report-spam": {
|
||||
"message": "Report Spam and Block",
|
||||
"description": "Shown as a button to let the user block a message request and report spam"
|
||||
},
|
||||
"MessageRequests--block-and-report-spam-success-toast": {
|
||||
"message": "Reported as spam and blocked.",
|
||||
"description": "Shown in a toast when you successfully block a user and report them as spam"
|
||||
},
|
||||
"MessageRequests--block-direct-confirm-title": {
|
||||
"message": "Block $name$?",
|
||||
|
|
|
@ -101,6 +101,11 @@
|
|||
margin-left: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
&--one-button-per-line {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides for a modal with important message
|
||||
|
|
|
@ -636,7 +636,7 @@ describe('Backup', () => {
|
|||
'Backup test: Check that all attachments were successfully imported'
|
||||
);
|
||||
const messageWithAttachmentsFromDB = await loadAllFilesFromDisk(
|
||||
messageFromDB
|
||||
omitUndefinedKeys(messageFromDB)
|
||||
);
|
||||
const expectedMessageWithAttachments = await loadAllFilesFromDisk(
|
||||
omitUndefinedKeys(message)
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { get, throttle } from 'lodash';
|
||||
import { WebAPIType } from './textsecure/WebAPI';
|
||||
import { connectToServerWithStoredCredentials } from './util/connectToServerWithStoredCredentials';
|
||||
|
||||
export type ConfigKeyType =
|
||||
| 'desktop.clientExpiration'
|
||||
|
@ -29,17 +29,6 @@ type ConfigListenersMapType = {
|
|||
[key: string]: Array<ConfigListenerType>;
|
||||
};
|
||||
|
||||
function getServer(): WebAPIType {
|
||||
const OLD_USERNAME = window.storage.get<string>('number_id');
|
||||
const USERNAME = window.storage.get<string>('uuid_id');
|
||||
const PASSWORD = window.storage.get<string>('password');
|
||||
|
||||
return window.WebAPI.connect({
|
||||
username: (USERNAME || OLD_USERNAME) as string,
|
||||
password: PASSWORD as string,
|
||||
});
|
||||
}
|
||||
|
||||
let config: ConfigMapType = {};
|
||||
const listeners: ConfigListenersMapType = {};
|
||||
|
||||
|
@ -63,7 +52,10 @@ export function onChange(
|
|||
|
||||
export const refreshRemoteConfig = async (): Promise<void> => {
|
||||
const now = Date.now();
|
||||
const server = getServer();
|
||||
const server = connectToServerWithStoredCredentials(
|
||||
window.WebAPI,
|
||||
window.storage
|
||||
);
|
||||
const newConfig = await server.getConfig();
|
||||
|
||||
// Process new configuration in light of the old configuration
|
||||
|
|
|
@ -22,6 +22,7 @@ import { ourProfileKeyService } from './services/ourProfileKey';
|
|||
import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey';
|
||||
import { setToExpire } from './services/MessageUpdater';
|
||||
import { LatestQueue } from './util/LatestQueue';
|
||||
import { connectToServerWithStoredCredentials } from './util/connectToServerWithStoredCredentials';
|
||||
|
||||
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
|
||||
|
||||
|
@ -2006,10 +2007,10 @@ export async function startApp(): Promise<void> {
|
|||
|
||||
const udSupportKey = 'hasRegisterSupportForUnauthenticatedDelivery';
|
||||
if (!window.storage.get(udSupportKey)) {
|
||||
const server = window.WebAPI.connect({
|
||||
username: USERNAME || OLD_USERNAME,
|
||||
password: PASSWORD,
|
||||
});
|
||||
const server = connectToServerWithStoredCredentials(
|
||||
window.WebAPI,
|
||||
window.storage
|
||||
);
|
||||
try {
|
||||
await server.registerSupportForUnauthenticatedDelivery();
|
||||
window.storage.put(udSupportKey, true);
|
||||
|
@ -2050,10 +2051,10 @@ export async function startApp(): Promise<void> {
|
|||
}
|
||||
|
||||
if (connectCount === 1) {
|
||||
const server = window.WebAPI.connect({
|
||||
username: USERNAME || OLD_USERNAME,
|
||||
password: PASSWORD,
|
||||
});
|
||||
const server = connectToServerWithStoredCredentials(
|
||||
window.WebAPI,
|
||||
window.storage
|
||||
);
|
||||
try {
|
||||
// Note: we always have to register our capabilities all at once, so we do this
|
||||
// after connect on every startup
|
||||
|
@ -3151,6 +3152,7 @@ export async function startApp(): Promise<void> {
|
|||
sourceUuid: data.sourceUuid,
|
||||
sourceDevice: data.sourceDevice,
|
||||
sent_at: data.timestamp,
|
||||
serverGuid: data.serverGuid,
|
||||
serverTimestamp: data.serverTimestamp,
|
||||
received_at: data.receivedAtCounter,
|
||||
received_at_ms: data.receivedAtDate,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -61,7 +61,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
conversationType: 'direct',
|
||||
onAccept: action('onAccept'),
|
||||
onBlock: action('onBlock'),
|
||||
onBlockAndDelete: action('onBlockAndDelete'),
|
||||
onBlockAndReportSpam: action('onBlockAndReportSpam'),
|
||||
onDelete: action('onDelete'),
|
||||
onUnblock: action('onUnblock'),
|
||||
messageRequestsEnabled: boolean(
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2019-2020 Signal Messenger, LLC
|
||||
// Copyright 2019-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -147,7 +147,7 @@ export const CompositionArea = ({
|
|||
name,
|
||||
onAccept,
|
||||
onBlock,
|
||||
onBlockAndDelete,
|
||||
onBlockAndReportSpam,
|
||||
onDelete,
|
||||
onUnblock,
|
||||
phoneNumber,
|
||||
|
@ -374,7 +374,7 @@ export const CompositionArea = ({
|
|||
conversationType={conversationType}
|
||||
isBlocked={isBlocked}
|
||||
onBlock={onBlock}
|
||||
onBlockAndDelete={onBlockAndDelete}
|
||||
onBlockAndReportSpam={onBlockAndReportSpam}
|
||||
onUnblock={onUnblock}
|
||||
onDelete={onDelete}
|
||||
onAccept={onAccept}
|
||||
|
@ -428,7 +428,7 @@ export const CompositionArea = ({
|
|||
i18n={i18n}
|
||||
conversationType={conversationType}
|
||||
onBlock={onBlock}
|
||||
onBlockAndDelete={onBlockAndDelete}
|
||||
onBlockAndReportSpam={onBlockAndReportSpam}
|
||||
onDelete={onDelete}
|
||||
onAccept={onAccept}
|
||||
name={name}
|
||||
|
|
|
@ -9,6 +9,7 @@ import { LocalizerType } from '../types/Util';
|
|||
import { ModalHost } from './ModalHost';
|
||||
import { Theme } from '../util/theme';
|
||||
import { getClassNamesFor } from '../util/getClassNamesFor';
|
||||
import { useHasWrapped } from '../util/hooks';
|
||||
|
||||
type PropsType = {
|
||||
children: ReactNode;
|
||||
|
@ -85,19 +86,29 @@ export function Modal({
|
|||
);
|
||||
}
|
||||
|
||||
Modal.ButtonFooter = ({
|
||||
Modal.ButtonFooter = function ButtonFooter({
|
||||
children,
|
||||
moduleClassName,
|
||||
}: Readonly<{
|
||||
children: ReactNode;
|
||||
moduleClassName?: string;
|
||||
}>): ReactElement => (
|
||||
<div
|
||||
className={getClassNamesFor(
|
||||
BASE_CLASS_NAME,
|
||||
moduleClassName
|
||||
)('__button-footer')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}>): ReactElement {
|
||||
const [ref, hasWrapped] = useHasWrapped<HTMLDivElement>();
|
||||
|
||||
const className = getClassNamesFor(
|
||||
BASE_CLASS_NAME,
|
||||
moduleClassName
|
||||
)('__button-footer');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
hasWrapped ? `${className}--one-button-per-line` : undefined
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -21,7 +21,7 @@ story.add('Default', () => (
|
|||
<ContactSpoofingReviewDialog
|
||||
i18n={i18n}
|
||||
onBlock={action('onBlock')}
|
||||
onBlockAndDelete={action('onBlockAndDelete')}
|
||||
onBlockAndReportSpam={action('onBlockAndReportSpam')}
|
||||
onClose={action('onClose')}
|
||||
onDelete={action('onDelete')}
|
||||
onShowContactModal={action('onShowContactModal')}
|
||||
|
|
|
@ -18,7 +18,7 @@ import { assert } from '../../util/assert';
|
|||
type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
onBlock: () => unknown;
|
||||
onBlockAndDelete: () => unknown;
|
||||
onBlockAndReportSpam: () => unknown;
|
||||
onClose: () => void;
|
||||
onDelete: () => unknown;
|
||||
onShowContactModal: (contactId: string) => unknown;
|
||||
|
@ -30,7 +30,7 @@ type PropsType = {
|
|||
export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> = ({
|
||||
i18n,
|
||||
onBlock,
|
||||
onBlockAndDelete,
|
||||
onBlockAndReportSpam,
|
||||
onClose,
|
||||
onDelete,
|
||||
onShowContactModal,
|
||||
|
@ -56,7 +56,7 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> = ({
|
|||
<MessageRequestActionsConfirmation
|
||||
i18n={i18n}
|
||||
onBlock={onBlock}
|
||||
onBlockAndDelete={onBlockAndDelete}
|
||||
onBlockAndReportSpam={onBlockAndReportSpam}
|
||||
onUnblock={onUnblock}
|
||||
onDelete={onDelete}
|
||||
name={possiblyUnsafeConversation.name}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -28,7 +28,7 @@ const getBaseProps = (
|
|||
? text('name', 'NYC Rock Climbers')
|
||||
: text('name', 'Cayce Bollard'),
|
||||
onBlock: action('block'),
|
||||
onBlockAndDelete: action('onBlockAndDelete'),
|
||||
onBlockAndReportSpam: action('onBlockAndReportSpam'),
|
||||
onDelete: action('delete'),
|
||||
onAccept: action('accept'),
|
||||
});
|
||||
|
|
|
@ -19,7 +19,7 @@ export type Props = {
|
|||
} & Omit<ContactNameProps, 'module' | 'i18n'> &
|
||||
Pick<
|
||||
MessageRequestActionsConfirmationProps,
|
||||
'conversationType' | 'onBlock' | 'onBlockAndDelete' | 'onDelete'
|
||||
'conversationType' | 'onBlock' | 'onBlockAndReportSpam' | 'onDelete'
|
||||
>;
|
||||
|
||||
export const MandatoryProfileSharingActions = ({
|
||||
|
@ -29,7 +29,7 @@ export const MandatoryProfileSharingActions = ({
|
|||
name,
|
||||
onAccept,
|
||||
onBlock,
|
||||
onBlockAndDelete,
|
||||
onBlockAndReportSpam,
|
||||
onDelete,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
|
@ -43,7 +43,7 @@ export const MandatoryProfileSharingActions = ({
|
|||
<MessageRequestActionsConfirmation
|
||||
i18n={i18n}
|
||||
onBlock={onBlock}
|
||||
onBlockAndDelete={onBlockAndDelete}
|
||||
onBlockAndReportSpam={onBlockAndReportSpam}
|
||||
onUnblock={() => {
|
||||
throw new Error(
|
||||
'Should not be able to unblock from MandatoryProfileSharingActions'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -27,7 +27,7 @@ const getBaseProps = (isGroup = false): MessageRequestActionsProps => ({
|
|||
: text('name', 'Cayce Bollard'),
|
||||
onBlock: action('block'),
|
||||
onDelete: action('delete'),
|
||||
onBlockAndDelete: action('blockAndDelete'),
|
||||
onBlockAndReportSpam: action('blockAndReportSpam'),
|
||||
onUnblock: action('unblock'),
|
||||
onAccept: action('accept'),
|
||||
});
|
||||
|
|
|
@ -30,7 +30,7 @@ export const MessageRequestActions = ({
|
|||
name,
|
||||
onAccept,
|
||||
onBlock,
|
||||
onBlockAndDelete,
|
||||
onBlockAndReportSpam,
|
||||
onDelete,
|
||||
onUnblock,
|
||||
phoneNumber,
|
||||
|
@ -45,7 +45,7 @@ export const MessageRequestActions = ({
|
|||
<MessageRequestActionsConfirmation
|
||||
i18n={i18n}
|
||||
onBlock={onBlock}
|
||||
onBlockAndDelete={onBlockAndDelete}
|
||||
onBlockAndReportSpam={onBlockAndReportSpam}
|
||||
onUnblock={onUnblock}
|
||||
onDelete={onDelete}
|
||||
name={name}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -19,7 +19,7 @@ export type Props = {
|
|||
conversationType: 'group' | 'direct';
|
||||
isBlocked?: boolean;
|
||||
onBlock(): unknown;
|
||||
onBlockAndDelete(): unknown;
|
||||
onBlockAndReportSpam(): unknown;
|
||||
onUnblock(): unknown;
|
||||
onDelete(): unknown;
|
||||
state: MessageRequestState;
|
||||
|
@ -31,7 +31,7 @@ export const MessageRequestActionsConfirmation = ({
|
|||
i18n,
|
||||
name,
|
||||
onBlock,
|
||||
onBlockAndDelete,
|
||||
onBlockAndReportSpam,
|
||||
onChangeState,
|
||||
onDelete,
|
||||
onUnblock,
|
||||
|
@ -64,16 +64,20 @@ export const MessageRequestActionsConfirmation = ({
|
|||
/>
|
||||
}
|
||||
actions={[
|
||||
...(conversationType === 'direct'
|
||||
? [
|
||||
{
|
||||
text: i18n('MessageRequests--block-and-report-spam'),
|
||||
action: onBlockAndReportSpam,
|
||||
style: 'negative' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
text: i18n('MessageRequests--block'),
|
||||
action: onBlock,
|
||||
style: 'negative',
|
||||
},
|
||||
{
|
||||
text: i18n('MessageRequests--block-and-delete'),
|
||||
action: onBlockAndDelete,
|
||||
style: 'negative',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{i18n(`MessageRequests--block-${conversationType}-confirm-body`)}
|
||||
|
|
|
@ -280,7 +280,7 @@ const actions = () => ({
|
|||
),
|
||||
|
||||
onBlock: action('onBlock'),
|
||||
onBlockAndDelete: action('onBlockAndDelete'),
|
||||
onBlockAndReportSpam: action('onBlockAndReportSpam'),
|
||||
onDelete: action('onDelete'),
|
||||
onUnblock: action('onUnblock'),
|
||||
|
||||
|
|
|
@ -110,7 +110,7 @@ type PropsActionsType = {
|
|||
loadNewestMessages: (messageId: string, setFocus?: boolean) => unknown;
|
||||
markMessageRead: (messageId: string) => unknown;
|
||||
onBlock: () => unknown;
|
||||
onBlockAndDelete: () => unknown;
|
||||
onBlockAndReportSpam: () => unknown;
|
||||
onDelete: () => unknown;
|
||||
onUnblock: () => unknown;
|
||||
selectMessage: (messageId: string, conversationId: string) => unknown;
|
||||
|
@ -1168,7 +1168,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
isGroupV1AndDisabled,
|
||||
items,
|
||||
onBlock,
|
||||
onBlockAndDelete,
|
||||
onBlockAndReportSpam,
|
||||
onDelete,
|
||||
onUnblock,
|
||||
showContactModal,
|
||||
|
@ -1314,7 +1314,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
<ContactSpoofingReviewDialog
|
||||
i18n={i18n}
|
||||
onBlock={onBlock}
|
||||
onBlockAndDelete={onBlockAndDelete}
|
||||
onBlockAndReportSpam={onBlockAndReportSpam}
|
||||
onClose={closeContactSpoofingReview}
|
||||
onDelete={onDelete}
|
||||
onShowContactModal={showContactModal}
|
||||
|
|
45
ts/jobs/helpers/addReportSpamJob.ts
Normal file
45
ts/jobs/helpers/addReportSpamJob.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from '../../util/assert';
|
||||
import * as log from '../../logging/log';
|
||||
import { ConversationType } from '../../state/ducks/conversations';
|
||||
import type { reportSpamJobQueue } from '../reportSpamJobQueue';
|
||||
|
||||
export async function addReportSpamJob({
|
||||
conversation,
|
||||
getMessageServerGuidsForSpam,
|
||||
jobQueue,
|
||||
}: Readonly<{
|
||||
conversation: Readonly<ConversationType>;
|
||||
getMessageServerGuidsForSpam: (
|
||||
conversationId: string
|
||||
) => Promise<Array<string>>;
|
||||
jobQueue: Pick<typeof reportSpamJobQueue, 'add'>;
|
||||
}>): Promise<void> {
|
||||
assert(
|
||||
conversation.type === 'direct',
|
||||
'addReportSpamJob: cannot report spam for non-direct conversations'
|
||||
);
|
||||
|
||||
const { e164 } = conversation;
|
||||
if (!e164) {
|
||||
log.info(
|
||||
'addReportSpamJob got a conversation with no E164, which the server does not support. Doing nothing'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const serverGuids = await getMessageServerGuidsForSpam(conversation.id);
|
||||
if (!serverGuids.length) {
|
||||
// This can happen under normal conditions. We haven't always stored server GUIDs, so
|
||||
// a user might try to report spam for a conversation that doesn't have them. (It
|
||||
// may also indicate developer error, but that's not necessarily the case.)
|
||||
log.info(
|
||||
'addReportSpamJob got no server GUIDs from the database. Doing nothing'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await jobQueue.add({ e164, serverGuids });
|
||||
}
|
|
@ -2,10 +2,12 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
|
||||
import { reportSpamJobQueue } from './reportSpamJobQueue';
|
||||
|
||||
/**
|
||||
* Start all of the job queues. Should be called when the database is ready.
|
||||
*/
|
||||
export function initializeAllJobQueues(): void {
|
||||
removeStorageKeyJobQueue.streamJobs();
|
||||
reportSpamJobQueue.streamJobs();
|
||||
}
|
||||
|
|
119
ts/jobs/reportSpamJobQueue.ts
Normal file
119
ts/jobs/reportSpamJobQueue.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as z from 'zod';
|
||||
import * as moment from 'moment';
|
||||
import { waitForOnline } from '../util/waitForOnline';
|
||||
import { isDone as isDeviceLinked } from '../util/registration';
|
||||
import * as log from '../logging/log';
|
||||
import { connectToServerWithStoredCredentials } from '../util/connectToServerWithStoredCredentials';
|
||||
import { map } from '../util/iterables';
|
||||
import { sleep } from '../util/sleep';
|
||||
|
||||
import { JobQueue } from './JobQueue';
|
||||
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
||||
import { parseIntWithFallback } from '../util/parseIntWithFallback';
|
||||
|
||||
const RETRY_WAIT_TIME = moment.duration(1, 'minute').asMilliseconds();
|
||||
const RETRYABLE_4XX_FAILURE_STATUSES = new Set([
|
||||
404,
|
||||
408,
|
||||
410,
|
||||
412,
|
||||
413,
|
||||
414,
|
||||
417,
|
||||
423,
|
||||
424,
|
||||
425,
|
||||
426,
|
||||
428,
|
||||
429,
|
||||
431,
|
||||
449,
|
||||
]);
|
||||
|
||||
const is4xxStatus = (code: number): boolean => code >= 400 && code <= 499;
|
||||
const is5xxStatus = (code: number): boolean => code >= 500 && code <= 599;
|
||||
const isRetriable4xxStatus = (code: number): boolean =>
|
||||
RETRYABLE_4XX_FAILURE_STATUSES.has(code);
|
||||
|
||||
const reportSpamJobDataSchema = z.object({
|
||||
e164: z.string().min(1),
|
||||
serverGuids: z.string().array().min(1).max(1000),
|
||||
});
|
||||
|
||||
export type ReportSpamJobData = z.infer<typeof reportSpamJobDataSchema>;
|
||||
|
||||
export const reportSpamJobQueue = new JobQueue<ReportSpamJobData>({
|
||||
store: jobQueueDatabaseStore,
|
||||
|
||||
queueType: 'report spam',
|
||||
|
||||
maxAttempts: 25,
|
||||
|
||||
parseData(data: unknown): ReportSpamJobData {
|
||||
return reportSpamJobDataSchema.parse(data);
|
||||
},
|
||||
|
||||
async run({ data }: Readonly<{ data: ReportSpamJobData }>): Promise<void> {
|
||||
const { e164, serverGuids } = data;
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
window.storage.onready(resolve);
|
||||
});
|
||||
|
||||
if (!isDeviceLinked()) {
|
||||
log.info("reportSpamJobQueue: skipping this job because we're unlinked");
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForOnline(window.navigator, window);
|
||||
|
||||
const server = connectToServerWithStoredCredentials(
|
||||
window.WebAPI,
|
||||
window.storage
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
map(serverGuids, serverGuid => server.reportMessage(e164, serverGuid))
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (!(err instanceof Error)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const code = parseIntWithFallback(err.code, -1);
|
||||
|
||||
// This is an unexpected case, except for -1, which can happen for network failures.
|
||||
if (code < 400) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (code === 508) {
|
||||
log.info(
|
||||
'reportSpamJobQueue: server responded with 508. Giving up on this job'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRetriable4xxStatus(code) || is5xxStatus(code)) {
|
||||
log.info(
|
||||
`reportSpamJobQueue: server responded with ${code} status code. Sleeping before our next attempt`
|
||||
);
|
||||
await sleep(RETRY_WAIT_TIME);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (is4xxStatus(code)) {
|
||||
log.error(
|
||||
`reportSpamJobQueue: server responded with ${code} status code. Giving up on this job`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
3
ts/model-types.d.ts
vendored
3
ts/model-types.d.ts
vendored
|
@ -171,6 +171,9 @@ export type MessageAttributesType = {
|
|||
// background, when we were still in IndexedDB, before attachments had gone to disk
|
||||
// We set this so that the idle message upgrade process doesn't pick this message up
|
||||
schemaVersion: number;
|
||||
// This should always be set for new messages, but older messages may not have them. We
|
||||
// may not have these for outbound messages, either, as we have not needed them.
|
||||
serverGuid?: string;
|
||||
serverTimestamp?: number;
|
||||
source?: string;
|
||||
sourceUuid?: string;
|
||||
|
|
|
@ -12,6 +12,7 @@ import { assert } from '../util/assert';
|
|||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { waitForOnline } from '../util/waitForOnline';
|
||||
import * as log from '../logging/log';
|
||||
import { connectToServerWithStoredCredentials } from '../util/connectToServerWithStoredCredentials';
|
||||
|
||||
// We define a stricter storage here that returns `unknown` instead of `any`.
|
||||
type Storage = {
|
||||
|
@ -200,20 +201,7 @@ export class SenderCertificateService {
|
|||
'Sender certificate service method was called before it was initialized'
|
||||
);
|
||||
|
||||
const username = storage.get('uuid_id') || storage.get('number_id');
|
||||
const password = storage.get('password');
|
||||
if (typeof username !== 'string') {
|
||||
throw new Error(
|
||||
'Sender certificate service: username in storage was not a string. Cannot connect'
|
||||
);
|
||||
}
|
||||
if (typeof password !== 'string') {
|
||||
throw new Error(
|
||||
'Sender certificate service: password in storage was not a string. Cannot connect'
|
||||
);
|
||||
}
|
||||
|
||||
const server = WebAPI.connect({ username, password });
|
||||
const server = connectToServerWithStoredCredentials(WebAPI, storage);
|
||||
const omitE164 = mode === SenderCertificateMode.WithoutE164;
|
||||
const { certificate } = await server.getSenderCertificate(omitE164);
|
||||
return certificate;
|
||||
|
|
|
@ -235,6 +235,7 @@ const dataInterface: ClientInterface = {
|
|||
getMessagesNeedingUpgrade,
|
||||
getMessagesWithVisualMediaAttachments,
|
||||
getMessagesWithFileAttachments,
|
||||
getMessageServerGuidsForSpam,
|
||||
|
||||
getJobsInQueue,
|
||||
insertJob,
|
||||
|
@ -1531,6 +1532,12 @@ async function getMessagesWithFileAttachments(
|
|||
});
|
||||
}
|
||||
|
||||
function getMessageServerGuidsForSpam(
|
||||
conversationId: string
|
||||
): Promise<Array<string>> {
|
||||
return channels.getMessageServerGuidsForSpam(conversationId);
|
||||
}
|
||||
|
||||
function getJobsInQueue(queueType: string): Promise<Array<StoredJob>> {
|
||||
return channels.getJobsInQueue(queueType);
|
||||
}
|
||||
|
|
|
@ -136,6 +136,7 @@ export type UnprocessedType = {
|
|||
source?: string;
|
||||
sourceUuid?: string;
|
||||
sourceDevice?: number;
|
||||
serverGuid?: string;
|
||||
serverTimestamp?: number;
|
||||
decrypted?: string;
|
||||
};
|
||||
|
@ -144,6 +145,7 @@ export type UnprocessedUpdateType = {
|
|||
source?: string;
|
||||
sourceUuid?: string;
|
||||
sourceDevice?: string;
|
||||
serverGuid?: string;
|
||||
serverTimestamp?: number;
|
||||
decrypted?: string;
|
||||
};
|
||||
|
@ -301,6 +303,9 @@ export type DataInterface = {
|
|||
conversationId: string,
|
||||
options: { limit: number }
|
||||
) => Promise<Array<MessageType>>;
|
||||
getMessageServerGuidsForSpam: (
|
||||
conversationId: string
|
||||
) => Promise<Array<string>>;
|
||||
|
||||
getJobsInQueue(queueType: string): Promise<Array<StoredJob>>;
|
||||
insertJob(job: Readonly<StoredJob>): Promise<void>;
|
||||
|
|
|
@ -225,6 +225,7 @@ const dataInterface: ServerInterface = {
|
|||
getMessagesNeedingUpgrade,
|
||||
getMessagesWithVisualMediaAttachments,
|
||||
getMessagesWithFileAttachments,
|
||||
getMessageServerGuidsForSpam,
|
||||
|
||||
getJobsInQueue,
|
||||
insertJob,
|
||||
|
@ -1834,6 +1835,25 @@ function updateToSchemaVersion31(currentVersion: number, db: Database): void {
|
|||
console.log('updateToSchemaVersion31: success!');
|
||||
}
|
||||
|
||||
function updateToSchemaVersion32(currentVersion: number, db: Database) {
|
||||
if (currentVersion >= 32) {
|
||||
return;
|
||||
}
|
||||
|
||||
db.transaction(() => {
|
||||
db.exec(`
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN serverGuid STRING NULL;
|
||||
|
||||
ALTER TABLE unprocessed
|
||||
ADD COLUMN serverGuid STRING NULL;
|
||||
`);
|
||||
|
||||
db.pragma('user_version = 32');
|
||||
})();
|
||||
console.log('updateToSchemaVersion32: success!');
|
||||
}
|
||||
|
||||
const SCHEMA_VERSIONS = [
|
||||
updateToSchemaVersion1,
|
||||
updateToSchemaVersion2,
|
||||
|
@ -1866,6 +1886,7 @@ const SCHEMA_VERSIONS = [
|
|||
updateToSchemaVersion29,
|
||||
updateToSchemaVersion30,
|
||||
updateToSchemaVersion31,
|
||||
updateToSchemaVersion32,
|
||||
];
|
||||
|
||||
function updateSchema(db: Database): void {
|
||||
|
@ -2934,6 +2955,7 @@ function saveMessageSync(
|
|||
received_at,
|
||||
schemaVersion,
|
||||
sent_at,
|
||||
serverGuid,
|
||||
source,
|
||||
sourceUuid,
|
||||
sourceDevice,
|
||||
|
@ -2959,6 +2981,7 @@ function saveMessageSync(
|
|||
isViewOnce: isViewOnce ? 1 : 0,
|
||||
received_at: received_at || null,
|
||||
schemaVersion,
|
||||
serverGuid: serverGuid || null,
|
||||
sent_at: sent_at || null,
|
||||
source: source || null,
|
||||
sourceUuid: sourceUuid || null,
|
||||
|
@ -2987,6 +3010,7 @@ function saveMessageSync(
|
|||
isViewOnce = $isViewOnce,
|
||||
received_at = $received_at,
|
||||
schemaVersion = $schemaVersion,
|
||||
serverGuid = $serverGuid,
|
||||
sent_at = $sent_at,
|
||||
source = $source,
|
||||
sourceUuid = $sourceUuid,
|
||||
|
@ -3024,6 +3048,7 @@ function saveMessageSync(
|
|||
isViewOnce,
|
||||
received_at,
|
||||
schemaVersion,
|
||||
serverGuid,
|
||||
sent_at,
|
||||
source,
|
||||
sourceUuid,
|
||||
|
@ -3046,6 +3071,7 @@ function saveMessageSync(
|
|||
$isViewOnce,
|
||||
$received_at,
|
||||
$schemaVersion,
|
||||
$serverGuid,
|
||||
$sent_at,
|
||||
$source,
|
||||
$sourceUuid,
|
||||
|
@ -4012,6 +4038,7 @@ function saveUnprocessedSync(data: UnprocessedType): string {
|
|||
source,
|
||||
sourceUuid,
|
||||
sourceDevice,
|
||||
serverGuid,
|
||||
serverTimestamp,
|
||||
decrypted,
|
||||
} = data;
|
||||
|
@ -4031,6 +4058,7 @@ function saveUnprocessedSync(data: UnprocessedType): string {
|
|||
source,
|
||||
sourceUuid,
|
||||
sourceDevice,
|
||||
serverGuid,
|
||||
serverTimestamp,
|
||||
decrypted
|
||||
) values (
|
||||
|
@ -4042,6 +4070,7 @@ function saveUnprocessedSync(data: UnprocessedType): string {
|
|||
$source,
|
||||
$sourceUuid,
|
||||
$sourceDevice,
|
||||
$serverGuid,
|
||||
$serverTimestamp,
|
||||
$decrypted
|
||||
);
|
||||
|
@ -4055,6 +4084,7 @@ function saveUnprocessedSync(data: UnprocessedType): string {
|
|||
source: source || null,
|
||||
sourceUuid: sourceUuid || null,
|
||||
sourceDevice: sourceDevice || null,
|
||||
serverGuid: serverGuid || null,
|
||||
serverTimestamp: serverTimestamp || null,
|
||||
decrypted: decrypted || null,
|
||||
});
|
||||
|
@ -4084,7 +4114,14 @@ function updateUnprocessedWithDataSync(
|
|||
data: UnprocessedUpdateType
|
||||
): void {
|
||||
const db = getInstance();
|
||||
const { source, sourceUuid, sourceDevice, serverTimestamp, decrypted } = data;
|
||||
const {
|
||||
source,
|
||||
sourceUuid,
|
||||
sourceDevice,
|
||||
serverGuid,
|
||||
serverTimestamp,
|
||||
decrypted,
|
||||
} = data;
|
||||
|
||||
prepare(
|
||||
db,
|
||||
|
@ -4093,6 +4130,7 @@ function updateUnprocessedWithDataSync(
|
|||
source = $source,
|
||||
sourceUuid = $sourceUuid,
|
||||
sourceDevice = $sourceDevice,
|
||||
serverGuid = $serverGuid,
|
||||
serverTimestamp = $serverTimestamp,
|
||||
decrypted = $decrypted
|
||||
WHERE id = $id;
|
||||
|
@ -4102,6 +4140,7 @@ function updateUnprocessedWithDataSync(
|
|||
source: source || null,
|
||||
sourceUuid: sourceUuid || null,
|
||||
sourceDevice: sourceDevice || null,
|
||||
serverGuid: serverGuid || null,
|
||||
serverTimestamp: serverTimestamp || null,
|
||||
decrypted: decrypted || null,
|
||||
});
|
||||
|
@ -4910,6 +4949,29 @@ async function getMessagesWithFileAttachments(
|
|||
return map(rows, row => jsonToObject(row.json));
|
||||
}
|
||||
|
||||
async function getMessageServerGuidsForSpam(
|
||||
conversationId: string
|
||||
): Promise<Array<string>> {
|
||||
const db = getInstance();
|
||||
|
||||
// The server's maximum is 3, which is why you see `LIMIT 3` in this query. Note that we
|
||||
// use `pluck` here to only get the first column!
|
||||
return db
|
||||
.prepare<Query>(
|
||||
`
|
||||
SELECT serverGuid
|
||||
FROM messages
|
||||
WHERE conversationId = $conversationId
|
||||
AND type = 'incoming'
|
||||
AND serverGuid IS NOT NULL
|
||||
ORDER BY received_at DESC, sent_at DESC
|
||||
LIMIT 3;
|
||||
`
|
||||
)
|
||||
.pluck(true)
|
||||
.all({ conversationId });
|
||||
}
|
||||
|
||||
function getExternalFilesForMessage(message: MessageType): Array<string> {
|
||||
const { attachments, contact, quote, preview, sticker } = message;
|
||||
const files: Array<string> = [];
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { assert } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
|
||||
import { connectToServerWithStoredCredentials } from '../../util/connectToServerWithStoredCredentials';
|
||||
|
||||
describe('connectToServerWithStoredCredentials', () => {
|
||||
let fakeWebApi: any;
|
||||
let fakeStorage: { get: sinon.SinonStub };
|
||||
let fakeWebApiConnect: { connect: sinon.SinonStub };
|
||||
|
||||
beforeEach(() => {
|
||||
fakeWebApi = {};
|
||||
fakeStorage = { get: sinon.stub() };
|
||||
fakeWebApiConnect = { connect: sinon.stub().returns(fakeWebApi) };
|
||||
});
|
||||
|
||||
it('throws if no ID is in storage', () => {
|
||||
fakeStorage.get.withArgs('password').returns('swordfish');
|
||||
|
||||
assert.throws(() => {
|
||||
connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the ID in storage is not a string', () => {
|
||||
fakeStorage.get.withArgs('uuid_id').returns(1234);
|
||||
fakeStorage.get.withArgs('password').returns('swordfish');
|
||||
|
||||
assert.throws(() => {
|
||||
connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if no password is in storage', () => {
|
||||
fakeStorage.get.withArgs('uuid_id').returns('foo');
|
||||
|
||||
assert.throws(() => {
|
||||
connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the password in storage is not a string', () => {
|
||||
fakeStorage.get.withArgs('uuid_id').returns('foo');
|
||||
fakeStorage.get.withArgs('password').returns(1234);
|
||||
|
||||
assert.throws(() => {
|
||||
connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage);
|
||||
});
|
||||
});
|
||||
|
||||
it('connects with the UUID ID (if available) and password', () => {
|
||||
fakeStorage.get.withArgs('uuid_id').returns('foo');
|
||||
fakeStorage.get.withArgs('number_id').returns('should not be used');
|
||||
fakeStorage.get.withArgs('password').returns('swordfish');
|
||||
|
||||
connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage);
|
||||
|
||||
sinon.assert.calledWith(fakeWebApiConnect.connect, {
|
||||
username: 'foo',
|
||||
password: 'swordfish',
|
||||
});
|
||||
});
|
||||
|
||||
it('connects with the number ID (if UUID ID not available) and password', () => {
|
||||
fakeStorage.get.withArgs('number_id').returns('bar');
|
||||
fakeStorage.get.withArgs('password').returns('swordfish');
|
||||
|
||||
connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage);
|
||||
|
||||
sinon.assert.calledWith(fakeWebApiConnect.connect, {
|
||||
username: 'bar',
|
||||
password: 'swordfish',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the connected WebAPI', () => {
|
||||
fakeStorage.get.withArgs('uuid_id').returns('foo');
|
||||
fakeStorage.get.withArgs('password').returns('swordfish');
|
||||
|
||||
assert.strictEqual(
|
||||
connectToServerWithStoredCredentials(fakeWebApiConnect, fakeStorage),
|
||||
fakeWebApi
|
||||
);
|
||||
});
|
||||
});
|
73
ts/test-node/jobs/helpers/addReportSpamJob_test.ts
Normal file
73
ts/test-node/jobs/helpers/addReportSpamJob_test.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as sinon from 'sinon';
|
||||
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
|
||||
import { Job } from '../../../jobs/Job';
|
||||
|
||||
import { addReportSpamJob } from '../../../jobs/helpers/addReportSpamJob';
|
||||
|
||||
describe('addReportSpamJob', () => {
|
||||
let getMessageServerGuidsForSpam: sinon.SinonStub;
|
||||
let jobQueue: { add: sinon.SinonStub };
|
||||
|
||||
beforeEach(() => {
|
||||
getMessageServerGuidsForSpam = sinon.stub().resolves(['abc', 'xyz']);
|
||||
jobQueue = {
|
||||
add: sinon
|
||||
.stub()
|
||||
.callsFake(
|
||||
async data =>
|
||||
new Job<unknown>(
|
||||
'fake-job-id',
|
||||
Date.now(),
|
||||
'fake job queue type',
|
||||
data,
|
||||
Promise.resolve()
|
||||
)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
it('does nothing if the conversation lacks an E164', async () => {
|
||||
await addReportSpamJob({
|
||||
conversation: getDefaultConversation({ e164: undefined }),
|
||||
getMessageServerGuidsForSpam,
|
||||
jobQueue,
|
||||
});
|
||||
|
||||
sinon.assert.notCalled(getMessageServerGuidsForSpam);
|
||||
sinon.assert.notCalled(jobQueue.add);
|
||||
});
|
||||
|
||||
it("doesn't enqueue a job if there are no messages with server GUIDs", async () => {
|
||||
getMessageServerGuidsForSpam.resolves([]);
|
||||
|
||||
await addReportSpamJob({
|
||||
conversation: getDefaultConversation(),
|
||||
getMessageServerGuidsForSpam,
|
||||
jobQueue,
|
||||
});
|
||||
|
||||
sinon.assert.notCalled(jobQueue.add);
|
||||
});
|
||||
|
||||
it('enqueues a job', async () => {
|
||||
const conversation = getDefaultConversation();
|
||||
|
||||
await addReportSpamJob({
|
||||
conversation,
|
||||
getMessageServerGuidsForSpam,
|
||||
jobQueue,
|
||||
});
|
||||
|
||||
sinon.assert.calledOnce(getMessageServerGuidsForSpam);
|
||||
sinon.assert.calledWith(getMessageServerGuidsForSpam, conversation.id);
|
||||
|
||||
sinon.assert.calledOnce(jobQueue.add);
|
||||
sinon.assert.calledWith(jobQueue.add, {
|
||||
e164: conversation.e164,
|
||||
serverGuids: ['abc', 'xyz'],
|
||||
});
|
||||
});
|
||||
});
|
1
ts/textsecure.d.ts
vendored
1
ts/textsecure.d.ts
vendored
|
@ -20,6 +20,7 @@ export type UnprocessedType = {
|
|||
envelope?: string;
|
||||
id: string;
|
||||
timestamp: number;
|
||||
serverGuid?: string;
|
||||
serverTimestamp?: number;
|
||||
source?: string;
|
||||
sourceDevice?: number;
|
||||
|
|
|
@ -838,6 +838,7 @@ class MessageReceiverInner extends EventTarget {
|
|||
source: envelope.source,
|
||||
sourceUuid: envelope.sourceUuid,
|
||||
sourceDevice: envelope.sourceDevice,
|
||||
serverGuid: envelope.serverGuid,
|
||||
serverTimestamp: envelope.serverTimestamp,
|
||||
decrypted: MessageReceiverInner.arrayBufferToStringBase64(
|
||||
plaintext
|
||||
|
@ -1561,6 +1562,7 @@ class MessageReceiverInner extends EventTarget {
|
|||
sourceUuid: envelope.sourceUuid,
|
||||
sourceDevice: envelope.sourceDevice,
|
||||
timestamp: envelope.timestamp.toNumber(),
|
||||
serverGuid: envelope.serverGuid,
|
||||
serverTimestamp: envelope.serverTimestamp,
|
||||
unidentifiedDeliveryReceived: envelope.unidentifiedDeliveryReceived,
|
||||
message,
|
||||
|
|
|
@ -733,6 +733,7 @@ const URL_CALLS = {
|
|||
profile: 'v1/profile',
|
||||
registerCapabilities: 'v1/devices/capabilities',
|
||||
removeSignalingKey: 'v1/accounts/signaling_key',
|
||||
reportMessage: 'v1/messages/report',
|
||||
signed: 'v2/keys/signed',
|
||||
storageManifest: 'v1/storage/manifest',
|
||||
storageModify: 'v1/storage/',
|
||||
|
@ -926,6 +927,7 @@ export type WebAPIType = {
|
|||
registerKeys: (genKeys: KeysType) => Promise<void>;
|
||||
registerSupportForUnauthenticatedDelivery: () => Promise<any>;
|
||||
removeSignalingKey: () => Promise<void>;
|
||||
reportMessage: (senderE164: string, serverGuid: string) => Promise<void>;
|
||||
requestVerificationSMS: (number: string) => Promise<any>;
|
||||
requestVerificationVoice: (number: string) => Promise<any>;
|
||||
sendMessages: (
|
||||
|
@ -1115,6 +1117,7 @@ export function initialize({
|
|||
registerKeys,
|
||||
registerSupportForUnauthenticatedDelivery,
|
||||
removeSignalingKey,
|
||||
reportMessage,
|
||||
requestVerificationSMS,
|
||||
requestVerificationVoice,
|
||||
sendMessages,
|
||||
|
@ -1404,6 +1407,18 @@ export function initialize({
|
|||
});
|
||||
}
|
||||
|
||||
async function reportMessage(
|
||||
senderE164: string,
|
||||
serverGuid: string
|
||||
): Promise<void> {
|
||||
await _ajax({
|
||||
call: 'reportMessage',
|
||||
httpType: 'POST',
|
||||
urlParameters: `/${senderE164}/${serverGuid}`,
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
}
|
||||
|
||||
async function requestVerificationSMS(number: string) {
|
||||
return _ajax({
|
||||
call: 'accounts',
|
||||
|
|
30
ts/util/connectToServerWithStoredCredentials.ts
Normal file
30
ts/util/connectToServerWithStoredCredentials.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { WebAPIConnectType, WebAPIType } from '../textsecure/WebAPI';
|
||||
|
||||
// We define a stricter storage here that returns `unknown` instead of `any`.
|
||||
type Storage = {
|
||||
get(key: string): unknown;
|
||||
};
|
||||
|
||||
export function connectToServerWithStoredCredentials(
|
||||
WebAPI: WebAPIConnectType,
|
||||
storage: Storage
|
||||
): WebAPIType {
|
||||
const username = storage.get('uuid_id') || storage.get('number_id');
|
||||
if (typeof username !== 'string') {
|
||||
throw new Error(
|
||||
'Username in storage was not a string. Cannot connect to WebAPI'
|
||||
);
|
||||
}
|
||||
|
||||
const password = storage.get('password');
|
||||
if (typeof password !== 'string') {
|
||||
throw new Error(
|
||||
'Password in storage was not a string. Cannot connect to WebAPI'
|
||||
);
|
||||
}
|
||||
|
||||
return WebAPI.connect({ username, password });
|
||||
}
|
|
@ -4,6 +4,7 @@
|
|||
import * as React from 'react';
|
||||
import { ActionCreatorsMapObject, bindActionCreators } from 'redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { first, last, noop } from 'lodash';
|
||||
|
||||
export function usePrevious<T>(initialValue: T, currentValue: T): T {
|
||||
const previousValueRef = React.useRef<T>(initialValue);
|
||||
|
@ -132,3 +133,57 @@ export function useIntersectionObserver(): [
|
|||
|
||||
return [setRef, intersectionObserverEntry];
|
||||
}
|
||||
|
||||
function getTop(element: Readonly<Element>): number {
|
||||
return element.getBoundingClientRect().top;
|
||||
}
|
||||
|
||||
function isWrapped(element: Readonly<null | HTMLElement>): boolean {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { children } = element;
|
||||
const firstChild = first(children);
|
||||
const lastChild = last(children);
|
||||
|
||||
return Boolean(
|
||||
firstChild &&
|
||||
lastChild &&
|
||||
firstChild !== lastChild &&
|
||||
getTop(firstChild) !== getTop(lastChild)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that returns a ref (to put on your element) and a boolean. The boolean will be
|
||||
* `true` if the element's children have different `top`s, and `false` otherwise.
|
||||
*/
|
||||
export function useHasWrapped<T extends HTMLElement>(): [
|
||||
React.Ref<T>,
|
||||
boolean
|
||||
] {
|
||||
const [element, setElement] = React.useState<null | T>(null);
|
||||
|
||||
const [hasWrapped, setHasWrapped] = React.useState(isWrapped(element));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!element) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
// We can remove this `any` when we upgrade to TypeScript 4.2+, which adds
|
||||
// `ResizeObserver` type definitions.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const observer = new (window as any).ResizeObserver(() => {
|
||||
setHasWrapped(isWrapped(element));
|
||||
});
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [element]);
|
||||
|
||||
return [setElement, hasWrapped];
|
||||
}
|
||||
|
|
|
@ -12,6 +12,8 @@ import { MessageModel } from '../models/messages';
|
|||
import { MessageType } from '../state/ducks/conversations';
|
||||
import { assert } from '../util/assert';
|
||||
import { maybeParseUrl } from '../util/url';
|
||||
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
|
||||
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
|
||||
|
||||
type GetLinkPreviewImageResult = {
|
||||
data: ArrayBuffer;
|
||||
|
@ -317,6 +319,11 @@ Whisper.AlreadyRequestedToJoinToast = Whisper.ToastView.extend({
|
|||
template: () => window.i18n('GroupV2--join--already-awaiting-approval'),
|
||||
});
|
||||
|
||||
const ReportedSpamAndBlockedToast = Whisper.ToastView.extend({
|
||||
template: () =>
|
||||
window.i18n('MessageRequests--block-and-report-spam-success-toast'),
|
||||
});
|
||||
|
||||
Whisper.ConversationLoadingScreen = Whisper.View.extend({
|
||||
template: () => $('#conversation-loading-screen').html(),
|
||||
className: 'conversation-loading-screen',
|
||||
|
@ -638,11 +645,8 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
onDelete: () => {
|
||||
this.syncMessageRequestResponse('onDelete', messageRequestEnum.DELETE);
|
||||
},
|
||||
onBlockAndDelete: () => {
|
||||
this.syncMessageRequestResponse(
|
||||
'onBlockAndDelete',
|
||||
messageRequestEnum.BLOCK_AND_DELETE
|
||||
);
|
||||
onBlockAndReportSpam: () => {
|
||||
this.blockAndReportSpam();
|
||||
},
|
||||
onStartGroupMigration: () => this.startMigrationToGV2(),
|
||||
onCancelJoinRequest: async () => {
|
||||
|
@ -963,11 +967,8 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
onBlock: () => {
|
||||
this.syncMessageRequestResponse('onBlock', messageRequestEnum.BLOCK);
|
||||
},
|
||||
onBlockAndDelete: () => {
|
||||
this.syncMessageRequestResponse(
|
||||
'onBlockAndDelete',
|
||||
messageRequestEnum.BLOCK_AND_DELETE
|
||||
);
|
||||
onBlockAndReportSpam: () => {
|
||||
this.blockAndReportSpam();
|
||||
},
|
||||
onDelete: () => {
|
||||
this.syncMessageRequestResponse(
|
||||
|
@ -1462,6 +1463,28 @@ Whisper.ConversationView = Whisper.View.extend({
|
|||
});
|
||||
},
|
||||
|
||||
blockAndReportSpam(): Promise<void> {
|
||||
const messageRequestEnum =
|
||||
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
|
||||
const { model }: { model: ConversationModel } = this;
|
||||
|
||||
return this.longRunningTaskWrapper({
|
||||
name: 'blockAndReportSpam',
|
||||
task: async () => {
|
||||
await Promise.all([
|
||||
model.syncMessageRequestResponse(messageRequestEnum.BLOCK),
|
||||
addReportSpamJob({
|
||||
conversation: model.format(),
|
||||
getMessageServerGuidsForSpam:
|
||||
window.Signal.Data.getMessageServerGuidsForSpam,
|
||||
jobQueue: reportSpamJobQueue,
|
||||
}),
|
||||
]);
|
||||
this.showToast(ReportedSpamAndBlockedToast);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
getPropsForAttachmentList() {
|
||||
const draftAttachments = this.model.get('draftAttachments') || [];
|
||||
|
||||
|
|
Loading…
Reference in a new issue