diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 47bb5aa96e3..dddfaf989b2 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3324,6 +3324,10 @@ "messageformat": "Spell check text entered in message composition box", "description": "Description of the spell check setting" }, + "icu:textFormattingDescripton": { + "messageformat": "Enable text formatting popover when text is selected", + "description": "Description of the text-formatting popover menu setting" + }, "spellCheckWillBeEnabled": { "message": "Spell check will be enabled the next time Signal starts.", "description": "(deleted 03/29/2023) Shown when the user enables spellcheck to indicate that they must restart Signal." @@ -5467,6 +5471,26 @@ "messageformat": "Composer", "description": "Header of the keyboard shortcuts guide - composer section" }, + "icu:Keyboard--composer--bold": { + "messageformat": "Mark selected text as bold", + "description": "Description of command to bold text in composer" + }, + "icu:Keyboard--composer--italic": { + "messageformat": "Mark selected text as italic", + "description": "Description of command to bold text in composer" + }, + "icu:Keyboard--composer--strikethrough": { + "messageformat": "Mark selected text as strikethrough", + "description": "Description of command to bold text in composer" + }, + "icu:Keyboard--composer--monospace": { + "messageformat": "Mark selected text as monospace", + "description": "Description of command to bold text in composer" + }, + "icu:Keyboard--composer--spoiler": { + "messageformat": "Mark selected text as a spoiler", + "description": "Description of command to bold text in composer" + }, "Keyboard--scroll-to-top": { "message": "Scroll to top of list", "description": "(deleted 03/29/2023) Shown in the shortcuts guide" @@ -5619,6 +5643,14 @@ "messageformat": "Chat marked unread", "description": "A toast that shows up when user marks a conversation as unread" }, + "icu:SendFormatting--dialog--title": { + "messageformat": "Sending formatted text", + "description": "Title of the modal shown before sending your first formatting message" + }, + "icu:SendFormatting--dialog--body": { + "messageformat": "Some people may be using a version of Signal that doesn’t support formatted text. They will not be able to see the formatting changes you’ve made to your message.", + "description": "Body text of the modal shown before sending your first formatting message" + }, "icu:AuthArtCreator--dialog--message": { "messageformat": "Would you like to open Signal Sticker Pack Creator?", "description": "A body of the dialog that is presented when user tries to open Signal Sticker Pack Creator from a link" diff --git a/images/icons/v3/bold.svg b/images/icons/v3/bold.svg new file mode 100644 index 00000000000..4cac71eac55 --- /dev/null +++ b/images/icons/v3/bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/icons/v3/italic.svg b/images/icons/v3/italic.svg new file mode 100644 index 00000000000..599871cd280 --- /dev/null +++ b/images/icons/v3/italic.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/icons/v3/monospace.svg b/images/icons/v3/monospace.svg new file mode 100644 index 00000000000..dcbc5f7bbfd --- /dev/null +++ b/images/icons/v3/monospace.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/icons/v3/spoiler.svg b/images/icons/v3/spoiler.svg new file mode 100644 index 00000000000..bd5dc628ba5 --- /dev/null +++ b/images/icons/v3/spoiler.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/images/icons/v3/strikethrough.svg b/images/icons/v3/strikethrough.svg new file mode 100644 index 00000000000..1ea00d1f77d --- /dev/null +++ b/images/icons/v3/strikethrough.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/stylesheets/components/CompositionInput.scss b/stylesheets/components/CompositionInput.scss index 1b87970857b..3c6bf8d3dc7 100644 --- a/stylesheets/components/CompositionInput.scss +++ b/stylesheets/components/CompositionInput.scss @@ -120,6 +120,144 @@ } } + &__format-menu { + padding: 6px 12px; + border-radius: 8px; + z-index: $z-index-above-popup; + + display: flex; + flex-direction: row; + + @include popper-shadow(); + + @include light-theme() { + background: $color-white; + } + @include dark-theme() { + background: $color-gray-80; + } + + &__item { + $parent: &; + @include button-reset; + + height: 24px; + width: 24px; + border-radius: 4px; + + margin-right: 8px; + &:last-child { + margin-right: 0; + } + + @include mouse-mode { + &:hover { + background-color: $color-gray-05; + } + } + @include dark-mouse-mode { + &:hover { + background-color: $color-gray-60; + } + } + + &__icon { + height: 20px; + width: 20px; + margin: 2px; + + &--bold { + @include dark-theme { + @include color-svg('../images/icons/v3/bold.svg', $color-gray-25); + } + @include light-theme { + @include color-svg('../images/icons/v3/bold.svg', $color-gray-60); + } + } + + &--italic { + @include dark-theme { + @include color-svg('../images/icons/v3/italic.svg', $color-gray-25); + } + @include light-theme { + @include color-svg('../images/icons/v3/italic.svg', $color-gray-60); + } + } + + &--strikethrough { + @include dark-theme { + @include color-svg( + '../images/icons/v3/strikethrough.svg', + $color-gray-25 + ); + } + @include light-theme { + @include color-svg( + '../images/icons/v3/strikethrough.svg', + $color-gray-60 + ); + } + } + + &--monospace { + @include dark-theme { + @include color-svg( + '../images/icons/v3/monospace.svg', + $color-gray-25 + ); + } + @include light-theme { + @include color-svg( + '../images/icons/v3/monospace.svg', + $color-gray-60 + ); + } + } + + &--spoiler { + @include dark-theme { + @include color-svg( + '../images/icons/v3/spoiler.svg', + $color-gray-25 + ); + } + @include light-theme { + @include color-svg( + '../images/icons/v3/spoiler.svg', + $color-gray-60 + ); + } + } + + // Here we look at hover for the parent so the 2px border in between is active + // We can't use the mixins because .mouse-mode would wend up after the > + .mouse-mode #{$parent}:hover & { + background-color: $color-gray-90; + } + .dark-theme.mouse-mode #{$parent}:hover & { + background-color: $color-gray-15; + } + + &--active { + @include dark-theme { + background-color: $color-ultramarine; + } + @include light-theme { + background-color: $color-ultramarine; + } + + // Override above hover behaviors + .mouse-mode #{$parent}:hover & { + background-color: $color-ultramarine; + } + .dark-theme.mouse-mode #{$parent}:hover & { + background-color: $color-ultramarine; + } + } + } + } + } + &__suggestions { padding: 0; margin-bottom: 6px; @@ -254,3 +392,19 @@ button.CompositionInput__link-preview__close-button { } } } + +.quill { + &--monospace { + font-family: monospace; + } + &--spoiler { + @include light-theme { + // vs color/$color-gray-90, background/$color-gray-05 + background-color: $color-gray-25; + } + @include dark-theme { + // vs color/$color-gray-05, background/$color-gray-95 + background-color: $color-gray-45; + } + } +} diff --git a/stylesheets/components/MessageTextRenderer.scss b/stylesheets/components/MessageTextRenderer.scss index 3412d93a604..b59525b3a66 100644 --- a/stylesheets/components/MessageTextRenderer.scss +++ b/stylesheets/components/MessageTextRenderer.scss @@ -3,32 +3,17 @@ .MessageTextRenderer { &__formatting { - &--bold { - font-weight: 600; - } - &--italic { - font-style: italic; - } + // bold is handled by element + // italic is handled by element + // strikethrough is handled by element + &--monospace { font-family: monospace; } - &--strikethrough { - text-decoration: line-through; - bdi { - text-decoration: line-through; - } - } - &--none { - text-decoration: none; - font-weight: 400; - bdi { - text-decoration: none; - } - } // Note: only used in the left pane for search results, not in message bubbles &--keywordHighlight { - font-weight: 600; + // Boldness of this is handled by element // To differentiate it from bold formatting, we increase the color contrast @include light-theme { @@ -44,6 +29,10 @@ user-select: none; cursor: pointer; + // Lighten things up a bit + opacity: 50%; + border-radius: 4px; + // make child text invisible color: transparent; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 345a8328549..d366f09a486 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -16,29 +16,31 @@ export type ConfigKeyType = | 'desktop.announcementGroup' | 'desktop.calling.audioLevelForSpeaking' | 'desktop.cdsi.returnAcisWithoutUaks' - | 'desktop.contactManagement' - | 'desktop.contactManagement.beta' | 'desktop.clientExpiration' - | 'desktop.groupCallOutboundRing2' + | 'desktop.contactManagement.beta' + | 'desktop.contactManagement' | 'desktop.groupCallOutboundRing2.beta' + | 'desktop.groupCallOutboundRing2' | 'desktop.internalUser' | 'desktop.mandatoryProfileSharing' | 'desktop.mediaQuality.levels' | 'desktop.messageCleanup' | 'desktop.messageRequests' | 'desktop.pnp' - | 'desktop.safetyNumberUUID' - | 'desktop.safetyNumberUUID.timestamp' | 'desktop.retryReceiptLifespan' | 'desktop.retryRespondMaxAge' + | 'desktop.safetyNumberUUID.timestamp' + | 'desktop.safetyNumberUUID' | 'desktop.senderKey.retry' | 'desktop.senderKey.send' | 'desktop.senderKeyMaxAge' | 'desktop.sendSenderKey3' | 'desktop.showUserBadges.beta' | 'desktop.showUserBadges2' - | 'desktop.stories2' | 'desktop.stories2.beta' + | 'desktop.stories2' + | 'desktop.textFormatting.spoilerSend' + | 'desktop.textFormatting' | 'desktop.usernames' | 'global.attachments.maxBytes' | 'global.calling.maxGroupCallRingSize' diff --git a/ts/components/AddCaptionModal.stories.tsx b/ts/components/AddCaptionModal.stories.tsx index 270ba795f06..72fda55c1ee 100644 --- a/ts/components/AddCaptionModal.stories.tsx +++ b/ts/components/AddCaptionModal.stories.tsx @@ -26,6 +26,8 @@ export default { = {}): Props => ({ sendCounter: 0, i18n, isDisabled: false, + isFormattingSpoilersEnabled: + overrideProps.isFormattingSpoilersEnabled === false + ? overrideProps.isFormattingSpoilersEnabled + : true, + isFormattingEnabled: + overrideProps.isFormattingEnabled === false + ? overrideProps.isFormattingEnabled + : true, messageCompositionId: '456', sendMultiMediaMessage: action('sendMultiMediaMessage'), processAttachments: action('processAttachments'), @@ -279,3 +287,13 @@ export function QuoteWithPayment(): JSX.Element { QuoteWithPayment.story = { name: 'Quote with payment', }; + +export function NoFormatting(): JSX.Element { + return ; +} + +export function NoSpoilerFormatting(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 3e905830c78..589e287f72b 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { get } from 'lodash'; import classNames from 'classnames'; -import type { DraftBodyRangeMention } from '../types/BodyRange'; +import type { DraftBodyRanges } from '../types/BodyRange'; import type { LocalizerType, ThemeType } from '../types/Util'; import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder'; import { RecordingState } from '../types/AudioRecorder'; @@ -93,6 +93,8 @@ export type OwnProps = Readonly<{ imageToBlurHash: typeof imageToBlurHash; isDisabled: boolean; isFetchingUUID?: boolean; + isFormattingEnabled: boolean; + isFormattingSpoilersEnabled: boolean; isGroupV1AndDisabled?: boolean; isMissingMandatoryProfileSharing?: boolean; isSignalConversation?: boolean; @@ -119,7 +121,7 @@ export type OwnProps = Readonly<{ conversationId: string, options: { draftAttachments?: ReadonlyArray; - draftBodyRanges?: ReadonlyArray; + bodyRanges?: DraftBodyRanges; message?: string; timestamp?: number; voiceNoteAttachment?: InMemoryAttachmentDraftType; @@ -232,6 +234,8 @@ export function CompositionArea({ draftText, getPreferredBadge, getQuotedMessage, + isFormattingSpoilersEnabled, + isFormattingEnabled, onEditorStateChange, onTextTooLong, sendCounter, @@ -305,15 +309,11 @@ export function CompositionArea({ }, [inputApiRef, setLarge]); const handleSubmit = useCallback( - ( - message: string, - mentions: ReadonlyArray, - timestamp: number - ) => { + (message: string, bodyRanges: DraftBodyRanges, timestamp: number) => { emojiButtonRef.current?.close(); sendMultiMediaMessage(conversationId, { draftAttachments, - draftBodyRanges: mentions, + bodyRanges, message, timestamp, }); @@ -511,14 +511,14 @@ export function CompositionArea({ const handler = (e: KeyboardEvent) => { const { shiftKey, ctrlKey, metaKey } = e; const key = KeyboardLayout.lookup(e); - // When using the ctrl key, `key` is `'X'`. When using the cmd key, `key` is `'x'` - const xKey = key === 'x' || key === 'X'; + // When using the ctrl key, `key` is `'K'`. When using the cmd key, `key` is `'k'` + const targetKey = key === 'k' || key === 'K'; const commandKey = get(window, 'platform') === 'darwin' && metaKey; const controlKey = get(window, 'platform') !== 'darwin' && ctrlKey; const commandOrCtrl = commandKey || controlKey; - // cmd/ctrl-shift-x - if (xKey && shiftKey && commandOrCtrl) { + // cmd/ctrl-shift-k + if (targetKey && shiftKey && commandOrCtrl) { e.preventDefault(); setLarge(x => !x); } @@ -797,6 +797,8 @@ export function CompositionArea({ getQuotedMessage={getQuotedMessage} i18n={i18n} inputApi={inputApiRef} + isFormattingSpoilersEnabled={isFormattingSpoilersEnabled} + isFormattingEnabled={isFormattingEnabled} large={large} linkPreviewLoading={linkPreviewLoading} linkPreviewResult={linkPreviewResult} diff --git a/ts/components/CompositionInput.stories.tsx b/ts/components/CompositionInput.stories.tsx index 96690b9f102..eaf7f4610c3 100644 --- a/ts/components/CompositionInput.stories.tsx +++ b/ts/components/CompositionInput.stories.tsx @@ -23,16 +23,24 @@ export default { const useProps = (overrideProps: Partial = {}): Props => ({ i18n, disabled: boolean('disabled', overrideProps.disabled || false), - onSubmit: action('onSubmit'), - onEditorStateChange: action('onEditorStateChange'), - onTextTooLong: action('onTextTooLong'), draftText: overrideProps.draftText || undefined, draftBodyRanges: overrideProps.draftBodyRanges || [], clearQuotedMessage: action('clearQuotedMessage'), getPreferredBadge: () => undefined, getQuotedMessage: action('getQuotedMessage'), - onPickEmoji: action('onPickEmoji'), + isFormattingSpoilersEnabled: + overrideProps.isFormattingSpoilersEnabled === false + ? overrideProps.isFormattingSpoilersEnabled + : true, + isFormattingEnabled: + overrideProps.isFormattingEnabled === false + ? overrideProps.isFormattingEnabled + : true, large: boolean('large', overrideProps.large || false), + onEditorStateChange: action('onEditorStateChange'), + onPickEmoji: action('onPickEmoji'), + onSubmit: action('onSubmit'), + onTextTooLong: action('onTextTooLong'), sendCounter: 0, sortedGroupMembers: overrideProps.sortedGroupMembers || [], skinTone: select( @@ -124,6 +132,7 @@ export function Mentions(): JSX.Element { start: 5, length: 1, mentionUuid: '0', + conversationID: 'k', replacementText: 'Kate Beaton', }, ], @@ -131,3 +140,13 @@ export function Mentions(): JSX.Element { return ; } + +export function NoFormatting(): JSX.Element { + return ; +} + +export function NoSpoilerFormatting(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index 45fac604931..cc748d6e067 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -11,10 +11,18 @@ import type { DeltaStatic, KeyboardStatic, RangeStatic } from 'quill'; import Quill from 'quill'; import { MentionCompletion } from '../quill/mentions/completion'; +import { FormattingMenu, QuillFormattingStyle } from '../quill/formatting/menu'; +import { MonospaceBlot } from '../quill/formatting/monospaceBlot'; +import { SpoilerBlot } from '../quill/formatting/spoilerBlot'; import { EmojiBlot, EmojiCompletion } from '../quill/emoji'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; import { convertShortName } from './emoji/lib'; -import type { DraftBodyRangeMention } from '../types/BodyRange'; +import type { + DraftBodyRanges, + HydratedBodyRangesType, + RangeNode, +} from '../types/BodyRange'; +import { collapseRangeTree, insertRange } from '../types/BodyRange'; import type { LocalizerType, ThemeType } from '../types/Util'; import type { ConversationType } from '../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; @@ -30,11 +38,11 @@ import { matchMention } from '../quill/mentions/matchers'; import { MemberRepository } from '../quill/memberRepository'; import { getDeltaToRemoveStaleMentions, - getTextAndMentionsFromOps, + getTextAndRangesFromOps, isMentionBlot, getDeltaToRestartMention, - insertMentionOps, insertEmojiOps, + insertFormattingAndMentionsOps, } from '../quill/util'; import { SignalClipboard } from '../quill/signal-clipboard'; import { DirectionalBlot } from '../quill/block/blot'; @@ -43,12 +51,16 @@ import * as log from '../logging/log'; import { useRefMerger } from '../hooks/useRefMerger'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; import { StagedLinkPreview } from './conversation/StagedLinkPreview'; +import { usePrevious } from '../hooks/usePrevious'; Quill.register('formats/emoji', EmojiBlot); Quill.register('formats/mention', MentionBlot); Quill.register('formats/block', DirectionalBlot); +Quill.register('formats/monospace', MonospaceBlot); +Quill.register('formats/spoiler', SpoilerBlot); Quill.register('modules/emojiCompletion', EmojiCompletion); Quill.register('modules/mentionCompletion', MentionCompletion); +Quill.register('modules/formattingMenu', FormattingMenu); Quill.register('modules/signalClipboard', SignalClipboard); type HistoryStatic = { @@ -61,7 +73,7 @@ export type InputApi = { insertEmoji: (e: EmojiPickDataType) => void; setContents: ( text: string, - draftBodyRanges?: ReadonlyArray, + draftBodyRanges?: HydratedBodyRangesType, cursorToEnd?: boolean ) => void; reset: () => void; @@ -76,10 +88,12 @@ export type Props = Readonly<{ getPreferredBadge: PreferredBadgeSelectorType; large?: boolean; inputApi?: React.MutableRefObject; + isFormattingEnabled: boolean; + isFormattingSpoilersEnabled: boolean; sendCounter: number; skinTone?: EmojiPickDataType['skinTone']; draftText?: string; - draftBodyRanges?: ReadonlyArray; + draftBodyRanges?: HydratedBodyRangesType; moduleClassName?: string; theme: ThemeType; placeholder?: string; @@ -87,7 +101,7 @@ export type Props = Readonly<{ scrollerRef?: React.RefObject; onDirtyChange?(dirty: boolean): unknown; onEditorStateChange?(options: { - bodyRanges: ReadonlyArray; + bodyRanges: DraftBodyRanges; caretLocation?: number; conversationId: string | undefined; messageText: string; @@ -97,7 +111,7 @@ export type Props = Readonly<{ onPickEmoji(o: EmojiPickDataType): unknown; onSubmit( message: string, - mentions: ReadonlyArray, + bodyRanges: DraftBodyRanges, timestamp: number ): unknown; onScroll?: (ev: React.UIEvent) => void; @@ -123,6 +137,8 @@ export function CompositionInput(props: Props): React.ReactElement { getQuotedMessage, i18n, inputApi, + isFormattingEnabled, + isFormattingSpoilersEnabled, large, linkPreviewLoading, linkPreviewResult, @@ -142,6 +158,8 @@ export function CompositionInput(props: Props): React.ReactElement { const [emojiCompletionElement, setEmojiCompletionElement] = React.useState(); + const [formattingChooserElement, setFormattingChooserElement] = + React.useState(); const [lastSelectionRange, setLastSelectionRange] = React.useState(null); const [mentionCompletionElement, setMentionCompletionElement] = @@ -161,38 +179,45 @@ export function CompositionInput(props: Props): React.ReactElement { const generateDelta = ( text: string, - mentions: ReadonlyArray + bodyRanges: HydratedBodyRangesType ): Delta => { - const initialOps = [{ insert: text }]; - const opsWithMentions = insertMentionOps(initialOps, mentions); - const opsWithEmojis = insertEmojiOps(opsWithMentions); + const textLength = text.length; + const tree = bodyRanges.reduce>((acc, range) => { + if (range.start < textLength) { + return insertRange(range, acc); + } + return acc; + }, []); + const nodes = collapseRangeTree({ tree, text }); + const opsWithFormattingAndMentions = insertFormattingAndMentionsOps(nodes); + const opsWithEmojis = insertEmojiOps(opsWithFormattingAndMentions); return new Delta(opsWithEmojis); }; - const getTextAndMentions = (): [ - string, - ReadonlyArray - ] => { + const getTextAndRanges = (): { + text: string; + bodyRanges: DraftBodyRanges; + } => { const quill = quillRef.current; if (quill === undefined) { - return ['', []]; + return { text: '', bodyRanges: [] }; } const contents = quill.getContents(); if (contents === undefined) { - return ['', []]; + return { text: '', bodyRanges: [] }; } const { ops } = contents; if (ops === undefined) { - return ['', []]; + return { text: '', bodyRanges: [] }; } - return getTextAndMentionsFromOps(ops); + return getTextAndRangesFromOps(ops); }; const focus = () => { @@ -251,7 +276,7 @@ export function CompositionInput(props: Props): React.ReactElement { const setContents = ( text: string, - mentions?: ReadonlyArray, + bodyRanges?: HydratedBodyRangesType, cursorToEnd?: boolean ) => { const quill = quillRef.current; @@ -260,7 +285,7 @@ export function CompositionInput(props: Props): React.ReactElement { return; } - const delta = generateDelta(text || '', mentions || []); + const delta = generateDelta(text || '', bodyRanges || []); canSendRef.current = true; // We need to cast here because we use @types/quill@1.3.10 which has types @@ -288,13 +313,13 @@ export function CompositionInput(props: Props): React.ReactElement { return; } - const [text, mentions] = getTextAndMentions(); + const { text, bodyRanges } = getTextAndRanges(); log.info( - `CompositionInput: Submitting message ${timestamp} with ${mentions.length} mentions` + `CompositionInput: Submitting message ${timestamp} with ${bodyRanges.length} ranges` ); canSendRef.current = false; - onSubmit(text, mentions, timestamp); + onSubmit(text, bodyRanges, timestamp); }; if (inputApi) { @@ -320,6 +345,39 @@ export function CompositionInput(props: Props): React.ReactElement { return false; }; + const previousFormattingEnabled = usePrevious( + isFormattingEnabled, + isFormattingEnabled + ); + const previousFormattingSpoilersEnabled = usePrevious( + isFormattingSpoilersEnabled, + isFormattingSpoilersEnabled + ); + + React.useEffect(() => { + const formattingChanged = + typeof previousFormattingEnabled === 'boolean' && + previousFormattingEnabled !== isFormattingEnabled; + const spoilersChanged = + typeof previousFormattingSpoilersEnabled === 'boolean' && + previousFormattingSpoilersEnabled !== isFormattingSpoilersEnabled; + + const quill = quillRef.current; + const changed = formattingChanged || spoilersChanged; + if (quill && changed) { + quill.getModule('formattingMenu').updateOptions({ + isEnabled: isFormattingEnabled, + isSpoilersEnabled: isFormattingSpoilersEnabled, + }); + } + }, [ + isFormattingEnabled, + isFormattingSpoilersEnabled, + previousFormattingEnabled, + previousFormattingSpoilersEnabled, + quillRef, + ]); + const onEnter = (): boolean => { const quill = quillRef.current; const emojiCompletion = emojiCompletionRef.current; @@ -439,7 +497,7 @@ export function CompositionInput(props: Props): React.ReactElement { const onChange = (): void => { const quill = quillRef.current; - const [text, mentions] = getTextAndMentions(); + const { text, bodyRanges } = getTextAndRanges(); if (quill !== undefined) { const historyModule: HistoryStatic = quill.getModule('history'); @@ -462,7 +520,7 @@ export function CompositionInput(props: Props): React.ReactElement { const selection = quill.getSelection(); onEditorStateChange({ - bodyRanges: mentions, + bodyRanges, caretLocation: selection ? selection.index : undefined, conversationId, messageText: text, @@ -614,6 +672,12 @@ export function CompositionInput(props: Props): React.ReactElement { callbacksRef.current.onPickEmoji(emoji), skinTone, }, + formattingMenu: { + i18n, + isEnabled: isFormattingEnabled, + isSpoilersEnabled: isFormattingSpoilersEnabled, + setFormattingChooserElement, + }, mentionCompletion: { getPreferredBadge, me: sortedGroupMembers @@ -625,7 +689,25 @@ export function CompositionInput(props: Props): React.ReactElement { theme, }, }} - formats={['emoji', 'mention']} + formats={[ + // For image replacement (local-only) + 'emoji', + // @mentions + 'mention', + ...(isFormattingEnabled + ? [ + // Custom + ...(isFormattingSpoilersEnabled + ? [QuillFormattingStyle.spoiler] + : []), + QuillFormattingStyle.monospace, + // Built-in + QuillFormattingStyle.bold, + QuillFormattingStyle.italic, + QuillFormattingStyle.strike, + ] + : []), + ]} placeholder={placeholder || i18n('icu:sendMessage')} readOnly={disabled} ref={element => { @@ -698,6 +780,7 @@ export function CompositionInput(props: Props): React.ReactElement { className={getClassName('__input')} ref={ref} data-testid="CompositionInput" + data-enabled={disabled ? 'false' : 'true'} > {conversationId && linkPreviewLoading && linkPreviewResult && ( {reactQuill} {emojiCompletionElement} + {formattingChooserElement} {mentionCompletionElement} diff --git a/ts/components/CompositionTextArea.tsx b/ts/components/CompositionTextArea.tsx index 22fded53895..50981b4f1eb 100644 --- a/ts/components/CompositionTextArea.tsx +++ b/ts/components/CompositionTextArea.tsx @@ -9,14 +9,20 @@ import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled'; import type { InputApi } from './CompositionInput'; import { CompositionInput } from './CompositionInput'; import { EmojiButton } from './emoji/EmojiButton'; -import type { DraftBodyRangeMention } from '../types/BodyRange'; +import type { + DraftBodyRanges, + HydratedBodyRangesType, +} from '../types/BodyRange'; import type { ThemeType } from '../types/Util'; import type { Props as EmojiButtonProps } from './emoji/EmojiButton'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import * as grapheme from '../util/grapheme'; export type CompositionTextAreaProps = { + bodyRanges?: HydratedBodyRangesType; i18n: LocalizerType; + isFormattingEnabled: boolean; + isFormattingSpoilersEnabled: boolean; maxLength?: number; placeholder?: string; whenToShowRemainingCount?: number; @@ -25,13 +31,13 @@ export type CompositionTextAreaProps = { onPickEmoji: (e: EmojiPickDataType) => void; onChange: ( messageText: string, - draftBodyRanges: ReadonlyArray, + draftBodyRanges: HydratedBodyRangesType, caretLocation?: number | undefined ) => void; onSetSkinTone: (tone: number) => void; onSubmit: ( message: string, - draftBodyRanges: ReadonlyArray, + draftBodyRanges: DraftBodyRanges, timestamp: number ) => void; onTextTooLong: () => void; @@ -48,22 +54,25 @@ export type CompositionTextAreaProps = { * basically a rectangle input with an emoji selector floating at the top-right */ export function CompositionTextArea({ + bodyRanges, + draftText, + getPreferredBadge, i18n, - placeholder, + isFormattingEnabled, + isFormattingSpoilersEnabled, maxLength, - whenToShowRemainingCount = Infinity, - scrollerRef, - onScroll, - onPickEmoji, onChange, + onPickEmoji, + onScroll, onSetSkinTone, onSubmit, onTextTooLong, - getPreferredBadge, - draftText, - theme, + placeholder, recentEmojis, + scrollerRef, skinTone, + theme, + whenToShowRemainingCount = Infinity, }: CompositionTextAreaProps): JSX.Element { const inputApiRef = React.useRef(); const [characterCount, setCharacterCount] = React.useState( @@ -87,7 +96,11 @@ export function CompositionTextArea({ }, [inputApiRef]); const handleChange = React.useCallback( - ({ bodyRanges, caretLocation, messageText: newValue }) => { + ({ + bodyRanges: updatedBodyRanges, + caretLocation, + messageText: newValue, + }) => { const inputEl = inputApiRef.current; if (!inputEl) { return; @@ -108,11 +121,11 @@ export function CompositionTextArea({ // was modifying text in the middle of the editor // a better solution would be to prevent the change to begin with, but // quill makes this VERY difficult - inputEl.setContents(newValueSized, bodyRanges, true); + inputEl.setContents(newValueSized, updatedBodyRanges, true); } } setCharacterCount(newCharacterCount); - onChange(newValue, bodyRanges, caretLocation); + onChange(newValue, updatedBodyRanges, caretLocation); }, [maxLength, onChange] ); @@ -121,10 +134,13 @@ export function CompositionTextArea({
void; + onCancel: () => void; +}; + +export function FormattingWarningModal({ + i18n, + onSendAnyway, + onCancel, +}: PropsType): JSX.Element | null { + return ( + + {i18n('icu:SendFormatting--dialog--body')} + + ); +} diff --git a/ts/components/ForwardMessagesModal.stories.tsx b/ts/components/ForwardMessagesModal.stories.tsx index 24dc5427a05..52f2613864c 100644 --- a/ts/components/ForwardMessagesModal.stories.tsx +++ b/ts/components/ForwardMessagesModal.stories.tsx @@ -59,6 +59,8 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ ; @@ -135,7 +137,14 @@ export function ForwardMessagesModal({ const previews = lonelyLinkPreview ? [lonelyLinkPreview] : []; doForwardMessages(conversationIds, [{ ...lonelyDraft, previews }]); } else { - doForwardMessages(conversationIds, drafts); + doForwardMessages( + conversationIds, + drafts.map(draft => ({ + ...draft, + // We don't keep @mention bodyRanges in multi-forward scenarios + bodyRanges: draft.bodyRanges?.filter(BodyRange.isFormatting), + })) + ); } }, [ drafts, @@ -304,8 +313,8 @@ export function ForwardMessagesModal({ { - onChange([{ ...lonelyDraft, messageBody }]); + onChange={(messageBody, bodyRanges) => { + onChange([{ ...lonelyDraft, messageBody, bodyRanges }]); }} removeLinkPreview={removeLinkPreview} theme={theme} @@ -420,7 +429,11 @@ type ForwardMessageEditorProps = Readonly<{ RenderCompositionTextArea: ( props: SmartCompositionTextAreaProps ) => JSX.Element; - onChange: (messageText: string, caretLocation?: number) => unknown; + onChange: ( + messageText: string, + bodyRanges: HydratedBodyRangesType, + caretLocation?: number + ) => unknown; onSubmit: () => unknown; theme: ThemeType; i18n: LocalizerType; @@ -470,10 +483,9 @@ function ForwardMessageEditor({ ) : null} { - onChange(messageText, caretLocation); - }} + onChange={onChange} onSubmit={onSubmit} theme={theme} /> diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index 27d568337ce..43555627ac9 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -7,15 +7,18 @@ import type { ContactModalStateType, DeleteMessagesPropsType, EditHistoryMessagesType, + FormattingWarningDataType, ForwardMessagesPropsType, SafetyNumberChangedBlockingDataType, UserNotFoundModalStateType, } from '../state/ducks/globalModals'; import type { LocalizerType, ThemeType } from '../types/Util'; +import type { ExplodePromiseResultType } from '../util/explodePromise'; import { missingCaseError } from '../util/missingCaseError'; import { ButtonVariant } from './Button'; import { ConfirmationDialog } from './ConfirmationDialog'; +import { FormattingWarningModal } from './FormattingWarningModal'; import { SignalConnectionsModal } from './SignalConnectionsModal'; import { WhatsNewModal } from './WhatsNewModal'; @@ -42,6 +45,11 @@ export type PropsType = { // DeleteMessageModal deleteMessagesProps: DeleteMessagesPropsType | undefined; renderDeleteMessagesModal: () => JSX.Element; + // FormattingWarningModal + showFormattingWarningModal: ( + explodedPromise: ExplodePromiseResultType | undefined + ) => void; + formattingWarningData: FormattingWarningDataType | undefined; // ForwardMessageModal forwardMessagesProps: ForwardMessagesPropsType | undefined; renderForwardMessagesModal: () => JSX.Element; @@ -99,6 +107,9 @@ export function GlobalModalContainer({ // DeleteMessageModal deleteMessagesProps, renderDeleteMessagesModal, + // FormattingWarningModal + showFormattingWarningModal, + formattingWarningData, // ForwardMessageModal forwardMessagesProps, renderForwardMessagesModal, @@ -169,6 +180,23 @@ export function GlobalModalContainer({ return renderDeleteMessagesModal(); } + if (formattingWarningData) { + const { resolve } = formattingWarningData.explodedPromise; + return ( + { + showFormattingWarningModal(undefined); + resolve(true); + }} + onCancel={() => { + showFormattingWarningModal(undefined); + resolve(false); + }} + /> + ); + } + if (forwardMessagesProps) { return renderForwardMessagesModal(); } diff --git a/ts/components/MediaEditor.stories.tsx b/ts/components/MediaEditor.stories.tsx index 45cde45236d..dd379c48d27 100644 --- a/ts/components/MediaEditor.stories.tsx +++ b/ts/components/MediaEditor.stories.tsx @@ -64,6 +64,8 @@ export function WithCaption(): JSX.Element { ({ hasRelayCalls: false, hasSpellCheck: true, hasStoriesDisabled: false, + hasTextFormatting: true, hasTypingIndicators: true, initialSpellCheckSetting: true, isAudioNotificationsSupported: true, isAutoDownloadUpdatesSupported: true, isAutoLaunchSupported: true, + isFormattingFlagEnabled: true, isHideMenuBarSupported: true, isNotificationAttentionSupported: true, isPhoneNumberSharingSupported: true, @@ -161,6 +163,7 @@ export default { onSelectedSpeakerChange: { action: true }, onSentMediaQualityChange: { action: true }, onSpellCheckChange: { action: true }, + onTextFormattingChange: { action: true }, onThemeChange: { action: true }, onUniversalExpireTimerChange: { action: true }, onWhoCanSeeMeChange: { action: true }, @@ -217,3 +220,8 @@ PNPDiscoverabilityDisabled.args = { PNPDiscoverabilityDisabled.story = { name: 'PNP Discoverability Disabled', }; + +export const FormattingDisabled = Template.bind({}); +FormattingDisabled.args = { + isFormattingFlagEnabled: false, +}; diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index 7b8166d6286..5d49482d396 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -80,6 +80,7 @@ export type PropsDataType = { hasRelayCalls?: boolean; hasSpellCheck: boolean; hasStoriesDisabled: boolean; + hasTextFormatting: boolean; hasTypingIndicators: boolean; lastSyncTime?: number; notificationContent: NotificationSettingType; @@ -98,6 +99,9 @@ export type PropsDataType = { initialSpellCheckSetting: boolean; shouldShowStoriesSettings: boolean; + // Feature flags + isFormattingFlagEnabled: boolean; + // Limited support features isAudioNotificationsSupported: boolean; isAutoDownloadUpdatesSupported: boolean; @@ -162,6 +166,7 @@ type PropsFunctionType = { onSelectedSpeakerChange: SelectChangeHandlerType; onSentMediaQualityChange: SelectChangeHandlerType; onSpellCheckChange: CheckboxChangeHandlerType; + onTextFormattingChange: CheckboxChangeHandlerType; onThemeChange: SelectChangeHandlerType; onUniversalExpireTimerChange: SelectChangeHandlerType; onWhoCanSeeMeChange: SelectChangeHandlerType; @@ -245,12 +250,14 @@ export function Preferences({ hasRelayCalls, hasSpellCheck, hasStoriesDisabled, + hasTextFormatting, hasTypingIndicators, i18n, initialSpellCheckSetting, isAudioNotificationsSupported, isAutoDownloadUpdatesSupported, isAutoLaunchSupported, + isFormattingFlagEnabled, isHideMenuBarSupported, isPhoneNumberSharingSupported, isNotificationAttentionSupported, @@ -284,6 +291,7 @@ export function Preferences({ onSelectedSpeakerChange, onSentMediaQualityChange, onSpellCheckChange, + onTextFormattingChange, onThemeChange, onUniversalExpireTimerChange, onWhoCanSeeMeChange, @@ -550,6 +558,15 @@ export function Preferences({ name="spellcheck" onChange={onSpellCheckChange} /> + {isFormattingFlagEnabled && ( + + )} = {}): Props => ({ i18n, close: action('close'), - hasInstalledStickers: boolean( - 'hasInstalledStickers', - overrideProps.hasInstalledStickers || false - ), - platform: select( - 'platform', - { - macOS: 'darwin', - other: 'other', - }, - overrideProps.platform || 'other' - ), + isFormattingFlagEnabled: + overrideProps.isFormattingFlagEnabled === false + ? overrideProps.isFormattingFlagEnabled + : true, + isFormattingSpoilersFlagEnabled: + overrideProps.isFormattingSpoilersFlagEnabled === false + ? overrideProps.isFormattingSpoilersFlagEnabled + : true, + hasInstalledStickers: overrideProps.hasInstalledStickers === true || false, + platform: overrideProps.platform || 'other', }); export function Default(): JSX.Element { @@ -47,3 +44,13 @@ export function HasStickers(): JSX.Element { const props = createProps({ hasInstalledStickers: true }); return ; } + +export function NoFormatting(): JSX.Element { + const props = createProps({ isFormattingFlagEnabled: false }); + return ; +} + +export function NoSpoilerFormatting(): JSX.Element { + const props = createProps({ isFormattingSpoilersFlagEnabled: false }); + return ; +} diff --git a/ts/components/ShortcutGuide.tsx b/ts/components/ShortcutGuide.tsx index 9e621200e8a..9b0771699e8 100644 --- a/ts/components/ShortcutGuide.tsx +++ b/ts/components/ShortcutGuide.tsx @@ -8,6 +8,8 @@ import type { LocalizerType } from '../types/Util'; export type Props = { hasInstalledStickers: boolean; + isFormattingFlagEnabled: boolean; + isFormattingSpoilersFlagEnabled: boolean; platform: string; readonly close: () => unknown; readonly i18n: LocalizerType; @@ -26,12 +28,15 @@ type KeyType = | ',' | '.' | 'A' + | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' + | 'I' | 'J' + | 'K' | 'L' | 'M' | 'N' @@ -206,8 +211,12 @@ function getMessageShortcuts(i18n: LocalizerType): Array { ]; } -function getComposerShortcuts(i18n: LocalizerType): Array { - return [ +function getComposerShortcuts( + i18n: LocalizerType, + isFormattingFlagEnabled: boolean, + isFormattingSpoilersFlagEnabled: boolean +): Array { + const shortcuts: Array = [ { id: 'Keyboard--add-newline', description: i18n('icu:Keyboard--add-newline'), @@ -216,7 +225,7 @@ function getComposerShortcuts(i18n: LocalizerType): Array { { id: 'Keyboard--expand-composer', description: i18n('icu:Keyboard--expand-composer'), - keys: [['commandOrCtrl', 'shift', 'X']], + keys: [['commandOrCtrl', 'shift', 'K']], }, { id: 'Keyboard--send-in-expanded-composer', @@ -239,6 +248,39 @@ function getComposerShortcuts(i18n: LocalizerType): Array { keys: [['commandOrCtrl', 'shift', 'P']], }, ]; + + if (isFormattingFlagEnabled) { + shortcuts.push({ + id: 'Keyboard--composer--bold', + description: i18n('icu:Keyboard--composer--bold'), + keys: [['commandOrCtrl', 'B']], + }); + shortcuts.push({ + id: 'Keyboard--composer--italic', + description: i18n('icu:Keyboard--composer--italic'), + keys: [['commandOrCtrl', 'I']], + }); + shortcuts.push({ + id: 'Keyboard--composer--strikethrough', + description: i18n('icu:Keyboard--composer--strikethrough'), + keys: [['commandOrCtrl', 'shift', 'X']], + }); + shortcuts.push({ + id: 'Keyboard--composer--monospace', + description: i18n('icu:Keyboard--composer--monospace'), + keys: [['commandOrCtrl', 'E']], + }); + + if (isFormattingSpoilersFlagEnabled) { + shortcuts.push({ + id: 'Keyboard--composer--spoiler', + description: i18n('icu:Keyboard--composer--spoiler'), + keys: [['commandOrCtrl', 'shift', 'B']], + }); + } + } + + return shortcuts; } function getCallingShortcuts(i18n: LocalizerType): Array { @@ -287,7 +329,14 @@ function getCallingShortcuts(i18n: LocalizerType): Array { } export function ShortcutGuide(props: Props): JSX.Element { - const { i18n, close, hasInstalledStickers, platform } = props; + const { + i18n, + close, + hasInstalledStickers, + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled, + platform, + } = props; const isMacOS = platform === 'darwin'; // Restore focus on teardown @@ -345,7 +394,11 @@ export function ShortcutGuide(props: Props): JSX.Element { {i18n('icu:Keyboard--composer-header')}
- {getComposerShortcuts(i18n).map((shortcut, index) => + {getComposerShortcuts( + i18n, + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled + ).map((shortcut, index) => renderShortcut(shortcut, index, isMacOS, i18n) )}
diff --git a/ts/components/ShortcutGuideModal.tsx b/ts/components/ShortcutGuideModal.tsx index aee6f89fb77..28904289a5c 100644 --- a/ts/components/ShortcutGuideModal.tsx +++ b/ts/components/ShortcutGuideModal.tsx @@ -8,6 +8,8 @@ import { ShortcutGuide } from './ShortcutGuide'; export type PropsType = { hasInstalledStickers: boolean; + isFormattingFlagEnabled: boolean; + isFormattingSpoilersFlagEnabled: boolean; platform: string; readonly closeShortcutGuideModal: () => unknown; readonly i18n: LocalizerType; @@ -16,8 +18,14 @@ export type PropsType = { export const ShortcutGuideModal = React.memo(function ShortcutGuideModalInner( props: PropsType ) { - const { i18n, closeShortcutGuideModal, hasInstalledStickers, platform } = - props; + const { + i18n, + closeShortcutGuideModal, + hasInstalledStickers, + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled, + platform, + } = props; const [root, setRoot] = React.useState(null); React.useEffect(() => { @@ -37,6 +45,8 @@ export const ShortcutGuideModal = React.memo(function ShortcutGuideModalInner( diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index eabf563cc9c..c295a5d75db 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -10,7 +10,7 @@ import React, { useState, } from 'react'; import classNames from 'classnames'; -import type { DraftBodyRangeMention } from '../types/BodyRange'; +import type { DraftBodyRanges } from '../types/BodyRange'; import type { LocalizerType } from '../types/Util'; import type { ContextMenuOptionType } from './ContextMenu'; import type { @@ -84,6 +84,8 @@ export type PropsType = { hasAllStoriesUnmuted: boolean; hasViewReceiptSetting: boolean; i18n: LocalizerType; + isFormattingEnabled: boolean; + isFormattingSpoilersEnabled: boolean; isInternalUser?: boolean; isSignalConversation?: boolean; isWindowActive: boolean; @@ -97,7 +99,7 @@ export type PropsType = { onReactToStory: (emoji: string, story: StoryViewType) => unknown; onReplyToStory: ( message: string, - mentions: ReadonlyArray, + bodyRanges: DraftBodyRanges, timestamp: number, story: StoryViewType ) => unknown; @@ -144,6 +146,8 @@ export function StoryViewer({ hasAllStoriesUnmuted, hasViewReceiptSetting, i18n, + isFormattingEnabled, + isFormattingSpoilersEnabled, isInternalUser, isSignalConversation, isWindowActive, @@ -933,6 +937,8 @@ export function StoryViewer({ hasViewsCapability={isSent} i18n={i18n} platform={platform} + isFormattingEnabled={isFormattingEnabled} + isFormattingSpoilersEnabled={isFormattingSpoilersEnabled} isInternalUser={isInternalUser} group={group} onClose={() => setCurrentViewTarget(null)} @@ -944,12 +950,12 @@ export function StoryViewer({ } setReactionEmoji(emoji); }} - onReply={(message, mentions, replyTimestamp) => { + onReply={(message, replyBodyRanges, replyTimestamp) => { if (!isGroupStory) { setCurrentViewTarget(null); showToast({ toastType: ToastType.StoryReply }); } - onReplyToStory(message, mentions, replyTimestamp, story); + onReplyToStory(message, replyBodyRanges, replyTimestamp, story); }} onSetSkinTone={onSetSkinTone} onTextTooLong={onTextTooLong} diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx index 33e3ee76f41..4879eb5d5f3 100644 --- a/ts/components/StoryViewsNRepliesModal.tsx +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -11,7 +11,7 @@ import React, { import classNames from 'classnames'; import { noop } from 'lodash'; -import type { DraftBodyRangeMention } from '../types/BodyRange'; +import type { DraftBodyRanges } from '../types/BodyRange'; import type { LocalizerType } from '../types/Util'; import type { ConversationType } from '../state/ducks/conversations'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; @@ -89,13 +89,15 @@ export type PropsType = { hasViewsCapability: boolean; i18n: LocalizerType; platform: string; + isFormattingEnabled: boolean; + isFormattingSpoilersEnabled: boolean; isInternalUser?: boolean; onChangeViewTarget: (target: StoryViewTargetType) => unknown; onClose: () => unknown; onReact: (emoji: string) => unknown; onReply: ( message: string, - mentions: ReadonlyArray, + bodyRanges: DraftBodyRanges, timestamp: number ) => unknown; onSetSkinTone: (tone: number) => unknown; @@ -123,6 +125,8 @@ export function StoryViewsNRepliesModal({ hasViewsCapability, i18n, platform, + isFormattingEnabled, + isFormattingSpoilersEnabled, isInternalUser, onChangeViewTarget, onClose, @@ -233,6 +237,8 @@ export function StoryViewsNRepliesModal({ getPreferredBadge={getPreferredBadge} i18n={i18n} inputApi={inputApiRef} + isFormattingEnabled={isFormattingEnabled} + isFormattingSpoilersEnabled={isFormattingSpoilersEnabled} moduleClassName="StoryViewsNRepliesModal__input" onEditorStateChange={({ messageText }) => { setMessageBodyText(messageText); diff --git a/ts/components/conversation/MessageTextRenderer.tsx b/ts/components/conversation/MessageTextRenderer.tsx index dc46f8eae9f..96d2c2c82e0 100644 --- a/ts/components/conversation/MessageTextRenderer.tsx +++ b/ts/components/conversation/MessageTextRenderer.tsx @@ -196,7 +196,7 @@ function renderNode({ ); } - const content = renderMentions({ + let content = renderMentions({ direction, disableLinks, emojiSizeClass, @@ -206,13 +206,19 @@ function renderNode({ text: node.text, }); + // We use separate elements for these because we want screenreaders to understand them + if (node.isBold || node.isKeywordHighlight) { + content = {content}; + } + if (node.isItalic) { + content = {content}; + } + if (node.isStrikethrough) { + content = {content}; + } + const formattingClasses = classNames( - node.isBold ? 'MessageTextRenderer__formatting--bold' : null, - node.isItalic ? 'MessageTextRenderer__formatting--italic' : null, node.isMonospace ? 'MessageTextRenderer__formatting--monospace' : null, - node.isStrikethrough - ? 'MessageTextRenderer__formatting--strikethrough' - : null, node.isKeywordHighlight ? 'MessageTextRenderer__formatting--keywordHighlight' : null, diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index 0c80cbf7f63..aaa74f32103 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -22,7 +22,7 @@ import type { ReactionType, } from '../../textsecure/SendMessage'; import type { LinkPreviewType } from '../../types/message/LinkPreviews'; -import { BodyRange } from '../../types/BodyRange'; +import type { RawBodyRange } from '../../types/BodyRange'; import type { StoryContextType } from '../../types/Util'; import type { LoggerType } from '../../types/Logging'; import type { StickerWithHydratedData } from '../../types/Stickers'; @@ -150,7 +150,7 @@ export async function sendNormalMessage( contact, deletedForEveryoneTimestamp, expireTimer, - mentions, + bodyRanges, messageTimestamp, preview, quote, @@ -208,6 +208,7 @@ export async function sendNormalMessage( const dataMessage = await messaging.getDataMessage({ attachments, body, + bodyRanges, contact, deletedForEveryoneTimestamp, expireTimer, @@ -252,6 +253,7 @@ export async function sendNormalMessage( contentHint: ContentHint.RESENDABLE, groupSendOptions: { attachments, + bodyRanges, contact, deletedForEveryoneTimestamp, expireTimer, @@ -267,7 +269,6 @@ export async function sendNormalMessage( storyContext, reaction, timestamp: messageTimestamp, - mentions, }, messageId, sendOptions, @@ -307,6 +308,7 @@ export async function sendNormalMessage( log.info('sending direct message'); innerPromise = messaging.sendMessageToIdentifier({ attachments, + bodyRanges, contact, contentHint: ContentHint.RESENDABLE, deletedForEveryoneTimestamp, @@ -472,7 +474,7 @@ async function getMessageSendData({ contact?: Array; deletedForEveryoneTimestamp: undefined | number; expireTimer: undefined | DurationInSeconds; - mentions: undefined | ReadonlyArray>; + bodyRanges: undefined | ReadonlyArray; messageTimestamp: number; preview: Array; quote: QuotedMessageType | null; @@ -539,7 +541,8 @@ async function getMessageSendData({ contact, deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'), expireTimer: message.get('expireTimer'), - mentions: message.get('bodyRanges')?.filter(BodyRange.isMention), + // TODO: we want filtration here if feature flag doesn't allow format/spoiler sends + bodyRanges: message.get('bodyRanges'), messageTimestamp, preview, quote, diff --git a/ts/main/settingsChannel.ts b/ts/main/settingsChannel.ts index 24aad04dc6f..9135c4fec4e 100644 --- a/ts/main/settingsChannel.ts +++ b/ts/main/settingsChannel.ts @@ -62,6 +62,7 @@ export class SettingsChannel extends EventEmitter { this.installCallback('isPrimary'); this.installCallback('syncRequest'); this.installCallback('isPhoneNumberSharingEnabled'); + this.installCallback('isFormattingFlagEnabled'); this.installCallback('shouldShowStoriesSettings'); // Getters only. These are set by the primary device @@ -87,6 +88,7 @@ export class SettingsChannel extends EventEmitter { this.installSetting('spellCheck', { isEphemeral: true, }); + this.installSetting('textFormatting'); this.installSetting('autoDownloadUpdate'); this.installSetting('autoLaunch'); diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 92721770a36..5712a468475 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -6,7 +6,7 @@ import * as Backbone from 'backbone'; import type { GroupV2ChangeType } from './groups'; -import type { DraftBodyRangeMention, RawBodyRange } from './types/BodyRange'; +import type { DraftBodyRanges, RawBodyRange } from './types/BodyRange'; import type { CallHistoryDetailsFromDiskType } from './types/Calling'; import type { CustomColorType, ConversationColorType } from './types/Colors'; import type { DeviceType } from './textsecure/Types.d'; @@ -298,7 +298,7 @@ export type ConversationAttributesType = { firstUnregisteredAt?: number; draftChanged?: boolean; draftAttachments?: ReadonlyArray; - draftBodyRanges?: ReadonlyArray; + draftBodyRanges?: DraftBodyRanges; draftTimestamp?: number | null; hideStory?: boolean; inbox_position?: number; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index fe8188bed8c..cfd16bee70f 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -84,7 +84,7 @@ import { deriveAccessKey, } from '../Crypto'; import * as Bytes from '../Bytes'; -import type { DraftBodyRangeMention } from '../types/BodyRange'; +import type { DraftBodyRanges } from '../types/BodyRange'; import { BodyRange, hydrateRanges } from '../types/BodyRange'; import { migrateColor } from '../util/migrateColor'; import { isNotNil } from '../util/isNotNil'; @@ -3870,7 +3870,7 @@ export class ConversationModel extends window.Backbone } private getDraftBodyRanges = memoizeByThis( - (): ReadonlyArray | undefined => { + (): DraftBodyRanges | undefined => { return this.get('draftBodyRanges'); } ); @@ -4133,7 +4133,7 @@ export class ConversationModel extends window.Backbone attachments, body, contact, - mentions, + bodyRanges, preview, quote, sticker, @@ -4141,7 +4141,7 @@ export class ConversationModel extends window.Backbone attachments: Array; body: string | undefined; contact?: Array; - mentions?: ReadonlyArray>; + bodyRanges?: DraftBodyRanges; preview?: Array; quote?: QuotedMessageType; sticker?: StickerWithHydratedData; @@ -4239,7 +4239,7 @@ export class ConversationModel extends window.Backbone readStatus: ReadStatus.Read, seenStatus: SeenStatus.NotApplicable, sticker, - bodyRanges: mentions, + bodyRanges, sendHQImages, sendStateByConversationId: zipObject( recipientConversationIds, diff --git a/ts/quill/formatting/menu.tsx b/ts/quill/formatting/menu.tsx new file mode 100644 index 00000000000..16054b1fc57 --- /dev/null +++ b/ts/quill/formatting/menu.tsx @@ -0,0 +1,323 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type Quill from 'quill'; +import React from 'react'; +import classNames from 'classnames'; +import { Popper } from 'react-popper'; +import { createPortal } from 'react-dom'; +import type { VirtualElement } from '@popperjs/core'; + +import * as log from '../../logging/log'; +import * as Errors from '../../types/errors'; +import type { LocalizerType } from '../../types/Util'; +import { handleOutsideClick } from '../../util/handleOutsideClick'; + +type FormattingPickerOptions = { + i18n: LocalizerType; + isEnabled: boolean; + isSpoilersEnabled: boolean; + setFormattingChooserElement: (element: JSX.Element | null) => void; +}; + +export enum QuillFormattingStyle { + bold = 'bold', + italic = 'italic', + monospace = 'monospace', + strike = 'strike', + spoiler = 'spoiler', +} + +export class FormattingMenu { + lastSelection: { start: number; end: number } | undefined; + + options: FormattingPickerOptions; + + outsideClickDestructor?: () => void; + + quill: Quill; + + referenceElement: VirtualElement | undefined; + + root: HTMLDivElement; + + constructor(quill: Quill, options: FormattingPickerOptions) { + this.quill = quill; + this.options = options; + this.root = document.body.appendChild(document.createElement('div')); + + this.quill.on('editor-change', this.onEditorChange.bind(this)); + + // Note: Bold and Italic are built-in + + this.quill.keyboard.addBinding({ key: 'E', shortKey: true }, () => + this.toggleForStyle(QuillFormattingStyle.monospace) + ); + this.quill.keyboard.addBinding( + { key: 'X', shortKey: true, shiftKey: true }, + () => this.toggleForStyle(QuillFormattingStyle.strike) + ); + this.quill.keyboard.addBinding( + { key: 'B', shortKey: true, shiftKey: true }, + () => this.toggleForStyle(QuillFormattingStyle.spoiler) + ); + } + + destroy(): void { + this.root.remove(); + } + + updateOptions(options: Partial): void { + this.options = { ...this.options, ...options }; + this.onEditorChange(); + } + + onEditorChange(): void { + if (!this.options.isEnabled) { + this.lastSelection = undefined; + this.referenceElement = undefined; + this.render(); + + return; + } + + const isFocused = this.quill.hasFocus(); + if (!isFocused) { + this.lastSelection = undefined; + this.referenceElement = undefined; + this.render(); + + return; + } + + const previousSelection = this.lastSelection; + const quillSelection = this.quill.getSelection(); + this.lastSelection = + quillSelection && quillSelection.length > 0 + ? { + start: quillSelection.index, + end: quillSelection.index + quillSelection.length, + } + : undefined; + + if (!this.lastSelection) { + this.referenceElement = undefined; + } else { + const noOverlapWithNewSelection = + previousSelection && + (this.lastSelection.end < previousSelection.start || + this.lastSelection.start > previousSelection.end); + const newSelectionStartsEarlier = + previousSelection && this.lastSelection.start < previousSelection.start; + + if (noOverlapWithNewSelection || newSelectionStartsEarlier) { + this.referenceElement = undefined; + } + // a virtual reference to the text we are trying to format + this.referenceElement = this.referenceElement || { + getBoundingClientRect() { + const selection = window.getSelection(); + + // there's a selection and at least one range + if (selection != null && selection.rangeCount !== 0) { + // grab the first range, the one the user is actually on right now + const range = selection.getRangeAt(0); + + const { activeElement } = document; + const editorElement = activeElement?.closest( + '.module-composition-input__input' + ); + + const rect = range.getClientRects()[0]; + + // If we've scrolled down and the top of the composer text is invisible, above + // where the editor ends, we fix the popover so it stays connected to the + // visible editor. Important for the 'Cmd-A' scenario when scrolled down. + const updatedY = Math.max( + editorElement?.getClientRects()[0]?.y || 0, + rect.y + ); + + return DOMRect.fromRect({ + x: rect.x, + y: updatedY, + height: rect.height, + width: rect.width, + }); + } + log.warn('No selection range when formatting text'); + return new DOMRect(); // don't crash just because we couldn't get a rectangle + }, + }; + } + + this.render(); + } + + isStyleEnabledInSelection(style: QuillFormattingStyle): boolean | undefined { + const selection = this.quill.getSelection(); + if (!selection || !selection.length) { + return; + } + const contents = this.quill.getContents(selection.index, selection.length); + return contents.ops.every(op => op.attributes?.[style]); + } + + toggleForStyle(style: QuillFormattingStyle): void { + try { + const isEnabled = this.isStyleEnabledInSelection(style); + if (isEnabled === undefined) { + return; + } + this.quill.format(style, !isEnabled); + } catch (error) { + log.error('toggleForStyle error:', Errors.toLogFormat(error)); + } + } + + render(): void { + if (!this.lastSelection) { + this.outsideClickDestructor?.(); + this.outsideClickDestructor = undefined; + + this.options.setFormattingChooserElement(null); + + return; + } + + const { i18n, isSpoilersEnabled } = this.options; + + // showing the popup format menu + const element = createPortal( + + {({ ref, style }) => ( +
+ + + + + {isSpoilersEnabled ? ( + + ) : null} +
+ )} +
, + this.root + ); + + // Just to make sure that we don't propagate outside clicks until this is closed. + this.outsideClickDestructor?.(); + this.outsideClickDestructor = handleOutsideClick( + () => { + return true; + }, + { + name: 'quill.emoji.completion', + containerElements: [this.root], + } + ); + + this.options.setFormattingChooserElement(element); + } +} diff --git a/ts/quill/formatting/monospaceBlot.ts b/ts/quill/formatting/monospaceBlot.ts new file mode 100644 index 00000000000..6bba7bf4ff5 --- /dev/null +++ b/ts/quill/formatting/monospaceBlot.ts @@ -0,0 +1,30 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type Parchment from 'parchment'; +import Quill from 'quill'; + +const Inline: typeof Parchment.Inline = Quill.import('blots/inline'); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyRecord = Record; + +export class MonospaceBlot extends Inline { + static override formats(): boolean { + return true; + } + + override optimize(context: AnyRecord): void { + super.optimize(context); + if (!this.domNode.classList.contains(this.statics.className)) { + this.domNode.classList.add(this.statics.className); + } + } +} + +MonospaceBlot.blotName = 'monospace'; +MonospaceBlot.className = 'quill--monospace'; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore See this workaround: https://github.com/quilljs/quill/issues/2312#issuecomment-1097922620 +Inline.order.splice(Inline.order.indexOf('bold'), 0, MonospaceBlot.blotName); diff --git a/ts/quill/formatting/spoilerBlot.ts b/ts/quill/formatting/spoilerBlot.ts new file mode 100644 index 00000000000..7d9e5554b65 --- /dev/null +++ b/ts/quill/formatting/spoilerBlot.ts @@ -0,0 +1,30 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type Parchment from 'parchment'; +import Quill from 'quill'; + +const Inline: typeof Parchment.Inline = Quill.import('blots/inline'); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyRecord = Record; + +export class SpoilerBlot extends Inline { + static override formats(): boolean { + return true; + } + + override optimize(context: AnyRecord): void { + super.optimize(context); + if (!this.domNode.classList.contains(this.statics.className)) { + this.domNode.classList.add(this.statics.className); + } + } +} + +SpoilerBlot.blotName = 'spoiler'; +SpoilerBlot.className = 'quill--spoiler'; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore See this workaround: https://github.com/quilljs/quill/issues/2312#issuecomment-1097922620 +Inline.order.splice(Inline.order.indexOf('bold'), 0, SpoilerBlot.blotName); diff --git a/ts/quill/types.d.ts b/ts/quill/types.d.ts index fc969fb2d45..1396253ca7f 100644 --- a/ts/quill/types.d.ts +++ b/ts/quill/types.d.ts @@ -4,6 +4,7 @@ import type UpdatedDelta from 'quill-delta'; import type { MentionCompletion } from './mentions/completion'; import type { EmojiCompletion } from './emoji/completion'; +import type { FormattingMenu } from './formatting/menu'; declare module 'react-quill' { // `react-quill` uses a different but compatible version of Delta @@ -21,6 +22,7 @@ declare module 'quill' { interface UpdatedKey { key: string | number; shiftKey?: boolean; + shortKey?: boolean; } export type UpdatedTextChangeHandler = ( @@ -29,6 +31,10 @@ declare module 'quill' { source: Sources ) => void; + export type UpdatedEditorChangeHandler = ( + eventName: 'text-change' | 'selection-change' + ) => void; + interface LeafBlot { text?: string; // Quill doesn't make it easy to type this result. @@ -61,11 +67,16 @@ declare module 'quill' { eventName: 'text-change', handler: UpdatedTextChangeHandler ): EventEmitter; + on( + eventName: 'editor-change', + handler: UpdatedEditorChangeHandler + ): EventEmitter; - getModule(module: 'history'): HistoryStatic; getModule(module: 'clipboard'): ClipboardStatic; - getModule(module: 'mentionCompletion'): MentionCompletion; getModule(module: 'emojiCompletion'): EmojiCompletion; + getModule(module: 'formattingMenu'): FormattingMenu; + getModule(module: 'history'): HistoryStatic; + getModule(module: 'mentionCompletion'): MentionCompletion; getModule(module: string): unknown; selection: SelectionStatic; diff --git a/ts/quill/util.ts b/ts/quill/util.ts index c7af31142e5..8c57a904da5 100644 --- a/ts/quill/util.ts +++ b/ts/quill/util.ts @@ -6,18 +6,30 @@ import Delta from 'quill-delta'; import type { LeafBlot, DeltaOperation } from 'quill'; import type Op from 'quill-delta/dist/Op'; -import type { DraftBodyRangeMention } from '../types/BodyRange'; +import type { + DisplayNode, + DraftBodyRange, + DraftBodyRanges, +} from '../types/BodyRange'; import { BodyRange } from '../types/BodyRange'; import type { MentionBlot } from './mentions/blot'; +import { QuillFormattingStyle } from './formatting/menu'; export type MentionBlotValue = { uuid: string; title: string; }; +export type FormattingBlotValue = { + style: BodyRange.Style; +}; + export const isMentionBlot = (blot: LeafBlot): blot is MentionBlot => blot.value() && blot.value().mention; +export const isFormatting = (blot: LeafBlot): blot is MentionBlot => + blot.value() && blot.value().style; + export type RetainOp = Op & { retain: number }; export type InsertOp = Op & { insert: { [V in K]: T } }; @@ -60,13 +72,102 @@ export const getTextFromOps = (ops: Array): string => }, '') .trim(); -export const getTextAndMentionsFromOps = ( +const { BOLD, ITALIC, MONOSPACE, SPOILER, STRIKETHROUGH, NONE } = + BodyRange.Style; + +function extractFormatRange({ + bodyRanges, + index, + previousData, + hasStyle, + style, +}: { + bodyRanges: Array; + index: number; + previousData: { start: number } | undefined; + hasStyle: boolean; + style: BodyRange.Style; +}) { + if (hasStyle && !previousData) { + return { start: index }; + } + if (!hasStyle && previousData) { + const { start } = previousData; + bodyRanges.push({ + length: index - start, + start, + style, + }); + return undefined; + } + + return previousData; +} + +function extractAllFormats( + bodyRanges: Array, + formats: Record, + index: number, + op?: Op +): Record { + const result = { ...formats }; + const params = { + bodyRanges, + index, + }; + + result[BOLD] = extractFormatRange({ + ...params, + style: BOLD, + previousData: result[BOLD], + hasStyle: op?.attributes?.[QuillFormattingStyle.bold], + }); + result[ITALIC] = extractFormatRange({ + ...params, + style: ITALIC, + previousData: result[ITALIC], + hasStyle: op?.attributes?.[QuillFormattingStyle.italic], + }); + result[MONOSPACE] = extractFormatRange({ + ...params, + style: MONOSPACE, + previousData: result[MONOSPACE], + hasStyle: op?.attributes?.[QuillFormattingStyle.monospace], + }); + result[SPOILER] = extractFormatRange({ + ...params, + style: SPOILER, + previousData: result[SPOILER], + hasStyle: op?.attributes?.[QuillFormattingStyle.spoiler], + }); + result[STRIKETHROUGH] = extractFormatRange({ + ...params, + style: STRIKETHROUGH, + previousData: result[STRIKETHROUGH], + hasStyle: op?.attributes?.[QuillFormattingStyle.strike], + }); + + return result; +} + +export const getTextAndRangesFromOps = ( ops: Array -): [string, ReadonlyArray] => { - const mentions: Array = []; +): { text: string; bodyRanges: DraftBodyRanges } => { + const bodyRanges: Array = []; + let formats: Record = { + [BOLD]: undefined, + [ITALIC]: undefined, + [MONOSPACE]: undefined, + [SPOILER]: undefined, + [STRIKETHROUGH]: undefined, + [NONE]: undefined, + }; const text = ops .reduce((acc, op, index) => { + // Start or finish format sections as needed + formats = extractAllFormats(bodyRanges, formats, acc.length, op); + if (typeof op.insert === 'string') { const toAdd = index === 0 ? op.insert.trimStart() : op.insert; return acc + toAdd; @@ -77,7 +178,7 @@ export const getTextAndMentionsFromOps = ( } if (isInsertMentionOp(op)) { - mentions.push({ + bodyRanges.push({ length: 1, // The length of `\uFFFC` mentionUuid: op.insert.mention.uuid, replacementText: op.insert.mention.title, @@ -91,7 +192,10 @@ export const getTextAndMentionsFromOps = ( }, '') .trimEnd(); // Trimming the start of this string will mess up mention indices - return [text, mentions]; + // Close off any pending formats + extractAllFormats(bodyRanges, formats, text.length); + + return { text, bodyRanges }; }; export const getBlotTextPartitions = ( @@ -167,13 +271,35 @@ export const getDeltaToRemoveStaleMentions = ( return new Delta(newOps); }; +export const insertFormattingAndMentionsOps = ( + nodes: ReadonlyArray +): ReadonlyArray => { + let ops: Array = []; + + nodes.forEach(node => { + const startingOp: Op = { + insert: node.text, + attributes: { + [QuillFormattingStyle.bold]: node.isBold, + [QuillFormattingStyle.italic]: node.isItalic, + [QuillFormattingStyle.monospace]: node.isMonospace, + [QuillFormattingStyle.spoiler]: node.isSpoiler, + [QuillFormattingStyle.strike]: node.isStrikethrough, + }, + }; + ops = ops.concat(insertMentionOps([startingOp], node.mentions)); + }); + + return ops; +}; + export const insertMentionOps = ( incomingOps: Array, - bodyRanges: ReadonlyArray + bodyRanges: DraftBodyRanges ): Array => { const ops = [...incomingOps]; - const sortableBodyRanges: Array = bodyRanges.slice(); + const sortableBodyRanges: Array = bodyRanges.slice(); // Working backwards through bodyRanges (to avoid offsetting later mentions), // Shift off the op with the text to the left of the last mention, @@ -191,7 +317,7 @@ export const insertMentionOps = ( const op = ops.shift(); if (op) { - const { insert } = op; + const { insert, attributes } = op; if (typeof insert === 'string') { const left = insert.slice(0, start); @@ -202,9 +328,9 @@ export const insertMentionOps = ( title: replacementText, }; - ops.unshift({ insert: right }); - ops.unshift({ insert: { mention } }); - ops.unshift({ insert: left }); + ops.unshift({ insert: right, attributes }); + ops.unshift({ insert: { mention }, attributes }); + ops.unshift({ insert: left, attributes }); } else { ops.unshift(op); } @@ -214,10 +340,11 @@ export const insertMentionOps = ( return ops; }; -export const insertEmojiOps = (incomingOps: Array): Array => { +export const insertEmojiOps = (incomingOps: ReadonlyArray): Array => { return incomingOps.reduce((ops, op) => { if (typeof op.insert === 'string') { const text = op.insert; + const { attributes } = op; const re = emojiRegex(); let index = 0; let match: RegExpExecArray | null; @@ -225,12 +352,12 @@ export const insertEmojiOps = (incomingOps: Array): Array => { // eslint-disable-next-line no-cond-assign while ((match = re.exec(text))) { const [emoji] = match; - ops.push({ insert: text.slice(index, match.index) }); - ops.push({ insert: { emoji } }); + ops.push({ insert: text.slice(index, match.index), attributes }); + ops.push({ insert: { emoji }, attributes }); index = match.index + emoji.length; } - ops.push({ insert: text.slice(index, text.length) }); + ops.push({ insert: text.slice(index, text.length), attributes }); } else { ops.push(op); } diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index 065dc3a4f32..afa5cf93416 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -3,7 +3,7 @@ import path from 'path'; -import { debounce } from 'lodash'; +import { debounce, isEqual } from 'lodash'; import type { ThunkAction } from 'redux-thunk'; import type { ReadonlyDeep } from 'type-fest'; @@ -17,7 +17,7 @@ import type { InMemoryAttachmentDraftType, } from '../../types/Attachment'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; -import type { DraftBodyRangeMention } from '../../types/BodyRange'; +import type { DraftBodyRanges } from '../../types/BodyRange'; import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import type { MessageAttributesType } from '../../model-types.d'; import type { NoopActionType } from './noop'; @@ -87,6 +87,7 @@ import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper'; import { drop } from '../../util/drop'; import { strictAssert } from '../../util/assert'; import { makeQuote } from '../../util/makeQuote'; +import { maybeBlockSendForFormattingModal } from '../../util/maybeBlockSendForFormattingModal'; // State // eslint-disable-next-line local-rules/type-alias-readonlydeep @@ -380,7 +381,7 @@ function sendMultiMediaMessage( conversationId: string, options: { draftAttachments?: ReadonlyArray; - draftBodyRanges?: ReadonlyArray; + bodyRanges?: DraftBodyRanges; message?: string; timestamp?: number; voiceNoteAttachment?: InMemoryAttachmentDraftType; @@ -404,7 +405,7 @@ function sendMultiMediaMessage( const { draftAttachments, - draftBodyRanges, + bodyRanges, message = '', timestamp = Date.now(), voiceNoteAttachment, @@ -430,7 +431,28 @@ function sendMultiMediaMessage( } } catch (error) { dispatch(setComposerDisabledState(conversationId, false)); - log.error('sendMessage error:', Errors.toLogFormat(error)); + log.error( + 'sendMessage block until verified error:', + Errors.toLogFormat(error) + ); + return; + } + + try { + if (bodyRanges?.length && !window.storage.get('formattingWarningShown')) { + const sendAnyway = await maybeBlockSendForFormattingModal(bodyRanges); + if (!sendAnyway) { + dispatch(setComposerDisabledState(conversationId, false)); + return; + } + drop(window.storage.put('formattingWarningShown', true)); + } + } catch (error) { + dispatch(setComposerDisabledState(conversationId, false)); + log.error( + 'sendMessage block for formatting modal:', + Errors.toLogFormat(error) + ); return; } @@ -493,7 +515,7 @@ function sendMultiMediaMessage( attachments, quote, preview: getLinkPreviewForSend(message), - mentions: draftBodyRanges, + bodyRanges, }, { sendHQImages, @@ -810,7 +832,7 @@ function onEditorStateChange({ messageText, sendCounter, }: { - bodyRanges: ReadonlyArray; + bodyRanges: DraftBodyRanges; caretLocation?: number; conversationId: string | undefined; messageText: string; @@ -1163,7 +1185,7 @@ const debouncedSaveDraft = debounce(saveDraft); function saveDraft( conversationId: string, messageText: string, - mentions: ReadonlyArray + bodyRanges: DraftBodyRanges ) { const conversation = window.ConversationController.get(conversationId); if (!conversation) { @@ -1183,7 +1205,10 @@ function saveDraft( return; } - if (messageText !== conversation.get('draft')) { + if ( + messageText !== conversation.get('draft') || + !isEqual(bodyRanges, conversation.get('draftBodyRanges')) + ) { log.info(`saveDraft(${conversation.idForLogging()})`); const now = Date.now(); let activeAt = conversation.get('active_at'); @@ -1197,7 +1222,7 @@ function saveDraft( conversation.set({ active_at: activeAt, draft: messageText, - draftBodyRanges: mentions, + draftBodyRanges: bodyRanges, draftChanged: true, timestamp, }); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 97d11a0fe11..cff06d29956 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -56,7 +56,7 @@ import type { MessageAttributesType, } from '../../model-types.d'; import type { - DraftBodyRangeMention, + DraftBodyRanges, HydratedBodyRangesType, } from '../../types/BodyRange'; import { CallMode } from '../../types/Calling'; @@ -288,7 +288,7 @@ export type ConversationType = ReadonlyDeep< shouldShowDraft?: boolean; // Full information for re-hydrating composition area draftText?: string; - draftBodyRanges?: ReadonlyArray; + draftBodyRanges?: DraftBodyRanges; // Summary for the left pane draftPreview?: DraftPreviewType; diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index e8181a8dc0e..92cbbc75554 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -59,6 +59,9 @@ export type SafetyNumberChangedBlockingDataType = ReadonlyDeep<{ promiseUuid: UUIDStringType; source?: SafetyNumberChangeSource; }>; +export type FormattingWarningDataType = ReadonlyDeep<{ + explodedPromise: ExplodePromiseResultType; +}>; export type AuthorizeArtCreatorDataType = ReadonlyDeep; @@ -72,27 +75,28 @@ type MigrateToGV2PropsType = ReadonlyDeep<{ export type GlobalModalsStateType = ReadonlyDeep<{ addUserToAnotherGroupModalContactId?: string; + authArtCreatorData?: AuthorizeArtCreatorDataType; contactModalState?: ContactModalStateType; + deleteMessagesProps?: DeleteMessagesPropsType; editHistoryMessages?: EditHistoryMessagesType; errorModalProps?: { description?: string; title?: string; }; - deleteMessagesProps?: DeleteMessagesPropsType; + formattingWarningData?: FormattingWarningDataType; forwardMessagesProps?: ForwardMessagesPropsType; gv2MigrationProps?: MigrateToGV2PropsType; hasConfirmationModal: boolean; + isAuthorizingArtCreator?: boolean; isProfileEditorVisible: boolean; - isSignalConnectionsVisible: boolean; isShortcutGuideModalVisible: boolean; + isSignalConnectionsVisible: boolean; isStoriesSettingsVisible: boolean; isWhatsNewVisible: boolean; profileEditorHasError: boolean; safetyNumberChangedBlockingData?: SafetyNumberChangedBlockingDataType; safetyNumberModalContactId?: string; stickerPackPreviewId?: string; - isAuthorizingArtCreator?: boolean; - authArtCreatorData?: AuthorizeArtCreatorDataType; userNotFoundModalState?: UserNotFoundModalStateType; }>; @@ -126,6 +130,8 @@ const SHOW_STICKER_PACK_PREVIEW = 'globalModals/SHOW_STICKER_PACK_PREVIEW'; const CLOSE_STICKER_PACK_PREVIEW = 'globalModals/CLOSE_STICKER_PACK_PREVIEW'; const CLOSE_ERROR_MODAL = 'globalModals/CLOSE_ERROR_MODAL'; const SHOW_ERROR_MODAL = 'globalModals/SHOW_ERROR_MODAL'; +const SHOW_FORMATTING_WARNING_MODAL = + 'globalModals/SHOW_FORMATTING_WARNING_MODAL'; const CLOSE_SHORTCUT_GUIDE_MODAL = 'globalModals/CLOSE_SHORTCUT_GUIDE_MODAL'; const SHOW_SHORTCUT_GUIDE_MODAL = 'globalModals/SHOW_SHORTCUT_GUIDE_MODAL'; const SHOW_AUTH_ART_CREATOR = 'globalModals/SHOW_AUTH_ART_CREATOR'; @@ -221,6 +227,13 @@ type ShowStoriesSettingsActionType = ReadonlyDeep<{ type: typeof SHOW_STORIES_SETTINGS; }>; +type ShowFormattingWarningModalActionType = ReadonlyDeep<{ + type: typeof SHOW_FORMATTING_WARNING_MODAL; + payload: { + explodedPromise: ExplodePromiseResultType | undefined; + }; +}>; + type HideStoriesSettingsActionType = ReadonlyDeep<{ type: typeof HIDE_STORIES_SETTINGS; }>; @@ -323,6 +336,7 @@ export type GlobalModalsActionType = ReadonlyDeep< | ShowContactModalActionType | ShowEditHistoryModalActionType | ShowErrorModalActionType + | ShowFormattingWarningModalActionType | ShowSendAnywayDialogActionType | ShowShortcutGuideModalActionType | ShowStickerPackPreviewActionType @@ -331,13 +345,13 @@ export type GlobalModalsActionType = ReadonlyDeep< | ShowWhatsNewModalActionType | StartMigrationToGV2ActionType | ToggleAddUserToAnotherGroupModalActionType + | ToggleConfirmationModalActionType | ToggleDeleteMessagesModalActionType | ToggleForwardMessagesModalActionType | ToggleProfileEditorActionType | ToggleProfileEditorErrorActionType | ToggleSafetyNumberModalActionType | ToggleSignalConnectionsModalActionType - | ToggleConfirmationModalActionType >; // Action Creators @@ -360,6 +374,7 @@ export const actions = { showContactModal, showEditHistoryModal, showErrorModal, + showFormattingWarningModal, showGV2MigrationDialog, showShortcutGuideModal, showStickerPackPreview, @@ -434,6 +449,12 @@ function showStoriesSettings(): ShowStoriesSettingsActionType { return { type: SHOW_STORIES_SETTINGS }; } +function showFormattingWarningModal( + explodedPromise: ExplodePromiseResultType | undefined +): ShowFormattingWarningModalActionType { + return { type: SHOW_FORMATTING_WARNING_MODAL, payload: { explodedPromise } }; +} + function showGV2MigrationDialog( conversationId: string ): ThunkAction { @@ -944,6 +965,21 @@ export function reducer( }; } + if (action.type === SHOW_FORMATTING_WARNING_MODAL) { + const { explodedPromise } = action.payload; + if (!explodedPromise) { + return { + ...state, + formattingWarningData: undefined, + }; + } + + return { + ...state, + formattingWarningData: { explodedPromise }, + }; + } + if (action.type === SHOW_STICKER_PACK_PREVIEW) { return { ...state, diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index 441ea4d7e95..10a87297d1c 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -7,7 +7,7 @@ import { isEqual, pick } from 'lodash'; import type { ReadonlyDeep } from 'type-fest'; import * as Errors from '../../types/errors'; import type { AttachmentType } from '../../types/Attachment'; -import type { DraftBodyRangeMention } from '../../types/BodyRange'; +import type { DraftBodyRanges } from '../../types/BodyRange'; import type { MessageAttributesType } from '../../model-types.d'; import type { MessageChangedActionType, @@ -559,7 +559,7 @@ function reactToStory( function replyToStory( conversationId: string, messageBody: string, - mentions: ReadonlyArray, + bodyRanges: DraftBodyRanges, timestamp: number, story: StoryViewType ): ThunkAction { @@ -575,7 +575,7 @@ function replyToStory( { body: messageBody, attachments: [], - mentions, + bodyRanges, }, { storyId: story.messageId, diff --git a/ts/state/selectors/composer.ts b/ts/state/selectors/composer.ts index f7a62eae60a..09539041d65 100644 --- a/ts/state/selectors/composer.ts +++ b/ts/state/selectors/composer.ts @@ -6,6 +6,11 @@ import { createSelector } from 'reselect'; import type { StateType } from '../reducer'; import type { ComposerStateType, QuotedMessageType } from '../ducks/composer'; import { getComposerStateForConversation } from '../ducks/composer'; +import { + getRemoteConfig, + getTextFormattingEnabled, + isRemoteConfigFlagEnabled, +} from './items'; export const getComposerState = (state: StateType): ComposerStateType => state.composer; @@ -22,3 +27,28 @@ export const getQuotedMessageSelector = createSelector( (conversationId: string): QuotedMessageType | undefined => composerStateForConversationIdSelector(conversationId).quotedMessage ); + +export const getIsFormattingEnabled = createSelector( + getTextFormattingEnabled, + getRemoteConfig, + (isOptionEnabled, remoteConfig) => { + return ( + isOptionEnabled && + isRemoteConfigFlagEnabled(remoteConfig, 'desktop.textFormatting') + ); + } +); + +export const getIsFormattingSpoilersEnabled = createSelector( + getTextFormattingEnabled, + getRemoteConfig, + (isOptionEnabled, remoteConfig) => { + return ( + isOptionEnabled && + isRemoteConfigFlagEnabled( + remoteConfig, + 'desktop.textFormatting.spoilerSend' + ) + ); + } +); diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index 7f95923917a..3ff3e553721 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -48,7 +48,7 @@ export const getUniversalExpireTimer = createSelector( DurationInSeconds.fromSeconds(state[UNIVERSAL_EXPIRE_TIMER_ITEM] || 0) ); -const isRemoteConfigFlagEnabled = ( +export const isRemoteConfigFlagEnabled = ( config: Readonly, key: ConfigKeyType ): boolean => Boolean(config[key]?.enabled); @@ -250,3 +250,8 @@ export const getAutoDownloadUpdate = createSelector( (state: ItemsStateType): boolean => Boolean(state['auto-download-update'] ?? true) ); + +export const getTextFormattingEnabled = createSelector( + getItems, + (state: ItemsStateType): boolean => Boolean(state.textFormatting ?? true) +); diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index 797e2206751..889e861243b 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -32,11 +32,16 @@ import { getRecentStickers, } from '../selectors/stickers'; import { isSignalConversation } from '../../util/isSignalConversation'; -import { getComposerStateForConversationIdSelector } from '../selectors/composer'; +import { + getComposerStateForConversationIdSelector, + getIsFormattingEnabled, + getIsFormattingSpoilersEnabled, +} from '../selectors/composer'; import type { SmartCompositionRecordingProps } from './CompositionRecording'; import { SmartCompositionRecording } from './CompositionRecording'; import type { SmartCompositionRecordingDraftProps } from './CompositionRecordingDraft'; import { SmartCompositionRecordingDraft } from './CompositionRecordingDraft'; +import { BodyRange } from '../../types/BodyRange'; type ExternalProps = { id: string; @@ -93,6 +98,9 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { const selectedMessageIds = getSelectedMessageIds(state); + const isFormattingEnabled = getIsFormattingEnabled(state); + const isFormattingSpoilersEnabled = getIsFormattingSpoilersEnabled(state); + return { // Base conversationId: id, @@ -100,6 +108,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { getPreferredBadge: getPreferredBadgeSelector(state), i18n: getIntl(state), isDisabled, + isFormattingSpoilersEnabled, + isFormattingEnabled, messageCompositionId, sendCounter, theme: getTheme(state), @@ -154,7 +164,19 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { groupAdmins: getGroupAdminsSelector(state)(conversation.id), draftText: dropNull(draftText), - draftBodyRanges, + draftBodyRanges: draftBodyRanges?.map(bodyRange => { + if (BodyRange.isMention(bodyRange)) { + const mentionConvo = conversationSelector(bodyRange.mentionUuid); + + return { + ...bodyRange, + conversationID: mentionConvo.id, + replacementText: mentionConvo.title, + }; + } + + return bodyRange; + }), renderSmartCompositionRecording: ( recProps: SmartCompositionRecordingProps ) => { diff --git a/ts/state/smart/CompositionTextArea.tsx b/ts/state/smart/CompositionTextArea.tsx index 66e29ba99e9..9bac7063d44 100644 --- a/ts/state/smart/CompositionTextArea.tsx +++ b/ts/state/smart/CompositionTextArea.tsx @@ -12,9 +12,14 @@ import { useActions as useEmojiActions } from '../ducks/emojis'; import { useActions as useItemsActions } from '../ducks/items'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { useComposerActions } from '../ducks/composer'; +import { + getIsFormattingEnabled, + getIsFormattingSpoilersEnabled, +} from '../selectors/composer'; export type SmartCompositionTextAreaProps = Pick< CompositionTextAreaProps, + | 'bodyRanges' | 'draftText' | 'placeholder' | 'onChange' @@ -36,11 +41,17 @@ export function SmartCompositionTextArea( const { onTextTooLong } = useComposerActions(); const getPreferredBadge = useSelector(getPreferredBadgeSelector); + const isFormattingEnabled = useSelector(getIsFormattingEnabled); + const isFormattingSpoilersEnabled = useSelector( + getIsFormattingSpoilersEnabled + ); return ( ( - BodyRange.isMention - ), - spoilers: [], - text, - }); - } - - return text; -} +import { hydrateRanges } from '../../types/BodyRange'; export function SmartForwardMessagesModal(): JSX.Element | null { const forwardMessagesProps = useSelector< @@ -87,11 +54,12 @@ export function SmartForwardMessagesModal(): JSX.Element | null { return ( forwardMessagesProps?.messages.map((props): MessageForwardDraft => { return { - originalMessageId: props.id, attachments: props.attachments ?? [], - messageBody: renderMentions(props, getConversation), - isSticker: Boolean(props.isSticker), + bodyRanges: hydrateRanges(props.bodyRanges, getConversation), hasContact: Boolean(props.contact), + isSticker: Boolean(props.isSticker), + messageBody: props.text, + originalMessageId: props.id, previews: props.previews ?? [], }; }) ?? [] diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx index b3acea59847..acaa7bf123b 100644 --- a/ts/state/smart/GlobalModalContainer.tsx +++ b/ts/state/smart/GlobalModalContainer.tsx @@ -68,6 +68,7 @@ export function SmartGlobalModalContainer(): JSX.Element { editHistoryMessages, errorModalProps, deleteMessagesProps, + formattingWarningData, forwardMessagesProps, isProfileEditorVisible, isShortcutGuideModalVisible, @@ -85,12 +86,13 @@ export function SmartGlobalModalContainer(): JSX.Element { ); const { - closeErrorModal, - hideWhatsNewModal, - hideUserNotFoundModal, - toggleSignalConnectionsModal, cancelAuthorizeArtCreator, + closeErrorModal, confirmAuthorizeArtCreator, + hideUserNotFoundModal, + hideWhatsNewModal, + showFormattingWarningModal, + toggleSignalConnectionsModal, } = useGlobalModalActions(); const renderAddUserToAnotherGroup = useCallback(() => { @@ -135,6 +137,7 @@ export function SmartGlobalModalContainer(): JSX.Element { editHistoryMessages={editHistoryMessages} errorModalProps={errorModalProps} deleteMessagesProps={deleteMessagesProps} + formattingWarningData={formattingWarningData} forwardMessagesProps={forwardMessagesProps} hasSafetyNumberChangeModal={hasSafetyNumberChangeModal} hideUserNotFoundModal={hideUserNotFoundModal} @@ -159,6 +162,7 @@ export function SmartGlobalModalContainer(): JSX.Element { renderStoriesSettings={renderStoriesSettings} safetyNumberChangedBlockingData={safetyNumberChangedBlockingData} safetyNumberModalContactId={safetyNumberModalContactId} + showFormattingWarningModal={showFormattingWarningModal} stickerPackPreviewId={stickerPackPreviewId} theme={theme} toggleSignalConnectionsModal={toggleSignalConnectionsModal} diff --git a/ts/state/smart/ShortcutGuideModal.tsx b/ts/state/smart/ShortcutGuideModal.tsx index 2d508441dae..d78ecd36894 100644 --- a/ts/state/smart/ShortcutGuideModal.tsx +++ b/ts/state/smart/ShortcutGuideModal.tsx @@ -14,6 +14,10 @@ import { getKnownStickerPacks, getReceivedStickerPacks, } from '../selectors/stickers'; +import { + getIsFormattingEnabled, + getIsFormattingSpoilersEnabled, +} from '../selectors/composer'; const mapStateToProps = (state: StateType) => { const blessedPacks = getBlessedStickerPacks(state); @@ -21,6 +25,9 @@ const mapStateToProps = (state: StateType) => { const knownPacks = getKnownStickerPacks(state); const receivedPacks = getReceivedStickerPacks(state); + const isFormattingFlagEnabled = getIsFormattingEnabled(state); + const isFormattingSpoilersFlagEnabled = getIsFormattingSpoilersEnabled(state); + const hasInstalledStickers = countStickers({ knownPacks, @@ -33,6 +40,8 @@ const mapStateToProps = (state: StateType) => { return { hasInstalledStickers, + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled, platform, i18n: getIntl(state), }; diff --git a/ts/state/smart/StoryViewer.tsx b/ts/state/smart/StoryViewer.tsx index c5673cc5e6c..2aa56d607b5 100644 --- a/ts/state/smart/StoryViewer.tsx +++ b/ts/state/smart/StoryViewer.tsx @@ -39,6 +39,10 @@ import { useAudioPlayerActions } from '../ducks/audioPlayer'; import { useGlobalModalActions } from '../ducks/globalModals'; import { useStoriesActions } from '../ducks/stories'; import { useIsWindowActive } from '../../hooks/useIsWindowActive'; +import { + getIsFormattingEnabled, + getIsFormattingSpoilersEnabled, +} from '../selectors/composer'; export function SmartStoryViewer(): JSX.Element | null { const storiesActions = useStoriesActions(); @@ -89,6 +93,11 @@ export function SmartStoryViewer(): JSX.Element | null { getHasStoryViewReceiptSetting ); + const isFormattingEnabled = useSelector(getIsFormattingEnabled); + const isFormattingSpoilersEnabled = useSelector( + getIsFormattingSpoilersEnabled + ); + const { pauseVoiceNotePlayer } = useAudioPlayerActions(); const storyInfo = getStoryById( @@ -114,7 +123,8 @@ export function SmartStoryViewer(): JSX.Element | null { i18n={i18n} platform={platform} isInternalUser={internalUser} - saveAttachment={internalUser ? saveAttachment : asyncShouldNeverBeCalled} + isFormattingEnabled={isFormattingEnabled} + isFormattingSpoilersEnabled={isFormattingSpoilersEnabled} isSignalConversation={isSignalConversation({ id: conversationStory.conversationId, })} @@ -149,6 +159,7 @@ export function SmartStoryViewer(): JSX.Element | null { renderEmojiPicker={renderEmojiPicker} replyState={replyState} retryMessageSend={retryMessageSend} + saveAttachment={internalUser ? saveAttachment : asyncShouldNeverBeCalled} showContactModal={showContactModal} showToast={showToast} skinTone={skinTone} diff --git a/ts/test-mock/benchmarks/group_send_bench.ts b/ts/test-mock/benchmarks/group_send_bench.ts index fb1abeb6a47..157cd243205 100644 --- a/ts/test-mock/benchmarks/group_send_bench.ts +++ b/ts/test-mock/benchmarks/group_send_bench.ts @@ -124,11 +124,7 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => { const deltaList = new Array(); for (let runId = 0; runId < RUN_COUNT + DISCARD_COUNT; runId += 1) { debug('finding composition input and clicking it'); - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); - - const input = composeArea.locator('[data-testid=CompositionInput]'); + const input = await app.waitForEnabledComposer(250); debug('entering message text'); await input.type(`my message ${runId}`); diff --git a/ts/test-mock/benchmarks/send_bench.ts b/ts/test-mock/benchmarks/send_bench.ts index 02ba84fe3ee..822558a0e55 100644 --- a/ts/test-mock/benchmarks/send_bench.ts +++ b/ts/test-mock/benchmarks/send_bench.ts @@ -78,10 +78,7 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => { const deltaList = new Array(); for (let runId = 0; runId < RUN_COUNT + DISCARD_COUNT; runId += 1) { debug('finding composition input and clicking it'); - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); - const input = composeArea.locator('[data-testid=CompositionInput]'); + const input = await app.waitForEnabledComposer(250); debug('entering message text'); await input.type(`my message ${runId}`); diff --git a/ts/test-mock/playwright.ts b/ts/test-mock/playwright.ts index 9778c5a3642..769d0a8238d 100644 --- a/ts/test-mock/playwright.ts +++ b/ts/test-mock/playwright.ts @@ -1,7 +1,7 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ElectronApplication, Page } from 'playwright'; +import type { ElectronApplication, Locator, Page } from 'playwright'; import { _electron as electron } from 'playwright'; import { EventEmitter } from 'events'; @@ -10,6 +10,7 @@ import type { IPCResponse as ChallengeResponseType, } from '../challenge'; import type { ReceiptType } from '../types/Receipt'; +import { sleep } from '../util/sleep'; export type AppLoadedInfoType = Readonly<{ loadTime: number; @@ -61,6 +62,22 @@ export class App extends EventEmitter { this.privApp.on('close', () => this.emit('close')); } + public async waitForEnabledComposer(sleepTimeout = 1000): Promise { + const window = await this.getWindow(); + const composeArea = window.locator( + '.composition-area-wrapper, .conversation .ConversationView' + ); + const composeContainer = composeArea.locator( + '[data-testid=CompositionInput][data-enabled=true]' + ); + await composeContainer.waitFor(); + + // Let quill start up + await sleep(sleepTimeout); + + return composeContainer.locator('.ql-editor'); + } + public async waitForProvisionURL(): Promise { return this.waitForEvent('provisioning-url'); } diff --git a/ts/test-mock/pnp/merge_test.ts b/ts/test-mock/pnp/merge_test.ts index 672ef042895..9464e009893 100644 --- a/ts/test-mock/pnp/merge_test.ts +++ b/ts/test-mock/pnp/merge_test.ts @@ -123,12 +123,7 @@ describe('pnp/merge', function needsName() { debug('Send message to ACI'); { - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); - const compositionInput = composeArea.locator( - '[data-testid=CompositionInput]' - ); + const compositionInput = await app.waitForEnabledComposer(); await compositionInput.type('Hello ACI'); await compositionInput.press('Enter'); @@ -159,12 +154,7 @@ describe('pnp/merge', function needsName() { if (withNotification) { debug('Send message to PNI'); - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); - const compositionInput = composeArea.locator( - '[data-testid=CompositionInput]' - ); + const compositionInput = await app.waitForEnabledComposer(); await compositionInput.type('Hello PNI'); await compositionInput.press('Enter'); @@ -273,12 +263,7 @@ describe('pnp/merge', function needsName() { debug('Send message to merged contact'); { - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); - const compositionInput = composeArea.locator( - '[data-testid=CompositionInput]' - ); + const compositionInput = await app.waitForEnabledComposer(); await compositionInput.type('Hello merged'); await compositionInput.press('Enter'); @@ -381,12 +366,7 @@ describe('pnp/merge', function needsName() { debug('Send message to merged contact'); { - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); - const compositionInput = composeArea.locator( - '[data-testid=CompositionInput]' - ); + const compositionInput = await app.waitForEnabledComposer(); await compositionInput.type('Hello merged'); await compositionInput.press('Enter'); diff --git a/ts/test-mock/pnp/pni_change_test.ts b/ts/test-mock/pnp/pni_change_test.ts index 0851324510c..aca4c21d9b2 100644 --- a/ts/test-mock/pnp/pni_change_test.ts +++ b/ts/test-mock/pnp/pni_change_test.ts @@ -101,12 +101,7 @@ describe('pnp/PNI Change', function needsName() { debug('Send message to contactA'); { - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); - const compositionInput = composeArea.locator( - '[data-testid=CompositionInput]' - ); + const compositionInput = await app.waitForEnabledComposer(); await compositionInput.type('message to contactA'); await compositionInput.press('Enter'); @@ -206,12 +201,7 @@ describe('pnp/PNI Change', function needsName() { debug('Send message to contactA'); { - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); - const compositionInput = composeArea.locator( - '[data-testid=CompositionInput]' - ); + const compositionInput = await app.waitForEnabledComposer(); await compositionInput.type('message to contactA'); await compositionInput.press('Enter'); @@ -313,12 +303,7 @@ describe('pnp/PNI Change', function needsName() { debug('Send message to contactA'); { - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); - const compositionInput = composeArea.locator( - '[data-testid=CompositionInput]' - ); + const compositionInput = await app.waitForEnabledComposer(); await compositionInput.type('message to contactA'); await compositionInput.press('Enter'); @@ -375,12 +360,7 @@ describe('pnp/PNI Change', function needsName() { debug('Send message to contactB'); { - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); - const compositionInput = composeArea.locator( - '[data-testid=CompositionInput]' - ); + const compositionInput = await app.waitForEnabledComposer(); await compositionInput.type('message to contactB'); await compositionInput.press('Enter'); @@ -455,12 +435,7 @@ describe('pnp/PNI Change', function needsName() { debug('Send message to contactA'); { - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); - const compositionInput = composeArea.locator( - '[data-testid=CompositionInput]' - ); + const compositionInput = await app.waitForEnabledComposer(); await compositionInput.type('message to contactA'); await compositionInput.press('Enter'); @@ -548,12 +523,7 @@ describe('pnp/PNI Change', function needsName() { debug('Send message to contactA'); { - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); - const compositionInput = composeArea.locator( - '[data-testid=CompositionInput]' - ); + const compositionInput = await app.waitForEnabledComposer(); await compositionInput.type('second message to contactA'); await compositionInput.press('Enter'); diff --git a/ts/test-mock/pnp/pni_signature_test.ts b/ts/test-mock/pnp/pni_signature_test.ts index bc9f9e5965e..1e4d461d117 100644 --- a/ts/test-mock/pnp/pni_signature_test.ts +++ b/ts/test-mock/pnp/pni_signature_test.ts @@ -104,9 +104,6 @@ describe('pnp/PNI Signature', function needsName() { const leftPane = window.locator('.left-pane-wrapper'); const conversationStack = window.locator('.conversation-stack'); - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); debug('creating a stranger'); const stranger = await server.createPrimaryDevice({ @@ -163,15 +160,13 @@ describe('pnp/PNI Signature', function needsName() { assert.strictEqual(source, desktop, 'initial message has valid source'); checkPniSignature(content.pniSignatureMessage, 'initial message'); } - debug('Enter first message text'); - const compositionInput = composeArea.locator( - '[data-testid=CompositionInput]' - ); - - await compositionInput.type('first'); - await compositionInput.press('Enter'); + { + const compositionInput = await app.waitForEnabledComposer(); + await compositionInput.type('first'); + await compositionInput.press('Enter'); + } debug('Waiting for the first message with pni signature'); { const { source, content, body, dataMessage } = @@ -193,12 +188,13 @@ describe('pnp/PNI Signature', function needsName() { timestamp: receiptTimestamp, }); } - debug('Enter second message text'); + { + const compositionInput = await app.waitForEnabledComposer(); - await compositionInput.type('second'); - await compositionInput.press('Enter'); - + await compositionInput.type('second'); + await compositionInput.press('Enter'); + } debug('Waiting for the second message with pni signature'); { const { source, content, body, dataMessage } = @@ -221,12 +217,13 @@ describe('pnp/PNI Signature', function needsName() { timestamp: receiptTimestamp, }); } - debug('Enter third message text'); + { + const compositionInput = await app.waitForEnabledComposer(); - await compositionInput.type('third'); - await compositionInput.press('Enter'); - + await compositionInput.type('third'); + await compositionInput.press('Enter'); + } debug('Waiting for the third message without pni signature'); { const { source, content, body } = await stranger.waitForMessage(); @@ -261,9 +258,6 @@ describe('pnp/PNI Signature', function needsName() { const window = await app.getWindow(); const leftPane = window.locator('.left-pane-wrapper'); - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); debug('opening conversation with the pni contact'); await leftPane @@ -272,12 +266,12 @@ describe('pnp/PNI Signature', function needsName() { .click(); debug('Enter a PNI message text'); - const compositionInput = composeArea.locator( - '[data-testid=CompositionInput]' - ); + { + const compositionInput = await app.waitForEnabledComposer(); - await compositionInput.type('Hello PNI'); - await compositionInput.press('Enter'); + await compositionInput.type('Hello PNI'); + await compositionInput.press('Enter'); + } debug('Waiting for a PNI message'); { @@ -296,7 +290,11 @@ describe('pnp/PNI Signature', function needsName() { const state = await phone.expectStorageState('state before merge'); debug('Enter a draft text without hitting enter'); - await compositionInput.type('Draft text'); + { + const compositionInput = await app.waitForEnabledComposer(); + + await compositionInput.type('Draft text'); + } debug('Send back the response with profile key and pni signature'); @@ -313,12 +311,14 @@ describe('pnp/PNI Signature', function needsName() { .locator(`[data-testid="${pniContact.toContact().uuid}"]`) .waitFor(); - debug('Wait for composition input to clear'); - await composeArea.locator('[data-testid=CompositionInput]').waitFor(); + { + debug('Wait for composition input to clear'); + const compositionInput = await app.waitForEnabledComposer(); - debug('Enter an ACI message text'); - await compositionInput.type('Hello ACI'); - await compositionInput.press('Enter'); + debug('Enter an ACI message text'); + await compositionInput.type('Hello ACI'); + await compositionInput.press('Enter'); + } debug('Waiting for a ACI message'); { diff --git a/ts/test-mock/pnp/username_test.ts b/ts/test-mock/pnp/username_test.ts index 9c4346f514f..b58145ef038 100644 --- a/ts/test-mock/pnp/username_test.ts +++ b/ts/test-mock/pnp/username_test.ts @@ -265,12 +265,7 @@ describe('pnp/username', function needsName() { debug('sending a message'); { - const composeArea = window.locator( - '.composition-area-wrapper, .conversation .ConversationView' - ); - const compositionInput = composeArea.locator( - '[data-testid=CompositionInput]' - ); + const compositionInput = await app.waitForEnabledComposer(); await compositionInput.type('Hello Carl'); await compositionInput.press('Enter'); diff --git a/ts/test-node/quill/util_test.ts b/ts/test-node/quill/util_test.ts index 17326a8214f..6cf3acd1252 100644 --- a/ts/test-node/quill/util_test.ts +++ b/ts/test-node/quill/util_test.ts @@ -5,9 +5,10 @@ import { assert } from 'chai'; import { getDeltaToRemoveStaleMentions, - getTextAndMentionsFromOps, + getTextAndRangesFromOps, getDeltaToRestartMention, } from '../../quill/util'; +import { BodyRange } from '../../types/BodyRange'; describe('getDeltaToRemoveStaleMentions', () => { const memberUuids = ['abcdef', 'ghijkl']; @@ -83,20 +84,20 @@ describe('getDeltaToRemoveStaleMentions', () => { }); }); -describe('getTextAndMentionsFromOps', () => { +describe('getTextAndRangesFromOps', () => { describe('given only text', () => { it('returns only text trimmed', () => { const ops = [{ insert: ' The ' }, { insert: ' text \n' }]; - const [resultText, resultMentions] = getTextAndMentionsFromOps(ops); - assert.equal(resultText, 'The text'); - assert.equal(resultMentions.length, 0); + const { text, bodyRanges } = getTextAndRangesFromOps(ops); + assert.equal(text, 'The text'); + assert.equal(bodyRanges.length, 0); }); it('returns trimmed of trailing newlines', () => { const ops = [{ insert: ' The\ntext\n\n\n' }]; - const [resultText, resultMentions] = getTextAndMentionsFromOps(ops); - assert.equal(resultText, 'The\ntext'); - assert.equal(resultMentions.length, 0); + const { text, bodyRanges } = getTextAndRangesFromOps(ops); + assert.equal(text, 'The\ntext'); + assert.equal(bodyRanges.length, 0); }); }); @@ -120,9 +121,9 @@ describe('getTextAndMentionsFromOps', () => { }, }, ]; - const [resultText, resultMentions] = getTextAndMentionsFromOps(ops); - assert.equal(resultText, '😂 wow, funny, \uFFFC'); - assert.deepEqual(resultMentions, [ + const { text, bodyRanges } = getTextAndRangesFromOps(ops); + assert.equal(text, '😂 wow, funny, \uFFFC'); + assert.deepEqual(bodyRanges, [ { length: 1, mentionUuid: 'abcdef', @@ -145,9 +146,9 @@ describe('getTextAndMentionsFromOps', () => { }, }, ]; - const [resultText, resultMentions] = getTextAndMentionsFromOps(ops); - assert.equal(resultText, '\uFFFC'); - assert.deepEqual(resultMentions, [ + const { text, bodyRanges } = getTextAndRangesFromOps(ops); + assert.equal(text, '\uFFFC'); + assert.deepEqual(bodyRanges, [ { length: 1, mentionUuid: 'abcdef', @@ -170,9 +171,9 @@ describe('getTextAndMentionsFromOps', () => { }, { insert: '\n test' }, ]; - const [resultText, resultMentions] = getTextAndMentionsFromOps(ops); - assert.equal(resultText, 'test \n\uFFFC\n test'); - assert.deepEqual(resultMentions, [ + const { text, bodyRanges } = getTextAndRangesFromOps(ops); + assert.equal(text, 'test \n\uFFFC\n test'); + assert.deepEqual(bodyRanges, [ { length: 1, mentionUuid: 'abcdef', @@ -182,6 +183,188 @@ describe('getTextAndMentionsFromOps', () => { ]); }); }); + + describe('given formatting on text, with emoji and mentions', () => { + it('handles overlapping and contiguous format sections properly', () => { + const ops = [ + { + insert: 'Hey, ', + attributes: { + spoiler: true, + }, + }, + { + insert: { + mention: { + uuid: 'a', + title: '@alice', + }, + }, + attributes: { + spoiler: true, + }, + }, + { + insert: ': this is ', + attributes: { + spoiler: true, + }, + }, + { + insert: 'bold', + attributes: { + bold: true, + spoiler: true, + }, + }, + { + insert: ' and', + attributes: { + bold: true, + italic: true, + spoiler: true, + }, + }, + { + insert: ' italic', + attributes: { + italic: true, + spoiler: true, + }, + }, + { + insert: ' and strikethrough', + attributes: { + strike: true, + }, + }, + { insert: ' ' }, + { + insert: 'and monospace', + attributes: { + monospace: true, + }, + }, + ]; + const { text, bodyRanges } = getTextAndRangesFromOps(ops); + assert.equal( + text, + 'Hey, \uFFFC: this is bold and italic and strikethrough and monospace' + ); + assert.deepEqual(bodyRanges, [ + { + start: 5, + length: 1, + mentionUuid: 'a', + replacementText: '@alice', + }, + { + start: 16, + length: 8, + style: BodyRange.Style.BOLD, + }, + { + start: 20, + length: 11, + style: BodyRange.Style.ITALIC, + }, + { + start: 0, + length: 31, + style: BodyRange.Style.SPOILER, + }, + { + start: 31, + length: 18, + style: BodyRange.Style.STRIKETHROUGH, + }, + { + start: 50, + length: 13, + style: BodyRange.Style.MONOSPACE, + }, + ]); + }); + + it('handles lots of the same format', () => { + const ops = [ + { + insert: 'Every', + attributes: { + bold: true, + }, + }, + { + insert: ' other ', + }, + { + insert: 'word', + attributes: { + bold: true, + }, + }, + { + insert: ' is ', + }, + { + insert: 'bold!', + attributes: { + bold: true, + }, + }, + ]; + const { text, bodyRanges } = getTextAndRangesFromOps(ops); + assert.equal(text, 'Every other word is bold!'); + assert.deepEqual(bodyRanges, [ + { + start: 0, + length: 5, + style: BodyRange.Style.BOLD, + }, + { + start: 12, + length: 4, + style: BodyRange.Style.BOLD, + }, + { + start: 20, + length: 5, + style: BodyRange.Style.BOLD, + }, + ]); + }); + + it('handles formatting on mentions', () => { + const ops = [ + { + insert: { + mention: { + uuid: 'a', + title: '@alice', + }, + }, + attributes: { + bold: true, + }, + }, + ]; + const { text, bodyRanges } = getTextAndRangesFromOps(ops); + assert.equal(text, '\uFFFC'); + assert.deepEqual(bodyRanges, [ + { + start: 0, + length: 1, + mentionUuid: 'a', + replacementText: '@alice', + }, + { + start: 0, + length: 1, + style: BodyRange.Style.BOLD, + }, + ]); + }); + }); }); describe('getDeltaToRestartMention', () => { diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index db686b48a10..648e54cb8c1 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -57,6 +57,7 @@ import { HTTPError, NoSenderKeyError, } from './Errors'; +import type { RawBodyRange } from '../types/BodyRange'; import { BodyRange } from '../types/BodyRange'; import type { StoryContextType } from '../types/Util'; import type { @@ -177,6 +178,7 @@ export type ContactWithHydratedAvatar = EmbeddedContactType & { export type MessageOptionsType = { attachments?: ReadonlyArray | null; body?: string; + bodyRanges?: ReadonlyArray; contact?: Array; expireTimer?: DurationInSeconds; flags?: number; @@ -194,12 +196,12 @@ export type MessageOptionsType = { reaction?: ReactionType; deletedForEveryoneTimestamp?: number; timestamp: number; - mentions?: ReadonlyArray>; groupCallUpdate?: GroupCallUpdateType; storyContext?: StoryContextType; }; export type GroupSendOptionsType = { attachments?: Array; + bodyRanges?: ReadonlyArray; contact?: Array; deletedForEveryoneTimestamp?: number; expireTimer?: DurationInSeconds; @@ -207,7 +209,6 @@ export type GroupSendOptionsType = { groupCallUpdate?: GroupCallUpdateType; groupV1?: GroupV1InfoType; groupV2?: GroupV2InfoType; - mentions?: ReadonlyArray>; messageText?: string; preview?: ReadonlyArray; profileKey?: Uint8Array; @@ -223,6 +224,8 @@ class Message { body?: string; + bodyRanges?: ReadonlyArray; + contact?: Array; expireTimer?: DurationInSeconds; @@ -258,8 +261,6 @@ class Message { deletedForEveryoneTimestamp?: number; - mentions?: ReadonlyArray>; - groupCallUpdate?: GroupCallUpdateType; storyContext?: StoryContextType; @@ -267,6 +268,7 @@ class Message { constructor(options: MessageOptionsType) { this.attachments = options.attachments || []; this.body = options.body; + this.bodyRanges = options.bodyRanges; this.contact = options.contact; this.expireTimer = options.expireTimer; this.flags = options.flags; @@ -281,7 +283,6 @@ class Message { this.reaction = options.reaction; this.timestamp = options.timestamp; this.deletedForEveryoneTimestamp = options.deletedForEveryoneTimestamp; - this.mentions = options.mentions; this.groupCallUpdate = options.groupCallUpdate; this.storyContext = options.storyContext; @@ -355,13 +356,21 @@ class Message { if (this.body) { proto.body = this.body; - const mentionCount = this.mentions ? this.mentions.length : 0; + const mentionCount = this.bodyRanges + ? this.bodyRanges.filter(BodyRange.isMention).length + : 0; + const otherRangeCount = this.bodyRanges + ? this.bodyRanges.length - mentionCount + : 0; const placeholders = this.body.match(/\uFFFC/g); const placeholderCount = placeholders ? placeholders.length : 0; + const storyInfo = this.storyContext + ? `, story: ${this.storyContext.timestamp}` + : ''; log.info( - `Sending a message with ${mentionCount} mentions and ${placeholderCount} placeholders${ - this.storyContext ? `, story: ${this.storyContext.timestamp}` : '' - }` + `Sending a message with ${mentionCount} mentions, ` + + `${placeholderCount} placeholders, ` + + `and ${otherRangeCount} other ranges${storyInfo}` ); } if (this.flags) { @@ -547,16 +556,28 @@ class Message { targetSentTimestamp: Long.fromNumber(this.deletedForEveryoneTimestamp), }; } - if (this.mentions) { + if (this.bodyRanges) { proto.requiredProtocolVersion = Proto.DataMessage.ProtocolVersion.MENTIONS; - proto.bodyRanges = this.mentions.map( - ({ start, length, mentionUuid }) => ({ - start, - length, - mentionUuid, - }) - ); + proto.bodyRanges = this.bodyRanges.map(bodyRange => { + const { start, length } = bodyRange; + + if (BodyRange.isMention(bodyRange)) { + return { + start, + length, + mentionUuid: bodyRange.mentionUuid, + }; + } + if (BodyRange.isFormatting(bodyRange)) { + return { + start, + length, + style: bodyRange.style, + }; + } + throw missingCaseError(bodyRange); + }); } if (this.groupCallUpdate) { @@ -1079,6 +1100,7 @@ export default class MessageSender { ): MessageOptionsType { const { attachments, + bodyRanges, contact, deletedForEveryoneTimestamp, expireTimer, @@ -1086,7 +1108,6 @@ export default class MessageSender { groupCallUpdate, groupV1, groupV2, - mentions, messageText, preview, profileKey, @@ -1129,6 +1150,7 @@ export default class MessageSender { return { attachments, + bodyRanges, body: messageText, contact, deletedForEveryoneTimestamp, @@ -1142,7 +1164,6 @@ export default class MessageSender { type: Proto.GroupContext.Type.DELIVER, } : undefined, - mentions, preview, profileKey, quote, @@ -1344,6 +1365,7 @@ export default class MessageSender { // message to just one person. async sendMessageToIdentifier({ attachments, + bodyRanges, contact, contentHint, deletedForEveryoneTimestamp, @@ -1364,6 +1386,7 @@ export default class MessageSender { includePniSignatureMessage, }: Readonly<{ attachments: ReadonlyArray | undefined; + bodyRanges?: ReadonlyArray; contact?: Array; contentHint: number; deletedForEveryoneTimestamp: number | undefined; @@ -1386,6 +1409,7 @@ export default class MessageSender { return this.sendMessage({ messageOptions: { attachments, + bodyRanges, body: messageText, contact, deletedForEveryoneTimestamp, diff --git a/ts/types/BodyRange.ts b/ts/types/BodyRange.ts index 386409741a8..8592e44d45d 100644 --- a/ts/types/BodyRange.ts +++ b/ts/types/BodyRange.ts @@ -89,6 +89,10 @@ export type DraftBodyRangeMention = BodyRange< replacementText: string; } >; +export type DraftBodyRange = + | DraftBodyRangeMention + | BodyRange; +export type DraftBodyRanges = ReadonlyArray; // Fully hydrated body range to be used in UI components. diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 5c6a2c4db6d..642082dafcb 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -54,13 +54,13 @@ export type StorageAccessType = { 'call-ringtone-notification': boolean; 'call-system-notification': boolean; 'hide-menu-bar': boolean; - 'system-tray-setting': SystemTraySetting; 'incoming-call-notification': boolean; 'notification-draw-attention': boolean; 'notification-setting': NotificationSettingType; 'read-receipt-setting': boolean; 'sent-media-quality': SentMediaQualitySettingType; 'spell-check': boolean; + 'system-tray-setting': SystemTraySetting; 'theme-setting': ThemeSettingType; attachmentMigration_isComplete: boolean; attachmentMigration_lastProcessedIndex: number; @@ -69,6 +69,7 @@ export type StorageAccessType = { customColors: CustomColorsItemType; device_name: string; existingOnboardingStoryMessageIds: ReadonlyArray | undefined; + formattingWarningShown: boolean; hasRegisterSupportForUnauthenticatedDelivery: boolean; hasSetMyStoriesPrivacy: boolean; hasCompletedUsernameOnboarding: boolean; @@ -110,6 +111,7 @@ export type StorageAccessType = { // Unlike `number_id` (which also includes device id) this field is only // updated whenever we receive a new storage manifest accountE164: string; + textFormatting: boolean; typingIndicators: boolean; sealedSenderIndicators: boolean; storageFetchComplete: boolean; diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index 30fe8137059..a3ef1063bde 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -41,6 +41,7 @@ import { import { lookupConversationWithoutUuid } from './lookupConversationWithoutUuid'; import * as log from '../logging/log'; import { deleteAllMyStories } from './deleteAllMyStories'; +import { isEnabled } from '../RemoteConfig'; type SentMediaQualityType = 'standard' | 'high'; type ThemeType = 'light' | 'dark' | 'system'; @@ -66,6 +67,7 @@ export type IPCEventsValuesType = { sentMediaQualitySetting: SentMediaQualityType; spellCheck: boolean; systemTraySetting: SystemTraySetting; + textFormatting: boolean; themeSetting: ThemeType; universalExpireTimer: DurationInSeconds; zoomFactor: ZoomFactorType; @@ -104,6 +106,7 @@ export type IPCEventsCallbacksType = { editCustomColor: (colorId: string, customColor: CustomColorType) => void; getConversationsWithCustomColor: (x: string) => Array; installStickerPack: (packId: string, key: string) => Promise; + isFormattingFlagEnabled: () => boolean; isPhoneNumberSharingEnabled: () => boolean; isPrimary: () => boolean; removeCustomColor: (x: string) => void; @@ -397,6 +400,8 @@ export function createIPCEvents( getSpellCheck: () => window.storage.get('spell-check', true), setSpellCheck: value => window.storage.put('spell-check', value), + getTextFormatting: () => window.storage.get('textFormatting', true), + setTextFormatting: value => window.storage.put('textFormatting', value), getAlwaysRelayCalls: () => window.storage.get('always-relay-calls'), setAlwaysRelayCalls: value => @@ -407,6 +412,7 @@ export function createIPCEvents( return window.IPC.setAutoLaunch(value); }, + isFormattingFlagEnabled: () => isEnabled('desktop.textFormatting'), isPhoneNumberSharingEnabled: () => isPhoneNumberSharingEnabled(), isPrimary: () => window.textsecure.storage.user.getDeviceId() === 1, shouldShowStoriesSettings: () => getStoriesAvailable(), diff --git a/ts/util/maybeBlockSendForFormattingModal.ts b/ts/util/maybeBlockSendForFormattingModal.ts new file mode 100644 index 00000000000..31c902c52b1 --- /dev/null +++ b/ts/util/maybeBlockSendForFormattingModal.ts @@ -0,0 +1,18 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { DraftBodyRanges } from '../types/BodyRange'; +import { BodyRange } from '../types/BodyRange'; +import { explodePromise } from './explodePromise'; + +export async function maybeBlockSendForFormattingModal( + bodyRanges: DraftBodyRanges +): Promise { + if (!bodyRanges.some(BodyRange.isFormatting)) { + return true; + } + + const explodedPromise = explodePromise(); + window.reduxActions.globalModals.showFormattingWarningModal(explodedPromise); + return explodedPromise.promise; +} diff --git a/ts/util/maybeForwardMessages.ts b/ts/util/maybeForwardMessages.ts index 55b72563d7b..0f517154bf0 100644 --- a/ts/util/maybeForwardMessages.ts +++ b/ts/util/maybeForwardMessages.ts @@ -16,18 +16,22 @@ import { isNotNil } from './isNotNil'; import { resetLinkPreview } from '../services/LinkPreview'; import { getRecipientsByConversation } from './getRecipientsByConversation'; import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage'; -import type { DraftBodyRangeMention } from '../types/BodyRange'; +import type { + DraftBodyRanges, + HydratedBodyRangesType, +} from '../types/BodyRange'; import type { StickerWithHydratedData } from '../types/Stickers'; import { drop } from './drop'; import { toLogFormat } from '../types/errors'; export type MessageForwardDraft = Readonly<{ - originalMessageId: string; attachments?: ReadonlyArray; - previews: ReadonlyArray; - isSticker: boolean; + bodyRanges?: HydratedBodyRangesType; hasContact: boolean; + isSticker: boolean; messageBody?: string; + originalMessageId: string; + previews: ReadonlyArray; }>; export type ForwardMessageData = Readonly<{ @@ -148,9 +152,9 @@ export async function maybeForwardMessages( // send along with the message and do the send to each conversation. const preparedMessages = await Promise.all( messages.map(async message => { - const { originalMessage, draft } = message; + const { draft, originalMessage } = message; const { sticker, contact } = originalMessage; - const { messageBody, previews, attachments } = draft; + const { attachments, bodyRanges, messageBody, previews } = draft; const idForLogging = getMessageIdForLogging(originalMessage); log.info(`maybeForwardMessage: Forwarding ${idForLogging}`); @@ -167,8 +171,8 @@ export async function maybeForwardMessages( let enqueuedMessage: { attachments: Array; body: string | undefined; + bodyRanges?: DraftBodyRanges; contact?: Array; - mentions?: Array; preview?: Array; quote?: QuotedMessageType; sticker?: StickerWithHydratedData; @@ -215,6 +219,7 @@ export async function maybeForwardMessages( enqueuedMessage = { body: messageBody || undefined, + bodyRanges, attachments: attachmentsToSend, preview, }; diff --git a/ts/windows/preload.ts b/ts/windows/preload.ts index 18234beeca0..38cba13c21a 100644 --- a/ts/windows/preload.ts +++ b/ts/windows/preload.ts @@ -32,6 +32,7 @@ installSetting('typingIndicatorSetting', { }); installCallback('deleteAllMyStories'); +installCallback('isFormattingFlagEnabled'); installCallback('isPhoneNumberSharingEnabled'); installCallback('isPrimary'); installCallback('shouldShowStoriesSettings'); @@ -54,6 +55,7 @@ installSetting('notificationSetting'); installSetting('spellCheck'); installSetting('systemTraySetting'); installSetting('sentMediaQualitySetting'); +installSetting('textFormatting'); installSetting('themeSetting'); installSetting('universalExpireTimer'); installSetting('zoomFactor'); diff --git a/ts/windows/settings/preload.ts b/ts/windows/settings/preload.ts index 90a4b2b878d..b5ea315d3c6 100644 --- a/ts/windows/settings/preload.ts +++ b/ts/windows/settings/preload.ts @@ -42,8 +42,9 @@ const settingNotificationDrawAttention = createSetting( ); const settingNotificationSetting = createSetting('notificationSetting'); const settingRelayCalls = createSetting('alwaysRelayCalls'); -const settingSpellCheck = createSetting('spellCheck'); const settingSentMediaQuality = createSetting('sentMediaQualitySetting'); +const settingSpellCheck = createSetting('spellCheck'); +const settingTextFormatting = createSetting('textFormatting'); const settingTheme = createSetting('themeSetting'); const settingSystemTraySetting = createSetting('systemTraySetting'); @@ -78,6 +79,7 @@ const settingUniversalExpireTimer = createSetting('universalExpireTimer'); // Callbacks const ipcGetAvailableIODevices = createCallback('getAvailableIODevices'); const ipcGetCustomColors = createCallback('getCustomColors'); +const ipcIsFormattingFlagEnabled = createCallback('isFormattingFlagEnabled'); const ipcIsSyncNotSupported = createCallback('isPrimary'); const ipcMakeSyncRequest = createCallback('syncRequest'); const ipcPNP = createCallback('isPhoneNumberSharingEnabled'); @@ -148,7 +150,9 @@ const renderPreferences = async () => { hasRelayCalls, hasSpellCheck, hasStoriesDisabled, + hasTextFormatting, hasTypingIndicators, + isFormattingFlagEnabled, isPhoneNumberSharingSupported, lastSyncTime, notificationContent, @@ -187,6 +191,7 @@ const renderPreferences = async () => { hasRelayCalls: settingRelayCalls.getValue(), hasSpellCheck: settingSpellCheck.getValue(), hasStoriesDisabled: settingHasStoriesDisabled.getValue(), + hasTextFormatting: settingTextFormatting.getValue(), hasTypingIndicators: settingTypingIndicators.getValue(), isPhoneNumberSharingSupported: ipcPNP(), lastSyncTime: settingLastSyncTime.getValue(), @@ -206,6 +211,7 @@ const renderPreferences = async () => { availableIODevices: ipcGetAvailableIODevices(), customColors: ipcGetCustomColors(), defaultConversationColor: ipcGetDefaultConversationColor(), + isFormattingFlagEnabled: ipcIsFormattingFlagEnabled(), isSyncNotSupported: ipcIsSyncNotSupported(), shouldShowStoriesSettings: ipcShouldShowStoriesSettings(), }); @@ -248,6 +254,7 @@ const renderPreferences = async () => { hasRelayCalls, hasSpellCheck, hasStoriesDisabled, + hasTextFormatting, hasTypingIndicators, lastSyncTime, notificationContent, @@ -294,6 +301,9 @@ const renderPreferences = async () => { SignalContext.getVersion() ), + // Feature flags + isFormattingFlagEnabled, + // Change handlers onAudioNotificationsChange: reRender(settingAudioNotification.setValue), onAutoDownloadUpdateChange: reRender(settingAutoDownloadUpdate.setValue), @@ -353,6 +363,7 @@ const renderPreferences = async () => { onSelectedSpeakerChange: reRender(settingAudioOutput.setValue), onSentMediaQualityChange: reRender(settingSentMediaQuality.setValue), onSpellCheckChange: reRender(settingSpellCheck.setValue), + onTextFormattingChange: reRender(settingTextFormatting.setValue), onThemeChange: reRender(settingTheme.setValue), onUniversalExpireTimerChange: (newValue: number): Promise => { return onUniversalExpireTimerChange(