Message Requests: Add new "Report spam and block" button

This commit is contained in:
Evan Hahn 2021-05-27 16:17:05 -04:00 committed by Scott Nonnenberg
parent 20e501d9f1
commit d4dc9b8e39
33 changed files with 630 additions and 92 deletions

View file

@ -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$?",

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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,

View file

@ -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(

View file

@ -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}

View file

@ -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>
);
};

View file

@ -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')}

View file

@ -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}

View file

@ -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'),
});

View file

@ -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'

View file

@ -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'),
});

View file

@ -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}

View file

@ -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`)}

View file

@ -280,7 +280,7 @@ const actions = () => ({
),
onBlock: action('onBlock'),
onBlockAndDelete: action('onBlockAndDelete'),
onBlockAndReportSpam: action('onBlockAndReportSpam'),
onDelete: action('onDelete'),
onUnblock: action('onUnblock'),

View file

@ -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}

View 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 });
}

View file

@ -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();
}

View 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
View file

@ -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;

View file

@ -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;

View file

@ -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);
}

View file

@ -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>;

View file

@ -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> = [];

View file

@ -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
);
});
});

View 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
View file

@ -20,6 +20,7 @@ export type UnprocessedType = {
envelope?: string;
id: string;
timestamp: number;
serverGuid?: string;
serverTimestamp?: number;
source?: string;
sourceDevice?: number;

View file

@ -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,

View file

@ -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',

View 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 });
}

View file

@ -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];
}

View file

@ -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') || [];