diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 74901cafb0..472aeb0c2b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1049,6 +1049,10 @@ "theirIdentityUnknown": { "message": "You haven't exchanged any messages with this contact yet. Your safety number with them will be available after the first message." }, + "back": { + "message": "Back", + "description": "Generic label for back" + }, "goBack": { "message": "Go back", "description": "Label for back button in a conversation" @@ -1061,6 +1065,10 @@ "message": "Retry Send", "description": "Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send" }, + "forwardMessage": { + "message": "Forward message", + "description": "Shown on the drop-down menu for an individual message, forwards a message" + }, "deleteMessage": { "message": "Delete message for me", "description": "Shown on the drop-down menu for an individual message, deletes single message" @@ -5152,5 +5160,9 @@ "composeIcon": { "message": "compose button", "description": "Shown in the left-pane when the inbox is empty. Describes the button that composes a new message." + }, + "ForwardMessageModal--continue": { + "message": "Continue", + "description": "aria-label for the 'next' button in the forward a message modal dialog" } } diff --git a/js/modules/signal.js b/js/modules/signal.js index a9624a5cb3..6e88f6ed44 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -73,6 +73,9 @@ const { createConversationHeader, } = require('../../ts/state/roots/createConversationHeader'); const { createCallManager } = require('../../ts/state/roots/createCallManager'); +const { + createForwardMessageModal, +} = require('../../ts/state/roots/createForwardMessageModal'); const { createGroupLinkManagement, } = require('../../ts/state/roots/createGroupLinkManagement'); @@ -111,6 +114,7 @@ const conversationsDuck = require('../../ts/state/ducks/conversations'); const emojisDuck = require('../../ts/state/ducks/emojis'); const expirationDuck = require('../../ts/state/ducks/expiration'); const itemsDuck = require('../../ts/state/ducks/items'); +const linkPreviewsDuck = require('../../ts/state/ducks/linkPreviews'); const networkDuck = require('../../ts/state/ducks/network'); const searchDuck = require('../../ts/state/ducks/search'); const stickersDuck = require('../../ts/state/ducks/stickers'); @@ -344,6 +348,7 @@ exports.setup = (options = {}) => { createContactModal, createConversationDetails, createConversationHeader, + createForwardMessageModal, createGroupLinkManagement, createGroupV1MigrationModal, createGroupV2JoinModal, @@ -364,6 +369,7 @@ exports.setup = (options = {}) => { emojis: emojisDuck, expiration: expirationDuck, items: itemsDuck, + linkPreviews: linkPreviewsDuck, network: networkDuck, updates: updatesDuck, user: userDuck, diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 864273bb67..c73f94c5ee 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -11064,6 +11064,23 @@ $contact-modal-padding: 18px; } } + &__forward-message::before { + transform: scaleX(-1); + @include light-theme { + @include color-svg( + '../images/icons/v2/reply-outline-24.svg', + $color-black + ); + } + + @include dark-theme { + @include color-svg( + '../images/icons/v2/reply-solid-24.svg', + $color-gray-15 + ); + } + } + &__delete-message::before { @include light-theme { @include color-svg( diff --git a/stylesheets/components/ForwardMessageModal.scss b/stylesheets/components/ForwardMessageModal.scss new file mode 100644 index 0000000000..60b2bedab9 --- /dev/null +++ b/stylesheets/components/ForwardMessageModal.scss @@ -0,0 +1,293 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-ForwardMessageModal { + $padding: 16px; + @include popper-shadow(); + border-radius: 8px; + display: flex; + flex-direction: column; + margin: 0 auto; + max-height: 90vh; + max-width: 360px; + width: 95%; + + @include light-theme() { + background: $color-white; + color: $color-gray-90; + } + + @include dark-theme() { + background: $color-gray-75; + color: $color-gray-05; + } + + &--link-preview { + border-bottom: 1px solid $color-gray-15; + padding: 12px 16px; + + @include dark-theme() { + border-color: $color-gray-60; + } + } + + &__input { + &__input { + background: inherit; + border: none; + border-radius: 0; + height: 100%; + + &:focus-within { + border: none; + } + + @include dark-theme() { + border: none; + + &:focus-within { + border: none; + } + } + + @include keyboard-mode { + &:focus-within { + border: solid 1px $ultramarine-ui-light; + } + } + } + + &__scroller { + max-height: 300px; + min-height: 300px; + padding-right: 36px; + padding: 16px; + } + } + + &__header { + align-items: center; + display: flex; + justify-content: center; + position: relative; + + &--edit { + border-bottom: 1px solid $color-gray-15; + + @include dark-theme() { + border-color: $color-gray-60; + } + } + + &--cancel { + @include button-reset; + position: absolute; + left: 16px; + + @include keyboard-mode { + &:focus { + color: $ultramarine-ui-light; + } + } + } + + &--back { + @include button-reset; + + height: 24px; + left: 16px; + position: absolute; + width: 24px; + + @include light-theme { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $color-gray-60 + ); + } + + @include keyboard-mode { + &:focus { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $ultramarine-ui-light + ); + } + } + + @include dark-theme { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $color-gray-25 + ); + } + @include dark-keyboard-mode { + &:hover { + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $ultramarine-ui-dark + ); + } + } + } + + h1 { + @include font-body-1-bold; + } + } + + &__search { + border-radius: 8px; + border: none; + margin: 10px 16px; + padding: 5px 12px; + position: relative; + + @include font-body-2; + + @include light-theme { + background-color: $color-gray-02; + border: solid 1px $color-gray-02; + color: $color-gray-90; + } + + @include dark-theme { + background: $color-gray-65; + border: solid 1px $color-gray-65; + color: $color-gray-05; + } + + &--icon { + cursor: text; + height: 16px; + left: 8px; + position: absolute; + top: 6px; + width: 16px; + + @include light-theme { + @include color-svg('../images/icons/v2/search-16.svg', $color-gray-45); + } + } + + @include keyboard-mode { + &:focus-within { + border: solid 1px $ultramarine-ui-light; + outline: none; + } + } + + &--input { + background: inherit; + border: none; + padding-left: 16px; + width: 100%; + + &:placeholder { + color: $color-gray-45; + } + + @include dark-theme { + color: $color-gray-05; + } + + &:focus { + outline: none; + } + } + } + + &__list-wrapper { + flex-grow: 1; + overflow: hidden; + } + + &__main-body { + display: flex; + flex-direction: column; + min-height: 300px; + } + + &__text-edit-area { + height: 100%; + position: relative; + } + + &__no-candidate-contacts { + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; + } + + &__send-button { + align-items: center; + border: none; + border-radius: 100%; + display: flex; + height: 32px; + justify-content: center; + width: 32px; + + &::after { + content: ''; + display: block; + flex-shrink: 0; + height: 24px; + width: 24px; + } + + &--continue { + &::after { + @include color-svg( + '../images/icons/v2/arrow-down-24.svg', + $color-white + ); + transform: rotate(270deg); + } + } + + &--forward { + &::after { + @include color-svg('../images/icons/v2/send-24.svg', $color-white); + } + } + } + + &__emoji { + position: absolute; + right: 8px; + top: 8px; + + button::after { + background-color: $color-black; + } + } + + &__footer { + @include font-body-2; + align-items: center; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + display: flex; + justify-content: space-between; + margin-top: 0; + padding: $padding; + position: relative; + + @include light-theme { + background-color: $color-gray-02; + color: $color-gray-60; + } + + @include dark-theme() { + background: $color-gray-65; + color: $color-gray-25; + } + } + + // Disable cursor since images are non-clickable + .module-image__image { + cursor: inherit; + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 61b6bf2a25..20e663e2f3 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -34,6 +34,7 @@ @import './components/ContactPills.scss'; @import './components/ConversationHeader.scss'; @import './components/EditConversationAttributesModal.scss'; +@import './components/ForwardMessageModal.scss'; @import './components/GroupDialog.scss'; @import './components/GroupTitleInput.scss'; @import './components/MessageAudio.scss'; diff --git a/ts/background.ts b/ts/background.ts index b1611ae1d5..c0520d1e4b 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -831,6 +831,10 @@ export async function startApp(): Promise { window.Signal.State.Ducks.items.actions, store.dispatch ); + actions.linkPreviews = window.Signal.State.bindActionCreators( + window.Signal.State.Ducks.linkPreviews.actions, + store.dispatch + ); actions.network = window.Signal.State.bindActionCreators( window.Signal.State.Ducks.network.actions, store.dispatch diff --git a/ts/components/Button.stories.tsx b/ts/components/Button.stories.tsx index 5d8c9a1e4c..a8cf6919e9 100644 --- a/ts/components/Button.stories.tsx +++ b/ts/components/Button.stories.tsx @@ -33,3 +33,11 @@ story.add('Kitchen sink', () => ( ))} )); + +story.add('aria-label', () => ( + + ) : ( + + )} +

