Signal-Desktop/ts/types/Attachment.ts

1045 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright 2018-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import is from '@sindresorhus/is';
import moment from 'moment';
import {
isNumber,
padStart,
isTypedArray,
isFunction,
isUndefined,
omit,
} from 'lodash';
import { blobToArrayBuffer } from 'blob-util';
import type { LoggerType } from './Logging';
import * as MIME from './MIME';
import * as log from '../logging/log';
import { toLogFormat } from './errors';
import { SignalService } from '../protobuf';
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../util/GoogleChrome';
import type { LocalizerType } from './Util';
import { ThemeType } from './Util';
import { scaleImageToLevel } from '../util/scaleImageToLevel';
import * as GoogleChrome from '../util/GoogleChrome';
import { parseIntOrThrow } from '../util/parseIntOrThrow';
import { getValue } from '../RemoteConfig';
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;
blurHash?: string;
caption?: string;
contentType: MIME.MIMEType;
fileName?: string;
/** 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;
fileSize?: string;
pending?: boolean;
width?: number;
height?: number;
path?: string;
screenshot?: {
height: number;
width: number;
url?: string;
contentType: MIME.MIMEType;
path: string;
data?: Uint8Array;
};
screenshotData?: Uint8Array;
screenshotPath?: string;
flags?: number;
thumbnail?: ThumbnailType;
isCorrupted?: boolean;
downloadJobId?: string;
cdnNumber?: number;
cdnId?: string;
cdnKey?: string;
data?: Uint8Array;
textAttachment?: TextAttachmentType;
/** 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;
};
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;
};
export type DownloadedAttachmentType = AttachmentType & {
data: Uint8Array;
};
export type BaseAttachmentDraftType = {
blurHash?: string;
contentType: MIME.MIMEType;
screenshotContentType?: string;
screenshotSize?: number;
size: number;
flags?: number;
};
// 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.
export type InMemoryAttachmentDraftType =
| ({
data: Uint8Array;
pending: false;
screenshotData?: Uint8Array;
fileName?: string;
path?: string;
} & BaseAttachmentDraftType)
| {
contentType: MIME.MIMEType;
fileName?: string;
path?: string;
pending: true;
size: number;
};
// What's stored in conversation.draftAttachments
export type AttachmentDraftType =
| ({
url?: string;
screenshotPath?: string;
pending: false;
// 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;
width?: number;
height?: number;
} & BaseAttachmentDraftType)
| {
contentType: MIME.MIMEType;
fileName?: string;
path?: string;
pending: true;
size: number;
};
export type ThumbnailType = {
height?: number;
width?: number;
url?: string;
contentType: MIME.MIMEType;
path?: string;
data?: Uint8Array;
// Only used when quote needed to make an in-memory thumbnail
objectUrl?: string;
};
export async function migrateDataToFileSystem(
attachment: AttachmentType,
{
writeNewAttachmentData,
}: {
writeNewAttachmentData: (data: Uint8Array) => Promise<string>;
}
): 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;
}
if (!isTypedArray(data)) {
throw new TypeError(
'Expected `attachment.data` to be a typed array;' +
` 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
// data: Uint8Array
// digest: Uint8Array
// fileName?: string
// flags: null
// key: Uint8Array
// size: integer
// thumbnail: Uint8Array
// }
// // Outgoing message attachment fields
// {
// contentType: MIMEType
// data: Uint8Array
// 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,
{
sendHQImages = false,
isIncoming = false,
}: {
sendHQImages?: boolean;
isIncoming?: boolean;
} = {}
): Promise<AttachmentType> {
if (isIncoming && !MIME.isJPEG(attachment.contentType)) {
return attachment;
}
if (!canBeTranscoded(attachment)) {
return attachment;
}
// 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
// the attachment as-is. Otherwise we'll have to further scale it down.
if (!attachment.data || sendHQImages) {
return attachment;
}
const dataBlob = new Blob([attachment.data], {
type: attachment.contentType,
});
const { blob: xcodedDataBlob } = await scaleImageToLevel(
dataBlob,
attachment.contentType,
isIncoming
);
const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob);
// IMPORTANT: We overwrite the existing `data` `Uint8Array` losing the original
// 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'),
data: new Uint8Array(xcodedDataArrayBuffer),
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 {
if (!isValid(attachment)) {
logger.error(
'Attachment.removeSchemaVersion: Invalid input attachment:',
attachment
);
return attachment;
}
return omit(attachment, 'schemaVersion');
}
export function hasData(attachment: AttachmentType): boolean {
return attachment.data instanceof Uint8Array;
}
export function loadData(
readAttachmentData: (path: string) => Promise<Uint8Array>
): (attachment: AttachmentType) => Promise<AttachmentWithHydratedData> {
if (!is.function_(readAttachmentData)) {
throw new TypeError("'readAttachmentData' must be a function");
}
return async (
attachment: AttachmentType
): Promise<AttachmentWithHydratedData> => {
if (!isValid(attachment)) {
throw new TypeError("'attachment' is not valid");
}
const isAlreadyLoaded = Boolean(attachment.data);
if (isAlreadyLoaded) {
return attachment as AttachmentWithHydratedData;
}
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: {
writeNewAttachmentData: (data: Uint8Array) => Promise<string>;
getAbsoluteAttachmentPath: (path: string) => string;
makeObjectUrl: (
data: Uint8Array | ArrayBuffer,
contentType: MIME.MIMEType
) => string;
revokeObjectUrl: (path: string) => void;
getImageDimensions: (params: {
objectUrl: string;
logger: LoggerType;
}) => Promise<{
width: number;
height: number;
}>;
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);
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,
})
);
const thumbnailPath = await writeNewAttachmentData(
new Uint8Array(thumbnailBuffer)
);
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,
});
const screenshotPath = await writeNewAttachmentData(
new Uint8Array(screenshotBuffer)
);
const thumbnailBuffer = await blobToArrayBuffer(
await makeImageThumbnail({
size: THUMBNAIL_SIZE,
objectUrl: screenshotObjectUrl,
contentType: THUMBNAIL_CONTENT_TYPE,
logger,
})
);
const thumbnailPath = await writeNewAttachmentData(
new Uint8Array(thumbnailBuffer)
);
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);
}
}
}
// UI-focused functions
export function getExtensionForDisplay({
fileName,
contentType,
}: {
fileName?: string;
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;
}
const slash = contentType.indexOf('/');
if (slash >= 0) {
return contentType.slice(slash + 1);
}
return undefined;
}
export function isAudio(attachments?: ReadonlyArray<AttachmentType>): boolean {
return Boolean(
attachments &&
attachments[0] &&
attachments[0].contentType &&
!attachments[0].isCorrupted &&
MIME.isAudio(attachments[0].contentType)
);
}
export function canDisplayImage(
attachments?: ReadonlyArray<AttachmentType>
): boolean {
const { height, width } =
attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 };
return Boolean(
height &&
height > 0 &&
height <= 4096 &&
width &&
width > 0 &&
width <= 4096
);
}
export function getThumbnailUrl(
attachment: AttachmentType
): string | undefined {
if (attachment.thumbnail) {
return attachment.thumbnail.url;
}
return getUrl(attachment);
}
export function getUrl(attachment: AttachmentType): string | undefined {
if (attachment.screenshot) {
return attachment.screenshot.url;
}
if (isVideoAttachment(attachment)) {
return undefined;
}
return attachment.url;
}
export function isImage(attachments?: ReadonlyArray<AttachmentType>): boolean {
return Boolean(
attachments &&
attachments[0] &&
attachments[0].contentType &&
isImageTypeSupported(attachments[0].contentType)
);
}
export function isImageAttachment(
attachment?: Pick<AttachmentType, 'contentType'>
): boolean {
return Boolean(
attachment &&
attachment.contentType &&
isImageTypeSupported(attachment.contentType)
);
}
export function canBeTranscoded(
attachment?: Pick<AttachmentType, 'contentType'>
): boolean {
return Boolean(
attachment &&
isImageAttachment(attachment) &&
!MIME.isGif(attachment.contentType)
);
}
export function hasImage(attachments?: ReadonlyArray<AttachmentType>): boolean {
return Boolean(
attachments &&
attachments[0] &&
(attachments[0].url || attachments[0].pending || attachments[0].blurHash)
);
}
export function isVideo(attachments?: ReadonlyArray<AttachmentType>): boolean {
if (!attachments || attachments.length === 0) {
return false;
}
return isVideoAttachment(attachments[0]);
}
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);
}
export function isDownloaded(attachment?: AttachmentType): boolean {
return Boolean(attachment && (attachment.path || attachment.textAttachment));
}
export function hasNotResolved(attachment?: AttachmentType): boolean {
return Boolean(attachment && !attachment.url && !attachment.textAttachment);
}
export function isDownloading(attachment?: AttachmentType): boolean {
return Boolean(attachment && attachment.downloadJobId && attachment.pending);
}
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 {
const firstAttachment = attachments ? attachments[0] : null;
return (
firstAttachment &&
firstAttachment.screenshot &&
firstAttachment.screenshot.url
);
}
type DimensionsType = {
height: number;
width: number;
};
export function getImageDimensions(
attachment: Pick<AttachmentType, 'width' | 'height'>,
forcedWidth?: number
): DimensionsType {
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);
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>
): 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>
): 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) {
// A B
return {
height: 150,
width: 300,
};
}
if (attachments.length === 3) {
// A A B
// A A C
return {
height: 200,
width: 300,
};
}
if (attachments.length === 4) {
// A B
// C D
return {
height: 300,
width: 300,
};
}
// 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
return {
height: 250,
width: 300,
};
}
export function getAlt(
attachment: AttachmentType,
i18n: LocalizerType
): string {
if (isVideoAttachment(attachment)) {
return i18n('videoAttachmentAlt');
}
return i18n('imageAttachmentAlt');
}
// Migration-related attachment stuff
export const isVisualMedia = (attachment: AttachmentType): boolean => {
const { contentType } = attachment;
if (is.undefined(contentType)) {
return false;
}
if (isVoiceMessage(attachment)) {
return false;
}
return MIME.isImage(contentType) || MIME.isVideo(contentType);
};
export const isFile = (attachment: AttachmentType): boolean => {
const { contentType } = attachment;
if (is.undefined(contentType)) {
return false;
}
if (isVisualMedia(attachment)) {
return false;
}
if (isVoiceMessage(attachment)) {
return false;
}
return true;
};
export const isVoiceMessage = (attachment: AttachmentType): boolean => {
const flag = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE;
const hasFlag =
// eslint-disable-next-line no-bitwise
!is.undefined(attachment.flags) && (attachment.flags & flag) === flag;
if (hasFlag) {
return true;
}
const isLegacyAndroidVoiceMessage =
!is.undefined(attachment.contentType) &&
MIME.isAudio(attachment.contentType) &&
!attachment.fileName;
if (isLegacyAndroidVoiceMessage) {
return true;
}
return false;
};
export const save = async ({
attachment,
index,
readAttachmentData,
saveAttachmentToDisk,
timestamp,
}: {
attachment: AttachmentType;
index?: number;
readAttachmentData: (relativePath: string) => Promise<Uint8Array>;
saveAttachmentToDisk: (options: {
data: Uint8Array;
name: string;
}) => Promise<{ name: string; fullPath: string } | null>;
timestamp?: number;
}): Promise<string | null> => {
let data: Uint8Array;
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');
}
const name = getSuggestedFilename({ attachment, timestamp, index });
const result = await saveAttachmentToDisk({
data,
name,
});
if (!result) {
return null;
}
return result.fullPath;
};
export const getSuggestedFilename = ({
attachment,
timestamp,
index,
}: {
attachment: AttachmentType;
timestamp?: number | Date;
index?: number;
}): string => {
if (!isNumber(index) && attachment.fileName) {
return attachment.fileName;
}
const prefix = 'signal';
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}`;
};
export const getFileExtension = (
attachment: AttachmentType
): string | undefined => {
if (!attachment.contentType) {
return undefined;
}
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;
}
};
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);
};