Option to send photos as high quality

This commit is contained in:
Josh Perez 2021-06-25 12:08:16 -04:00 committed by GitHub
parent 6c56d5a5f1
commit 01eabf9ec6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 1263 additions and 363 deletions

View file

@ -10,6 +10,7 @@ export type ConfigKeyType =
| 'desktop.groupCalling'
| 'desktop.gv2'
| 'desktop.mandatoryProfileSharing'
| 'desktop.mediaQuality.levels'
| 'desktop.messageRequests'
| 'desktop.retryReceiptLifespan'
| 'desktop.retryRespondMaxAge'

View file

@ -984,6 +984,7 @@ export async function startApp(): Promise<void> {
store.dispatch
),
calling: bindActionCreators(actionCreators.calling, store.dispatch),
composer: bindActionCreators(actionCreators.composer, store.dispatch),
conversations: bindActionCreators(
actionCreators.conversations,
store.dispatch

View file

@ -32,6 +32,25 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n,
micCellEl,
onChooseAttachment: action('onChooseAttachment'),
// AttachmentList
draftAttachments: [],
onAddAttachment: action('onAddAttachment'),
onClearAttachments: action('onClearAttachments'),
onClickAttachment: action('onClickAttachment'),
onCloseAttachment: action('onCloseAttachment'),
// StagedLinkPreview
linkPreviewLoading: Boolean(overrideProps.linkPreviewLoading),
linkPreviewResult: overrideProps.linkPreviewResult,
onCloseLinkPreview: action('onCloseLinkPreview'),
// Quote
quotedMessageProps: overrideProps.quotedMessageProps,
onClickQuotedMessage: action('onClickQuotedMessage'),
setQuotedMessage: action('setQuotedMessage'),
// MediaQualitySelector
onSelectMediaQuality: action('onSelectMediaQuality'),
shouldSendHighQualityAttachments: Boolean(
overrideProps.shouldSendHighQualityAttachments
),
// CompositionInput
onSubmit: action('onSubmit'),
onEditorStateChange: action('onEditorStateChange'),

View file

@ -31,6 +31,12 @@ import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileS
import { countStickers } from './stickers/lib';
import { LocalizerType } from '../types/Util';
import { EmojiPickDataType } from './emoji/EmojiPicker';
import { AttachmentType, isImageAttachment } from '../types/Attachment';
import { AttachmentList } from './conversation/AttachmentList';
import { MediaQualitySelector } from './MediaQualitySelector';
import { Quote, Props as QuoteProps } from './conversation/Quote';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { LinkPreviewWithDomain } from '../types/LinkPreview';
export type OwnProps = {
readonly i18n: LocalizerType;
@ -50,14 +56,24 @@ export type OwnProps = {
setDisabled: (disabled: boolean) => void;
setShowMic: (showMic: boolean) => void;
setMicActive: (micActive: boolean) => void;
attSlotRef: React.RefObject<HTMLDivElement>;
reset: InputApi['reset'];
resetEmojiResults: InputApi['resetEmojiResults'];
}>;
readonly micCellEl?: HTMLElement;
readonly attCellEl?: HTMLElement;
readonly attachmentListEl?: HTMLElement;
readonly draftAttachments: Array<AttachmentType>;
readonly shouldSendHighQualityAttachments: boolean;
onChooseAttachment(): unknown;
onAddAttachment(): unknown;
onClickAttachment(): unknown;
onCloseAttachment(): unknown;
onClearAttachments(): unknown;
onSelectMediaQuality(isHQ: boolean): unknown;
readonly quotedMessageProps?: QuoteProps;
onClickQuotedMessage(): unknown;
setQuotedMessage(message: undefined): unknown;
linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewWithDomain;
onCloseLinkPreview(): unknown;
};
export type Props = Pick<
@ -103,9 +119,25 @@ const emptyElement = (el: HTMLElement) => {
export const CompositionArea = ({
i18n,
attachmentListEl,
micCellEl,
onChooseAttachment,
// AttachmentList
draftAttachments,
onAddAttachment,
onClearAttachments,
onClickAttachment,
onCloseAttachment,
// StagedLinkPreview
linkPreviewLoading,
linkPreviewResult,
onCloseLinkPreview,
// Quote
quotedMessageProps,
onClickQuotedMessage,
setQuotedMessage,
// MediaQualitySelector
onSelectMediaQuality,
shouldSendHighQualityAttachments,
// CompositionInput
onSubmit,
compositionApi,
@ -198,9 +230,6 @@ export const CompositionArea = ({
receivedPacks,
}) > 0;
// A ref to grab a slot where backbone can insert link previews and attachments
const attSlotRef = React.useRef<HTMLDivElement>(null);
if (compositionApi) {
// Using a React.MutableRefObject, so we need to reassign this prop.
// eslint-disable-next-line no-param-reassign
@ -210,7 +239,6 @@ export const CompositionArea = ({
setDisabled,
setShowMic,
setMicActive,
attSlotRef,
reset: () => {
if (inputApiRef.current) {
inputApiRef.current.reset();
@ -251,27 +279,31 @@ export const CompositionArea = ({
return noop;
}, [micCellRef, micCellEl, large, dirty, showMic]);
React.useLayoutEffect(() => {
const { current: attSlot } = attSlotRef;
if (attSlot && attachmentListEl) {
attSlot.appendChild(attachmentListEl);
}
const showMediaQualitySelector = draftAttachments.some(isImageAttachment);
return noop;
}, [attSlotRef, attachmentListEl]);
const emojiButtonFragment = (
<div className="module-composition-area__button-cell">
<EmojiButton
i18n={i18n}
doSend={handleForceSend}
onPickEmoji={insertEmoji}
onClose={focusInput}
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
/>
</div>
const leftHandSideButtonsFragment = (
<>
<div className="module-composition-area__button-cell">
<EmojiButton
i18n={i18n}
doSend={handleForceSend}
onPickEmoji={insertEmoji}
onClose={focusInput}
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
/>
</div>
{showMediaQualitySelector ? (
<div className="module-composition-area__button-cell">
<MediaQualitySelector
i18n={i18n}
isHighQuality={shouldSendHighQualityAttachments}
onSelectQuality={onSelectMediaQuality}
/>
</div>
) : null}
</>
);
const micButtonFragment = showMic ? (
@ -480,15 +512,52 @@ export const CompositionArea = ({
'module-composition-area__row',
'module-composition-area__row--column'
)}
ref={attSlotRef}
/>
>
{quotedMessageProps && (
<div className="quote-wrapper">
<Quote
{...quotedMessageProps}
i18n={i18n}
onClick={onClickQuotedMessage}
onClose={() => {
// This one is for redux...
setQuotedMessage(undefined);
// and this is for conversation_view.
clearQuotedMessage();
}}
withContentAbove
/>
</div>
)}
{linkPreviewLoading && (
<div className="preview-wrapper">
<StagedLinkPreview
{...(linkPreviewResult || {})}
i18n={i18n}
onClose={onCloseLinkPreview}
/>
</div>
)}
{draftAttachments.length ? (
<div className="module-composition-area__attachment-list">
<AttachmentList
attachments={draftAttachments}
i18n={i18n}
onAddAttachment={onAddAttachment}
onClickAttachment={onClickAttachment}
onClose={onClearAttachments}
onCloseAttachment={onCloseAttachment}
/>
</div>
) : null}
</div>
<div
className={classNames(
'module-composition-area__row',
large ? 'module-composition-area__row--padded' : null
)}
>
{!large ? emojiButtonFragment : null}
{!large ? leftHandSideButtonsFragment : null}
<div className="module-composition-area__input">
<CompositionInput
i18n={i18n}
@ -523,7 +592,7 @@ export const CompositionArea = ({
'module-composition-area__row--control-row'
)}
>
{emojiButtonFragment}
{leftHandSideButtonsFragment}
{stickerButtonFragment}
{attButton}
{!dirty ? micButtonFragment : null}

View file

@ -269,7 +269,6 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
domain={linkPreview.url}
i18n={i18n}
image={linkPreview.image}
isLoaded
onClose={() => removeLinkPreview()}
title={linkPreview.title}
/>

View file

@ -0,0 +1,34 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { boolean } from '@storybook/addon-knobs';
import enMessages from '../../_locales/en/messages.json';
import { MediaQualitySelector, PropsType } from './MediaQualitySelector';
import { setup as setupI18n } from '../../js/modules/i18n';
const story = storiesOf('Components/MediaQualitySelector', module);
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
i18n,
isHighQuality: boolean('isHighQuality', Boolean(overrideProps.isHighQuality)),
onSelectQuality: action('onSelectQuality'),
});
story.add('Standard Quality', () => (
<MediaQualitySelector {...createProps()} />
));
story.add('High Quality', () => (
<MediaQualitySelector
{...createProps({
isHighQuality: true,
})}
/>
));

View file

@ -0,0 +1,152 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { KeyboardEvent, useCallback, useEffect, useState } from 'react';
import { noop } from 'lodash';
import { createPortal } from 'react-dom';
import classNames from 'classnames';
import { Manager, Popper, Reference } from 'react-popper';
import { LocalizerType } from '../types/Util';
export type PropsType = {
i18n: LocalizerType;
isHighQuality: boolean;
onSelectQuality: (isHQ: boolean) => unknown;
};
export const MediaQualitySelector = ({
i18n,
isHighQuality,
onSelectQuality,
}: PropsType): JSX.Element => {
const [menuShowing, setMenuShowing] = useState(false);
const [popperRoot, setPopperRoot] = useState<HTMLElement | null>(null);
// We use regular MouseEvent below, and this one uses React.MouseEvent
const handleClick = (ev: KeyboardEvent | React.MouseEvent) => {
setMenuShowing(true);
ev.stopPropagation();
ev.preventDefault();
};
const handleClose = useCallback(() => {
setMenuShowing(false);
}, [setMenuShowing]);
useEffect(() => {
if (menuShowing) {
const root = document.createElement('div');
setPopperRoot(root);
document.body.appendChild(root);
const handleOutsideClick = (event: MouseEvent) => {
if (!root.contains(event.target as Node)) {
handleClose();
event.stopPropagation();
event.preventDefault();
}
};
document.addEventListener('click', handleOutsideClick);
return () => {
document.body.removeChild(root);
document.removeEventListener('click', handleOutsideClick);
setPopperRoot(null);
};
}
return noop;
}, [menuShowing, setPopperRoot, handleClose]);
return (
<Manager>
<Reference>
{({ ref }) => (
<button
aria-label={i18n('MediaQualitySelector--button')}
className={classNames({
MediaQualitySelector__button: true,
'MediaQualitySelector__button--hq': isHighQuality,
'MediaQualitySelector__button--active': menuShowing,
})}
onClick={handleClick}
ref={ref}
type="button"
/>
)}
</Reference>
{menuShowing && popperRoot
? createPortal(
<Popper placement="top-start" positionFixed>
{({ ref, style, placement }) => (
<div
className="MediaQualitySelector__popper"
data-placement={placement}
ref={ref}
style={style}
>
<div className="MediaQualitySelector__title">
{i18n('MediaQualitySelector--title')}
</div>
<button
aria-label={i18n(
'MediaQualitySelector--standard-quality-title'
)}
className="MediaQualitySelector__option"
type="button"
onClick={() => {
onSelectQuality(false);
setMenuShowing(false);
}}
>
<div
className={classNames({
'MediaQualitySelector__option--checkmark': true,
'MediaQualitySelector__option--selected': !isHighQuality,
})}
/>
<div>
<div className="MediaQualitySelector__option--title">
{i18n('MediaQualitySelector--standard-quality-title')}
</div>
<div className="MediaQualitySelector__option--description">
{i18n(
'MediaQualitySelector--standard-quality-description'
)}
</div>
</div>
</button>
<button
aria-label={i18n(
'MediaQualitySelector--high-quality-title'
)}
className="MediaQualitySelector__option"
type="button"
onClick={() => {
onSelectQuality(true);
setMenuShowing(false);
}}
>
<div
className={classNames({
'MediaQualitySelector__option--checkmark': true,
'MediaQualitySelector__option--selected': isHighQuality,
})}
/>
<div>
<div className="MediaQualitySelector__option--title">
{i18n('MediaQualitySelector--high-quality-title')}
</div>
<div className="MediaQualitySelector__option--description">
{i18n('MediaQualitySelector--high-quality-description')}
</div>
</div>
</button>
</div>
)}
</Popper>,
popperRoot
)
: null}
</Manager>
);
};

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';
@ -6,7 +6,7 @@ import moment, { Moment } from 'moment';
import { isLinkPreviewDateValid } from '../../linkPreviews/isLinkPreviewDateValid';
type Props = {
date: null | number;
date?: null | number;
className?: string;
};

View file

@ -1,9 +1,9 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { boolean, date, text, withKnobs } from '@storybook/addon-knobs';
import { date, text, withKnobs } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { AttachmentType } from '../../types/Attachment';
@ -36,7 +36,6 @@ const createAttachment = (
});
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isLoaded: boolean('isLoaded', overrideProps.isLoaded !== false),
title: text(
'title',
typeof overrideProps.title === 'string'
@ -57,9 +56,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
});
story.add('Loading', () => {
const props = createProps({
isLoaded: false,
});
const props = createProps({ domain: '' });
return <StagedLinkPreview {...props} />;
});

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 React from 'react';
@ -11,11 +11,10 @@ import { AttachmentType, isImageAttachment } from '../../types/Attachment';
import { LocalizerType } from '../../types/Util';
export type Props = {
isLoaded: boolean;
title: string;
description: null | string;
date: null | number;
domain: string;
title?: string;
description?: null | string;
date?: null | number;
domain?: string;
image?: AttachmentType;
i18n: LocalizerType;
@ -23,7 +22,6 @@ export type Props = {
};
export const StagedLinkPreview: React.FC<Props> = ({
isLoaded,
onClose,
i18n,
title,
@ -33,6 +31,7 @@ export const StagedLinkPreview: React.FC<Props> = ({
domain,
}: Props) => {
const isImage = isImageAttachment(image);
const isLoaded = Boolean(domain);
return (
<div
@ -46,7 +45,7 @@ export const StagedLinkPreview: React.FC<Props> = ({
{i18n('loadingPreview')}
</div>
) : null}
{isLoaded && image && isImage ? (
{isLoaded && image && isImage && domain ? (
<div className="module-staged-link-preview__icon-container">
<Image
alt={i18n('stagedPreviewThumbnail', [domain])}

7
ts/model-types.d.ts vendored
View file

@ -56,12 +56,13 @@ export type QuotedMessageType = {
// `author` is an old attribute that holds the author's E164. We shouldn't use it for
// new messages, but old messages might have this attribute.
author?: string;
authorUuid: string;
bodyRanges: BodyRangesType;
authorUuid?: string;
bodyRanges?: BodyRangesType;
id: string;
referencedMessageNotFound: boolean;
isViewOnce: boolean;
text: string;
text?: string;
messageId: string;
};
export type RetryOptions = Readonly<{

View file

@ -6,12 +6,13 @@
import { ProfileKeyCredentialRequestContext } from 'zkgroup';
import { compact, sample } from 'lodash';
import {
MessageModelCollectionType,
WhatIsThis,
MessageAttributesType,
ReactionModelType,
ConversationAttributesType,
MessageAttributesType,
MessageModelCollectionType,
QuotedMessageType,
ReactionModelType,
VerificationOptions,
WhatIsThis,
} from '../model-types.d';
import { CallMode, CallHistoryDetailsType } from '../types/Calling';
import { CallbackResultType, GroupV2InfoType } from '../textsecure/SendMessage';
@ -40,7 +41,6 @@ import {
verifyAccessKey,
} from '../Crypto';
import * as Bytes from '../Bytes';
import { DataMessageClass } from '../textsecure.d';
import { BodyRangesType } from '../types/Util';
import { getTextWithMentions } from '../util';
import { migrateColor } from '../util/migrateColor';
@ -3083,7 +3083,7 @@ export class ConversationModel extends window.Backbone
async makeQuote(
quotedMessage: typeof window.Whisper.MessageType
): Promise<DataMessageClass.Quote> {
): Promise<QuotedMessageType> {
const { getName } = Contact;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const contact = quotedMessage.getContact()!;
@ -3100,13 +3100,15 @@ export class ConversationModel extends window.Backbone
return {
authorUuid: contact.get('uuid'),
bodyRanges: quotedMessage.get('bodyRanges'),
id: quotedMessage.get('sent_at'),
text: body || embeddedContactName,
isViewOnce: isTapToView(quotedMessage.attributes),
attachments: isTapToView(quotedMessage.attributes)
? [{ contentType: 'image/jpeg', fileName: null }]
: await this.getQuoteAttachment(attachments, preview, sticker),
bodyRanges: quotedMessage.get('bodyRanges'),
id: String(quotedMessage.get('sent_at')),
isViewOnce: isTapToView(quotedMessage.attributes),
messageId: quotedMessage.get('id'),
referencedMessageNotFound: false,
text: body || embeddedContactName,
};
}
@ -3476,10 +3478,13 @@ export class ConversationModel extends window.Backbone
mentions?: BodyRangesType,
{
dontClearDraft,
sendHQImages,
timestamp,
}: { dontClearDraft: boolean; timestamp?: number } = {
dontClearDraft: false,
}
}: {
dontClearDraft?: boolean;
sendHQImages?: boolean;
timestamp?: number;
} = {}
): void {
if (this.isGroupV1AndDisabled()) {
return;
@ -3530,6 +3535,7 @@ export class ConversationModel extends window.Backbone
recipients,
sticker,
bodyRanges: mentions,
sendHQImages,
});
if (isDirectConversation(this.attributes)) {

View file

@ -5,6 +5,7 @@ import { actions as accounts } from './ducks/accounts';
import { actions as app } from './ducks/app';
import { actions as audioPlayer } from './ducks/audioPlayer';
import { actions as calling } from './ducks/calling';
import { actions as composer } from './ducks/composer';
import { actions as conversations } from './ducks/conversations';
import { actions as emojis } from './ducks/emojis';
import { actions as expiration } from './ducks/expiration';
@ -24,6 +25,7 @@ export const actionCreators: ReduxActions = {
app,
audioPlayer,
calling,
composer,
conversations,
emojis,
expiration,
@ -43,6 +45,7 @@ export const mapDispatchToProps = {
...app,
...audioPlayer,
...calling,
...composer,
...conversations,
...emojis,
...expiration,

168
ts/state/ducks/composer.ts Normal file
View file

@ -0,0 +1,168 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { AttachmentType } from '../../types/Attachment';
import { MessageAttributesType } from '../../model-types.d';
import { LinkPreviewWithDomain } from '../../types/LinkPreview';
// State
export type ComposerStateType = {
attachments: ReadonlyArray<AttachmentType>;
linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewWithDomain;
quotedMessage?: Pick<MessageAttributesType, 'conversationId' | 'quote'>;
shouldSendHighQualityAttachments: boolean;
};
// Actions
const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS';
const RESET_COMPOSER = 'composer/RESET_COMPOSER';
const SET_HIGH_QUALITY_SETTING = 'composer/SET_HIGH_QUALITY_SETTING';
const SET_LINK_PREVIEW_RESULT = 'composer/SET_LINK_PREVIEW_RESULT';
const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE';
type ReplaceAttachmentsActionType = {
type: typeof REPLACE_ATTACHMENTS;
payload: ReadonlyArray<AttachmentType>;
};
type ResetComposerActionType = {
type: typeof RESET_COMPOSER;
};
type SetHighQualitySettingActionType = {
type: typeof SET_HIGH_QUALITY_SETTING;
payload: boolean;
};
type SetLinkPreviewResultActionType = {
type: typeof SET_LINK_PREVIEW_RESULT;
payload: {
isLoading: boolean;
linkPreview?: LinkPreviewWithDomain;
};
};
type SetQuotedMessageActionType = {
type: typeof SET_QUOTED_MESSAGE;
payload?: Pick<MessageAttributesType, 'conversationId' | 'quote'>;
};
type ComposerActionType =
| ReplaceAttachmentsActionType
| ResetComposerActionType
| SetHighQualitySettingActionType
| SetLinkPreviewResultActionType
| SetQuotedMessageActionType;
// Action Creators
export const actions = {
replaceAttachments,
resetComposer,
setLinkPreviewResult,
setMediaQualitySetting,
setQuotedMessage,
};
function replaceAttachments(
payload: ReadonlyArray<AttachmentType>
): ReplaceAttachmentsActionType {
return {
type: REPLACE_ATTACHMENTS,
payload,
};
}
function resetComposer(): ResetComposerActionType {
return {
type: RESET_COMPOSER,
};
}
function setLinkPreviewResult(
isLoading: boolean,
linkPreview?: LinkPreviewWithDomain
): SetLinkPreviewResultActionType {
return {
type: SET_LINK_PREVIEW_RESULT,
payload: {
isLoading,
linkPreview,
},
};
}
function setMediaQualitySetting(
payload: boolean
): SetHighQualitySettingActionType {
return {
type: SET_HIGH_QUALITY_SETTING,
payload,
};
}
function setQuotedMessage(
payload?: Pick<MessageAttributesType, 'conversationId' | 'quote'>
): SetQuotedMessageActionType {
return {
type: SET_QUOTED_MESSAGE,
payload,
};
}
// Reducer
export function getEmptyState(): ComposerStateType {
return {
attachments: [],
linkPreviewLoading: false,
shouldSendHighQualityAttachments: false,
};
}
export function reducer(
state: Readonly<ComposerStateType> = getEmptyState(),
action: Readonly<ComposerActionType>
): ComposerStateType {
if (action.type === RESET_COMPOSER) {
return getEmptyState();
}
if (action.type === REPLACE_ATTACHMENTS) {
const { payload: attachments } = action;
return {
...state,
attachments,
...(attachments.length
? {}
: { shouldSendHighQualityAttachments: false }),
};
}
if (action.type === SET_HIGH_QUALITY_SETTING) {
return {
...state,
shouldSendHighQualityAttachments: action.payload,
};
}
if (action.type === SET_QUOTED_MESSAGE) {
return {
...state,
quotedMessage: action.payload,
};
}
if (action.type === SET_LINK_PREVIEW_RESULT) {
return {
...state,
linkPreviewLoading: action.payload.isLoading,
linkPreviewResult: action.payload.linkPreview,
};
}
return state;
}

View file

@ -7,6 +7,7 @@ import { reducer as accounts } from './ducks/accounts';
import { reducer as app } from './ducks/app';
import { reducer as audioPlayer } from './ducks/audioPlayer';
import { reducer as calling } from './ducks/calling';
import { reducer as composer } from './ducks/composer';
import { reducer as conversations } from './ducks/conversations';
import { reducer as emojis } from './ducks/emojis';
import { reducer as expiration } from './ducks/expiration';
@ -25,6 +26,7 @@ export const reducer = combineReducers({
app,
audioPlayer,
calling,
composer,
conversations,
emojis,
expiration,

View file

@ -9,11 +9,12 @@ import { StateType } from '../reducer';
import { isConversationSMSOnly } from '../../util/isConversationSMSOnly';
import { selectRecentEmojis } from '../selectors/emojis';
import { getIntl } from '../selectors/user';
import { getIntl, getUserConversationId } from '../selectors/user';
import {
getConversationSelector,
isMissingRequiredProfileSharing,
} from '../selectors/conversations';
import { getPropsForQuote } from '../selectors/message';
import {
getBlessedStickerPacks,
getInstalledStickerPacks,
@ -25,12 +26,14 @@ import {
type ExternalProps = {
id: string;
onClickQuotedMessage: (id?: string) => unknown;
};
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props;
const { id, onClickQuotedMessage } = props;
const conversation = getConversationSelector(state)(id);
const conversationSelector = getConversationSelector(state);
const conversation = conversationSelector(id);
if (!conversation) {
throw new Error(`Conversation id ${id} not found!`);
}
@ -54,6 +57,14 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
get(state.items, ['showStickerPickerHint'], false) &&
receivedPacks.length > 0;
const {
attachments: draftAttachments,
linkPreviewLoading,
linkPreviewResult,
quotedMessage,
shouldSendHighQualityAttachments,
} = state.composer;
const recentEmojis = selectRecentEmojis(state);
return {
@ -61,6 +72,23 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
i18n: getIntl(state),
draftText,
draftBodyRanges,
// AttachmentsList
draftAttachments,
// MediaQualitySelector
shouldSendHighQualityAttachments,
// StagedLinkPreview
linkPreviewLoading,
linkPreviewResult,
// Quote
quotedMessageProps: quotedMessage
? getPropsForQuote(
quotedMessage,
conversationSelector,
getUserConversationId(state)
)
: undefined,
onClickQuotedMessage: () =>
onClickQuotedMessage(quotedMessage?.quote?.messageId),
// Emojis
recentEmojis,
skinTone: get(state, ['items', 'skinTone'], 0),

View file

@ -5,6 +5,7 @@ import { actions as accounts } from './ducks/accounts';
import { actions as app } from './ducks/app';
import { actions as audioPlayer } from './ducks/audioPlayer';
import { actions as calling } from './ducks/calling';
import { actions as composer } from './ducks/composer';
import { actions as conversations } from './ducks/conversations';
import { actions as emojis } from './ducks/emojis';
import { actions as expiration } from './ducks/expiration';
@ -23,6 +24,7 @@ export type ReduxActions = {
app: typeof app;
audioPlayer: typeof audioPlayer;
calling: typeof calling;
composer: typeof composer;
conversations: typeof conversations;
emojis: typeof emojis;
expiration: typeof expiration;

View file

@ -0,0 +1,120 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { actions, getEmptyState, reducer } from '../../../state/ducks/composer';
import { IMAGE_JPEG } from '../../../types/MIME';
import { AttachmentType } from '../../../types/Attachment';
describe('both/state/ducks/composer', () => {
const QUOTED_MESSAGE = {
conversationId: '123',
quote: {
attachments: [],
id: '456',
isViewOnce: false,
messageId: '789',
referencedMessageNotFound: false,
},
};
describe('replaceAttachments', () => {
it('replaces the attachments state', () => {
const { replaceAttachments } = actions;
const state = getEmptyState();
const attachments: Array<AttachmentType> = [{ contentType: IMAGE_JPEG }];
const nextState = reducer(state, replaceAttachments(attachments));
assert.deepEqual(nextState.attachments, attachments);
});
it('sets the high quality setting to false when there are no attachments', () => {
const { replaceAttachments } = actions;
const state = getEmptyState();
const attachments: Array<AttachmentType> = [];
const nextState = reducer(
{ ...state, shouldSendHighQualityAttachments: true },
replaceAttachments(attachments)
);
assert.deepEqual(nextState.attachments, attachments);
assert.isFalse(nextState.shouldSendHighQualityAttachments);
});
});
describe('resetComposer', () => {
it('returns composer back to empty state', () => {
const { resetComposer } = actions;
const nextState = reducer(
{
attachments: [],
linkPreviewLoading: true,
quotedMessage: QUOTED_MESSAGE,
shouldSendHighQualityAttachments: true,
},
resetComposer()
);
assert.deepEqual(nextState, getEmptyState());
});
});
describe('setLinkPreviewResult', () => {
it('sets loading state when loading', () => {
const { setLinkPreviewResult } = actions;
const state = getEmptyState();
const nextState = reducer(state, setLinkPreviewResult(true));
assert.isTrue(nextState.linkPreviewLoading);
});
it('sets the link preview result', () => {
const { setLinkPreviewResult } = actions;
const state = getEmptyState();
const nextState = reducer(
state,
setLinkPreviewResult(false, {
domain: 'https://www.signal.org/',
title: 'Signal >> Careers',
url: 'https://www.signal.org/workworkwork',
description:
'Join an organization that empowers users by making private communication simple.',
date: null,
})
);
assert.isFalse(nextState.linkPreviewLoading);
assert.equal(nextState.linkPreviewResult?.title, 'Signal >> Careers');
});
});
describe('setMediaQualitySetting', () => {
it('toggles the media quality setting', () => {
const { setMediaQualitySetting } = actions;
const state = getEmptyState();
assert.isFalse(state.shouldSendHighQualityAttachments);
const nextState = reducer(state, setMediaQualitySetting(true));
assert.isTrue(nextState.shouldSendHighQualityAttachments);
const nextNextState = reducer(nextState, setMediaQualitySetting(false));
assert.isFalse(nextNextState.shouldSendHighQualityAttachments);
});
});
describe('setQuotedMessage', () => {
it('sets the quoted message', () => {
const { setQuotedMessage } = actions;
const state = getEmptyState();
const nextState = reducer(state, setQuotedMessage(QUOTED_MESSAGE));
assert.equal(nextState.quotedMessage?.conversationId, '123');
assert.equal(nextState.quotedMessage?.quote?.id, '456');
});
});
});

View file

@ -0,0 +1,27 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { canvasToBlob } from '../../util/canvasToBlob';
describe('canvasToBlob', () => {
it('converts a canvas to an Blob', async () => {
const canvas = document.createElement('canvas');
canvas.width = 100;
canvas.height = 200;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Test setup error: cannot get canvas rendering context');
}
context.fillStyle = '#ff9900';
context.fillRect(10, 10, 20, 20);
const result = await canvasToBlob(canvas);
// These are just smoke tests.
assert.instanceOf(result, Blob);
assert.isAtLeast(result.size, 50);
});
});

View file

@ -43,6 +43,7 @@ export type AttachmentType = {
contentType: MIME.MIMEType;
path: string;
};
screenshotPath?: string;
flags?: number;
thumbnail?: ThumbnailType;
isCorrupted?: boolean;
@ -52,6 +53,29 @@ export type AttachmentType = {
cdnKey?: string;
};
type BaseAttachmentDraftType = {
blurHash?: string;
contentType: MIME.MIMEType;
fileName: string;
screenshotContentType?: string;
screenshotSize?: number;
size: number;
};
export type InMemoryAttachmentDraftType = {
data?: ArrayBuffer;
screenshotData?: ArrayBuffer;
} & BaseAttachmentDraftType;
export type OnDiskAttachmentDraftType = {
path?: string;
screenshotPath?: string;
} & BaseAttachmentDraftType;
export type AttachmentDraftType = {
url: string;
} & BaseAttachmentDraftType;
export type ThumbnailType = {
height: number;
width: number;

20
ts/types/LinkPreview.ts Normal file
View file

@ -0,0 +1,20 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { AttachmentType } from './Attachment';
export type LinkPreviewImage = AttachmentType & {
data: ArrayBuffer;
};
export type LinkPreviewResult = {
title: string;
url: string;
image?: LinkPreviewImage;
description: string | null;
date: number | null;
};
export type LinkPreviewWithDomain = {
domain: string;
} & LinkPreviewResult;

View file

@ -0,0 +1,31 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import loadImage, { LoadImageOptions } from 'blueimp-load-image';
import { IMAGE_JPEG } from '../types/MIME';
import { canvasToBlob } from './canvasToBlob';
const DEFAULT_JPEG_QUALITY = 0.85;
export async function autoOrientImage(blob: Blob): Promise<Blob> {
const options: LoadImageOptions = {
canvas: true,
orientation: true,
};
try {
const data = await loadImage(blob, options);
const { image } = data;
if (image instanceof HTMLCanvasElement) {
// We `return await`, instead of just `return`, so we capture the rejection in this
// try/catch block. See [this blog post][0] for more background.
// [0]: https://jakearchibald.com/2017/await-vs-return-vs-return-await/
return await canvasToBlob(image, IMAGE_JPEG, DEFAULT_JPEG_QUALITY);
}
throw new Error('image not a canvas');
} catch (err) {
const error = new Error('autoOrientImage: Failed to process image');
error.originalError = err;
throw error;
}
}

View file

@ -1,17 +1,11 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { canvasToBlob } from './canvasToBlob';
export async function canvasToArrayBuffer(
canvas: HTMLCanvasElement
): Promise<ArrayBuffer> {
const blob: Blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(result => {
if (result) {
resolve(result);
} else {
reject(new Error("Couldn't convert the canvas to a Blob"));
}
}, 'image/webp');
});
const blob = await canvasToBlob(canvas);
return blob.arrayBuffer();
}

29
ts/util/canvasToBlob.ts Normal file
View file

@ -0,0 +1,29 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { IMAGE_JPEG } from '../types/MIME';
/**
* Similar to [the built-in `toBlob` method][0], but returns a Promise.
*
* [0]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob
*/
export async function canvasToBlob(
canvas: HTMLCanvasElement,
mimeType = IMAGE_JPEG,
quality?: number
): Promise<Blob> {
return new Promise((resolve, reject) =>
canvas.toBlob(
result => {
if (result) {
resolve(result);
} else {
reject(new Error("Couldn't convert the canvas to a Blob"));
}
},
mimeType,
quality
)
);
}

View file

@ -1913,19 +1913,6 @@
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
{
"rule": "jQuery-append(",
"path": "node_modules/blueimp-canvas-to-blob/js/canvas-to-blob.js",
"line": " bb.append(arrayBuffer)",
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
{
"rule": "jQuery-append(",
"path": "node_modules/blueimp-canvas-to-blob/js/canvas-to-blob.min.js",
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
{
"rule": "jQuery-wrap(",
"path": "node_modules/boom/lib/index.js",
@ -13436,14 +13423,6 @@
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionArea.js",
"line": " const attSlotRef = React.useRef(null);",
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Needed for the composition area."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionArea.js",

View file

@ -0,0 +1,145 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import loadImage from 'blueimp-load-image';
import { IMAGE_JPEG } from '../types/MIME';
import { canvasToBlob } from './canvasToBlob';
import { getValue } from '../RemoteConfig';
enum MediaQualityLevels {
One = 1,
Two = 2,
Three = 3,
}
const DEFAULT_LEVEL = MediaQualityLevels.One;
const MiB = 1024 * 1024;
const DEFAULT_LEVEL_DATA = {
maxDimensions: 1600,
quality: 0.7,
size: MiB,
};
const MEDIA_QUALITY_LEVEL_DATA = new Map([
[MediaQualityLevels.One, DEFAULT_LEVEL_DATA],
[
MediaQualityLevels.Two,
{
maxDimensions: 2048,
quality: 0.75,
size: MiB * 1.5,
},
],
[
MediaQualityLevels.Three,
{
maxDimensions: 4096,
quality: 0.75,
size: MiB * 3,
},
],
]);
const SCALABLE_DIMENSIONS = [3072, 2048, 1600, 1024, 768];
const MIN_DIMENSIONS = 512;
function parseCountryValues(values: string): Map<string, MediaQualityLevels> {
const map = new Map<string, MediaQualityLevels>();
values.split(',').forEach(value => {
const [countryCode, level] = value.split(':');
map.set(
countryCode,
Number(level) === 2 ? MediaQualityLevels.Two : MediaQualityLevels.One
);
});
return map;
}
function getMediaQualityLevel(): MediaQualityLevels {
const values = getValue('desktop.mediaQuality.levels');
if (!values) {
return DEFAULT_LEVEL;
}
const countryValues = parseCountryValues(values);
const e164 = window.textsecure.storage.user.getNumber();
if (!e164) {
return DEFAULT_LEVEL;
}
const parsedPhoneNumber = window.libphonenumber.util.parseNumber(e164);
if (!parsedPhoneNumber.isValidNumber) {
return DEFAULT_LEVEL;
}
const level = countryValues.get(parsedPhoneNumber.countryCode);
if (level) {
return level;
}
return countryValues.get('*') || DEFAULT_LEVEL;
}
async function getCanvasBlob(
image: HTMLCanvasElement,
dimensions: number,
quality: number
): Promise<Blob> {
const canvas = loadImage.scale(image, {
canvas: true,
maxHeight: dimensions,
maxWidth: dimensions,
});
if (!(canvas instanceof HTMLCanvasElement)) {
throw new Error('image not a canvas');
}
return canvasToBlob(canvas, IMAGE_JPEG, quality);
}
export async function scaleImageToLevel(
fileOrBlobOrURL: File | Blob,
sendAsHighQuality?: boolean
): Promise<Blob> {
let image: HTMLCanvasElement;
try {
const data = await loadImage(fileOrBlobOrURL, {
canvas: true,
orientation: true,
});
if (!(data.image instanceof HTMLCanvasElement)) {
throw new Error('image not a canvas');
}
({ image } = data);
if (!(image instanceof HTMLCanvasElement)) {
throw new Error('image not a canvas');
}
} catch (err) {
const error = new Error('scaleImageToLevel: Failed to process image');
error.originalError = err;
throw error;
}
const level = sendAsHighQuality
? MediaQualityLevels.Three
: getMediaQualityLevel();
const { maxDimensions, quality, size } =
MEDIA_QUALITY_LEVEL_DATA.get(level) || DEFAULT_LEVEL_DATA;
for (let i = 0; i < SCALABLE_DIMENSIONS.length; i += 1) {
const scalableDimensions = SCALABLE_DIMENSIONS[i];
if (maxDimensions < scalableDimensions) {
continue;
}
// We need these operations to be in serial
// eslint-disable-next-line no-await-in-loop
const blob = await getCanvasBlob(image, scalableDimensions, quality);
if (blob.size <= size) {
return blob;
}
}
return getCanvasBlob(image, MIN_DIMENSIONS, quality);
}

View file

@ -3,7 +3,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { AttachmentType } from '../types/Attachment';
import {
AttachmentDraftType,
AttachmentType,
InMemoryAttachmentDraftType,
OnDiskAttachmentDraftType,
} from '../types/Attachment';
import { IMAGE_JPEG } from '../types/MIME';
import { ConversationModel } from '../models/conversations';
import {
GroupV2PendingMemberType,
@ -28,30 +34,19 @@ import * as Bytes from '../Bytes';
import {
canReply,
getAttachmentsForMessage,
getPropsForQuote,
isOutgoing,
isTapToView,
} from '../state/selectors/message';
import { getMessagesByConversation } from '../state/selectors/conversations';
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog';
type GetLinkPreviewImageResult = {
data: ArrayBuffer;
size: number;
contentType: string;
width?: number;
height?: number;
blurHash: string;
};
type GetLinkPreviewResult = {
title: string;
url: string;
image?: GetLinkPreviewImageResult;
description: string | null;
date: number | null;
};
import { autoOrientImage } from '../util/autoOrientImage';
import { canvasToBlob } from '../util/canvasToBlob';
import {
LinkPreviewImage,
LinkPreviewResult,
LinkPreviewWithDomain,
} from '../types/LinkPreview';
type AttachmentOptions = {
messageId: string;
@ -421,21 +416,12 @@ Whisper.ConversationView = Whisper.View.extend({
this.loadingScreen.render();
this.loadingScreen.$el.prependTo(this.$('.discussion-container'));
const attachmentListEl = $(
'<div class="module-composition-area__attachment-list"></div>'
);
this.attachmentListView = new Whisper.ReactWrapperView({
el: attachmentListEl,
Component: window.Signal.Components.AttachmentList,
props: this.getPropsForAttachmentList(),
});
this.setupHeader();
this.setupTimeline();
this.setupCompositionArea({ attachmentListEl: attachmentListEl[0] });
this.setupCompositionArea();
this.linkPreviewAbortController = null;
this.updateAttachmentsView();
},
events: {
@ -615,7 +601,9 @@ Whisper.ConversationView = Whisper.View.extend({
window.reduxActions.conversations.setSelectedConversationHeaderTitle();
},
setupCompositionArea({ attachmentListEl }: any) {
setupCompositionArea() {
window.reduxActions.composer.resetComposer();
const { model }: { model: ConversationModel } = this;
const compositionApi = { current: null };
@ -650,7 +638,6 @@ Whisper.ConversationView = Whisper.View.extend({
getQuotedMessage: () => model.get('quotedMessageId'),
clearQuotedMessage: () => this.setQuoteMessage(null),
micCellEl,
attachmentListEl,
onAccept: () => {
this.syncMessageRequestResponse(
'onAccept',
@ -698,6 +685,21 @@ Whisper.ConversationView = Whisper.View.extend({
},
});
},
onAddAttachment: this.onChooseAttachment.bind(this),
onClickAttachment: this.onClickAttachment.bind(this),
onCloseAttachment: this.onCloseAttachment.bind(this),
onClearAttachments: this.clearAttachments.bind(this),
onSelectMediaQuality: (isHQ: boolean) => {
window.reduxActions.composer.setMediaQualitySetting(isHQ);
},
onClickQuotedMessage: (id?: string) => this.scrollToMessage(id),
onCloseLinkPreview: () => {
this.disableLinkPreviews = true;
this.removeLinkPreview();
},
};
this.compositionAreaView = new Whisper.ReactWrapperView({
@ -1444,9 +1446,6 @@ Whisper.ConversationView = Whisper.View.extend({
this.timelineView.remove();
this.compositionAreaView.remove();
if (this.attachmentListView) {
this.attachmentListView.remove();
}
if (this.captionEditorView) {
this.captionEditorView.remove();
}
@ -1468,9 +1467,6 @@ Whisper.ConversationView = Whisper.View.extend({
if (this.scrollDownButton) {
this.scrollDownButton.remove();
}
if (this.quoteView) {
this.quoteView.remove();
}
if (this.lightboxView) {
this.lightboxView.remove();
}
@ -1587,37 +1583,6 @@ Whisper.ConversationView = Whisper.View.extend({
});
},
getPropsForAttachmentList() {
const { model }: { model: ConversationModel } = this;
const draftAttachments = model.get('draftAttachments') || [];
return {
// In conversation model/redux
attachments: draftAttachments.map(attachment => {
let url = '';
if (attachment.screenshotPath) {
url = getAbsoluteDraftPath(attachment.screenshotPath);
} else if (attachment.path) {
url = getAbsoluteDraftPath(attachment.path);
} else {
window.log.warn(
'getPropsForAttachmentList: Attachment was missing both screenshotPath and path fields'
);
}
return {
...attachment,
url,
};
}),
// Passed in from ConversationView
onAddAttachment: this.onChooseAttachment.bind(this),
onClickAttachment: this.onClickAttachment.bind(this),
onCloseAttachment: this.onCloseAttachment.bind(this),
onClose: this.clearAttachments.bind(this),
};
},
onClickAttachment(attachment: any) {
const getProps = () => ({
url: attachment.url,
@ -1663,9 +1628,7 @@ Whisper.ConversationView = Whisper.View.extend({
window.Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el);
},
async deleteDraftAttachment(
attachment: Readonly<{ screenshotPath?: string; path?: string }>
) {
async deleteDraftAttachment(attachment: AttachmentType) {
if (attachment.screenshotPath) {
await deleteDraftFile(attachment.screenshotPath);
}
@ -1679,7 +1642,7 @@ Whisper.ConversationView = Whisper.View.extend({
window.Signal.Data.updateConversation(model.attributes);
},
async addAttachment(attachment: any) {
async addAttachment(attachment: InMemoryAttachmentDraftType) {
const { model }: { model: ConversationModel } = this;
const onDisk = await this.writeDraftAttachment(attachment);
@ -1692,6 +1655,26 @@ Whisper.ConversationView = Whisper.View.extend({
await this.saveModel();
},
resolveOnDiskAttachment(
attachment: OnDiskAttachmentDraftType
): AttachmentDraftType {
let url = '';
if (attachment.screenshotPath) {
url = getAbsoluteDraftPath(attachment.screenshotPath);
} else if (attachment.path) {
url = getAbsoluteDraftPath(attachment.path);
} else {
window.log.warn(
'resolveOnDiskAttachment: Attachment was missing both screenshotPath and path fields'
);
}
return {
...attachment,
url,
};
},
async onCloseAttachment(attachment: any) {
const { model }: { model: ConversationModel } = this;
const draftAttachments = model.get('draftAttachments') || [];
@ -1801,14 +1784,21 @@ Whisper.ConversationView = Whisper.View.extend({
},
updateAttachmentsView() {
this.attachmentListView.update(this.getPropsForAttachmentList());
const draftAttachments = this.model.get('draftAttachments') || [];
window.reduxActions.composer.replaceAttachments(
draftAttachments.map((att: AttachmentType) =>
this.resolveOnDiskAttachment(att)
)
);
this.toggleMicrophone();
if (this.hasFiles()) {
this.removeLinkPreview();
}
},
async writeDraftAttachment(attachment: any) {
async writeDraftAttachment(
attachment: InMemoryAttachmentDraftType
): Promise<OnDiskAttachmentDraftType> {
let toWrite = attachment;
if (toWrite.data) {
@ -1869,7 +1859,7 @@ Whisper.ConversationView = Whisper.View.extend({
return;
}
let attachment;
let attachment: InMemoryAttachmentDraftType;
try {
if (window.Signal.Util.GoogleChrome.isImageTypeSupported(file.type)) {
@ -1949,7 +1939,7 @@ Whisper.ConversationView = Whisper.View.extend({
return true;
},
async handleVideoAttachment(file: any) {
async handleVideoAttachment(file: any): Promise<InMemoryAttachmentDraftType> {
const objectUrl = URL.createObjectURL(file);
if (!objectUrl) {
throw new Error('Failed to create object url for video!');
@ -1980,11 +1970,10 @@ Whisper.ConversationView = Whisper.View.extend({
}
},
async handleImageAttachment(file: any) {
async handleImageAttachment(file: any): Promise<InMemoryAttachmentDraftType> {
const blurHash = await window.imageToBlurHash(file);
if (MIME.isJPEG(file.type)) {
const rotatedDataUrl = await window.autoOrientImage(file);
const rotatedBlob = window.dataURLToBlobSync(rotatedDataUrl);
const rotatedBlob = await autoOrientImage(file);
const { contentType, file: resizedBlob, fileName } = await this.autoScale(
{
contentType: file.type,
@ -1992,7 +1981,7 @@ Whisper.ConversationView = Whisper.View.extend({
file: rotatedBlob,
}
);
const data = await await VisualAttachment.blobToArrayBuffer(resizedBlob);
const data = await VisualAttachment.blobToArrayBuffer(resizedBlob);
return {
fileName: fileName || file.name,
@ -2008,7 +1997,7 @@ Whisper.ConversationView = Whisper.View.extend({
fileName: file.name,
file,
});
const data = await await VisualAttachment.blobToArrayBuffer(resizedBlob);
const data = await VisualAttachment.blobToArrayBuffer(resizedBlob);
return {
fileName: fileName || file.name,
contentType,
@ -2028,7 +2017,7 @@ Whisper.ConversationView = Whisper.View.extend({
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = document.createElement('img');
img.onload = () => {
img.onload = async () => {
URL.revokeObjectURL(url);
const maxSize = 6000 * 1024;
@ -2054,7 +2043,7 @@ Whisper.ConversationView = Whisper.View.extend({
return;
}
const targetContentType = 'image/jpeg';
const targetContentType = IMAGE_JPEG;
const canvas = window.loadImage.scale(img, {
canvas: true,
maxWidth,
@ -2066,9 +2055,9 @@ Whisper.ConversationView = Whisper.View.extend({
let blob;
do {
i -= 1;
blob = window.dataURLToBlobSync(
canvas.toDataURL(targetContentType, quality)
);
// We want to do these operations in serial.
// eslint-disable-next-line no-await-in-loop
blob = await canvasToBlob(canvas, targetContentType, quality);
quality = (quality * maxSize) / blob.size;
// NOTE: During testing with a large image, we observed the
// `quality` value being > 1. Should we clamp it to [0.5, 1.0]?
@ -3780,11 +3769,6 @@ Whisper.ConversationView = Whisper.View.extend({
await this.saveModel();
}
if (this.quoteView) {
this.quoteView.remove();
this.quoteView = null;
}
if (message) {
const quotedMessage = window.MessageController.register(
message.id,
@ -3806,47 +3790,15 @@ Whisper.ConversationView = Whisper.View.extend({
renderQuotedMessage() {
const { model }: { model: ConversationModel } = this;
if (this.quoteView) {
this.quoteView.remove();
this.quoteView = null;
}
if (!this.quotedMessage) {
window.reduxActions.composer.setQuotedMessage(undefined);
return;
}
const props = getPropsForQuote(
{
conversationId: model.id,
quote: this.quote,
},
findAndFormatContact,
window.ConversationController.getOurConversationIdOrThrow()
);
const contact = this.quotedMessage.getContact();
this.quoteView = new Whisper.ReactWrapperView({
className: 'quote-wrapper',
Component: window.Signal.Components.Quote,
elCallback: (el: any) =>
this.$(this.compositionApi.current.attSlotRef.current).prepend(el),
props: {
...props,
withContentAbove: true,
onClick: () => this.scrollToMessage(this.quotedMessage.id),
onClose: () => {
// This can't be the normal 'onClose' because that is always run when this
// view is removed from the DOM, and would clear the draft quote.
this.setQuoteMessage(null);
},
},
window.reduxActions.composer.setQuotedMessage({
conversationId: model.id,
quote: this.quote,
});
if (contact) {
this.quoteView.listenTo(contact, 'change', () => {
this.renderQuotedMessage();
});
}
},
showInvalidMessageToast(messageText?: string): boolean {
@ -3939,7 +3891,13 @@ Whisper.ConversationView = Whisper.View.extend({
this.quote,
this.getLinkPreview(),
undefined, // sticker
mentions
mentions,
{
sendHQImages:
window.reduxStore &&
window.reduxStore.getState().composer
.shouldSendHighQualityAttachments,
}
);
this.compositionApi.current.reset();
@ -3947,6 +3905,7 @@ Whisper.ConversationView = Whisper.View.extend({
this.setQuoteMessage(null);
this.resetLinkPreview();
this.clearAttachments();
window.reduxActions.composer.resetComposer();
} catch (error) {
window.log.error(
'Error pulling attached files before send',
@ -4068,7 +4027,7 @@ Whisper.ConversationView = Whisper.View.extend({
async getStickerPackPreview(
url: string,
abortSignal: Readonly<AbortSignal>
): Promise<null | GetLinkPreviewResult> {
): Promise<null | LinkPreviewResult> {
const isPackDownloaded = (pack: any) =>
pack && (pack.status === 'downloaded' || pack.status === 'installed');
const isPackValid = (pack: any) =>
@ -4144,7 +4103,7 @@ Whisper.ConversationView = Whisper.View.extend({
async getGroupPreview(
url: string,
abortSignal: Readonly<AbortSignal>
): Promise<null | GetLinkPreviewResult> {
): Promise<null | LinkPreviewResult> {
const urlObject = maybeParseUrl(url);
if (!urlObject) {
return null;
@ -4187,7 +4146,7 @@ Whisper.ConversationView = Whisper.View.extend({
: window.i18n('GroupV2--join--member-count--multiple', {
count: result.memberCount.toString(),
});
let image: undefined | GetLinkPreviewImageResult;
let image: undefined | LinkPreviewImage;
if (result.avatar) {
try {
@ -4198,10 +4157,10 @@ Whisper.ConversationView = Whisper.View.extend({
image = {
data,
size: data.byteLength,
contentType: 'image/jpeg',
contentType: IMAGE_JPEG,
blurHash: await window.imageToBlurHash(
new Blob([data], {
type: 'image/jpeg',
type: IMAGE_JPEG,
})
),
};
@ -4229,7 +4188,7 @@ Whisper.ConversationView = Whisper.View.extend({
async getPreview(
url: string,
abortSignal: Readonly<AbortSignal>
): Promise<null | GetLinkPreviewResult> {
): Promise<null | LinkPreviewResult> {
if (window.Signal.LinkPreviews.isStickerPack(url)) {
return this.getStickerPackPreview(url, abortSignal);
}
@ -4410,32 +4369,10 @@ Whisper.ConversationView = Whisper.View.extend({
if (this.forwardMessageModal) {
return;
}
if (this.previewView) {
this.previewView.remove();
this.previewView = null;
}
if (!this.currentlyMatchedLink) {
return;
}
const first = (this.preview && this.preview[0]) || null;
const props = {
...first,
domain: first && window.Signal.LinkPreviews.getDomain(first.url),
isLoaded: Boolean(first),
onClose: () => {
this.disableLinkPreviews = true;
this.removeLinkPreview();
},
};
this.previewView = new Whisper.ReactWrapperView({
className: 'preview-wrapper',
Component: window.Signal.Components.StagedLinkPreview,
elCallback: (el: any) =>
this.$(this.compositionApi.current.attSlotRef.current).prepend(el),
props,
});
window.reduxActions.composer.setLinkPreviewResult(
Boolean(this.currentlyMatchedLink),
this.getLinkPreviewWithDomain()
);
},
getLinkPreview() {
@ -4461,6 +4398,18 @@ Whisper.ConversationView = Whisper.View.extend({
});
},
getLinkPreviewWithDomain(): LinkPreviewWithDomain | undefined {
if (!this.preview || !this.preview.length) {
return undefined;
}
const [preview] = this.preview;
return {
...preview,
domain: window.Signal.LinkPreviews.getDomain(preview.url),
};
},
// Called whenever the user changes the message composition field. But only
// fires if there's content in the message field after the change.
maybeBumpTyping(messageText: string) {

4
ts/window.d.ts vendored
View file

@ -150,8 +150,6 @@ declare global {
moment: typeof moment;
imageToBlurHash: typeof imageToBlurHash;
autoOrientImage: any;
dataURLToBlobSync: any;
loadImage: any;
isBehindProxy: () => boolean;
getAutoLaunch: () => boolean;
@ -220,7 +218,7 @@ declare global {
getRegionCodeForNumber: (number: string) => string;
parseNumber: (
e164: string,
defaultRegionCode: string
defaultRegionCode?: string
) =>
| { isValidNumber: false; error: unknown }
| {