{i18n('forwardMessage')}

+ + {isEditingMessage ? ( +
+ {linkPreview ? ( +
+ removeLinkPreview()} + title={linkPreview.title} + /> +
+ ) : null} + {attachmentsToForward && attachmentsToForward.length ? ( + { + const newAttachments = attachmentsToForward.filter( + currentAttachment => currentAttachment !== attachment + ); + setAttachmentsToForward(newAttachments); + }} + /> + ) : null} +
+ { + setMessageBodyText(messageText); + onEditorStateChange(messageText, bodyRanges, caretLocation); + }} + onPickEmoji={onPickEmoji} + onSubmit={forwardMessage} + onTextTooLong={onTextTooLong} + /> +
+ +
+
+
+ ) : ( +
+
+ + { + setSearchTerm(event.target.value); + }} + ref={inputRef} + value={searchTerm} + /> +
+ {candidateConversations.length ? ( + + {({ contentRect, measureRef }: MeasuredComponentProps) => { + // We disable this ESLint rule because we're capturing a bubbled keydown + // event. See [this note in the jsx-a11y docs][0]. + // + // [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/c275964f52c35775208bd00cb612c6f82e42e34f/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events + /* eslint-disable jsx-a11y/no-static-element-interactions */ + return ( +
{ + if (event.key === 'Enter') { + inputRef.current?.focus(); + } + }} + > + { + if ( + disabledReason !== + ContactCheckboxDisabledReason.MaximumContactsSelected + ) { + toggleSelectedContact(conversationId); + } + }} + onSelectConversation={shouldNeverBeCalled} + renderMessageSearchResult={() => { + shouldNeverBeCalled(); + return
; + }} + rowCount={rowCount} + shouldRecomputeRowHeights={false} + showChooseGroupMembers={shouldNeverBeCalled} + startNewConversationFromPhoneNumber={ + shouldNeverBeCalled + } + /> +
+ ); + /* eslint-enable jsx-a11y/no-static-element-interactions */ + }} + + ) : ( +
+ {i18n('noContactsFound')} +
+ )} +
+ )} +
+
+ {Boolean(selectedContacts.length) && + selectedContacts.map(contact => contact.title).join(', ')} +
+
+ {isEditingMessage || !isMessageEditable ? ( +
+
+
+ + ); +}; + +function shouldNeverBeCalled(..._args: ReadonlyArray): void { + assert(false, 'This should never be called. Doing nothing'); +} diff --git a/ts/components/conversation/AttachmentList.tsx b/ts/components/conversation/AttachmentList.tsx index 5fe3361287..96b992c0da 100644 --- a/ts/components/conversation/AttachmentList.tsx +++ b/ts/components/conversation/AttachmentList.tsx @@ -18,10 +18,10 @@ import { export type Props = { attachments: Array; i18n: LocalizerType; - onClickAttachment: (attachment: AttachmentType) => void; + onAddAttachment?: () => void; + onClickAttachment?: (attachment: AttachmentType) => void; + onClose?: () => void; onCloseAttachment: (attachment: AttachmentType) => void; - onAddAttachment: () => void; - onClose: () => void; }; const IMAGE_WIDTH = 120; @@ -47,7 +47,7 @@ export const AttachmentList = ({ return (
- {attachments.length > 1 ? ( + {onClose && attachments.length > 1 ? (
diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index 72204e92d9..93beb24186 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -130,6 +130,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ showExpiredOutgoingTapToViewToast: action( 'showExpiredOutgoingTapToViewToast' ), + showForwardMessageModal: action('showForwardMessageModal'), showMessageDetail: action('showMessageDetail'), showVisualAttachment: action('showVisualAttachment'), status: overrideProps.status || 'sent', diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 6a3ee4bdbc..070938152f 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -170,6 +170,7 @@ export type PropsActions = { ) => void; replyToMessage: (id: string) => void; retrySend: (id: string) => void; + showForwardMessageModal: (id: string) => void; deleteMessage: (id: string) => void; deleteMessageForEveryone: (id: string) => void; showMessageDetail: (id: string) => void; @@ -1401,6 +1402,7 @@ export class Message extends React.PureComponent { canReply, deleteMessage, deleteMessageForEveryone, + deletedForEveryone, direction, i18n, id, @@ -1408,10 +1410,13 @@ export class Message extends React.PureComponent { isTapToView, replyToMessage, retrySend, + showForwardMessageModal, showMessageDetail, status, } = this.props; + const canForward = !isTapToView && !deletedForEveryone; + const { canDeleteForEveryone } = this.state; const showRetry = @@ -1499,6 +1504,22 @@ export class Message extends React.PureComponent { {i18n('retrySend')} ) : null} + {canForward ? ( + { + event.stopPropagation(); + event.preventDefault(); + + showForwardMessageModal(id); + }} + > + {i18n('forwardMessage')} + + ) : null} = {}): Props => ({ showContactModal: () => null, showExpiredIncomingTapToViewToast: () => null, showExpiredOutgoingTapToViewToast: () => null, + showForwardMessageModal: () => null, showVisualAttachment: () => null, }); diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 0e2646a2a5..209bbf43fa 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -65,6 +65,7 @@ export type Props = { | 'showContactModal' | 'showExpiredIncomingTapToViewToast' | 'showExpiredOutgoingTapToViewToast' + | 'showForwardMessageModal' | 'showVisualAttachment' >; @@ -235,6 +236,7 @@ export class MessageDetail extends React.Component { showContactModal, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, + showForwardMessageModal, showVisualAttachment, } = this.props; @@ -263,6 +265,7 @@ export class MessageDetail extends React.Component { renderEmojiPicker={renderEmojiPicker} replyToMessage={replyToMessage} retrySend={retrySend} + showForwardMessageModal={showForwardMessageModal} scrollToQuotedMessage={() => { assert( false, diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index 84e48cb646..492e9c37d2 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -61,6 +61,7 @@ const defaultMessageProps: MessagesProps = { showContactModal: () => null, showExpiredIncomingTapToViewToast: () => null, showExpiredOutgoingTapToViewToast: () => null, + showForwardMessageModal: () => null, showMessageDetail: () => null, showVisualAttachment: () => null, status: 'sent', diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 88569d6c0e..7dcad27137 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -249,6 +249,7 @@ const actions = () => ({ showExpiredOutgoingTapToViewToast: action( 'showExpiredOutgoingTapToViewToast' ), + showForwardMessageModal: action('showForwardMessageModal'), showIdentity: action('showIdentity'), diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 9f8876809a..fbb9fecd6b 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -53,6 +53,7 @@ const getDefaultProps = () => ({ openConversation: action('openConversation'), showContactDetail: action('showContactDetail'), showContactModal: action('showContactModal'), + showForwardMessageModal: action('showForwardMessageModal'), showVisualAttachment: action('showVisualAttachment'), downloadAttachment: action('downloadAttachment'), displayTapToViewMessage: action('displayTapToViewMessage'), diff --git a/ts/components/conversationList/ContactCheckbox.tsx b/ts/components/conversationList/ContactCheckbox.tsx index f8ac6b2ae2..22cbe5baf3 100644 --- a/ts/components/conversationList/ContactCheckbox.tsx +++ b/ts/components/conversationList/ContactCheckbox.tsx @@ -22,6 +22,7 @@ export type PropsDataType = { color?: ColorType; disabledReason?: ContactCheckboxDisabledReason; id: string; + isMe?: boolean; isChecked: boolean; name?: string; phoneNumber?: string; @@ -49,6 +50,7 @@ export const ContactCheckbox: FunctionComponent = React.memo( i18n, id, isChecked, + isMe, name, onClick, phoneNumber, @@ -58,7 +60,9 @@ export const ContactCheckbox: FunctionComponent = React.memo( }) => { const disabled = Boolean(disabledReason); - const headerName = ( + const headerName = isMe ? ( + i18n('noteToSelf') + ) : ( = React.memo( headerName={headerName} i18n={i18n} id={id} + isMe={isMe} isSelected={false} messageText={messageText} name={name} diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 175ba3fe63..0d14915dc5 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -1397,6 +1397,9 @@ export class ConversationModel extends window.Backbone.Model< sortedGroupMembers, timestamp, title: this.getTitle()!, + searchableTitle: this.isMe() + ? window.i18n('noteToSelf') + : this.getTitle(), type: (this.isPrivate() ? 'direct' : 'group') as ConversationTypeType, unreadCount: this.get('unreadCount')! || 0, }; @@ -2235,7 +2238,7 @@ export class ConversationModel extends window.Backbone.Model< }); } - getUntrusted(): Backbone.Collection { + getUntrusted(): Backbone.Collection { if (this.isPrivate()) { if (this.isUntrusted()) { return new window.Backbone.Collection([this]); @@ -2243,16 +2246,14 @@ export class ConversationModel extends window.Backbone.Model< return new window.Backbone.Collection(); } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const results = this.contactCollection!.map(contact => { - if (contact.isMe()) { - return [false, contact]; - } - return [contact.isUntrusted(), contact]; - }); - return new window.Backbone.Collection( - results.filter(result => result[0]).map(result => result[1]) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.contactCollection!.filter(contact => { + if (contact.isMe()) { + return false; + } + return contact.isUntrusted(); + }) ); } @@ -3320,13 +3321,21 @@ export class ConversationModel extends window.Backbone.Model< quote: WhatIsThis, preview: WhatIsThis, sticker?: WhatIsThis, - mentions?: BodyRangesType + mentions?: BodyRangesType, + { dontClearDraft = false } = {} ): void { this.clearTypingTimers(); const { clearUnreadMetrics } = window.reduxActions.conversations; clearUnreadMetrics(this.id); + const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled( + 'desktop.mandatoryProfileSharing' + ); + if (mandatoryProfileSharingEnabled && !this.get('profileSharing')) { + this.set({ profileSharing: true }); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const destination = this.getSendTarget()!; const expireTimer = this.get('expireTimer'); @@ -3382,15 +3391,22 @@ export class ConversationModel extends window.Backbone.Model< Message: window.Whisper.Message, }); + const draftProperties = dontClearDraft + ? {} + : { + draft: null, + draftTimestamp: null, + lastMessage: model.getNotificationText(), + lastMessageStatus: 'sending' as const, + }; + this.set({ - lastMessage: model.getNotificationText(), - lastMessageStatus: 'sending', + ...draftProperties, active_at: now, timestamp: now, isArchived: false, - draft: null, - draftTimestamp: null, }); + this.incrementSentMessageCount(); window.Signal.Data.updateConversation(this.attributes); diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 17c8847137..54adfda609 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -46,6 +46,7 @@ import { import { PropsType as ProfileChangeNotificationPropsType } from '../components/conversation/ProfileChangeNotification'; import { AttachmentType, isImage, isVideo } from '../types/Attachment'; import { MIMEType } from '../types/MIME'; +import { LinkPreviewType } from '../types/message/LinkPreviews'; /* eslint-disable camelcase */ /* eslint-disable more/no-then */ @@ -1139,7 +1140,7 @@ export class MessageModel extends window.Backbone.Model { }; } - getPropsForPreview(): WhatIsThis { + getPropsForPreview(): Array { const previews = this.get('preview') || []; return previews.map(preview => ({ @@ -1592,6 +1593,17 @@ export class MessageModel extends window.Backbone.Model { return { text: '' }; } + getRawText(): string { + const body = (this.get('body') || '').trim(); + + const bodyRanges = this.processBodyRanges(); + if (bodyRanges) { + return getTextWithMentions(bodyRanges, body); + } + + return body; + } + getNotificationText(): string { const { text, emoji } = this.getNotificationData(); diff --git a/ts/state/actions.ts b/ts/state/actions.ts index 9284012f72..9b6642be48 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -7,6 +7,7 @@ import { actions as conversations } from './ducks/conversations'; import { actions as emojis } from './ducks/emojis'; import { actions as expiration } from './ducks/expiration'; import { actions as items } from './ducks/items'; +import { actions as linkPreviews } from './ducks/linkPreviews'; import { actions as network } from './ducks/network'; import { actions as safetyNumber } from './ducks/safetyNumber'; import { actions as search } from './ducks/search'; @@ -21,6 +22,7 @@ export const mapDispatchToProps = { ...emojis, ...expiration, ...items, + ...linkPreviews, ...network, ...safetyNumber, ...search, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 915430f59b..1fc49c05f4 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -108,6 +108,7 @@ export type ConversationType = { // This is used by the CompositionInput for @mentions sortedGroupMembers?: Array; title: string; + searchableTitle?: string; unreadCount?: number; isSelected?: boolean; typingContact?: { diff --git a/ts/state/ducks/items.ts b/ts/state/ducks/items.ts index d6dc400625..284b798b77 100644 --- a/ts/state/ducks/items.ts +++ b/ts/state/ducks/items.ts @@ -2,11 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { omit } from 'lodash'; -import { createSelector } from 'reselect'; -import { useSelector } from 'react-redux'; -import { StateType } from '../reducer'; import * as storageShim from '../../shims/storage'; -import { isShortName } from '../../components/emoji/lib'; import { useBoundActions } from '../../util/hooks'; // State @@ -54,6 +50,7 @@ export type ItemsActionType = // Action Creators export const actions = { + onSetSkinTone, putItem, putItemExternal, removeItem, @@ -72,6 +69,10 @@ function putItem(key: string, value: unknown): ItemPutAction { }; } +function onSetSkinTone(tone: number): ItemPutAction { + return putItem('skinTone', tone); +} + function putItemExternal(key: string, value: unknown): ItemPutExternalAction { return { type: 'items/PUT_EXTERNAL', @@ -133,13 +134,3 @@ export function reducer( return state; } - -// Selectors - -const selectRecentEmojis = createSelector( - ({ emojis }: StateType) => emojis.recents, - recents => recents.filter(isShortName) -); - -export const useRecentEmojis = (): Array => - useSelector(selectRecentEmojis); diff --git a/ts/state/ducks/linkPreviews.ts b/ts/state/ducks/linkPreviews.ts new file mode 100644 index 0000000000..bc820fa9c2 --- /dev/null +++ b/ts/state/ducks/linkPreviews.ts @@ -0,0 +1,77 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { LinkPreviewType } from '../../types/message/LinkPreviews'; + +// State + +export type LinkPreviewsStateType = { + readonly linkPreview?: LinkPreviewType; +}; + +// Actions + +const ADD_PREVIEW = 'linkPreviews/ADD_PREVIEW'; +const REMOVE_PREVIEW = 'linkPreviews/REMOVE_PREVIEW'; + +type AddLinkPreviewActionType = { + type: 'linkPreviews/ADD_PREVIEW'; + payload: LinkPreviewType; +}; + +type RemoveLinkPreviewActionType = { + type: 'linkPreviews/REMOVE_PREVIEW'; +}; + +type LinkPreviewsActionType = + | AddLinkPreviewActionType + | RemoveLinkPreviewActionType; + +// Action Creators + +export const actions = { + addLinkPreview, + removeLinkPreview, +}; + +function addLinkPreview(payload: LinkPreviewType): AddLinkPreviewActionType { + return { + type: ADD_PREVIEW, + payload, + }; +} + +function removeLinkPreview(): RemoveLinkPreviewActionType { + return { + type: REMOVE_PREVIEW, + }; +} + +// Reducer + +export function getEmptyState(): LinkPreviewsStateType { + return { + linkPreview: undefined, + }; +} + +export function reducer( + state: Readonly = getEmptyState(), + action: Readonly +): LinkPreviewsStateType { + if (action.type === ADD_PREVIEW) { + const { payload } = action; + + return { + linkPreview: payload, + }; + } + + if (action.type === REMOVE_PREVIEW) { + return { + linkPreview: undefined, + }; + } + + return state; +} diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index c9c3b10a02..c8b69f4500 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -9,6 +9,7 @@ import { reducer as conversations } from './ducks/conversations'; import { reducer as emojis } from './ducks/emojis'; import { reducer as expiration } from './ducks/expiration'; import { reducer as items } from './ducks/items'; +import { reducer as linkPreviews } from './ducks/linkPreviews'; import { reducer as network } from './ducks/network'; import { reducer as safetyNumber } from './ducks/safetyNumber'; import { reducer as search } from './ducks/search'; @@ -23,6 +24,7 @@ export const reducer = combineReducers({ emojis, expiration, items, + linkPreviews, network, safetyNumber, search, diff --git a/ts/state/roots/createForwardMessageModal.tsx b/ts/state/roots/createForwardMessageModal.tsx new file mode 100644 index 0000000000..b28ffb0a08 --- /dev/null +++ b/ts/state/roots/createForwardMessageModal.tsx @@ -0,0 +1,21 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { Provider } from 'react-redux'; + +import { Store } from 'redux'; + +import { + SmartForwardMessageModal, + SmartForwardMessageModalProps, +} from '../smart/ForwardMessageModal'; + +export const createForwardMessageModal = ( + store: Store, + props: SmartForwardMessageModalProps +): React.ReactElement => ( + + + +); diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 6b053e1f9a..ab80b21eeb 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -18,7 +18,6 @@ import { OneTimeModalState, PreJoinConversationType, } from '../ducks/conversations'; -import { LocalizerType } from '../../types/Util'; import { getOwn } from '../../util/getOwn'; import { deconstructLookup } from '../../util/deconstructLookup'; import type { CallsByConversationType } from '../ducks/calling'; @@ -350,6 +349,29 @@ function canComposeConversation(conversation: ConversationType): boolean { ); } +export const getAllComposableConversations = createSelector( + getConversationLookup, + (conversationLookup: ConversationLookupType): Array => + Object.values(conversationLookup).filter( + contact => + !contact.isBlocked && + !isConversationUnregistered(contact) && + (isString(contact.name) || contact.profileSharing) + ) +); + +const getContactsAndMe = createSelector( + getConversationLookup, + (conversationLookup: ConversationLookupType): Array => + Object.values(conversationLookup).filter( + contact => + contact.type === 'direct' && + !contact.isBlocked && + !isConversationUnregistered(contact) && + (isString(contact.name) || contact.profileSharing) + ) +); + /** * This returns contacts for the composer and group members, which isn't just your primary * system contacts. It may include false positives, which is better than missing contacts. @@ -381,29 +403,14 @@ const getNormalizedComposerConversationSearchTerm = createSelector( (searchTerm: string): string => searchTerm.trim() ); -const getNoteToSelfTitle = createSelector(getIntl, (i18n: LocalizerType) => - i18n('noteToSelf').toLowerCase() -); - export const getComposeContacts = createSelector( getNormalizedComposerConversationSearchTerm, - getComposableContacts, - getMe, - getNoteToSelfTitle, + getContactsAndMe, ( searchTerm: string, - contacts: Array, - noteToSelf: ConversationType, - noteToSelfTitle: string + contacts: Array ): Array => { - const result: Array = filterAndSortConversations( - contacts, - searchTerm - ); - if (!searchTerm || noteToSelfTitle.includes(searchTerm)) { - result.push(noteToSelf); - } - return result; + return filterAndSortConversations(contacts, searchTerm); } ); diff --git a/ts/state/selectors/emojis.ts b/ts/state/selectors/emojis.ts new file mode 100644 index 0000000000..6bca976a74 --- /dev/null +++ b/ts/state/selectors/emojis.ts @@ -0,0 +1,16 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { createSelector } from 'reselect'; +import { useSelector } from 'react-redux'; + +import { StateType } from '../reducer'; +import { isShortName } from '../../components/emoji/lib'; + +export const selectRecentEmojis = createSelector( + ({ emojis }: StateType) => emojis.recents, + recents => recents.filter(isShortName) +); + +export const useRecentEmojis = (): Array => + useSelector(selectRecentEmojis); diff --git a/ts/state/selectors/linkPreviews.ts b/ts/state/selectors/linkPreviews.ts new file mode 100644 index 0000000000..10790acf88 --- /dev/null +++ b/ts/state/selectors/linkPreviews.ts @@ -0,0 +1,21 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { createSelector } from 'reselect'; + +import { StateType } from '../reducer'; + +export const getLinkPreview = createSelector( + ({ linkPreviews }: StateType) => linkPreviews.linkPreview, + linkPreview => { + if (linkPreview) { + return { + ...linkPreview, + domain: window.Signal.LinkPreviews.getDomain(linkPreview.url), + isLoaded: true, + }; + } + + return undefined; + } +); diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index 43b9722b3a..8003e045a9 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -2,13 +2,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; import { get } from 'lodash'; import { mapDispatchToProps } from '../actions'; import { CompositionArea } from '../../components/CompositionArea'; import { StateType } from '../reducer'; -import { isShortName } from '../../components/emoji/lib'; +import { selectRecentEmojis } from '../selectors/emojis'; import { getIntl } from '../selectors/user'; import { getConversationSelector } from '../selectors/conversations'; import { @@ -24,11 +23,6 @@ type ExternalProps = { id: string; }; -const selectRecentEmojis = createSelector( - ({ emojis }: StateType) => emojis.recents, - recents => recents.filter(isShortName) -); - const mapStateToProps = (state: StateType, props: ExternalProps) => { const { id } = props; diff --git a/ts/state/smart/EmojiPicker.tsx b/ts/state/smart/EmojiPicker.tsx index 1e26baca92..9bfe73d510 100644 --- a/ts/state/smart/EmojiPicker.tsx +++ b/ts/state/smart/EmojiPicker.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { useSelector } from 'react-redux'; import { get } from 'lodash'; import { StateType } from '../reducer'; -import { useActions as useItemActions, useRecentEmojis } from '../ducks/items'; +import { useRecentEmojis } from '../selectors/emojis'; import { useActions as useEmojiActions } from '../ducks/emojis'; import { @@ -17,8 +17,8 @@ import { LocalizerType } from '../../types/Util'; export const SmartEmojiPicker = React.forwardRef< HTMLDivElement, - Pick ->(({ onPickEmoji, onClose, style }, ref) => { + Pick +>(({ onPickEmoji, onSetSkinTone, onClose, style }, ref) => { const i18n = useSelector(getIntl); const skinTone = useSelector(state => get(state, ['items', 'skinTone'], 0) @@ -26,15 +26,6 @@ export const SmartEmojiPicker = React.forwardRef< const recentEmojis = useRecentEmojis(); - const { putItem } = useItemActions(); - - const onSetSkinTone = React.useCallback( - tone => { - putItem('skinTone', tone); - }, - [putItem] - ); - const { onUseEmoji } = useEmojiActions(); const handlePickEmoji = React.useCallback( diff --git a/ts/state/smart/ForwardMessageModal.tsx b/ts/state/smart/ForwardMessageModal.tsx new file mode 100644 index 0000000000..9bf1c47ab4 --- /dev/null +++ b/ts/state/smart/ForwardMessageModal.tsx @@ -0,0 +1,79 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { connect } from 'react-redux'; +import { get } from 'lodash'; +import { mapDispatchToProps } from '../actions'; +import { + ForwardMessageModal, + DataPropsType, +} from '../../components/ForwardMessageModal'; +import { StateType } from '../reducer'; +import { BodyRangeType } from '../../types/Util'; +import { LinkPreviewType } from '../../types/message/LinkPreviews'; +import { getAllComposableConversations } from '../selectors/conversations'; +import { getLinkPreview } from '../selectors/linkPreviews'; +import { getIntl } from '../selectors/user'; +import { selectRecentEmojis } from '../selectors/emojis'; +import { AttachmentType } from '../../types/Attachment'; + +export type SmartForwardMessageModalProps = { + attachments?: Array; + doForwardMessage: ( + selectedContacts: Array, + messageBody?: string, + attachments?: Array, + linkPreview?: LinkPreviewType + ) => void; + isSticker: boolean; + messageBody?: string; + onClose: () => void; + onEditorStateChange: ( + messageText: string, + bodyRanges: Array, + caretLocation?: number + ) => unknown; + onTextTooLong: () => void; +}; + +const mapStateToProps = ( + state: StateType, + props: SmartForwardMessageModalProps +): DataPropsType => { + const { + attachments, + doForwardMessage, + isSticker, + messageBody, + onClose, + onEditorStateChange, + onTextTooLong, + } = props; + + const candidateConversations = getAllComposableConversations(state); + const recentEmojis = selectRecentEmojis(state); + const skinTone = get(state, ['items', 'skinTone'], 0); + const linkPreview = getLinkPreview(state); + + return { + attachments, + candidateConversations, + doForwardMessage, + i18n: getIntl(state), + isSticker, + linkPreview, + messageBody, + onClose, + onEditorStateChange, + recentEmojis, + skinTone, + onTextTooLong, + }; +}; + +const smart = connect(mapStateToProps, { + ...mapDispatchToProps, + onPickEmoji: mapDispatchToProps.onUseEmoji, +}); + +export const SmartForwardMessageModal = smart(ForwardMessageModal); diff --git a/ts/state/smart/MessageDetail.tsx b/ts/state/smart/MessageDetail.tsx index 639ac812ec..97a804478f 100644 --- a/ts/state/smart/MessageDetail.tsx +++ b/ts/state/smart/MessageDetail.tsx @@ -42,6 +42,7 @@ export type OwnProps = { | 'showContactModal' | 'showExpiredIncomingTapToViewToast' | 'showExpiredOutgoingTapToViewToast' + | 'showForwardMessageModal' | 'showVisualAttachment' >; @@ -72,6 +73,7 @@ const mapStateToProps = ( showContactModal, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, + showForwardMessageModal, showVisualAttachment, } = props; @@ -103,6 +105,7 @@ const mapStateToProps = ( showContactModal, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, + showForwardMessageModal, showVisualAttachment, }; }; diff --git a/ts/state/types.ts b/ts/state/types.ts index c981ba31f4..24815cc332 100644 --- a/ts/state/types.ts +++ b/ts/state/types.ts @@ -7,6 +7,7 @@ import { actions as conversations } from './ducks/conversations'; import { actions as emojis } from './ducks/emojis'; import { actions as expiration } from './ducks/expiration'; import { actions as items } from './ducks/items'; +import { actions as linkPreviews } from './ducks/linkPreviews'; import { actions as network } from './ducks/network'; import { actions as safetyNumber } from './ducks/safetyNumber'; import { actions as search } from './ducks/search'; @@ -21,6 +22,7 @@ export type ReduxActions = { emojis: typeof emojis; expiration: typeof expiration; items: typeof items; + linkPreviews: typeof linkPreviews; network: typeof network; safetyNumber: typeof safetyNumber; search: typeof search; diff --git a/ts/test-both/state/ducks/linkPreviews_test.ts b/ts/test-both/state/ducks/linkPreviews_test.ts new file mode 100644 index 0000000000..94ed5cca6f --- /dev/null +++ b/ts/test-both/state/ducks/linkPreviews_test.ts @@ -0,0 +1,48 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { + actions, + getEmptyState, + reducer, +} from '../../../state/ducks/linkPreviews'; +import { LinkPreviewType } from '../../../types/message/LinkPreviews'; + +describe('both/state/ducks/linkPreviews', () => { + function getMockLinkPreview(): LinkPreviewType { + return { + title: 'Hello World', + domain: 'signal.org', + url: 'https://www.signal.org', + isStickerPack: false, + }; + } + + describe('addLinkPreview', () => { + const { addLinkPreview } = actions; + + it('updates linkPreview', () => { + const state = getEmptyState(); + const linkPreview = getMockLinkPreview(); + const nextState = reducer(state, addLinkPreview(linkPreview)); + + assert.strictEqual(nextState.linkPreview, linkPreview); + }); + }); + + describe('removeLinkPreview', () => { + const { removeLinkPreview } = actions; + + it('removes linkPreview', () => { + const state = { + ...getEmptyState(), + linkPreview: getMockLinkPreview(), + }; + const nextState = reducer(state, removeLinkPreview()); + + assert.isUndefined(nextState.linkPreview); + }); + }); +}); diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 4165344ca5..9cff0ffcaf 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -46,6 +46,7 @@ describe('both/state/selectors/conversations', () => { return { id, type: 'direct', + searchableTitle: `${id} title`, title: `${id} title`, }; } @@ -478,6 +479,13 @@ describe('both/state/selectors/conversations', () => { const getRootStateWithConversations = (searchTerm = ''): StateType => { const result = getRootState(searchTerm); Object.assign(result.conversations.conversationLookup, { + 'convo-0': { + ...getDefaultConversation('convo-0'), + name: 'Me, Myself, and I', + title: 'Me, Myself, and I', + searchableTitle: 'Note to Self', + isMe: true, + }, 'convo-1': { ...getDefaultConversation('convo-1'), name: 'In System Contacts', @@ -517,32 +525,20 @@ describe('both/state/selectors/conversations', () => { return result; }; - it('only returns Note to Self when there are no other contacts', () => { - const state = getRootState(); - const result = getComposeContacts(state); - - assert.lengthOf(result, 1); - assert.strictEqual(result[0]?.id, 'our-conversation-id'); - }); - - it("returns no results when search doesn't match Note to Self and there are no other contacts", () => { + it('returns no results when there are no contacts', () => { const state = getRootState('foo bar baz'); const result = getComposeContacts(state); assert.isEmpty(result); }); - it('returns contacts with Note to Self at the end when there is no search term', () => { + it('includes Note to Self', () => { const state = getRootStateWithConversations(); const result = getComposeContacts(state); const ids = result.map(contact => contact.id); - assert.deepEqual(ids, [ - 'convo-1', - 'convo-5', - 'convo-6', - 'our-conversation-id', - ]); + // convo-6 is sorted last because it doesn't have a name + assert.deepEqual(ids, ['convo-1', 'convo-5', 'convo-0', 'convo-6']); }); it('can search for contacts', () => { @@ -553,6 +549,22 @@ describe('both/state/selectors/conversations', () => { // NOTE: convo-6 matches because you can't write "Sharing" without "in" assert.deepEqual(ids, ['convo-1', 'convo-5', 'convo-6']); }); + + it('can search for note to self', () => { + const state = getRootStateWithConversations('note'); + const result = getComposeContacts(state); + + const ids = result.map(contact => contact.id); + assert.deepEqual(ids, ['convo-0']); + }); + + it('returns not to self when searching for your own name', () => { + const state = getRootStateWithConversations('Myself'); + const result = getComposeContacts(state); + + const ids = result.map(contact => contact.id); + assert.deepEqual(ids, ['convo-0']); + }); }); describe('#getComposeGroups', () => { diff --git a/ts/util/filterAndSortConversations.ts b/ts/util/filterAndSortConversations.ts index 328677d031..7e28c58d8a 100644 --- a/ts/util/filterAndSortConversations.ts +++ b/ts/util/filterAndSortConversations.ts @@ -11,6 +11,10 @@ const FUSE_OPTIONS: FuseOptions = { threshold: 0.05, tokenize: true, keys: [ + { + name: 'searchableTitle', + weight: 1, + }, { name: 'title', weight: 1, diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 1442b8a2f8..d5787fbda9 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -16238,7 +16238,7 @@ "rule": "React-useRef", "path": "ts/components/CompositionInput.js", "line": " const emojiCompletionRef = React.useRef();", - "lineNumber": 56, + "lineNumber": 62, "reasonCategory": "falseMatch", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Doesn't refer to a DOM element." @@ -16247,7 +16247,7 @@ "rule": "React-useRef", "path": "ts/components/CompositionInput.js", "line": " const mentionCompletionRef = React.useRef();", - "lineNumber": 57, + "lineNumber": 63, "reasonCategory": "falseMatch", "updated": "2020-10-26T23:54:34.273Z", "reasonDetail": "Doesn't refer to a DOM element." @@ -16256,7 +16256,7 @@ "rule": "React-useRef", "path": "ts/components/CompositionInput.js", "line": " const quillRef = React.useRef();", - "lineNumber": 58, + "lineNumber": 64, "reasonCategory": "falseMatch", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Doesn't refer to a DOM element." @@ -16265,7 +16265,7 @@ "rule": "React-useRef", "path": "ts/components/CompositionInput.js", "line": " const scrollerRef = React.useRef(null);", - "lineNumber": 59, + "lineNumber": 65, "reasonCategory": "usageTrusted", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Used with Quill for scrolling." @@ -16274,7 +16274,7 @@ "rule": "React-useRef", "path": "ts/components/CompositionInput.js", "line": " const propsRef = React.useRef(props);", - "lineNumber": 60, + "lineNumber": 66, "reasonCategory": "falseMatch", "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Doesn't refer to a DOM element." @@ -16283,11 +16283,27 @@ "rule": "React-useRef", "path": "ts/components/CompositionInput.js", "line": " const memberRepositoryRef = React.useRef(new memberRepository_1.MemberRepository());", - "lineNumber": 61, + "lineNumber": 67, "reasonCategory": "falseMatch", "updated": "2020-10-26T23:56:13.482Z", "reasonDetail": "Doesn't refer to a DOM element." }, + { + "rule": "React-useRef", + "path": "ts/components/CompositionInput.js", + "line": " const callbacksRef = React.useRef(unstaleCallbacks);", + "lineNumber": 338, + "reasonCategory": "usageTrusted", + "updated": "2021-04-21T21:35:38.757Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/CompositionInput.tsx", + "line": " const callbacksRef = React.useRef(unstaleCallbacks);", + "lineNumber": 500, + "reasonCategory": "usageTrusted", + "updated": "2021-04-21T21:35:38.757Z" + }, { "rule": "React-useRef", "path": "ts/components/ContactPills.js", @@ -16315,6 +16331,22 @@ "updated": "2020-11-11T21:56:04.179Z", "reasonDetail": "Needed to render the remote video element." }, + { + "rule": "React-useRef", + "path": "ts/components/ForwardMessageModal.js", + "line": " const inputRef = react_1.useRef(null);", + "lineNumber": 44, + "reasonCategory": "usageTrusted", + "updated": "2021-04-19T18:13:21.664Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/ForwardMessageModal.js", + "line": " const inputApiRef = react_1.default.useRef();", + "lineNumber": 45, + "reasonCategory": "usageTrusted", + "updated": "2021-04-19T18:13:21.664Z" + }, { "rule": "React-useRef", "path": "ts/components/GroupCallOverflowArea.js", @@ -16557,7 +16589,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " public focusRef: React.RefObject = React.createRef();", - "lineNumber": 241, + "lineNumber": 242, "reasonCategory": "usageTrusted", "updated": "2021-03-05T19:57:01.431Z", "reasonDetail": "Used for managing focus only" @@ -16566,7 +16598,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " public audioButtonRef: React.RefObject = React.createRef();", - "lineNumber": 243, + "lineNumber": 244, "reasonCategory": "usageTrusted", "updated": "2021-03-05T19:57:01.431Z", "reasonDetail": "Used for propagating click from the Message to MessageAudio's button" @@ -16575,7 +16607,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " > = React.createRef();", - "lineNumber": 247, + "lineNumber": 248, "reasonCategory": "usageTrusted", "updated": "2021-03-05T19:57:01.431Z", "reasonDetail": "Used for detecting clicks outside reaction viewer" diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 4205151501..68c568aa52 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -4,11 +4,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { AttachmentType } from '../types/Attachment'; -import { GroupV2PendingMemberType } from '../model-types.d'; -import { MediaItemType } from '../components/LightboxGallery'; -import { MessageType } from '../state/ducks/conversations'; import { ConversationModel } from '../models/conversations'; +import { GroupV2PendingMemberType } from '../model-types.d'; +import { LinkPreviewType } from '../types/message/LinkPreviews'; +import { MediaItemType } from '../components/LightboxGallery'; import { MessageModel } from '../models/messages'; +import { MessageType } from '../state/ducks/conversations'; import { assert } from '../util/assert'; type GetLinkPreviewImageResult = { @@ -48,6 +49,8 @@ const { getAbsoluteAttachmentPath, getAbsoluteDraftPath, getAbsoluteTempPath, + loadPreviewData, + loadStickerData, openFileInFolder, readAttachmentData, readDraftData, @@ -608,7 +611,7 @@ Whisper.ConversationView = Whisper.View.extend({ onEditorStateChange: ( msg: string, bodyRanges: Array, - caretLocation: number + caretLocation?: number ) => this.onEditorStateChange(msg, bodyRanges, caretLocation), onTextTooLong: () => this.showToast(Whisper.MessageBodyTooLongToast), onChooseAttachment: this.onChooseAttachment.bind(this), @@ -774,6 +777,7 @@ Whisper.ConversationView = Whisper.View.extend({ const showExpiredOutgoingTapToViewToast = () => { this.showToast(Whisper.TapToViewExpiredOutgoingToast); }; + const showForwardMessageModal = this.showForwardMessageModal.bind(this); return { deleteMessage, @@ -792,6 +796,7 @@ Whisper.ConversationView = Whisper.View.extend({ showContactModal, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, + showForwardMessageModal, showIdentity, showMessageDetail, showVisualAttachment, @@ -980,14 +985,18 @@ Whisper.ConversationView = Whisper.View.extend({ this.$('.timeline-placeholder').append(this.timelineView.el); }, - showToast(ToastView: any, options: any) { + showToast(ToastView: any, options: any, element: Element) { const toast = new ToastView(options); - const lightboxEl = $('.module-lightbox'); - if (lightboxEl.length > 0) { - toast.$el.appendTo(lightboxEl); + if (element) { + toast.$el.appendTo(element); } else { - toast.$el.appendTo(this.$el); + const lightboxEl = $('.module-lightbox'); + if (lightboxEl.length > 0) { + toast.$el.appendTo(lightboxEl); + } else { + toast.$el.appendTo(this.$el); + } } toast.render(); @@ -2139,6 +2148,196 @@ Whisper.ConversationView = Whisper.View.extend({ await message.retrySend(); }, + showForwardMessageModal(messageId: string) { + const message = this.model.messageCollection.get(messageId); + if (!message) { + throw new Error( + `showForwardMessageModal: Did not find message for id ${messageId}` + ); + } + + const attachments = message.getAttachmentsForMessage(); + this.forwardMessageModal = new Whisper.ReactWrapperView({ + JSX: window.Signal.State.Roots.createForwardMessageModal( + window.reduxStore, + { + attachments, + doForwardMessage: async ( + conversationIds: Array, + messageBody?: string, + includedAttachments?: Array, + linkPreview?: LinkPreviewType + ) => { + const didForwardSuccessfully = await this.maybeForwardMessage( + message, + conversationIds, + messageBody, + includedAttachments, + linkPreview + ); + + if (didForwardSuccessfully) { + this.forwardMessageModal.remove(); + this.forwardMessageModal = null; + } + }, + isSticker: Boolean(message.get('sticker')), + messageBody: message.getRawText(), + onClose: () => { + this.forwardMessageModal.remove(); + this.forwardMessageModal = null; + this.resetLinkPreview(); + }, + onEditorStateChange: ( + messageText: string, + _: Array, + caretLocation?: number + ) => { + if (!attachments.length) { + this.debouncedMaybeGrabLinkPreview(messageText, caretLocation); + } + }, + onTextTooLong: () => + this.showToast( + Whisper.MessageBodyTooLongToast, + {}, + document.querySelector('.module-ForwardMessageModal') + ), + } + ), + }); + this.forwardMessageModal.render(); + }, + + async maybeForwardMessage( + message: MessageModel, + conversationIds: Array, + messageBody?: string, + attachments?: Array, + linkPreview?: LinkPreviewType + ): Promise { + const attachmentLookup = new Set(); + if (attachments) { + attachments.forEach(attachment => { + attachmentLookup.add( + `${attachment.fileName}/${attachment.contentType}` + ); + }); + } + + const conversations = conversationIds.map(id => + window.ConversationController.get(id) + ); + + // Verify that all contacts that we're forwarding + // to are verified and trusted + const unverifiedContacts: Array = []; + const untrustedContacts: Array = []; + await Promise.all( + conversations.map(async conversation => { + if (conversation) { + await conversation.updateVerified(); + const unverifieds = conversation.getUnverified(); + if (unverifieds.length) { + unverifieds.forEach(unverifiedConversation => + unverifiedContacts.push(unverifiedConversation) + ); + } + + const untrusted = conversation.getUntrusted(); + if (untrusted.length) { + untrusted.forEach(untrustedConversation => + untrustedContacts.push(untrustedConversation) + ); + } + } + }) + ); + + // If there are any unverified or untrusted contacts, show the + // SendAnywayDialog and if we're fine with sending then mark all as + // verified and trusted and continue the send. + const iffyConversations = [...unverifiedContacts, ...untrustedContacts]; + if (iffyConversations.length) { + const forwardMessageModal = document.querySelector( + '.module-ForwardMessageModal' + ); + if (forwardMessageModal) { + forwardMessageModal.style.display = 'none'; + } + const sendAnyway = await this.showSendAnywayDialog(iffyConversations); + + if (!sendAnyway) { + if (forwardMessageModal) { + forwardMessageModal.style.display = 'block'; + } + return false; + } + + let verifyPromise: Promise | undefined; + let approvePromise: Promise | undefined; + if (unverifiedContacts.length) { + verifyPromise = this.markAllAsVerifiedDefault(unverifiedContacts); + } + if (untrustedContacts.length) { + approvePromise = this.markAllAsApproved(untrustedContacts); + } + await Promise.all([verifyPromise, approvePromise]); + } + + const sendMessageOptions = { dontClearDraft: true }; + + // Actually send the message + // load any sticker data, attachments, or link previews that we need to + // send along with the message and do the send to each conversation. + await Promise.all( + conversations.map(async conversation => { + if (conversation) { + const sticker = message.get('sticker'); + if (sticker) { + const stickerWithData = await loadStickerData(sticker); + conversation.sendMessage( + null, + [], + null, + [], + stickerWithData, + undefined, + sendMessageOptions + ); + } else { + const preview = linkPreview + ? await loadPreviewData([linkPreview]) + : []; + const allAttachments = message.getAttachmentsForMessage(); + const attachmentsToSend = allAttachments.filter( + (attachment: Partial) => + attachmentLookup.has( + `${attachment.fileName}/${attachment.contentType}` + ) + ); + + conversation.sendMessage( + messageBody || null, + attachmentsToSend, + null, // quote + preview, + null, // sticker + undefined, // BodyRanges + sendMessageOptions + ); + } + } + }) + ); + + if (linkPreview) { + this.resetLinkPreview(); + } + + return true; + }, + async showAllMedia() { // We fetch more documents than media as they don’t require to be loaded // into memory right away. Revisit this once we have infinite scrolling: @@ -3203,7 +3402,10 @@ Whisper.ConversationView = Whisper.View.extend({ return true; }, - showSendAnywayDialog(contacts: any, confirmText: any) { + showSendAnywayDialog( + contacts: Array, + confirmText?: string + ) { return new Promise(resolve => { const dialog = new Whisper.SafetyNumberChangeDialogView({ confirmText, @@ -3255,13 +3457,6 @@ Whisper.ConversationView = Whisper.View.extend({ return; } - const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled( - 'desktop.mandatoryProfileSharing' - ); - if (mandatoryProfileSharingEnabled && !this.model.get('profileSharing')) { - this.model.set({ profileSharing: true }); - } - if (this.showInvalidMessageToast()) { return; } @@ -3474,13 +3669,6 @@ Whisper.ConversationView = Whisper.View.extend({ return; } - const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled( - 'desktop.mandatoryProfileSharing' - ); - if (mandatoryProfileSharingEnabled && !this.model.get('profileSharing')) { - this.model.set({ profileSharing: true }); - } - const attachments = await this.getFiles(); const sendDelta = Date.now() - this.sendStart; window.log.info('Send pre-checks took', sendDelta, 'milliseconds'); @@ -3607,6 +3795,7 @@ Whisper.ConversationView = Whisper.View.extend({ URL.revokeObjectURL(item.url); } }); + window.reduxActions.linkPreviews.removeLinkPreview(); this.preview = null; this.currentlyMatchedLink = null; this.linkPreviewAbortController?.abort(); @@ -3881,6 +4070,7 @@ Whisper.ConversationView = Whisper.View.extend({ URL.revokeObjectURL(item.url); } }); + window.reduxActions.linkPreviews.removeLinkPreview(); this.preview = null; // Cancel other in-flight link preview requests. @@ -3937,6 +4127,7 @@ Whisper.ConversationView = Whisper.View.extend({ return; } + window.reduxActions.linkPreviews.addLinkPreview(result); this.preview = [result]; this.renderLinkPreview(); } catch (error) { @@ -3952,6 +4143,9 @@ Whisper.ConversationView = Whisper.View.extend({ }, renderLinkPreview() { + if (this.forwardMessageModal) { + return; + } if (this.previewView) { this.previewView.remove(); this.previewView = null; diff --git a/ts/window.d.ts b/ts/window.d.ts index 9ecdda9fcd..ea37f21346 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -46,6 +46,7 @@ import { createCompositionArea } from './state/roots/createCompositionArea'; import { createContactModal } from './state/roots/createContactModal'; import { createConversationDetails } from './state/roots/createConversationDetails'; import { createConversationHeader } from './state/roots/createConversationHeader'; +import { createForwardMessageModal } from './state/roots/createForwardMessageModal'; import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement'; import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal'; import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal'; @@ -63,6 +64,7 @@ import * as conversationsDuck from './state/ducks/conversations'; import * as emojisDuck from './state/ducks/emojis'; import * as expirationDuck from './state/ducks/expiration'; import * as itemsDuck from './state/ducks/items'; +import * as linkPreviewsDuck from './state/ducks/linkPreviews'; import * as networkDuck from './state/ducks/network'; import * as updatesDuck from './state/ducks/updates'; import * as userDuck from './state/ducks/user'; @@ -491,6 +493,7 @@ declare global { createContactModal: typeof createContactModal; createConversationDetails: typeof createConversationDetails; createConversationHeader: typeof createConversationHeader; + createForwardMessageModal: typeof createForwardMessageModal; createGroupLinkManagement: typeof createGroupLinkManagement; createGroupV1MigrationModal: typeof createGroupV1MigrationModal; createGroupV2JoinModal: typeof createGroupV2JoinModal; @@ -510,6 +513,7 @@ declare global { emojis: typeof emojisDuck; expiration: typeof expirationDuck; items: typeof itemsDuck; + linkPreviews: typeof linkPreviewsDuck; network: typeof networkDuck; updates: typeof updatesDuck; user: typeof userDuck;