Signal-Desktop/ts/types/Attachment.ts

1045 lines
25 KiB
TypeScript
Raw Normal View History

2022-02-11 21:38:52 +00:00
// Copyright 2018-2022 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
2018-04-09 23:29:38 +00:00
import is from '@sindresorhus/is';
2018-04-25 17:27:28 +00:00
import moment from 'moment';
2021-07-14 23:39:52 +00:00
import {
isNumber,
padStart,
2021-09-24 00:49:05 +00:00
isTypedArray,
2021-07-14 23:39:52 +00:00
isFunction,
isUndefined,
omit,
} from 'lodash';
2021-09-24 00:49:05 +00:00
import { blobToArrayBuffer } from 'blob-util';
2021-07-14 23:39:52 +00:00
import type { LoggerType } from './Logging';
2018-05-07 15:19:58 +00:00
import * as MIME from './MIME';
import * as log from '../logging/log';
2021-07-14 23:39:52 +00:00
import { toLogFormat } from './errors';
2018-05-07 18:15:06 +00:00
import { SignalService } from '../protobuf';
2019-01-14 21:49:58 +00:00
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../util/GoogleChrome';
import type { LocalizerType } from './Util';
import { ThemeType } from './Util';
2021-07-14 23:39:52 +00:00
import { scaleImageToLevel } from '../util/scaleImageToLevel';
import * as GoogleChrome from '../util/GoogleChrome';
import { parseIntOrThrow } from '../util/parseIntOrThrow';
import { getValue } from '../RemoteConfig';
2019-01-14 21:49:58 +00:00
const MAX_WIDTH = 300;
const MAX_HEIGHT = MAX_WIDTH * 1.5;
const MIN_WIDTH = 200;
const MIN_HEIGHT = 50;
// Used for display
export type AttachmentType = {
error?: boolean;
2020-05-27 21:37:06 +00:00
blurHash?: string;
2019-01-14 21:49:58 +00:00
caption?: string;
contentType: MIME.MIMEType;
fileName?: string;
2019-01-14 21:49:58 +00:00
/** Not included in protobuf, needs to be pulled from flags */
isVoiceMessage?: boolean;
/** For messages not already on disk, this will be a data url */
url?: string;
size: number;
2021-06-24 19:05:27 +00:00
fileSize?: string;
2019-01-14 21:49:58 +00:00
pending?: boolean;
width?: number;
height?: number;
path?: string;
2019-01-14 21:49:58 +00:00
screenshot?: {
height: number;
width: number;
2021-07-14 23:39:52 +00:00
url?: string;
2019-01-14 21:49:58 +00:00
contentType: MIME.MIMEType;
path: string;
data?: Uint8Array;
2019-01-14 21:49:58 +00:00
};
2021-09-24 20:02:30 +00:00
screenshotData?: Uint8Array;
2021-06-25 16:08:16 +00:00
screenshotPath?: string;
flags?: number;
thumbnail?: ThumbnailType;
isCorrupted?: boolean;
downloadJobId?: string;
cdnNumber?: number;
cdnId?: string;
cdnKey?: string;
2021-09-24 00:49:05 +00:00
data?: Uint8Array;
2022-04-06 01:18:07 +00:00
textAttachment?: TextAttachmentType;
2021-07-09 19:36:10 +00:00
/** Legacy field. Used only for downloading old attachments */
id?: number;
/** Legacy field, used long ago for migrating attachments to disk. */
schemaVersion?: number;
/** Removed once we download the attachment */
digest?: string;
key?: string;
};
export type AttachmentWithHydratedData = AttachmentType & {
data: Uint8Array;
};
2022-04-06 01:18:07 +00:00
export enum TextAttachmentStyleType {
DEFAULT = 0,
REGULAR = 1,
BOLD = 2,
SERIF = 3,
SCRIPT = 4,
CONDENSED = 5,
}
export type TextAttachmentType = {
text?: string | null;
textStyle?: number | null;
textForegroundColor?: number | null;
textBackgroundColor?: number | null;
preview?: {
url?: string | null;
title?: string | null;
} | null;
gradient?: {
startColor?: number | null;
endColor?: number | null;
angle?: number | null;
} | null;
color?: number | null;
};
2021-07-14 23:39:52 +00:00
export type DownloadedAttachmentType = AttachmentType & {
2021-09-24 00:49:05 +00:00
data: Uint8Array;
2021-07-14 23:39:52 +00:00
};
2021-08-30 21:32:56 +00:00
export type BaseAttachmentDraftType = {
2021-06-25 16:08:16 +00:00
blurHash?: string;
contentType: MIME.MIMEType;
screenshotContentType?: string;
screenshotSize?: number;
size: number;
flags?: number;
2021-06-25 16:08:16 +00:00
};
// An ephemeral attachment type, used between user's request to add the attachment as
// a draft and final save on disk and in conversation.draftAttachments.
2021-08-30 21:32:56 +00:00
export type InMemoryAttachmentDraftType =
| ({
data: Uint8Array;
2021-08-30 21:32:56 +00:00
pending: false;
2021-09-24 00:49:05 +00:00
screenshotData?: Uint8Array;
fileName?: string;
path?: string;
2021-08-30 21:32:56 +00:00
} & BaseAttachmentDraftType)
| {
contentType: MIME.MIMEType;
fileName?: string;
path?: string;
2021-08-30 21:32:56 +00:00
pending: true;
size: number;
2021-08-30 21:32:56 +00:00
};
2021-06-25 16:08:16 +00:00
// What's stored in conversation.draftAttachments
2021-08-30 21:32:56 +00:00
export type AttachmentDraftType =
| ({
url?: string;
2021-08-30 21:32:56 +00:00
screenshotPath?: string;
pending: false;
2021-11-10 00:25:29 +00:00
// Old draft attachments may have a caption, though they are no longer editable
// because we removed the caption editor.
caption?: string;
fileName?: string;
path: string;
2021-11-30 23:32:55 +00:00
width?: number;
height?: number;
2021-08-30 21:32:56 +00:00
} & BaseAttachmentDraftType)
| {
contentType: MIME.MIMEType;
fileName?: string;
path?: string;
2021-08-30 21:32:56 +00:00
pending: true;
size: number;
2021-08-30 21:32:56 +00:00
};
2021-06-25 16:08:16 +00:00
export type ThumbnailType = {
height?: number;
width?: number;
2021-07-14 23:39:52 +00:00
url?: string;
contentType: MIME.MIMEType;
path?: string;
data?: Uint8Array;
// Only used when quote needed to make an in-memory thumbnail
objectUrl?: string;
};
2019-01-14 21:49:58 +00:00
2021-07-14 23:39:52 +00:00
export async function migrateDataToFileSystem(
attachment: AttachmentType,
{
writeNewAttachmentData,
}: {
2021-09-24 00:49:05 +00:00
writeNewAttachmentData: (data: Uint8Array) => Promise<string>;
2021-07-14 23:39:52 +00:00
}
): Promise<AttachmentType> {
if (!isFunction(writeNewAttachmentData)) {
throw new TypeError("'writeNewAttachmentData' must be a function");
}
const { data } = attachment;
const attachmentHasData = !isUndefined(data);
const shouldSkipSchemaUpgrade = !attachmentHasData;
if (shouldSkipSchemaUpgrade) {
return attachment;
}
2021-09-24 00:49:05 +00:00
if (!isTypedArray(data)) {
2021-07-14 23:39:52 +00:00
throw new TypeError(
2021-09-24 00:49:05 +00:00
'Expected `attachment.data` to be a typed array;' +
2021-07-14 23:39:52 +00:00
` got: ${typeof attachment.data}`
);
}
const path = await writeNewAttachmentData(data);
const attachmentWithoutData = omit({ ...attachment, path }, ['data']);
return attachmentWithoutData;
}
// // Incoming message attachment fields
// {
// id: string
// contentType: MIMEType
2021-09-24 00:49:05 +00:00
// data: Uint8Array
// digest: Uint8Array
2021-07-14 23:39:52 +00:00
// fileName?: string
// flags: null
2021-09-24 00:49:05 +00:00
// key: Uint8Array
2021-07-14 23:39:52 +00:00
// size: integer
2021-09-24 00:49:05 +00:00
// thumbnail: Uint8Array
2021-07-14 23:39:52 +00:00
// }
// // Outgoing message attachment fields
// {
// contentType: MIMEType
2021-09-24 00:49:05 +00:00
// data: Uint8Array
2021-07-14 23:39:52 +00:00
// fileName: string
// size: integer
// }
// Returns true if `rawAttachment` is a valid attachment based on our current schema.
// Over time, we can expand this definition to become more narrow, e.g. require certain
// fields, etc.
export function isValid(
rawAttachment?: AttachmentType
): rawAttachment is AttachmentType {
// NOTE: We cannot use `_.isPlainObject` because `rawAttachment` is
// deserialized by protobuf:
if (!rawAttachment) {
return false;
}
return true;
}
// Upgrade steps
// NOTE: This step strips all EXIF metadata from JPEG images as
// part of re-encoding the image:
export async function autoOrientJPEG(
attachment: AttachmentType,
_: unknown,
2021-07-20 19:31:23 +00:00
{
sendHQImages = false,
isIncoming = false,
}: {
sendHQImages?: boolean;
isIncoming?: boolean;
} = {}
2021-07-14 23:39:52 +00:00
): Promise<AttachmentType> {
2021-07-20 19:31:23 +00:00
if (isIncoming && !MIME.isJPEG(attachment.contentType)) {
return attachment;
}
2021-07-14 23:39:52 +00:00
if (!canBeTranscoded(attachment)) {
return attachment;
}
2021-08-09 20:06:21 +00:00
// If we haven't downloaded the attachment yet, we won't have the data.
// All images go through handleImageAttachment before being sent and thus have
// already been scaled to level, oriented, stripped of exif data, and saved
// in high quality format. If we want to send the image in HQ we can return
2022-02-09 20:33:19 +00:00
// the attachment as-is. Otherwise we'll have to further scale it down.
2021-08-09 20:06:21 +00:00
if (!attachment.data || sendHQImages) {
2021-07-14 23:39:52 +00:00
return attachment;
}
2021-09-24 00:49:05 +00:00
const dataBlob = new Blob([attachment.data], {
type: attachment.contentType,
});
const { blob: xcodedDataBlob } = await scaleImageToLevel(
dataBlob,
attachment.contentType,
isIncoming
);
2021-07-14 23:39:52 +00:00
const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob);
2021-09-24 00:49:05 +00:00
// IMPORTANT: We overwrite the existing `data` `Uint8Array` losing the original
2021-07-14 23:39:52 +00:00
// image data. Ideally, wed preserve the original image data for users who want to
// retain it but due to reports of data loss, we dont want to overburden IndexedDB
// by potentially doubling stored image data.
// See: https://github.com/signalapp/Signal-Desktop/issues/1589
const xcodedAttachment = {
// `digest` is no longer valid for auto-oriented image data, so we discard it:
...omit(attachment, 'digest'),
2021-09-24 00:49:05 +00:00
data: new Uint8Array(xcodedDataArrayBuffer),
2021-07-14 23:39:52 +00:00
size: xcodedDataArrayBuffer.byteLength,
};
return xcodedAttachment;
}
const UNICODE_LEFT_TO_RIGHT_OVERRIDE = '\u202D';
const UNICODE_RIGHT_TO_LEFT_OVERRIDE = '\u202E';
const UNICODE_REPLACEMENT_CHARACTER = '\uFFFD';
const INVALID_CHARACTERS_PATTERN = new RegExp(
`[${UNICODE_LEFT_TO_RIGHT_OVERRIDE}${UNICODE_RIGHT_TO_LEFT_OVERRIDE}]`,
'g'
);
// NOTE: Expose synchronous version to do property-based testing using `testcheck`,
// which currently doesnt support async testing:
// https://github.com/leebyron/testcheck-js/issues/45
export function _replaceUnicodeOrderOverridesSync(
attachment: AttachmentType
): AttachmentType {
if (!is.string(attachment.fileName)) {
return attachment;
}
const normalizedFilename = attachment.fileName.replace(
INVALID_CHARACTERS_PATTERN,
UNICODE_REPLACEMENT_CHARACTER
);
const newAttachment = { ...attachment, fileName: normalizedFilename };
return newAttachment;
}
export const replaceUnicodeOrderOverrides = async (
attachment: AttachmentType
): Promise<AttachmentType> => {
return _replaceUnicodeOrderOverridesSync(attachment);
};
// \u202A-\u202E is LRE, RLE, PDF, LRO, RLO
// \u2066-\u2069 is LRI, RLI, FSI, PDI
// \u200E is LRM
// \u200F is RLM
// \u061C is ALM
const V2_UNWANTED_UNICODE = /[\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]/g;
export async function replaceUnicodeV2(
attachment: AttachmentType
): Promise<AttachmentType> {
if (!is.string(attachment.fileName)) {
return attachment;
}
const fileName = attachment.fileName.replace(
V2_UNWANTED_UNICODE,
UNICODE_REPLACEMENT_CHARACTER
);
return {
...attachment,
fileName,
};
}
export function removeSchemaVersion({
attachment,
logger,
}: {
attachment: AttachmentType;
logger: LoggerType;
}): AttachmentType {
2022-02-11 21:38:52 +00:00
if (!isValid(attachment)) {
2021-07-14 23:39:52 +00:00
logger.error(
'Attachment.removeSchemaVersion: Invalid input attachment:',
attachment
);
return attachment;
}
return omit(attachment, 'schemaVersion');
}
export function hasData(attachment: AttachmentType): boolean {
2021-09-24 00:49:05 +00:00
return attachment.data instanceof Uint8Array;
2021-07-14 23:39:52 +00:00
}
export function loadData(
2021-09-24 00:49:05 +00:00
readAttachmentData: (path: string) => Promise<Uint8Array>
): (attachment: AttachmentType) => Promise<AttachmentWithHydratedData> {
2021-07-14 23:39:52 +00:00
if (!is.function_(readAttachmentData)) {
throw new TypeError("'readAttachmentData' must be a function");
}
return async (
attachment: AttachmentType
): Promise<AttachmentWithHydratedData> => {
2021-07-14 23:39:52 +00:00
if (!isValid(attachment)) {
throw new TypeError("'attachment' is not valid");
}
const isAlreadyLoaded = Boolean(attachment.data);
if (isAlreadyLoaded) {
return attachment as AttachmentWithHydratedData;
2021-07-14 23:39:52 +00:00
}
if (!is.string(attachment.path)) {
throw new TypeError("'attachment.path' is required");
}
const data = await readAttachmentData(attachment.path);
return { ...attachment, data, size: data.byteLength };
};
}
export function deleteData(
deleteOnDisk: (path: string) => Promise<void>
): (attachment?: AttachmentType) => Promise<void> {
if (!is.function_(deleteOnDisk)) {
throw new TypeError('deleteData: deleteOnDisk must be a function');
}
return async (attachment?: AttachmentType): Promise<void> => {
if (!isValid(attachment)) {
throw new TypeError('deleteData: attachment is not valid');
}
const { path, thumbnail, screenshot } = attachment;
if (is.string(path)) {
await deleteOnDisk(path);
}
if (thumbnail && is.string(thumbnail.path)) {
await deleteOnDisk(thumbnail.path);
}
if (screenshot && is.string(screenshot.path)) {
await deleteOnDisk(screenshot.path);
}
};
}
const THUMBNAIL_SIZE = 150;
const THUMBNAIL_CONTENT_TYPE = MIME.IMAGE_PNG;
export async function captureDimensionsAndScreenshot(
attachment: AttachmentType,
params: {
2021-09-24 00:49:05 +00:00
writeNewAttachmentData: (data: Uint8Array) => Promise<string>;
getAbsoluteAttachmentPath: (path: string) => string;
2021-09-24 00:49:05 +00:00
makeObjectUrl: (
data: Uint8Array | ArrayBuffer,
contentType: MIME.MIMEType
) => string;
2021-07-14 23:39:52 +00:00
revokeObjectUrl: (path: string) => void;
getImageDimensions: (params: {
objectUrl: string;
logger: LoggerType;
}) => Promise<{
2021-11-11 22:43:05 +00:00
width: number;
height: number;
}>;
2021-07-14 23:39:52 +00:00
makeImageThumbnail: (params: {
size: number;
objectUrl: string;
contentType: MIME.MIMEType;
logger: LoggerType;
}) => Promise<Blob>;
makeVideoScreenshot: (params: {
objectUrl: string;
contentType: MIME.MIMEType;
logger: LoggerType;
}) => Promise<Blob>;
logger: LoggerType;
}
): Promise<AttachmentType> {
const { contentType } = attachment;
const {
writeNewAttachmentData,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions: getImageDimensionsFromURL,
makeImageThumbnail,
makeVideoScreenshot,
logger,
} = params;
if (
!GoogleChrome.isImageTypeSupported(contentType) &&
!GoogleChrome.isVideoTypeSupported(contentType)
) {
return attachment;
}
// If the attachment hasn't been downloaded yet, we won't have a path
if (!attachment.path) {
return attachment;
}
const absolutePath = getAbsoluteAttachmentPath(attachment.path);
2021-07-14 23:39:52 +00:00
if (GoogleChrome.isImageTypeSupported(contentType)) {
try {
const { width, height } = await getImageDimensionsFromURL({
objectUrl: absolutePath,
logger,
});
const thumbnailBuffer = await blobToArrayBuffer(
await makeImageThumbnail({
size: THUMBNAIL_SIZE,
objectUrl: absolutePath,
contentType: THUMBNAIL_CONTENT_TYPE,
logger,
})
);
2021-09-24 00:49:05 +00:00
const thumbnailPath = await writeNewAttachmentData(
new Uint8Array(thumbnailBuffer)
);
2021-07-14 23:39:52 +00:00
return {
...attachment,
width,
height,
thumbnail: {
path: thumbnailPath,
contentType: THUMBNAIL_CONTENT_TYPE,
width: THUMBNAIL_SIZE,
height: THUMBNAIL_SIZE,
},
};
} catch (error) {
logger.error(
'captureDimensionsAndScreenshot:',
'error processing image; skipping screenshot generation',
toLogFormat(error)
);
return attachment;
}
}
let screenshotObjectUrl: string | undefined;
try {
const screenshotBuffer = await blobToArrayBuffer(
await makeVideoScreenshot({
objectUrl: absolutePath,
contentType: THUMBNAIL_CONTENT_TYPE,
logger,
})
);
screenshotObjectUrl = makeObjectUrl(
screenshotBuffer,
THUMBNAIL_CONTENT_TYPE
);
const { width, height } = await getImageDimensionsFromURL({
objectUrl: screenshotObjectUrl,
logger,
});
2021-09-24 00:49:05 +00:00
const screenshotPath = await writeNewAttachmentData(
new Uint8Array(screenshotBuffer)
);
2021-07-14 23:39:52 +00:00
const thumbnailBuffer = await blobToArrayBuffer(
await makeImageThumbnail({
size: THUMBNAIL_SIZE,
objectUrl: screenshotObjectUrl,
contentType: THUMBNAIL_CONTENT_TYPE,
logger,
})
);
2021-09-24 00:49:05 +00:00
const thumbnailPath = await writeNewAttachmentData(
new Uint8Array(thumbnailBuffer)
);
2021-07-14 23:39:52 +00:00
return {
...attachment,
screenshot: {
contentType: THUMBNAIL_CONTENT_TYPE,
path: screenshotPath,
width,
height,
},
thumbnail: {
path: thumbnailPath,
contentType: THUMBNAIL_CONTENT_TYPE,
width: THUMBNAIL_SIZE,
height: THUMBNAIL_SIZE,
},
width,
height,
};
} catch (error) {
logger.error(
'captureDimensionsAndScreenshot: error processing video; skipping screenshot generation',
toLogFormat(error)
);
return attachment;
} finally {
if (screenshotObjectUrl !== undefined) {
revokeObjectUrl(screenshotObjectUrl);
}
}
}
2019-01-14 21:49:58 +00:00
// UI-focused functions
export function getExtensionForDisplay({
fileName,
contentType,
}: {
fileName?: string;
2019-01-14 21:49:58 +00:00
contentType: MIME.MIMEType;
}): string | undefined {
if (fileName && fileName.indexOf('.') >= 0) {
const lastPeriod = fileName.lastIndexOf('.');
const extension = fileName.slice(lastPeriod + 1);
if (extension.length) {
return extension;
}
}
if (!contentType) {
return undefined;
2019-01-14 21:49:58 +00:00
}
const slash = contentType.indexOf('/');
if (slash >= 0) {
return contentType.slice(slash + 1);
}
return undefined;
2019-01-14 21:49:58 +00:00
}
export function isAudio(attachments?: ReadonlyArray<AttachmentType>): boolean {
return Boolean(
2019-01-14 21:49:58 +00:00
attachments &&
attachments[0] &&
attachments[0].contentType &&
!attachments[0].isCorrupted &&
MIME.isAudio(attachments[0].contentType)
2019-01-14 21:49:58 +00:00
);
}
export function canDisplayImage(
attachments?: ReadonlyArray<AttachmentType>
): boolean {
2019-01-14 21:49:58 +00:00
const { height, width } =
attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 };
return Boolean(
2019-01-14 21:49:58 +00:00
height &&
height > 0 &&
height <= 4096 &&
width &&
width > 0 &&
width <= 4096
2019-01-14 21:49:58 +00:00
);
}
export function getThumbnailUrl(
attachment: AttachmentType
): string | undefined {
2019-01-14 21:49:58 +00:00
if (attachment.thumbnail) {
return attachment.thumbnail.url;
}
return getUrl(attachment);
}
export function getUrl(attachment: AttachmentType): string | undefined {
2019-01-14 21:49:58 +00:00
if (attachment.screenshot) {
return attachment.screenshot.url;
}
if (isVideoAttachment(attachment)) {
return undefined;
}
2019-01-14 21:49:58 +00:00
return attachment.url;
}
export function isImage(attachments?: ReadonlyArray<AttachmentType>): boolean {
return Boolean(
2019-01-14 21:49:58 +00:00
attachments &&
attachments[0] &&
attachments[0].contentType &&
isImageTypeSupported(attachments[0].contentType)
2019-01-14 21:49:58 +00:00
);
}
export function isImageAttachment(
attachment?: Pick<AttachmentType, 'contentType'>
): boolean {
return Boolean(
2019-01-14 21:49:58 +00:00
attachment &&
attachment.contentType &&
isImageTypeSupported(attachment.contentType)
2019-01-14 21:49:58 +00:00
);
}
2021-07-07 17:06:01 +00:00
export function canBeTranscoded(
attachment?: Pick<AttachmentType, 'contentType'>
): boolean {
2021-07-07 17:06:01 +00:00
return Boolean(
attachment &&
isImageAttachment(attachment) &&
!MIME.isGif(attachment.contentType)
2021-07-07 17:06:01 +00:00
);
}
export function hasImage(attachments?: ReadonlyArray<AttachmentType>): boolean {
return Boolean(
2019-01-14 21:49:58 +00:00
attachments &&
attachments[0] &&
(attachments[0].url || attachments[0].pending || attachments[0].blurHash)
2019-01-14 21:49:58 +00:00
);
}
export function isVideo(attachments?: ReadonlyArray<AttachmentType>): boolean {
2021-04-27 22:11:59 +00:00
if (!attachments || attachments.length === 0) {
return false;
}
return isVideoAttachment(attachments[0]);
2019-01-14 21:49:58 +00:00
}
2021-04-27 22:11:59 +00:00
export function isVideoAttachment(attachment?: AttachmentType): boolean {
if (!attachment || !attachment.contentType) {
return false;
}
return isVideoTypeSupported(attachment.contentType);
}
export function isGIF(attachments?: ReadonlyArray<AttachmentType>): boolean {
if (!attachments || attachments.length !== 1) {
return false;
}
const [attachment] = attachments;
const flag = SignalService.AttachmentPointer.Flags.GIF;
const hasFlag =
// eslint-disable-next-line no-bitwise
!is.undefined(attachment.flags) && (attachment.flags & flag) === flag;
return hasFlag && isVideoAttachment(attachment);
2019-01-14 21:49:58 +00:00
}
2022-03-29 01:10:08 +00:00
export function isDownloaded(attachment?: AttachmentType): boolean {
return Boolean(attachment && (attachment.path || attachment.textAttachment));
2022-03-29 01:10:08 +00:00
}
export function hasNotResolved(attachment?: AttachmentType): boolean {
return Boolean(attachment && !attachment.url && !attachment.textAttachment);
2021-01-29 22:58:28 +00:00
}
2022-03-29 01:10:08 +00:00
export function isDownloading(attachment?: AttachmentType): boolean {
return Boolean(attachment && attachment.downloadJobId && attachment.pending);
}
2021-01-29 22:58:28 +00:00
export function hasVideoBlurHash(attachments?: Array<AttachmentType>): boolean {
const firstAttachment = attachments ? attachments[0] : null;
return Boolean(firstAttachment && firstAttachment.blurHash);
}
export function hasVideoScreenshot(
attachments?: Array<AttachmentType>
): string | null | undefined {
2019-01-14 21:49:58 +00:00
const firstAttachment = attachments ? attachments[0] : null;
return (
firstAttachment &&
firstAttachment.screenshot &&
firstAttachment.screenshot.url
);
}
type DimensionsType = {
height: number;
width: number;
};
export function getImageDimensions(
2021-07-14 23:39:52 +00:00
attachment: Pick<AttachmentType, 'width' | 'height'>,
forcedWidth?: number
): DimensionsType {
2019-01-14 21:49:58 +00:00
const { height, width } = attachment;
if (!height || !width) {
return {
height: MIN_HEIGHT,
width: MIN_WIDTH,
};
}
const aspectRatio = height / width;
const targetWidth =
forcedWidth || Math.max(Math.min(MAX_WIDTH, width), MIN_WIDTH);
2019-01-14 21:49:58 +00:00
const candidateHeight = Math.round(targetWidth * aspectRatio);
return {
width: targetWidth,
height: Math.max(Math.min(MAX_HEIGHT, candidateHeight), MIN_HEIGHT),
};
}
export function areAllAttachmentsVisual(
attachments?: ReadonlyArray<AttachmentType>
2019-01-14 21:49:58 +00:00
): boolean {
if (!attachments) {
return false;
}
const max = attachments.length;
for (let i = 0; i < max; i += 1) {
const attachment = attachments[i];
if (!isImageAttachment(attachment) && !isVideoAttachment(attachment)) {
return false;
}
}
return true;
}
export function getGridDimensions(
attachments?: ReadonlyArray<AttachmentType>
2019-01-14 21:49:58 +00:00
): null | DimensionsType {
if (!attachments || !attachments.length) {
return null;
}
if (!isImage(attachments) && !isVideo(attachments)) {
return null;
}
if (attachments.length === 1) {
return getImageDimensions(attachments[0]);
}
if (attachments.length === 2) {
2021-06-24 21:00:11 +00:00
// A B
2019-01-14 21:49:58 +00:00
return {
height: 150,
width: 300,
};
}
2021-06-24 21:00:11 +00:00
if (attachments.length === 3) {
// A A B
// A A C
return {
height: 200,
width: 300,
};
}
2019-01-14 21:49:58 +00:00
if (attachments.length === 4) {
2021-06-24 21:00:11 +00:00
// A B
// C D
2019-01-14 21:49:58 +00:00
return {
height: 300,
width: 300,
};
}
2021-06-24 21:00:11 +00:00
// A A A B B B
// A A A B B B
// A A A B B B
// C C D D E E
// C C D D E E
2019-01-14 21:49:58 +00:00
return {
2021-06-24 21:00:11 +00:00
height: 250,
2019-01-14 21:49:58 +00:00
width: 300,
};
}
export function getAlt(
attachment: AttachmentType,
i18n: LocalizerType
): string {
2021-04-27 22:11:59 +00:00
if (isVideoAttachment(attachment)) {
return i18n('videoAttachmentAlt');
}
return i18n('imageAttachmentAlt');
2019-01-14 21:49:58 +00:00
}
// Migration-related attachment stuff
2018-04-09 23:28:54 +00:00
2021-07-14 23:39:52 +00:00
export const isVisualMedia = (attachment: AttachmentType): boolean => {
2018-04-09 23:29:38 +00:00
const { contentType } = attachment;
if (is.undefined(contentType)) {
return false;
}
if (isVoiceMessage(attachment)) {
return false;
}
return MIME.isImage(contentType) || MIME.isVideo(contentType);
2018-04-09 23:29:38 +00:00
};
2018-04-25 17:27:28 +00:00
2021-07-14 23:39:52 +00:00
export const isFile = (attachment: AttachmentType): boolean => {
2018-05-07 19:24:46 +00:00
const { contentType } = attachment;
if (is.undefined(contentType)) {
return false;
}
if (isVisualMedia(attachment)) {
return false;
}
if (isVoiceMessage(attachment)) {
return false;
}
return true;
};
2021-07-14 23:39:52 +00:00
export const isVoiceMessage = (attachment: AttachmentType): boolean => {
2018-05-07 16:40:59 +00:00
const flag = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE;
const hasFlag =
// eslint-disable-next-line no-bitwise
2018-05-07 16:40:59 +00:00
!is.undefined(attachment.flags) && (attachment.flags & flag) === flag;
if (hasFlag) {
return true;
}
const isLegacyAndroidVoiceMessage =
!is.undefined(attachment.contentType) &&
MIME.isAudio(attachment.contentType) &&
2019-01-14 21:49:58 +00:00
!attachment.fileName;
2018-05-07 16:40:59 +00:00
if (isLegacyAndroidVoiceMessage) {
return true;
}
return false;
};
export const save = async ({
2018-04-25 17:27:28 +00:00
attachment,
index,
readAttachmentData,
saveAttachmentToDisk,
2018-04-25 17:27:28 +00:00
timestamp,
}: {
2021-07-14 23:39:52 +00:00
attachment: AttachmentType;
index?: number;
2021-09-24 00:49:05 +00:00
readAttachmentData: (relativePath: string) => Promise<Uint8Array>;
saveAttachmentToDisk: (options: {
2021-09-24 00:49:05 +00:00
data: Uint8Array;
name: string;
2021-09-24 00:49:05 +00:00
}) => Promise<{ name: string; fullPath: string } | null>;
2018-04-25 17:27:28 +00:00
timestamp?: number;
}): Promise<string | null> => {
2021-09-24 00:49:05 +00:00
let data: Uint8Array;
2021-07-14 23:39:52 +00:00
if (attachment.path) {
data = await readAttachmentData(attachment.path);
} else if (attachment.data) {
data = attachment.data;
} else {
throw new Error('Attachment had neither path nor data');
2018-04-30 15:01:57 +00:00
}
const name = getSuggestedFilename({ attachment, timestamp, index });
const result = await saveAttachmentToDisk({
data,
name,
});
if (!result) {
return null;
}
return result.fullPath;
2018-04-25 17:27:28 +00:00
};
export const getSuggestedFilename = ({
attachment,
timestamp,
index,
2018-04-25 17:27:28 +00:00
}: {
2021-07-14 23:39:52 +00:00
attachment: AttachmentType;
2018-04-25 17:27:28 +00:00
timestamp?: number | Date;
index?: number;
2018-04-25 17:27:28 +00:00
}): string => {
if (!isNumber(index) && attachment.fileName) {
2018-04-25 17:27:28 +00:00
return attachment.fileName;
}
const prefix = 'signal';
2018-04-25 17:27:28 +00:00
const suffix = timestamp
? moment(timestamp).format('-YYYY-MM-DD-HHmmss')
: '';
const fileType = getFileExtension(attachment);
const extension = fileType ? `.${fileType}` : '';
const indexSuffix = index ? `_${padStart(index.toString(), 3, '0')}` : '';
return `${prefix}${suffix}${indexSuffix}${extension}`;
2018-04-25 17:27:28 +00:00
};
2019-01-14 21:49:58 +00:00
export const getFileExtension = (
2021-07-14 23:39:52 +00:00
attachment: AttachmentType
2019-01-14 21:49:58 +00:00
): string | undefined => {
2018-04-25 17:27:28 +00:00
if (!attachment.contentType) {
return undefined;
2018-04-25 17:27:28 +00:00
}
switch (attachment.contentType) {
case 'video/quicktime':
return 'mov';
default:
return attachment.contentType.split('/')[1];
}
};
const MEBIBYTE = 1024 * 1024;
const DEFAULT_MAX = 100 * MEBIBYTE;
export const getMaximumAttachmentSize = (): number => {
try {
return parseIntOrThrow(
getValue('global.attachments.maxBytes'),
'preProcessAttachment/maxAttachmentSize'
);
} catch (error) {
log.warn(
'Failed to parse integer out of global.attachments.maxBytes feature flag'
);
return DEFAULT_MAX;
}
};
2021-04-27 22:11:59 +00:00
export const defaultBlurHash = (theme: ThemeType = ThemeType.light): string => {
if (theme === ThemeType.dark) {
return 'L05OQnoffQofoffQfQfQfQfQfQfQ';
}
return 'L1Q]+w-;fQ-;~qfQfQfQfQfQfQfQ';
};
export const canBeDownloaded = (
attachment: Pick<AttachmentType, 'key' | 'digest'>
): boolean => {
return Boolean(attachment.key && attachment.digest);
};