diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 54e173e3ee1a..a9a6aa6a65b4 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3355,8 +3355,8 @@ "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", + "icu:textFormattingDescription": { + "messageformat": "Show text formatting popover when text is selected", "description": "Description of the text-formatting popover menu setting" }, "spellCheckWillBeEnabled": { @@ -5542,6 +5542,26 @@ "messageformat": "Mark selected text as a spoiler", "description": "Description of command to bold text in composer" }, + "icu:FormatMenu--guide--bold": { + "messageformat": "Bold", + "description": "Shown when you hover over the bold button in the popup formatting menu" + }, + "icu:FormatMenu--guide--italic": { + "messageformat": "Italic", + "description": "Shown when you hover over the bold button in the popup formatting menu" + }, + "icu:FormatMenu--guide--strikethrough": { + "messageformat": "Strikethrough", + "description": "Shown when you hover over the bold button in the popup formatting menu" + }, + "icu:FormatMenu--guide--monospace": { + "messageformat": "Monospace", + "description": "Shown when you hover over the bold button in the popup formatting menu" + }, + "icu:FormatMenu--guide--spoiler": { + "messageformat": "Spoiler", + "description": "Shown when you hover over the bold button in the popup formatting menu" + }, "Keyboard--scroll-to-top": { "message": "Scroll to top of list", "description": "(deleted 03/29/2023) Shown in the shortcuts guide" diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 10c47747f94f..5765fa903b5e 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -93,6 +93,10 @@ line-height: 16px; letter-spacing: 0; } +@mixin font-subtitle-bold { + @include font-subtitle; + font-weight: 600; +} @mixin font-caption { @include font-family; diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index fa582498ab0b..5b6132962c95 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -5,6 +5,7 @@ $inter: Inter, 'Helvetica Neue', 'Source Sans Pro', 'Source Han Sans SC', 'Source Han Sans CN', 'Hiragino Sans GB', 'Hiragino Kaku Gothic', 'Microsoft Yahei UI', Helvetica, Arial, sans-serif; +// Note: This font-family is checked for in matchMonospace, to support paste scenarios $monospace: 'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', Menlo, Consolas, monospace; diff --git a/stylesheets/components/CompositionInput.scss b/stylesheets/components/CompositionInput.scss index a6bc1da51149..f3f954068325 100644 --- a/stylesheets/components/CompositionInput.scss +++ b/stylesheets/components/CompositionInput.scss @@ -119,6 +119,7 @@ } } + // Note: This is referenced in ModalHost to ensure 'external' clicks on it still work &__format-menu { padding-block: 6px; padding-inline: 12px; @@ -128,13 +129,16 @@ display: flex; flex-direction: row; + opacity: 0; + transition: opacity ease 200ms; + @include popper-shadow(); @include light-theme() { background: $color-white; } @include dark-theme() { - background: $color-gray-80; + background: $color-gray-65; } &__item { @@ -161,6 +165,35 @@ } } + &__popover { + @include font-subtitle-bold; + padding-block: 5px; + padding-inline: 8px; + text-align: center; + border-radius: 4px; + margin-bottom: 8px; + + @include light-theme { + background-color: $color-black; + color: $color-gray-05; + } + @include dark-theme { + background-color: $color-gray-65; + color: $color-gray-05; + } + + &__shortcut { + @include font-caption-bold; + + @include light-theme { + color: $color-gray-15; + } + @include dark-theme { + color: $color-gray-25; + } + } + } + &__icon { height: 20px; width: 20px; @@ -196,7 +229,7 @@ } } - &--strikethrough { + &--strike { @include dark-theme { @include color-svg( '../images/icons/v3/text_format/textformat-strikethrough.svg', @@ -252,7 +285,7 @@ &--active { @include dark-theme { - background-color: $color-ultramarine; + background-color: $color-ultramarine-light; } @include light-theme { background-color: $color-ultramarine; @@ -263,13 +296,14 @@ background-color: $color-ultramarine; } .dark-theme.mouse-mode #{$parent}:hover & { - background-color: $color-ultramarine; + background-color: $color-ultramarine-light; } } } } } + // Note: This is referenced in ModalHost to ensure 'external' clicks on it still work &__suggestions { padding: 0; margin-bottom: 6px; @@ -435,6 +469,7 @@ button.CompositionInput__link-preview__close-button { } } +// Note: These are referenced in formatting/matchers.ts, to detect these styles on paste .quill { &--monospace { font-family: $monospace; diff --git a/stylesheets/components/MessageTextRenderer.scss b/stylesheets/components/MessageTextRenderer.scss index 822abc49f408..bec15faa05e5 100644 --- a/stylesheets/components/MessageTextRenderer.scss +++ b/stylesheets/components/MessageTextRenderer.scss @@ -12,6 +12,7 @@ } // Note: only used in the left pane for search results, not in message bubbles + // Note: This is referenced in formatting/matchers.ts, to detect these styles on paste &--keywordHighlight { // Boldness of this is handled by element @@ -26,9 +27,11 @@ // Note: Spoiler must be last to override any other formatting applied to the section &--spoiler { - user-select: none; cursor: pointer; + // Prepare for our inner copy target + position: relative; + // Lighten things up a bit opacity: 50%; border-radius: 4px; @@ -46,6 +49,23 @@ } } + &--spoiler--copy-target { + // We don't want this thing to affect the layout of the message + position: absolute; + top: 0; + // We can use left here; this is not visible to the user + /* stylelint-disable liberty/use-logical-spec */ + left: 0; + + height: 1px; + width: 1px; + + // Hide text + color: transparent; + overflow: hidden; + } + + // Note: This is referenced in formatting/matchers.ts, to detect these styles on paste &--spoiler--noninteractive { cursor: inherit; box-shadow: none; @@ -55,6 +75,9 @@ &--spoiler-StoryViewer { background-color: $color-white; } + &--spoiler-MediaEditor { + background-color: $color-gray-15; + } // The left pane &--spoiler-ConversationList, diff --git a/ts/components/AddCaptionModal.stories.tsx b/ts/components/AddCaptionModal.stories.tsx index 72fda55c1ee4..a98a7686fea3 100644 --- a/ts/components/AddCaptionModal.stories.tsx +++ b/ts/components/AddCaptionModal.stories.tsx @@ -25,14 +25,16 @@ export default { defaultValue: (props: SmartCompositionTextAreaProps) => ( undefined} i18n={i18n} - isFormattingEnabled={false} - isFormattingSpoilersEnabled={false} + isFormattingEnabled + isFormattingFlagEnabled + isFormattingSpoilersFlagEnabled onPickEmoji={action('onPickEmoji')} onChange={action('onChange')} onTextTooLong={action('onTextTooLong')} onSetSkinTone={action('onSetSkinTone')} - getPreferredBadge={() => undefined} + platform="darwin" /> ), }, diff --git a/ts/components/AddCaptionModal.tsx b/ts/components/AddCaptionModal.tsx index ed169305a6de..24ea2ba73af3 100644 --- a/ts/components/AddCaptionModal.tsx +++ b/ts/components/AddCaptionModal.tsx @@ -7,12 +7,17 @@ import { Button } from './Button'; import { Modal } from './Modal'; import type { LocalizerType, ThemeType } from '../types/Util'; import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea'; +import type { HydratedBodyRangesType } from '../types/BodyRange'; export type Props = { i18n: LocalizerType; onClose: () => void; - onSubmit: (text: string) => void; + onSubmit: ( + text: string, + bodyRanges: HydratedBodyRangesType | undefined + ) => void; draftText: string; + draftBodyRanges: HydratedBodyRangesType | undefined; theme: ThemeType; RenderCompositionTextArea: ( props: SmartCompositionTextAreaProps @@ -24,10 +29,14 @@ export function AddCaptionModal({ onClose, onSubmit, draftText, + draftBodyRanges, RenderCompositionTextArea, theme, }: Props): JSX.Element { const [messageText, setMessageText] = React.useState(''); + const [bodyRanges, setBodyRanges] = React.useState< + HydratedBodyRangesType | undefined + >(); const [isScrolledTop, setIsScrolledTop] = React.useState(true); const [isScrolledBottom, setIsScrolledBottom] = React.useState(true); @@ -51,8 +60,8 @@ export function AddCaptionModal({ }, [updateScrollState]); const handleSubmit = React.useCallback(() => { - onSubmit(messageText); - }, [messageText, onSubmit]); + onSubmit(messageText, bodyRanges); + }, [bodyRanges, messageText, onSubmit]); return ( { + setMessageText(updatedMessageText); + setBodyRanges(updatedBodyRanges); + }} scrollerRef={scrollerRef} draftText={draftText} + bodyRanges={draftBodyRanges} onSubmit={noop} onScroll={updateScrollState} theme={theme} diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index 9d01937e5f7d..08c3461c4e51 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -39,9 +39,13 @@ const useProps = (overrideProps: Partial = {}): Props => ({ sendCounter: 0, i18n, isDisabled: false, - isFormattingSpoilersEnabled: - overrideProps.isFormattingSpoilersEnabled === false - ? overrideProps.isFormattingSpoilersEnabled + isFormattingFlagEnabled: + overrideProps.isFormattingFlagEnabled === false + ? overrideProps.isFormattingFlagEnabled + : true, + isFormattingSpoilersFlagEnabled: + overrideProps.isFormattingSpoilersFlagEnabled === false + ? overrideProps.isFormattingSpoilersFlagEnabled : true, isFormattingEnabled: overrideProps.isFormattingEnabled === false @@ -50,6 +54,7 @@ const useProps = (overrideProps: Partial = {}): Props => ({ messageCompositionId: '456', sendEditedMessage: action('sendEditedMessage'), sendMultiMediaMessage: action('sendMultiMediaMessage'), + platform: 'darwin', processAttachments: action('processAttachments'), removeAttachment: action('removeAttachment'), theme: React.useContext(StorybookThemeContext), @@ -290,12 +295,18 @@ QuoteWithPayment.story = { name: 'Quote with payment', }; -export function NoFormatting(): JSX.Element { +export function NoFormattingMenu(): JSX.Element { return ; } -export function NoSpoilerFormatting(): JSX.Element { +export function NoFormattingFlag(): JSX.Element { + return ; +} + +export function NoSpoilerFormattingFlag(): JSX.Element { return ( - + ); } diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index efb68175e137..5cd5929f91e0 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { get } from 'lodash'; import classNames from 'classnames'; import type { ReadonlyDeep } from 'type-fest'; @@ -99,7 +98,8 @@ export type OwnProps = Readonly<{ isDisabled: boolean; isFetchingUUID?: boolean; isFormattingEnabled: boolean; - isFormattingSpoilersEnabled: boolean; + isFormattingFlagEnabled: boolean; + isFormattingSpoilersFlagEnabled: boolean; isGroupV1AndDisabled?: boolean; isMissingMandatoryProfileSharing?: boolean; isSignalConversation?: boolean; @@ -112,6 +112,7 @@ export type OwnProps = Readonly<{ messageRequestsEnabled?: boolean; onClearAttachments(conversationId: string): unknown; onCloseLinkPreview(conversationId: string): unknown; + platform: string; showToast: ShowToastAction; processAttachments: (options: { conversationId: string; @@ -226,6 +227,7 @@ export function CompositionArea({ messageCompositionId, showToast, pushPanelForConversation, + platform, processAttachments, removeAttachment, sendEditedMessage, @@ -259,8 +261,9 @@ export function CompositionArea({ draftText, getPreferredBadge, getQuotedMessage, - isFormattingSpoilersEnabled, isFormattingEnabled, + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled, onEditorStateChange, onTextTooLong, sendCounter, @@ -616,8 +619,8 @@ export function CompositionArea({ const key = KeyboardLayout.lookup(e); // 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 commandKey = platform === 'darwin' && metaKey; + const controlKey = platform !== 'darwin' && ctrlKey; const commandOrCtrl = commandKey || controlKey; // cmd/ctrl-shift-k @@ -632,7 +635,7 @@ export function CompositionArea({ return () => { document.removeEventListener('keydown', handler); }; - }, [setLarge]); + }, [platform, setLarge]); const handleRecordingBeforeSend = useCallback(() => { emojiButtonRef.current?.close(); @@ -914,8 +917,9 @@ export function CompositionArea({ getQuotedMessage={getQuotedMessage} i18n={i18n} inputApi={inputApiRef} - isFormattingSpoilersEnabled={isFormattingSpoilersEnabled} isFormattingEnabled={isFormattingEnabled} + isFormattingFlagEnabled={isFormattingFlagEnabled} + isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled} large={large} linkPreviewLoading={linkPreviewLoading} linkPreviewResult={linkPreviewResult} @@ -925,6 +929,7 @@ export function CompositionArea({ onPickEmoji={onPickEmoji} onSubmit={handleSubmit} onTextTooLong={onTextTooLong} + platform={platform} sendCounter={sendCounter} skinTone={skinTone} sortedGroupMembers={sortedGroupMembers} diff --git a/ts/components/CompositionInput.stories.tsx b/ts/components/CompositionInput.stories.tsx index b37c5dae2eb1..48834ff2a667 100644 --- a/ts/components/CompositionInput.stories.tsx +++ b/ts/components/CompositionInput.stories.tsx @@ -28,9 +28,13 @@ const useProps = (overrideProps: Partial = {}): Props => ({ clearQuotedMessage: action('clearQuotedMessage'), getPreferredBadge: () => undefined, getQuotedMessage: action('getQuotedMessage'), - isFormattingSpoilersEnabled: - overrideProps.isFormattingSpoilersEnabled === false - ? overrideProps.isFormattingSpoilersEnabled + isFormattingFlagEnabled: + overrideProps.isFormattingFlagEnabled === false + ? overrideProps.isFormattingFlagEnabled + : true, + isFormattingSpoilersFlagEnabled: + overrideProps.isFormattingSpoilersFlagEnabled === false + ? overrideProps.isFormattingSpoilersFlagEnabled : true, isFormattingEnabled: overrideProps.isFormattingEnabled === false @@ -42,6 +46,7 @@ const useProps = (overrideProps: Partial = {}): Props => ({ onPickEmoji: action('onPickEmoji'), onSubmit: action('onSubmit'), onTextTooLong: action('onTextTooLong'), + platform: 'darwin', sendCounter: 0, sortedGroupMembers: overrideProps.sortedGroupMembers || [], skinTone: select( @@ -142,12 +147,18 @@ export function Mentions(): JSX.Element { return ; } -export function NoFormatting(): JSX.Element { +export function NoFormattingMenu(): JSX.Element { return ; } -export function NoSpoilerFormatting(): JSX.Element { +export function NoFormattingFlag(): JSX.Element { + return ; +} + +export function NoSpoilerFormattingFlag(): JSX.Element { return ( - + ); } diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index bdb27c61e656..2a830e904634 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -22,7 +22,7 @@ import type { HydratedBodyRangesType, RangeNode, } from '../types/BodyRange'; -import { collapseRangeTree, insertRange } from '../types/BodyRange'; +import { BodyRange, 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'; @@ -31,7 +31,6 @@ import { MentionBlot } from '../quill/mentions/blot'; import { matchEmojiImage, matchEmojiBlot, - matchReactEmoji, matchEmojiText, } from '../quill/emoji/matchers'; import { matchMention } from '../quill/mentions/matchers'; @@ -53,6 +52,14 @@ import type { LinkPreviewType } from '../types/message/LinkPreviews'; import { StagedLinkPreview } from './conversation/StagedLinkPreview'; import type { DraftEditMessageType } from '../model-types.d'; import { usePrevious } from '../hooks/usePrevious'; +import { + matchBold, + matchItalic, + matchMonospace, + matchSpoiler, + matchStrikethrough, +} from '../quill/formatting/matchers'; +import { missingCaseError } from '../util/missingCaseError'; Quill.register('formats/emoji', EmojiBlot); Quill.register('formats/mention', MentionBlot); @@ -91,7 +98,8 @@ export type Props = Readonly<{ large?: boolean; inputApi?: React.MutableRefObject; isFormattingEnabled: boolean; - isFormattingSpoilersEnabled: boolean; + isFormattingFlagEnabled: boolean; + isFormattingSpoilersFlagEnabled: boolean; sendCounter: number; skinTone?: EmojiPickDataType['skinTone']; draftText?: string; @@ -117,6 +125,7 @@ export type Props = Readonly<{ timestamp: number ): unknown; onScroll?: (ev: React.UIEvent) => void; + platform: string; getQuotedMessage?(): unknown; clearQuotedMessage?(): unknown; linkPreviewLoading?: boolean; @@ -141,7 +150,8 @@ export function CompositionInput(props: Props): React.ReactElement { i18n, inputApi, isFormattingEnabled, - isFormattingSpoilersEnabled, + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled, large, linkPreviewLoading, linkPreviewResult, @@ -151,6 +161,7 @@ export function CompositionInput(props: Props): React.ReactElement { onScroll, onSubmit, placeholder, + platform, skinTone, sendCounter, sortedGroupMembers, @@ -220,7 +231,29 @@ export function CompositionInput(props: Props): React.ReactElement { return { text: '', bodyRanges: [] }; } - return getTextAndRangesFromOps(ops); + const { text, bodyRanges } = getTextAndRangesFromOps(ops); + + return { + text, + bodyRanges: bodyRanges.filter(range => { + if (BodyRange.isMention(range)) { + return true; + } + if (BodyRange.isFormatting(range)) { + if (!isFormattingFlagEnabled) { + return false; + } + if ( + range.style === BodyRange.Style.SPOILER && + !isFormattingSpoilersFlagEnabled + ) { + return false; + } + return true; + } + throw missingCaseError(range); + }), + }; }; const focus = () => { @@ -352,32 +385,46 @@ export function CompositionInput(props: Props): React.ReactElement { isFormattingEnabled, isFormattingEnabled ); - const previousFormattingSpoilersEnabled = usePrevious( - isFormattingSpoilersEnabled, - isFormattingSpoilersEnabled + const previousFormattingFlagEnabled = usePrevious( + isFormattingFlagEnabled, + isFormattingFlagEnabled + ); + const previousFormattingSpoilersFlagEnabled = usePrevious( + isFormattingSpoilersFlagEnabled, + isFormattingSpoilersFlagEnabled ); React.useEffect(() => { const formattingChanged = typeof previousFormattingEnabled === 'boolean' && previousFormattingEnabled !== isFormattingEnabled; - const spoilersChanged = - typeof previousFormattingSpoilersEnabled === 'boolean' && - previousFormattingSpoilersEnabled !== isFormattingSpoilersEnabled; + const flagChanged = + typeof previousFormattingFlagEnabled === 'boolean' && + previousFormattingFlagEnabled !== isFormattingFlagEnabled; + const spoilersFlagChanged = + typeof previousFormattingSpoilersFlagEnabled === 'boolean' && + previousFormattingSpoilersFlagEnabled !== isFormattingSpoilersFlagEnabled; const quill = quillRef.current; - const changed = formattingChanged || spoilersChanged; + const changed = formattingChanged || flagChanged || spoilersFlagChanged; if (quill && changed) { quill.getModule('formattingMenu').updateOptions({ - isEnabled: isFormattingEnabled, - isSpoilersEnabled: isFormattingSpoilersEnabled, + isMenuEnabled: isFormattingEnabled, + isEnabled: isFormattingFlagEnabled, + isSpoilersEnabled: isFormattingSpoilersFlagEnabled, + }); + quill.options.formats = getQuillFormats({ + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled, }); } }, [ isFormattingEnabled, - isFormattingSpoilersEnabled, + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled, previousFormattingEnabled, - previousFormattingSpoilersEnabled, + previousFormattingFlagEnabled, + previousFormattingSpoilersFlagEnabled, quillRef, ]); @@ -643,7 +690,11 @@ export function CompositionInput(props: Props): React.ReactElement { matchers: [ ['IMG', matchEmojiImage], ['IMG', matchEmojiBlot], - ['SPAN', matchReactEmoji], + ['STRONG', matchBold], + ['EM', matchItalic], + ['SPAN', matchMonospace], + ['S', matchStrikethrough], + ['SPAN', matchSpoiler], [Node.TEXT_NODE, matchEmojiText], ['SPAN', matchMention(memberRepositoryRef)], ], @@ -677,8 +728,10 @@ export function CompositionInput(props: Props): React.ReactElement { }, formattingMenu: { i18n, - isEnabled: isFormattingEnabled, - isSpoilersEnabled: isFormattingSpoilersEnabled, + isMenuEnabled: isFormattingEnabled, + isEnabled: isFormattingFlagEnabled, + isSpoilersEnabled: isFormattingSpoilersFlagEnabled, + platform, setFormattingChooserElement, }, mentionCompletion: { @@ -692,25 +745,10 @@ export function CompositionInput(props: Props): React.ReactElement { theme, }, }} - formats={[ - // For image replacement (local-only) - 'emoji', - // @mentions - 'mention', - ...(isFormattingEnabled - ? [ - // Custom - ...(isFormattingSpoilersEnabled - ? [QuillFormattingStyle.spoiler] - : []), - QuillFormattingStyle.monospace, - // Built-in - QuillFormattingStyle.bold, - QuillFormattingStyle.italic, - QuillFormattingStyle.strike, - ] - : []), - ]} + formats={getQuillFormats({ + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled, + })} placeholder={placeholder || i18n('icu:sendMessage')} readOnly={disabled} ref={element => { @@ -838,3 +876,31 @@ export function CompositionInput(props: Props): React.ReactElement { ); } + +function getQuillFormats({ + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled, +}: { + isFormattingFlagEnabled: boolean; + isFormattingSpoilersFlagEnabled: boolean; +}): Array { + return [ + // For image replacement (local-only) + 'emoji', + // @mentions + 'mention', + ...(isFormattingFlagEnabled + ? [ + // Custom + ...(isFormattingSpoilersFlagEnabled + ? [QuillFormattingStyle.spoiler] + : []), + QuillFormattingStyle.monospace, + // Built-in + QuillFormattingStyle.bold, + QuillFormattingStyle.italic, + QuillFormattingStyle.strike, + ] + : []), + ]; +} diff --git a/ts/components/CompositionTextArea.tsx b/ts/components/CompositionTextArea.tsx index 50981b4f1ebc..e06aeb879db9 100644 --- a/ts/components/CompositionTextArea.tsx +++ b/ts/components/CompositionTextArea.tsx @@ -22,7 +22,8 @@ export type CompositionTextAreaProps = { bodyRanges?: HydratedBodyRangesType; i18n: LocalizerType; isFormattingEnabled: boolean; - isFormattingSpoilersEnabled: boolean; + isFormattingFlagEnabled: boolean; + isFormattingSpoilersFlagEnabled: boolean; maxLength?: number; placeholder?: string; whenToShowRemainingCount?: number; @@ -41,6 +42,7 @@ export type CompositionTextAreaProps = { timestamp: number ) => void; onTextTooLong: () => void; + platform: string; getPreferredBadge: PreferredBadgeSelectorType; draftText: string; theme: ThemeType; @@ -59,7 +61,8 @@ export function CompositionTextArea({ getPreferredBadge, i18n, isFormattingEnabled, - isFormattingSpoilersEnabled, + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled, maxLength, onChange, onPickEmoji, @@ -68,6 +71,7 @@ export function CompositionTextArea({ onSubmit, onTextTooLong, placeholder, + platform, recentEmojis, scrollerRef, skinTone, @@ -140,7 +144,8 @@ export function CompositionTextArea({ getQuotedMessage={noop} i18n={i18n} isFormattingEnabled={isFormattingEnabled} - isFormattingSpoilersEnabled={isFormattingSpoilersEnabled} + isFormattingFlagEnabled={isFormattingFlagEnabled} + isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled} inputApi={inputApiRef} large moduleClassName="CompositionTextArea__input" @@ -150,6 +155,7 @@ export function CompositionTextArea({ onSubmit={onSubmit} onTextTooLong={onTextTooLong} placeholder={placeholder} + platform={platform} scrollerRef={scrollerRef} sendCounter={0} theme={theme} diff --git a/ts/components/EditHistoryMessagesModal.tsx b/ts/components/EditHistoryMessagesModal.tsx index 6a6c7f91accf..7683765747e3 100644 --- a/ts/components/EditHistoryMessagesModal.tsx +++ b/ts/components/EditHistoryMessagesModal.tsx @@ -89,7 +89,7 @@ export function EditHistoryMessagesModal({ // These states aren't in redux; they are meant to last only as long as this dialog. const [revealedSpoilersById, setRevealedSpoilersById] = useState< - Record + Record | undefined> >({}); const [displayLimitById, setDisplayLimitById] = useState< Record @@ -118,7 +118,7 @@ export function EditHistoryMessagesModal({ displayLimit={displayLimitById[syntheticId]} getPreferredBadge={getPreferredBadge} i18n={i18n} - isSpoilerExpanded={revealedSpoilersById[syntheticId] || false} + isSpoilerExpanded={revealedSpoilersById[syntheticId] || {}} key={messageAttributes.timestamp} kickOffAttachmentDownload={kickOffAttachmentDownload} messageExpanded={(messageId, displayLimit) => { @@ -130,10 +130,10 @@ export function EditHistoryMessagesModal({ }} platform={platform} showLightbox={closeAndShowLightbox} - showSpoiler={messageId => { + showSpoiler={(messageId, data) => { const update = { ...revealedSpoilersById, - [messageId]: true, + [messageId]: data, }; setRevealedSpoilersById(update); }} diff --git a/ts/components/ForwardMessagesModal.stories.tsx b/ts/components/ForwardMessagesModal.stories.tsx index 52f2613864c6..0cf043ed2d52 100644 --- a/ts/components/ForwardMessagesModal.stories.tsx +++ b/ts/components/ForwardMessagesModal.stories.tsx @@ -58,14 +58,16 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ RenderCompositionTextArea: props => ( undefined} i18n={i18n} - isFormattingSpoilersEnabled isFormattingEnabled + isFormattingFlagEnabled + isFormattingSpoilersFlagEnabled onPickEmoji={action('onPickEmoji')} - skinTone={0} onSetSkinTone={action('onSetSkinTone')} onTextTooLong={action('onTextTooLong')} - getPreferredBadge={() => undefined} + platform="darwin" + skinTone={0} /> ), showToast: action('showToast'), diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index 711eb707f098..d4566e6913c2 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -13,6 +13,8 @@ import type { AvatarColorType } from '../types/Colors'; import type { BadgeType } from '../badges/types'; import { handleOutsideClick } from '../util/handleOutsideClick'; +const EMPTY_OBJECT = Object.freeze(Object.create(null)); + export type PropsType = { areStoriesEnabled: boolean; avatarPath?: string; @@ -186,7 +188,7 @@ export function MainHeader({ showArchivedConversations(); setShowAvatarPopup(false); }} - style={{}} + style={EMPTY_OBJECT} /> , portalElement diff --git a/ts/components/MediaEditor.stories.tsx b/ts/components/MediaEditor.stories.tsx index dd379c48d27d..0e8adf1778fb 100644 --- a/ts/components/MediaEditor.stories.tsx +++ b/ts/components/MediaEditor.stories.tsx @@ -63,13 +63,15 @@ export function WithCaption(): JSX.Element { renderCompositionTextArea={props => ( undefined} i18n={i18n} - isFormattingSpoilersEnabled isFormattingEnabled + isFormattingFlagEnabled + isFormattingSpoilersFlagEnabled onPickEmoji={action('onPickEmoji')} onSetSkinTone={action('onSetSkinTone')} onTextTooLong={action('onTextTooLong')} - getPreferredBadge={() => undefined} + platform="darwin" /> )} /> diff --git a/ts/components/MediaEditor.tsx b/ts/components/MediaEditor.tsx index 0be953f4d9ad..a0e4708bfb44 100644 --- a/ts/components/MediaEditor.tsx +++ b/ts/components/MediaEditor.tsx @@ -41,10 +41,11 @@ import { } from '../mediaEditor/util/getTextStyleAttributes'; import { AddCaptionModal } from './AddCaptionModal'; import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea'; -import { Emojify } from './conversation/Emojify'; -import { AddNewLines } from './conversation/AddNewLines'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; import { Spinner } from './Spinner'; +import type { HydratedBodyRangesType } from '../types/BodyRange'; +import { MessageBody } from './conversation/MessageBody'; +import { RenderLocation } from './conversation/MessageTextRenderer'; import { arrow } from '../util/keyboard'; export type MediaEditorResultType = Readonly<{ @@ -52,6 +53,7 @@ export type MediaEditorResultType = Readonly<{ contentType: MIMEType; blurHash: string; caption?: string; + captionBodyRanges?: HydratedBodyRangesType; }>; export type PropsType = { @@ -137,6 +139,9 @@ export function MediaEditor({ useState(false); const [caption, setCaption] = useState(''); + const [captionBodyRanges, setCaptionBodyRanges] = useState< + HydratedBodyRangesType | undefined + >(); const [showAddCaptionModal, setShowAddCaptionModal] = useState(false); @@ -948,11 +953,12 @@ export function MediaEditor({ > {caption !== '' ? ( - ( - - )} /> ) : ( @@ -964,8 +970,10 @@ export function MediaEditor({ { + draftBodyRanges={captionBodyRanges} + onSubmit={(messageText, bodyRanges) => { setCaption(messageText.trim()); + setCaptionBodyRanges(bodyRanges); setShowAddCaptionModal(false); }} onClose={() => setShowAddCaptionModal(false)} @@ -1230,6 +1238,7 @@ export function MediaEditor({ contentType: IMAGE_PNG, data, caption: caption !== '' ? caption : undefined, + captionBodyRanges, blurHash, }); }} diff --git a/ts/components/ModalHost.tsx b/ts/components/ModalHost.tsx index 5c9b8e36bbda..33cacbf7d057 100644 --- a/ts/components/ModalHost.tsx +++ b/ts/components/ModalHost.tsx @@ -133,6 +133,7 @@ export const ModalHost = React.memo(function ModalHostInner({ const exemptParent = target.closest( '.TitleBarContainer__title, ' + '.module-composition-input__suggestions, ' + + '.module-composition-input__format-menu, ' + '.module-calling__modal-container' ); if (exemptParent) { diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index d04ec8a13489..fc1316953c40 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -594,7 +594,7 @@ export function Preferences({ {isFormattingFlagEnabled && ( , conversationIds: Array, - attachment: AttachmentType + attachment: AttachmentType, + bodyRanges: DraftBodyRanges | undefined ) => unknown; imageToBlurHash: typeof imageToBlurHash; processAttachment: ( @@ -123,6 +125,7 @@ export function StoryCreator({ >(); const [isReadyToSend, setIsReadyToSend] = useState(false); const [attachmentUrl, setAttachmentUrl] = useState(); + const [bodyRanges, setBodyRanges] = useState(); useEffect(() => { let url: string | undefined; @@ -192,7 +195,7 @@ export function StoryCreator({ onRepliesNReactionsChanged={onRepliesNReactionsChanged} onSelectedStoryList={onSelectedStoryList} onSend={(listIds, groupIds) => { - onSend(listIds, groupIds, draftAttachment); + onSend(listIds, groupIds, draftAttachment, bodyRanges); setDraftAttachment(undefined); }} onViewersUpdated={onViewersUpdated} @@ -219,7 +222,13 @@ export function StoryCreator({ supportsCaption renderCompositionTextArea={renderCompositionTextArea} imageToBlurHash={imageToBlurHash} - onDone={({ contentType, data, blurHash, caption }) => { + onDone={({ + contentType, + data, + blurHash, + caption, + captionBodyRanges, + }) => { setDraftAttachment({ ...draftAttachment, contentType, @@ -228,6 +237,7 @@ export function StoryCreator({ blurHash, caption, }); + setBodyRanges(captionBodyRanges); setIsReadyToSend(true); }} recentStickers={recentStickers} diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 8cacec52ba9e..a374c890eb69 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -86,7 +86,8 @@ export type PropsType = { hasViewReceiptSetting: boolean; i18n: LocalizerType; isFormattingEnabled: boolean; - isFormattingSpoilersEnabled: boolean; + isFormattingFlagEnabled: boolean; + isFormattingSpoilersFlagEnabled: boolean; isInternalUser?: boolean; isSignalConversation?: boolean; isWindowActive: boolean; @@ -148,7 +149,8 @@ export function StoryViewer({ hasViewReceiptSetting, i18n, isFormattingEnabled, - isFormattingSpoilersEnabled, + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled, isInternalUser, isSignalConversation, isWindowActive, @@ -242,7 +244,9 @@ export function StoryViewer({ // Caption related hooks const [hasExpandedCaption, setHasExpandedCaption] = useState(false); - const [isSpoilerExpanded, setIsSpoilerExpanded] = useState(false); + const [isSpoilerExpanded, setIsSpoilerExpanded] = useState< + Record + >({}); const caption = useMemo(() => { if (!attachment?.caption) { @@ -259,7 +263,7 @@ export function StoryViewer({ // Reset expansion if messageId changes useEffect(() => { setHasExpandedCaption(false); - setIsSpoilerExpanded(false); + setIsSpoilerExpanded({}); }, [messageId]); // messageId is set as a dependency so that we can reset the story duration @@ -343,7 +347,7 @@ export function StoryViewer({ setConfirmDeleteStory(undefined); setHasConfirmHideStory(false); setHasExpandedCaption(false); - setIsSpoilerExpanded(false); + setIsSpoilerExpanded({}); setIsShowingContextMenu(false); setPauseStory(false); @@ -692,7 +696,7 @@ export function StoryViewer({ bodyRanges={bodyRanges} i18n={i18n} isSpoilerExpanded={isSpoilerExpanded} - onExpandSpoiler={() => setIsSpoilerExpanded(true)} + onExpandSpoiler={data => setIsSpoilerExpanded(data)} renderLocation={RenderLocation.StoryViewer} text={caption.text} /> @@ -941,7 +945,8 @@ export function StoryViewer({ i18n={i18n} platform={platform} isFormattingEnabled={isFormattingEnabled} - isFormattingSpoilersEnabled={isFormattingSpoilersEnabled} + isFormattingFlagEnabled={isFormattingFlagEnabled} + isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled} isInternalUser={isInternalUser} group={group} onClose={() => setCurrentViewTarget(null)} diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx index fb798717698f..754153a5104b 100644 --- a/ts/components/StoryViewsNRepliesModal.tsx +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -91,7 +91,8 @@ export type PropsType = { i18n: LocalizerType; platform: string; isFormattingEnabled: boolean; - isFormattingSpoilersEnabled: boolean; + isFormattingFlagEnabled: boolean; + isFormattingSpoilersFlagEnabled: boolean; isInternalUser?: boolean; onChangeViewTarget: (target: StoryViewTargetType) => unknown; onClose: () => unknown; @@ -127,7 +128,8 @@ export function StoryViewsNRepliesModal({ i18n, platform, isFormattingEnabled, - isFormattingSpoilersEnabled, + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled, isInternalUser, onChangeViewTarget, onClose, @@ -155,7 +157,7 @@ export function StoryViewsNRepliesModal({ // These states aren't in redux; they are meant to last only as long as this dialog. const [revealedSpoilersById, setRevealedSpoilersById] = useState< - Record + Record | undefined> >({}); const [displayLimitById, setDisplayLimitById] = useState< Record @@ -239,7 +241,8 @@ export function StoryViewsNRepliesModal({ i18n={i18n} inputApi={inputApiRef} isFormattingEnabled={isFormattingEnabled} - isFormattingSpoilersEnabled={isFormattingSpoilersEnabled} + isFormattingFlagEnabled={isFormattingFlagEnabled} + isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled} moduleClassName="StoryViewsNRepliesModal__input" onCloseLinkPreview={noop} onEditorStateChange={({ messageText }) => { @@ -259,6 +262,7 @@ export function StoryViewsNRepliesModal({ firstName: authorTitle, }) } + platform={platform} sendCounter={0} sortedGroupMembers={sortedGroupMembers} theme={ThemeType.dark} @@ -310,7 +314,7 @@ export function StoryViewsNRepliesModal({ platform={platform} id={reply.id} isInternalUser={isInternalUser} - isSpoilerExpanded={revealedSpoilersById[reply.id] || false} + isSpoilerExpanded={revealedSpoilersById[reply.id] || {}} messageExpanded={(messageId, displayLimit) => { const update = { ...displayLimitById, @@ -322,10 +326,10 @@ export function StoryViewsNRepliesModal({ shouldCollapseAbove={shouldCollapse(reply, replies[index - 1])} shouldCollapseBelow={shouldCollapse(reply, replies[index + 1])} showContactModal={showContactModal} - showSpoiler={messageId => { + showSpoiler={(messageId, data) => { const update = { ...revealedSpoilersById, - [messageId]: true, + [messageId]: data, }; setRevealedSpoilersById(update); }} @@ -504,14 +508,14 @@ type ReplyOrReactionMessageProps = { platform: string; id: string; isInternalUser?: boolean; - isSpoilerExpanded: boolean; + isSpoilerExpanded: Record; onContextMenu?: (ev: React.MouseEvent) => void; reply: ReplyType; shouldCollapseAbove: boolean; shouldCollapseBelow: boolean; showContactModal: (contactId: string, conversationId?: string) => void; messageExpanded: (messageId: string, displayLimit: number) => void; - showSpoiler: (messageId: string) => void; + showSpoiler: (messageId: string, data: Record) => void; }; function ReplyOrReactionMessage({ diff --git a/ts/components/Tooltip.tsx b/ts/components/Tooltip.tsx index 4f16732eae80..55d9634b8f36 100644 --- a/ts/components/Tooltip.tsx +++ b/ts/components/Tooltip.tsx @@ -21,7 +21,7 @@ type EventWrapperPropsType = { // disabled button. This uses native browser events to avoid that. // // See . -const TooltipEventWrapper = React.forwardRef< +export const TooltipEventWrapper = React.forwardRef< HTMLSpanElement, EventWrapperPropsType >(function TooltipEvent({ onHoverChanged, children }, ref): JSX.Element { diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 221d5bc11b83..a08052cc2ea8 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -218,7 +218,7 @@ export type PropsData = { isTargetedCounter?: number; isSelected: boolean; isSelectMode: boolean; - isSpoilerExpanded?: boolean; + isSpoilerExpanded?: Record; direction: DirectionType; timestamp: number; status?: MessageStatusType; @@ -324,7 +324,7 @@ export type PropsActions = { pushPanelForConversation: PushPanelForConversationActionType; retryMessageSend: (messageId: string) => unknown; showContactModal: (contactId: string, conversationId?: string) => void; - showSpoiler: (messageId: string) => void; + showSpoiler: (messageId: string, data: Record) => void; kickOffAttachmentDownload: (options: { attachment: AttachmentType; @@ -1803,7 +1803,7 @@ export class Message extends React.PureComponent { displayLimit={displayLimit} i18n={i18n} id={id} - isSpoilerExpanded={isSpoilerExpanded || false} + isSpoilerExpanded={isSpoilerExpanded || {}} kickOffBodyDownload={() => { if (!textAttachment) { return; @@ -1816,7 +1816,7 @@ export class Message extends React.PureComponent { messageExpanded={messageExpanded} showConversation={showConversation} renderLocation={RenderLocation.Timeline} - onExpandSpoiler={() => showSpoiler(id)} + onExpandSpoiler={data => showSpoiler(id, data)} text={contents || ''} textAttachment={textAttachment} /> diff --git a/ts/components/conversation/MessageBody.stories.tsx b/ts/components/conversation/MessageBody.stories.tsx index e5c9fb36c1cc..916a6bfd5a9e 100644 --- a/ts/components/conversation/MessageBody.stories.tsx +++ b/ts/components/conversation/MessageBody.stories.tsx @@ -23,7 +23,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ disableLinks: overrideProps.disableLinks || false, direction: 'incoming', i18n, - isSpoilerExpanded: overrideProps.isSpoilerExpanded || false, + isSpoilerExpanded: overrideProps.isSpoilerExpanded || {}, onExpandSpoiler: overrideProps.onExpandSpoiler || action('onExpandSpoiler'), renderLocation: RenderLocation.Timeline, showConversation: @@ -216,7 +216,7 @@ ComplexMessageBody.story = { }; export function FormattingBasic(): JSX.Element { - const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false); + const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState({}); const props = createProps({ bodyRanges: [ @@ -258,7 +258,7 @@ export function FormattingBasic(): JSX.Element { }, ], isSpoilerExpanded, - onExpandSpoiler: () => setIsSpoilerExpanded(true), + onExpandSpoiler: data => setIsSpoilerExpanded(data), text: '… It’s in words that the magic is – Abracadabra, Open Sesame, and the rest – but the magic words in one story aren’t magical in the next. The real magic is to understand which words work, and when, and for what; the trick is to learn the trick. … And those words are made from the letters of our alphabet: a couple-dozen squiggles we can draw with the pen. This is the key! And the treasure, too, if we can only get our hands on it! It’s as if – as if the key to the treasure is the treasure!', }); @@ -272,7 +272,7 @@ export function FormattingBasic(): JSX.Element { } export function FormattingSpoiler(): JSX.Element { - const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false); + const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState({}); const props = createProps({ bodyRanges: [ @@ -312,7 +312,7 @@ export function FormattingSpoiler(): JSX.Element { }, ], isSpoilerExpanded, - onExpandSpoiler: () => setIsSpoilerExpanded(true), + onExpandSpoiler: data => setIsSpoilerExpanded(data), text: "This is a very secret https://somewhere.com 💡 thing, \uFFFC and \uFFFC, that you shouldn't be able to read. Stay away!", }); @@ -322,9 +322,9 @@ export function FormattingSpoiler(): JSX.Element {

- +
- + ); } @@ -406,7 +406,7 @@ export function FormattingNesting(): JSX.Element { } export function FormattingComplex(): JSX.Element { - const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false); + const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState({}); const text = 'Computational processes \uFFFC are abstract beings that inhabit computers. ' + 'As they evolve, processes manipulate other abstract things called data. ' + @@ -461,7 +461,7 @@ export function FormattingComplex(): JSX.Element { }, ], isSpoilerExpanded, - onExpandSpoiler: () => setIsSpoilerExpanded(true), + onExpandSpoiler: data => setIsSpoilerExpanded(data), text, }); diff --git a/ts/components/conversation/MessageBody.tsx b/ts/components/conversation/MessageBody.tsx index 9059e8192bf6..4693e6aa7899 100644 --- a/ts/components/conversation/MessageBody.tsx +++ b/ts/components/conversation/MessageBody.tsx @@ -24,9 +24,9 @@ export type Props = { // If set, interactive elements will be left as plain text: links, mentions, spoilers disableLinks?: boolean; i18n: LocalizerType; - isSpoilerExpanded: boolean; + isSpoilerExpanded: Record; kickOffBodyDownload?: () => void; - onExpandSpoiler?: () => unknown; + onExpandSpoiler?: (data: Record) => unknown; onIncreaseTextLength?: () => unknown; prefix?: string; renderLocation: RenderLocation; diff --git a/ts/components/conversation/MessageBodyReadMore.stories.tsx b/ts/components/conversation/MessageBodyReadMore.stories.tsx index f7e2ee250730..d987402f33a0 100644 --- a/ts/components/conversation/MessageBodyReadMore.stories.tsx +++ b/ts/components/conversation/MessageBodyReadMore.stories.tsx @@ -25,7 +25,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ displayLimit: overrideProps.displayLimit, i18n, id: 'some-id', - isSpoilerExpanded: overrideProps.isSpoilerExpanded === true, + isSpoilerExpanded: overrideProps.isSpoilerExpanded || {}, messageExpanded: action('messageExpanded'), onExpandSpoiler: overrideProps.onExpandSpoiler || action('onExpandSpoiler'), renderLocation: RenderLocation.Timeline, @@ -39,8 +39,8 @@ function MessageBodyReadMoreTest({ text: messageBodyText, }: { bodyRanges?: HydratedBodyRangesType; - isSpoilerExpanded?: boolean; - onExpandSpoiler?: () => void; + isSpoilerExpanded?: Record; + onExpandSpoiler?: (data: Record) => void; text: string; }): JSX.Element { const [displayLimit, setDisplayLimit] = useState(); @@ -132,7 +132,7 @@ export function LongTextWithFormatting(): JSX.Element { } export function LongTextMostlySpoiler(): JSX.Element { - const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false); + const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState({}); const bodyRanges = [ { start: 7, @@ -148,7 +148,7 @@ export function LongTextMostlySpoiler(): JSX.Element { bodyRanges={bodyRanges} text={text} isSpoilerExpanded={isSpoilerExpanded} - onExpandSpoiler={() => setIsSpoilerExpanded(true)} + onExpandSpoiler={data => setIsSpoilerExpanded(data)} /> ); } diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index 9af50286ce5b..13d9025914e9 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -42,7 +42,7 @@ const defaultMessage: MessageDataPropsType = { isMessageRequestAccepted: true, isSelected: false, isSelectMode: false, - isSpoilerExpanded: false, + isSpoilerExpanded: {}, previews: [], readStatus: ReadStatus.Read, status: 'sent', diff --git a/ts/components/conversation/MessageTextRenderer.tsx b/ts/components/conversation/MessageTextRenderer.tsx index 96d2c2c82e03..f1cfc9a330b7 100644 --- a/ts/components/conversation/MessageTextRenderer.tsx +++ b/ts/components/conversation/MessageTextRenderer.tsx @@ -5,16 +5,18 @@ import React from 'react'; import type { ReactElement } from 'react'; import classNames from 'classnames'; import emojiRegex from 'emoji-regex'; +import { sortBy } from 'lodash'; import { linkify, SUPPORTED_PROTOCOLS } from './Linkify'; import type { - BodyRange, BodyRangesForDisplayType, DisplayNode, HydratedBodyRangeMention, RangeNode, } from '../../types/BodyRange'; import { + SPOILER_REPLACEMENT, + BodyRange, insertRange, collapseRangeTree, groupContiguousSpoilers, @@ -30,6 +32,7 @@ const EMOJI_REGEXP = emojiRegex(); export enum RenderLocation { ConversationList = 'ConversationList', Quote = 'Quote', + MediaEditor = 'MediaEditor', SearchResult = 'SearchResult', StoryViewer = 'StoryViewer', Timeline = 'Timeline', @@ -41,9 +44,9 @@ type Props = { disableLinks: boolean; emojiSizeClass: SizeClassType | undefined; i18n: LocalizerType; - isSpoilerExpanded: boolean; + isSpoilerExpanded: Record; messageText: string; - onExpandSpoiler?: () => void; + onExpandSpoiler?: (data: Record) => void; onMentionTrigger: (conversationId: string) => void; renderLocation: RenderLocation; // Sometimes we're passed a string with a suffix (like '...'); we won't process that @@ -63,19 +66,32 @@ export function MessageTextRenderer({ renderLocation, textLength, }: Props): JSX.Element { - const links = disableLinks ? [] : extractLinks(messageText); - const tree = bodyRanges.reduce>( - (acc, range) => { - // Drop bodyRanges that don't apply. Read More means truncated strings. - if (range.start < textLength) { - return insertRange(range, acc); - } - return acc; - }, - links.map(b => ({ ...b, ranges: [] })) - ); - const nodes = collapseRangeTree({ tree, text: messageText }); - const finalNodes = groupContiguousSpoilers(nodes); + const finalNodes = React.useMemo(() => { + const links = disableLinks ? [] : extractLinks(messageText); + + // We need mentions to come last; they can't have children for proper rendering + const sortedRanges = sortBy(bodyRanges, range => + BodyRange.isMention(range) ? 1 : 0 + ); + + // Create range tree, dropping bodyRanges that don't apply. Read More means truncated + // strings. + const tree = sortedRanges.reduce>( + (acc, range) => { + if (range.start < textLength) { + return insertRange(range, acc); + } + return acc; + }, + links.map(b => ({ ...b, ranges: [] })) + ); + + // Turn tree into flat list for proper spoiler rendering + const nodes = collapseRangeTree({ tree, text: messageText }); + + // Group all contigusous spoilers to create one parent spoiler element in the DOM + return groupContiguousSpoilers(nodes); + }, [bodyRanges, disableLinks, messageText, textLength]); return ( <> @@ -114,16 +130,18 @@ function renderNode({ emojiSizeClass: SizeClassType | undefined; i18n: LocalizerType; isInvisible: boolean; - isSpoilerExpanded: boolean; + isSpoilerExpanded: Record; node: DisplayNode; - onExpandSpoiler?: () => void; + onExpandSpoiler?: (data: Record) => void; onMentionTrigger: ((conversationId: string) => void) | undefined; renderLocation: RenderLocation; }): ReactElement { const key = node.start; if (node.isSpoiler && node.spoilerChildren?.length) { - const isSpoilerHidden = Boolean(node.isSpoiler && !isSpoilerExpanded); + const isSpoilerHidden = Boolean( + node.isSpoiler && !isSpoilerExpanded[node.spoilerIndex || 0] + ); const content = node.spoilerChildren?.map(spoilerNode => renderNode({ direction, @@ -174,7 +192,10 @@ function renderNode({ if (onExpandSpoiler) { event.preventDefault(); event.stopPropagation(); - onExpandSpoiler(); + onExpandSpoiler({ + ...isSpoilerExpanded, + [node.spoilerIndex || 0]: true, + }); } } } @@ -187,10 +208,19 @@ function renderNode({ } event.preventDefault(); event.stopPropagation(); - onExpandSpoiler?.(); + onExpandSpoiler?.({ + ...isSpoilerExpanded, + [node.spoilerIndex || 0]: true, + }); } } > + + {SPOILER_REPLACEMENT} + {content} ); diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index 4258ebbcd707..52ed0d697de5 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -114,7 +114,7 @@ const defaultMessageProps: TimelineMessagesProps = { isMessageRequestAccepted: true, isSelected: false, isSelectMode: false, - isSpoilerExpanded: false, + isSpoilerExpanded: {}, toggleSelectMessage: action('toggleSelectMessage'), kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'), markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'), diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index 11807fa80021..f7c944039362 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -27,6 +27,8 @@ import { PaymentEventKind } from '../../types/Payment'; import { getPaymentEventNotificationText } from '../../messages/helpers'; import { RenderLocation } from './MessageTextRenderer'; +const EMPTY_OBJECT = Object.freeze(Object.create(null)); + export type Props = { authorTitle: string; conversationColor: ConversationColorType; @@ -359,7 +361,7 @@ export function Quote(props: Props): JSX.Element | null { disableLinks disableJumbomoji i18n={i18n} - isSpoilerExpanded={false} + isSpoilerExpanded={EMPTY_OBJECT} renderLocation={RenderLocation.Quote} text={text} /> diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index b01558176544..a2f2b3a61971 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -65,7 +65,7 @@ function mockMessageTimelineItem( isMessageRequestAccepted: true, isSelected: false, isSelectMode: false, - isSpoilerExpanded: false, + isSpoilerExpanded: {}, previews: [], readStatus: ReadStatus.Read, canRetryDeleteForEveryone: true, diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx index ab5cb1023f1a..7b5897d07a92 100644 --- a/ts/components/conversation/TimelineMessage.stories.tsx +++ b/ts/components/conversation/TimelineMessage.stories.tsx @@ -300,9 +300,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ isSelectMode: isBoolean(overrideProps.isSelectMode) ? overrideProps.isSelectMode : false, - isSpoilerExpanded: isBoolean(overrideProps.isSpoilerExpanded) - ? overrideProps.isSpoilerExpanded - : false, + isSpoilerExpanded: overrideProps.isSpoilerExpanded || {}, isTapToView: overrideProps.isTapToView, isTapToViewError: overrideProps.isTapToViewError, isTapToViewExpired: overrideProps.isTapToViewExpired, diff --git a/ts/components/conversationList/ConversationListItem.tsx b/ts/components/conversationList/ConversationListItem.tsx index 74d4004795ea..03061191a272 100644 --- a/ts/components/conversationList/ConversationListItem.tsx +++ b/ts/components/conversationList/ConversationListItem.tsx @@ -21,6 +21,7 @@ import type { BadgeType } from '../../badges/types'; import { isSignalConversation } from '../../util/isSignalConversation'; import { RenderLocation } from '../conversation/MessageTextRenderer'; +const EMPTY_OBJECT = Object.freeze(Object.create(null)); const MESSAGE_STATUS_ICON_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__status-icon`; export const MessageStatuses = [ @@ -149,7 +150,7 @@ export const ConversationListItem: FunctionComponent = React.memo( disableJumbomoji disableLinks i18n={i18n} - isSpoilerExpanded={false} + isSpoilerExpanded={{}} prefix={draftPreview.prefix} renderLocation={RenderLocation.ConversationList} text={draftPreview.text} @@ -170,7 +171,7 @@ export const ConversationListItem: FunctionComponent = React.memo( disableJumbomoji disableLinks i18n={i18n} - isSpoilerExpanded={false} + isSpoilerExpanded={EMPTY_OBJECT} prefix={lastMessage.prefix} renderLocation={RenderLocation.ConversationList} text={lastMessage.text} diff --git a/ts/components/conversationList/MessageSearchResult.tsx b/ts/components/conversationList/MessageSearchResult.tsx index f91dd9bfab2b..96049dd8a828 100644 --- a/ts/components/conversationList/MessageSearchResult.tsx +++ b/ts/components/conversationList/MessageSearchResult.tsx @@ -3,6 +3,7 @@ import type { FunctionComponent, ReactNode } from 'react'; import React, { useCallback } from 'react'; +import { noop } from 'lodash'; import { ContactName } from '../conversation/ContactName'; @@ -21,6 +22,8 @@ import { RenderLocation, } from '../conversation/MessageTextRenderer'; +const EMPTY_OBJECT = Object.freeze(Object.create(null)); + export type PropsDataType = { isSelected?: boolean; isSearchingInConversation?: boolean; @@ -166,8 +169,8 @@ export const MessageSearchResult: FunctionComponent = React.memo( disableLinks emojiSizeClass={undefined} i18n={i18n} - isSpoilerExpanded={false} - onMentionTrigger={() => null} + isSpoilerExpanded={EMPTY_OBJECT} + onMentionTrigger={noop} renderLocation={RenderLocation.SearchResult} textLength={cleanedSnippet.length} /> diff --git a/ts/jobs/helpers/sendStory.ts b/ts/jobs/helpers/sendStory.ts index 2cf3e6708a15..2283008cede2 100644 --- a/ts/jobs/helpers/sendStory.ts +++ b/ts/jobs/helpers/sendStory.ts @@ -125,6 +125,7 @@ export async function sendStory( } const attachments = originalMessage.get('attachments') || []; + const bodyRanges = originalMessage.get('bodyRanges')?.slice(); const [attachment] = attachments; if (!attachment) { @@ -180,6 +181,7 @@ export async function sendStory( // attributes inside it. originalStoryMessage = await messaging.getStoryMessage({ allowsReplies: true, + bodyRanges, fileAttachment, groupV2, textAttachment, @@ -317,6 +319,7 @@ export async function sendStory( ); const storyMessage = new Proto.StoryMessage(); + storyMessage.bodyRanges = originalStoryMessage.bodyRanges; storyMessage.profileKey = originalStoryMessage.profileKey; storyMessage.fileAttachment = originalStoryMessage.fileAttachment; storyMessage.textAttachment = originalStoryMessage.textAttachment; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index b3e0ed99fa18..e59f6c60dfeb 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -4164,6 +4164,7 @@ export class ConversationModel extends window.Backbone draftTimestamp: null, quotedMessageId: undefined, lastMessageAuthor: message.getAuthorText(), + lastMessageBodyRanges: message.get('bodyRanges'), lastMessage: message.getNotificationText(), lastMessageStatus: 'sending' as const, }; diff --git a/ts/quill/emoji/completion.tsx b/ts/quill/emoji/completion.tsx index ea846d243c5e..127e399733aa 100644 --- a/ts/quill/emoji/completion.tsx +++ b/ts/quill/emoji/completion.tsx @@ -204,6 +204,15 @@ export class EmojiCompletion { return PASS_THROUGH; } + getAttributesForInsert(index: number): Record { + const character = index > 0 ? index - 1 : 0; + const contents = this.quill.getContents(character, 1); + return contents.ops.reduce( + (acc, op) => ({ acc, ...op.attributes }), + {} as Record + ); + } + completeEmoji(): void { const range = this.quill.getSelection(); @@ -241,7 +250,9 @@ export class EmojiCompletion { const delta = new Delta().retain(index).delete(range).insert({ emoji }); if (withTrailingSpace) { - this.quill.updateContents(delta.insert(' '), 'user'); + // The extra space we add won't be formatted unless we manually provide attributes + const attributes = this.getAttributesForInsert(range - 1); + this.quill.updateContents(delta.insert(' ', attributes), 'user'); this.quill.setSelection(index + 2, 0, 'user'); } else { this.quill.updateContents(delta, 'user'); diff --git a/ts/quill/emoji/matchers.ts b/ts/quill/emoji/matchers.ts index 06340e7f567f..c543d4fe4d81 100644 --- a/ts/quill/emoji/matchers.ts +++ b/ts/quill/emoji/matchers.ts @@ -4,12 +4,16 @@ import Delta from 'quill-delta'; import { insertEmojiOps } from '../util'; -export const matchEmojiImage = (node: Element): Delta => { - if (node.classList.contains('emoji')) { - const emoji = node.getAttribute('title'); +export const matchEmojiImage = (node: Element, delta: Delta): Delta => { + if ( + (node.classList.contains('emoji') || + node.classList.contains('module-emoji__image--16px')) && + !node.classList.contains('emoji--invisible') + ) { + const emoji = node.getAttribute('aria-label'); return new Delta().insert({ emoji }); } - return new Delta(); + return delta; }; export const matchEmojiBlot = (node: HTMLElement, delta: Delta): Delta => { @@ -20,14 +24,6 @@ export const matchEmojiBlot = (node: HTMLElement, delta: Delta): Delta => { return delta; }; -export const matchReactEmoji = (node: HTMLElement, delta: Delta): Delta => { - if (node.classList.contains('module-emoji')) { - const emoji = node.innerText.trim(); - return new Delta().insert({ emoji }); - } - return delta; -}; - export const matchEmojiText = (node: Text): Delta => { const nodeAsInsert = { insert: node.data }; diff --git a/ts/quill/formatting/matchers.ts b/ts/quill/formatting/matchers.ts new file mode 100644 index 000000000000..31d4c2bae229 --- /dev/null +++ b/ts/quill/formatting/matchers.ts @@ -0,0 +1,80 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import Delta from 'quill-delta'; +import { QuillFormattingStyle } from './menu'; + +function applyStyleToOps(delta: Delta, style: QuillFormattingStyle): Delta { + return new Delta( + delta.map(op => ({ + ...op, + attributes: { + ...op.attributes, + [style]: true, + }, + })) + ); +} + +export const matchBold = (_node: HTMLElement, delta: Delta): Delta => { + if (delta.length() > 0) { + return applyStyleToOps(delta, QuillFormattingStyle.bold); + } + + return delta; +}; + +export const matchItalic = (_node: HTMLElement, delta: Delta): Delta => { + if (delta.length() > 0) { + return applyStyleToOps(delta, QuillFormattingStyle.italic); + } + + return delta; +}; + +export const matchStrikethrough = (_node: HTMLElement, delta: Delta): Delta => { + if (delta.length() > 0) { + return applyStyleToOps(delta, QuillFormattingStyle.strike); + } + + return delta; +}; + +export const matchMonospace = (node: HTMLElement, delta: Delta): Delta => { + const classes = [ + 'MessageTextRenderer__formatting--monospace', + 'quill--monospace', + ]; + // Note: This is defined as $monospace in _variables.scss + const fontFamily = + 'font-family: "SF Mono", SFMono-Regular, ui-monospace, "DejaVu Sans Mono", Menlo, Consolas, monospace;'; + + if ( + delta.length() > 0 && + (node.classList.contains(classes[0]) || + node.classList.contains(classes[1]) || + node.attributes.getNamedItem('style')?.value?.includes(fontFamily)) + ) { + return applyStyleToOps(delta, QuillFormattingStyle.monospace); + } + + return delta; +}; + +export const matchSpoiler = (node: HTMLElement, delta: Delta): Delta => { + const classes = [ + 'quill--spoiler', + 'MessageTextRenderer__formatting--spoiler--revealed', + // Note: we don't match on hidden spoilers in message body; we use copy-target text + ]; + + if ( + delta.length() > 0 && + (node.classList.contains(classes[0]) || + node.classList.contains(classes[1]) || + node.classList.contains(classes[2])) + ) { + return applyStyleToOps(delta, QuillFormattingStyle.spoiler); + } + return delta; +}; diff --git a/ts/quill/formatting/menu.tsx b/ts/quill/formatting/menu.tsx index 82174344f564..e55c70cf7dc4 100644 --- a/ts/quill/formatting/menu.tsx +++ b/ts/quill/formatting/menu.tsx @@ -2,21 +2,36 @@ // SPDX-License-Identifier: AGPL-3.0-only import type Quill from 'quill'; +import type { KeyboardContext } 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 { pick } from 'lodash'; import * as log from '../../logging/log'; import * as Errors from '../../types/errors'; import type { LocalizerType } from '../../types/Util'; import { handleOutsideClick } from '../../util/handleOutsideClick'; +import { SECOND } from '../../util/durations/constants'; + +const BUTTON_HOVER_TIMEOUT = 2 * SECOND; + +// Note: Keyboard shortcuts are defined in the constructor below, and when using +// below. They're also referenced in ShortcutGuide.tsx. +const BOLD_CHAR = 'B'; +const ITALIC_CHAR = 'I'; +const MONOSPACE_CHAR = 'E'; +const SPOILER_CHAR = 'B'; +const STRIKETHROUGH_CHAR = 'X'; type FormattingPickerOptions = { i18n: LocalizerType; + isMenuEnabled: boolean; isEnabled: boolean; isSpoilersEnabled: boolean; + platform: string; setFormattingChooserElement: (element: JSX.Element | null) => void; }; @@ -28,9 +43,50 @@ export enum QuillFormattingStyle { spoiler = 'spoiler', } -export class FormattingMenu { - lastSelection: { start: number; end: number } | undefined; +function findMaximumRect(rects: DOMRectList): + | { + x: number; + y: number; + height: number; + width: number; + } + | undefined { + const first = rects[0]; + if (!first) { + return undefined; + } + let result = pick(first, ['top', 'left', 'right', 'bottom']); + + for (let i = 1, max = rects.length; i < max; i += 1) { + const rect = rects[i]; + + result = { + top: Math.min(rect.top, result.top), + left: Math.min(rect.left, result.left), + bottom: Math.max(rect.bottom, result.bottom), + right: Math.max(rect.right, result.right), + }; + } + + return { + x: result.left, + y: result.top, + height: result.bottom - result.top, + width: result.right - result.left, + }; +} + +function getMetaKey(platform: string, i18n: LocalizerType) { + const isMacOS = platform === 'darwin'; + + if (isMacOS) { + return '⌘'; + } + return i18n('icu:Keyboard--Key--ctrl'); +} + +export class FormattingMenu { options: FormattingPickerOptions; outsideClickDestructor?: () => void; @@ -51,19 +107,21 @@ export class FormattingMenu { // We override these keybindings, which means that we need to move their priority // above the built-in shortcuts, which don't exactly do what we want. - const boldChar = 'B'; - const boldCharCode = boldChar.charCodeAt(0); - this.quill.keyboard.addBinding({ key: boldChar, shortKey: true }, () => - this.toggleForStyle(QuillFormattingStyle.bold) + const boldCharCode = BOLD_CHAR.charCodeAt(0); + this.quill.keyboard.addBinding( + { key: BOLD_CHAR, shortKey: true }, + (_range, context) => + this.toggleForStyle(QuillFormattingStyle.bold, context) ); quill.keyboard.bindings[boldCharCode].unshift( quill.keyboard.bindings[boldCharCode].pop() ); - const italicChar = 'I'; - const italicCharCode = italicChar.charCodeAt(0); - this.quill.keyboard.addBinding({ key: italicChar, shortKey: true }, () => - this.toggleForStyle(QuillFormattingStyle.italic) + const italicCharCode = ITALIC_CHAR.charCodeAt(0); + this.quill.keyboard.addBinding( + { key: ITALIC_CHAR, shortKey: true }, + (_range, context) => + this.toggleForStyle(QuillFormattingStyle.italic, context) ); quill.keyboard.bindings[italicCharCode].unshift( quill.keyboard.bindings[italicCharCode].pop() @@ -71,16 +129,20 @@ export class FormattingMenu { // No need for changing priority for these new keybindings - this.quill.keyboard.addBinding({ key: 'E', shortKey: true }, () => - this.toggleForStyle(QuillFormattingStyle.monospace) + this.quill.keyboard.addBinding( + { key: MONOSPACE_CHAR, shortKey: true }, + (_range, context) => + this.toggleForStyle(QuillFormattingStyle.monospace, context) ); this.quill.keyboard.addBinding( - { key: 'X', shortKey: true, shiftKey: true }, - () => this.toggleForStyle(QuillFormattingStyle.strike) + { key: STRIKETHROUGH_CHAR, shortKey: true, shiftKey: true }, + (_range, context) => + this.toggleForStyle(QuillFormattingStyle.strike, context) ); this.quill.keyboard.addBinding( - { key: 'B', shortKey: true, shiftKey: true }, - () => this.toggleForStyle(QuillFormattingStyle.spoiler) + { key: SPOILER_CHAR, shortKey: true, shiftKey: true }, + (_range, context) => + this.toggleForStyle(QuillFormattingStyle.spoiler, context) ); } @@ -94,8 +156,7 @@ export class FormattingMenu { } onEditorChange(): void { - if (!this.options.isEnabled) { - this.lastSelection = undefined; + if (!this.options.isMenuEnabled) { this.referenceElement = undefined; this.render(); @@ -104,38 +165,19 @@ export class FormattingMenu { 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) { + if (!quillSelection || quillSelection.length === 0) { 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 || { + this.referenceElement = { getBoundingClientRect() { const selection = window.getSelection(); @@ -148,26 +190,37 @@ export class FormattingMenu { const editorElement = activeElement?.closest( '.module-composition-input__input' ); + const editorRect = editorElement?.getClientRects()[0]; + if (!editorRect) { + log.warn('No editor rect when showing formatting menu'); + return new DOMRect(); + } - const rect = range.getClientRects()[0]; + const rect = findMaximumRect(range.getClientRects()); + if (!rect) { + log.warn('No maximum rect when showing formatting menu'); + return new DOMRect(); + } // 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) - 10, - (rect?.y || 0) - 10 + (editorRect.y || 0) - 10, + (rect.y || 0) - 10 ); + const updatedHeight = rect.height + (rect.y - updatedY); return DOMRect.fromRect({ x: rect.x, y: updatedY, - height: rect.height, + height: updatedHeight, 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 + + log.warn('No selection range when showing formatting menu'); + return new DOMRect(); }, }; } @@ -184,9 +237,21 @@ export class FormattingMenu { return contents.ops.every(op => op.attributes?.[style]); } - toggleForStyle(style: QuillFormattingStyle): void { + toggleForStyle(style: QuillFormattingStyle, context?: KeyboardContext): void { + if (!this.options.isEnabled) { + return; + } + if ( + !this.options.isSpoilersEnabled && + style === QuillFormattingStyle.spoiler + ) { + return; + } + try { - const isEnabled = this.isStyleEnabledInSelection(style); + const isEnabled = context + ? Boolean(context.format[style]) + : this.isStyleEnabledInSelection(style); if (isEnabled === undefined) { return; } @@ -197,7 +262,7 @@ export class FormattingMenu { } render(): void { - if (!this.lastSelection) { + if (!this.referenceElement) { this.outsideClickDestructor?.(); this.outsideClickDestructor = undefined; @@ -206,123 +271,103 @@ export class FormattingMenu { return; } - const { i18n, isSpoilersEnabled } = this.options; + const { i18n, isSpoilersEnabled, platform } = this.options; + const metaKey = getMetaKey(platform, i18n); + const shiftKey = i18n('icu:Keyboard--Key--shift'); // showing the popup format menu + const isStyleEnabledInSelection = this.isStyleEnabledInSelection.bind(this); + const toggleForStyle = this.toggleForStyle.bind(this); const element = createPortal( - - {({ ref, style }) => ( -
- - - - - {isSpoilersEnabled ? ( - - ) : null} -
- )} + ) : null} + + ); + }}
, this.root ); @@ -342,3 +387,92 @@ export class FormattingMenu { this.options.setFormattingChooserElement(element); } } + +function FormattingButton({ + hasLongHovered, + isStyleEnabledInSelection, + label, + onLongHover, + popupGuideText, + popupGuideShortcut, + style, + toggleForStyle, +}: { + hasLongHovered: boolean; + isStyleEnabledInSelection: ( + style: QuillFormattingStyle + ) => boolean | undefined; + label: string; + onLongHover: (value: boolean) => unknown; + popupGuideText: string; + popupGuideShortcut: string; + style: QuillFormattingStyle; + toggleForStyle: (style: QuillFormattingStyle) => unknown; +}): JSX.Element { + const buttonRef = React.useRef(null); + const timerRef = React.useRef(); + const [isHovered, setIsHovered] = React.useState(false); + + return ( + <> + {hasLongHovered && isHovered && buttonRef.current ? ( + + {({ ref, style: popperStyles }) => ( +
+ {popupGuideText} +
+ {popupGuideShortcut} +
+
+ )} +
+ ) : null} + + + ); +} diff --git a/ts/quill/mentions/completion.tsx b/ts/quill/mentions/completion.tsx index b33a62869c26..c5e735cb849b 100644 --- a/ts/quill/mentions/completion.tsx +++ b/ts/quill/mentions/completion.tsx @@ -184,16 +184,31 @@ export class MentionCompletion { } } + getAttributesForInsert(index: number): Record { + const character = index > 0 ? index - 1 : 0; + const contents = this.quill.getContents(character, 1); + return contents.ops.reduce( + (acc, op) => ({ acc, ...op.attributes }), + {} as Record + ); + } + insertMention( mention: ConversationType, index: number, range: number, withTrailingSpace = false ): void { - const delta = new Delta().retain(index).delete(range).insert({ mention }); + // The mention + space we add won't be formatted unless we manually provide attributes + const attributes = this.getAttributesForInsert(range - 1); + + const delta = new Delta() + .retain(index) + .delete(range) + .insert({ mention }, attributes); if (withTrailingSpace) { - this.quill.updateContents(delta.insert(' '), 'user'); + this.quill.updateContents(delta.insert(' ', attributes), 'user'); this.quill.setSelection(index + 2, 0, 'user'); } else { this.quill.updateContents(delta, 'user'); diff --git a/ts/quill/mentions/matchers.ts b/ts/quill/mentions/matchers.ts index 526663816f5e..1189e6f1d7cc 100644 --- a/ts/quill/mentions/matchers.ts +++ b/ts/quill/mentions/matchers.ts @@ -13,7 +13,10 @@ export const matchMention = if (memberRepository) { const { title } = node.dataset; - if (node.classList.contains('MessageBody__at-mention')) { + if ( + node.classList.contains('MessageBody__at-mention') && + !node.classList.contains('MessageBody__at-mention--invisible') + ) { const { id } = node.dataset; const conversation = memberRepository.getMemberById(id); diff --git a/ts/quill/signal-clipboard/index.ts b/ts/quill/signal-clipboard/index.ts index 63a4ea88cbc1..e56d387275a1 100644 --- a/ts/quill/signal-clipboard/index.ts +++ b/ts/quill/signal-clipboard/index.ts @@ -4,24 +4,6 @@ import type Quill from 'quill'; import Delta from 'quill-delta'; -import { getTextFromOps } from '../util'; - -const getSelectionHTML = () => { - const selection = window.getSelection(); - - if (selection == null) { - return ''; - } - - const range = selection.getRangeAt(0); - const contents = range.cloneContents(); - const div = document.createElement('div'); - - div.appendChild(contents); - - return div.innerHTML; -}; - const replaceAngleBrackets = (text: string) => { const entities: Array<[RegExp, string]> = [ [/&/g, '&'], @@ -41,47 +23,14 @@ export class SignalClipboard { constructor(quill: Quill) { this.quill = quill; - this.quill.root.addEventListener('copy', e => this.onCaptureCopy(e, false)); - this.quill.root.addEventListener('cut', e => this.onCaptureCopy(e, true)); this.quill.root.addEventListener('paste', e => this.onCapturePaste(e)); + + const clipboard = this.quill.getModule('clipboard'); + // We don't want any of the default matchers! + clipboard.matchers = clipboard.matchers.slice(11); } - onCaptureCopy(event: ClipboardEvent, isCut = false): void { - event.preventDefault(); - - if (event.clipboardData == null) { - return; - } - - const range = this.quill.getSelection(); - - if (range == null) { - return; - } - - const contents = this.quill.getContents(range.index, range.length); - - if (contents == null) { - return; - } - - const { ops } = contents; - - if (!ops || !ops.length) { - return; - } - - const text = getTextFromOps(ops); - const html = getSelectionHTML(); - - event.clipboardData.setData('text/plain', text); - event.clipboardData.setData('text/signal', html); - - if (isCut) { - this.quill.deleteText(range.index, range.length, 'user'); - } - } - + // TODO: do we need this anymore, given that we aren't using signal/html? onCapturePaste(event: ClipboardEvent): void { if (event.clipboardData == null) { return; @@ -97,7 +46,7 @@ export class SignalClipboard { } const text = event.clipboardData.getData('text/plain'); - const html = event.clipboardData.getData('text/signal'); + const html = event.clipboardData.getData('text/html'); const clipboardDelta = html ? clipboard.convert(html) diff --git a/ts/quill/types.d.ts b/ts/quill/types.d.ts index 1396253ca7fa..cb661d307e69 100644 --- a/ts/quill/types.d.ts +++ b/ts/quill/types.d.ts @@ -50,6 +50,7 @@ declare module 'quill' { interface ClipboardStatic { convert(html: string): UpdatedDelta; + matchers: Array; } interface SelectionStatic { @@ -80,13 +81,17 @@ declare module 'quill' { getModule(module: string): unknown; selection: SelectionStatic; + options: Record; } + export type KeyboardContext = { + format: Record; + }; + interface KeyboardStatic { addBinding( key: UpdatedKey, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - callback: (range: RangeStatic, context: any) => void + callback: (range: RangeStatic, context: KeyboardContext) => void ): void; // in-code reference missing in @types bindings: Record>; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 1e8ea02a2f61..784fd5ac035d 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -185,7 +185,7 @@ export type MessageType = MessageAttributesType & { // eslint-disable-next-line local-rules/type-alias-readonlydeep export type MessageWithUIFieldsType = MessageAttributesType & { displayLimit?: number; - isSpoilerExpanded?: boolean; + isSpoilerExpanded?: Record; }; export const ConversationTypes = ['direct', 'group'] as const; @@ -737,6 +737,7 @@ export type ShowSpoilerActionType = ReadonlyDeep<{ type: typeof SHOW_SPOILER; payload: { id: string; + data: Record; }; }>; @@ -2740,11 +2741,15 @@ function messageExpanded( }, }; } -function showSpoiler(id: string): ShowSpoilerActionType { +function showSpoiler( + id: string, + data: Record +): ShowSpoilerActionType { return { type: SHOW_SPOILER, payload: { id, + data, }, }; } @@ -4981,7 +4986,7 @@ export function reducer( }; } if (action.type === SHOW_SPOILER) { - const { id } = action.payload; + const { id, data } = action.payload; const existingMessage = state.messagesLookup[id]; if (!existingMessage) { @@ -4990,7 +4995,7 @@ export function reducer( const updatedMessage = { ...existingMessage, - isSpoilerExpanded: true, + isSpoilerExpanded: data, }; return { diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index a40ea6afcf63..aaf8764e5059 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -617,7 +617,8 @@ function replyToStory( function sendStoryMessage( listIds: Array, conversationIds: Array, - attachment: AttachmentType + attachment: AttachmentType, + bodyRanges: DraftBodyRanges | undefined ): ThunkAction< void, RootStateType, @@ -661,7 +662,12 @@ function sendStoryMessage( } try { - await doSendStoryMessage(listIds, conversationIds, attachment); + await doSendStoryMessage( + listIds, + conversationIds, + attachment, + bodyRanges + ); // Note: Only when we've successfully queued the message do we dismiss the story // composer view. diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index 124922d8b290..69dbdfb6ad81 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -15,7 +15,12 @@ import { imageToBlurHash } from '../../util/imageToBlurHash'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { selectRecentEmojis } from '../selectors/emojis'; -import { getIntl, getTheme, getUserConversationId } from '../selectors/user'; +import { + getIntl, + getPlatform, + getTheme, + getUserConversationId, +} from '../selectors/user'; import { getEmojiSkinTone, getTextFormattingEnabled } from '../selectors/items'; import { getConversationSelector, @@ -52,6 +57,7 @@ export type CompositionAreaPropsType = ExternalProps & ComponentPropsType; const mapStateToProps = (state: StateType, props: ExternalProps) => { const { id } = props; + const platform = getPlatform(state); const conversationSelector = getConversationSelector(state); const conversation = conversationSelector(id); @@ -112,11 +118,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { const selectedMessageIds = getSelectedMessageIds(state); - const isFormattingEnabled = - getIsFormattingFlagEnabled(state) && getTextFormattingEnabled(state); - const isFormattingSpoilersEnabled = - getIsFormattingSpoilersFlagEnabled(state) && - getTextFormattingEnabled(state); + const isFormattingEnabled = getTextFormattingEnabled(state); + const isFormattingFlagEnabled = getIsFormattingFlagEnabled(state); + const isFormattingSpoilersFlagEnabled = + getIsFormattingSpoilersFlagEnabled(state); return { // Base @@ -126,9 +131,11 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { getPreferredBadge: getPreferredBadgeSelector(state), i18n: getIntl(state), isDisabled, - isFormattingSpoilersEnabled, isFormattingEnabled, + isFormattingFlagEnabled, + isFormattingSpoilersFlagEnabled, messageCompositionId, + platform, sendCounter, theme: getTheme(state), diff --git a/ts/state/smart/CompositionTextArea.tsx b/ts/state/smart/CompositionTextArea.tsx index 10de8388c5a9..ed92e9f257a8 100644 --- a/ts/state/smart/CompositionTextArea.tsx +++ b/ts/state/smart/CompositionTextArea.tsx @@ -5,9 +5,7 @@ import React from 'react'; import { useSelector } from 'react-redux'; import type { CompositionTextAreaProps } from '../../components/CompositionTextArea'; import { CompositionTextArea } from '../../components/CompositionTextArea'; -import type { LocalizerType } from '../../types/I18N'; -import type { StateType } from '../reducer'; -import { getIntl } from '../selectors/user'; +import { getIntl, getPlatform } from '../selectors/user'; import { useActions as useEmojiActions } from '../ducks/emojis'; import { useActions as useItemsActions } from '../ducks/items'; import { getPreferredBadgeSelector } from '../selectors/badges'; @@ -35,30 +33,32 @@ export type SmartCompositionTextAreaProps = Pick< export function SmartCompositionTextArea( props: SmartCompositionTextAreaProps ): JSX.Element { - const i18n = useSelector(getIntl); + const i18n = useSelector(getIntl); + const platform = useSelector(getPlatform); const { onUseEmoji: onPickEmoji } = useEmojiActions(); const { onSetSkinTone } = useItemsActions(); const { onTextTooLong } = useComposerActions(); const getPreferredBadge = useSelector(getPreferredBadgeSelector); - const isFormattingOptionEnabled = useSelector(getTextFormattingEnabled); - const isFormattingEnabled = - useSelector(getIsFormattingFlagEnabled) && isFormattingOptionEnabled; - const isFormattingSpoilersEnabled = - useSelector(getIsFormattingSpoilersFlagEnabled) && - isFormattingOptionEnabled; + const isFormattingEnabled = useSelector(getTextFormattingEnabled); + const isFormattingFlagEnabled = useSelector(getIsFormattingFlagEnabled); + const isFormattingSpoilersFlagEnabled = useSelector( + getIsFormattingSpoilersFlagEnabled + ); return ( ); } diff --git a/ts/state/smart/StoryViewer.tsx b/ts/state/smart/StoryViewer.tsx index b1ec89c10b23..4d52bcf468f5 100644 --- a/ts/state/smart/StoryViewer.tsx +++ b/ts/state/smart/StoryViewer.tsx @@ -94,12 +94,11 @@ export function SmartStoryViewer(): JSX.Element | null { getHasStoryViewReceiptSetting ); - const isFormattingOptionEnabled = useSelector(getTextFormattingEnabled); - const isFormattingEnabled = - useSelector(getIsFormattingFlagEnabled) && isFormattingOptionEnabled; - const isFormattingSpoilersEnabled = - useSelector(getIsFormattingSpoilersFlagEnabled) && - isFormattingOptionEnabled; + const isFormattingEnabled = useSelector(getTextFormattingEnabled); + const isFormattingFlagEnabled = useSelector(getIsFormattingFlagEnabled); + const isFormattingSpoilersFlagEnabled = useSelector( + getIsFormattingSpoilersFlagEnabled + ); const { pauseVoiceNotePlayer } = useAudioPlayerActions(); @@ -127,7 +126,8 @@ export function SmartStoryViewer(): JSX.Element | null { platform={platform} isInternalUser={internalUser} isFormattingEnabled={isFormattingEnabled} - isFormattingSpoilersEnabled={isFormattingSpoilersEnabled} + isFormattingFlagEnabled={isFormattingFlagEnabled} + isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled} isSignalConversation={isSignalConversation({ id: conversationStory.conversationId, })} diff --git a/ts/test-node/quill/util_test.ts b/ts/test-node/quill/util_test.ts index 6cf3acd12522..088e4e19ed9d 100644 --- a/ts/test-node/quill/util_test.ts +++ b/ts/test-node/quill/util_test.ts @@ -101,6 +101,64 @@ describe('getTextAndRangesFromOps', () => { }); }); + describe('given formatting', () => { + it('handles trimming at the end of the message', () => { + const ops = [ + { + insert: 'Text with trailing ', + attributes: { bold: true }, + }, + { + insert: 'whitespace ', + attributes: { bold: true, italic: true }, + }, + ]; + const { text, bodyRanges } = getTextAndRangesFromOps(ops); + assert.equal(text, 'Text with trailing whitespace'); + assert.equal(bodyRanges.length, 2); + assert.deepEqual(bodyRanges, [ + { + start: 0, + length: 29, + style: BodyRange.Style.BOLD, + }, + { + start: 19, + length: 10, + style: BodyRange.Style.ITALIC, + }, + ]); + }); + + it('handles trimming at beginning of the message', () => { + const ops = [ + { + insert: ' Text with leading ', + attributes: { bold: true }, + }, + { + insert: 'whitespace!!', + attributes: { bold: true, italic: true }, + }, + ]; + const { text, bodyRanges } = getTextAndRangesFromOps(ops); + assert.equal(text, 'Text with leading whitespace!!'); + assert.equal(bodyRanges.length, 2); + assert.deepEqual(bodyRanges, [ + { + start: 0, + length: 30, + style: BodyRange.Style.BOLD, + }, + { + start: 18, + length: 12, + style: BodyRange.Style.ITALIC, + }, + ]); + }); + }); + describe('given text, emoji, and mentions', () => { it('returns the trimmed text with placeholders and mentions', () => { const ops = [ diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index b22b5eca8791..0d688c331332 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -696,21 +696,27 @@ export default class MessageSender { async getStoryMessage({ allowsReplies, + bodyRanges, fileAttachment, groupV2, profileKey, textAttachment, }: { allowsReplies?: boolean; + bodyRanges?: Array; fileAttachment?: UploadedAttachmentType; groupV2?: GroupV2InfoType; profileKey: Uint8Array; textAttachment?: OutgoingTextAttachmentType; }): Promise { const storyMessage = new Proto.StoryMessage(); + storyMessage.profileKey = profileKey; if (fileAttachment) { + if (bodyRanges) { + storyMessage.bodyRanges = bodyRanges; + } try { storyMessage.fileAttachment = fileAttachment; } catch (error) { diff --git a/ts/types/BodyRange.ts b/ts/types/BodyRange.ts index 8592e44d45dd..7443f5c97630 100644 --- a/ts/types/BodyRange.ts +++ b/ts/types/BodyRange.ts @@ -326,6 +326,7 @@ export type DisplayNode = { isKeywordHighlight?: boolean; // Only for spoilers, only to represent contiguous groupings + spoilerIndex?: number; spoilerChildren?: ReadonlyArray; }; type PartialDisplayNode = Omit< @@ -450,15 +451,18 @@ export function groupContiguousSpoilers( const result: Array = []; let spoilerContainer: DisplayNode | undefined; + let spoilerIndex = 0; nodes.forEach(node => { if (node.isSpoiler) { if (!spoilerContainer) { spoilerContainer = { ...node, + spoilerIndex, isSpoiler: true, spoilerChildren: [], }; + spoilerIndex += 1; result.push(spoilerContainer); } if (spoilerContainer) { @@ -567,7 +571,7 @@ export function processBodyRangesForSearchResult({ }; } -const SPOILER_REPLACEMENT = '■■■■'; +export const SPOILER_REPLACEMENT = '■■■■'; export function applyRangesForText({ text, diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index a6ffa7cdf8c0..afa23f5fd7f4 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -693,9 +693,8 @@ "rule": "thenify-multiArgs", "path": "node_modules/default-browser-id/node_modules/pify/index.js", "line": "\t\t\t\t} else if (opts.multiArgs) {", - "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", - "updated": "2023-04-20T16:43:40.643Z", - "reasonDetail": "" + "reasonCategory": "usageTrusted", + "updated": "2023-04-20T16:43:40.643Z" }, { "rule": "DOM-outerHTML", @@ -2370,9 +2369,8 @@ "rule": "React-useRef", "path": "ts/components/conversation/InlineNotificationWrapper.tsx", "line": " const focusRef = useRef(null);", - "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", - "updated": "2023-04-12T15:51:28.066Z", - "reasonDetail": "" + "reasonCategory": "usageTrusted", + "updated": "2023-04-12T15:51:28.066Z" }, { "rule": "React-createRef", @@ -2402,17 +2400,15 @@ "rule": "React-useRef", "path": "ts/components/conversation/MessageDetail.tsx", "line": " const focusRef = useRef(null);", - "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", - "updated": "2023-04-12T15:51:28.066Z", - "reasonDetail": "" + "reasonCategory": "usageTrusted", + "updated": "2023-04-12T15:51:28.066Z" }, { "rule": "React-useRef", "path": "ts/components/conversation/MessageDetail.tsx", "line": " const messageContainerRef = useRef(null);", - "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", - "updated": "2023-04-12T15:51:28.066Z", - "reasonDetail": "" + "reasonCategory": "usageTrusted", + "updated": "2023-04-12T15:51:28.066Z" }, { "rule": "React-useRef", @@ -2546,12 +2542,20 @@ "updated": "2021-10-22T00:52:39.251Z" }, { - "rule": "DOM-innerHTML", - "path": "ts/quill/signal-clipboard/index.ts", - "line": " return div.innerHTML;", + "rule": "React-useRef", + "path": "ts/quill/formatting/menu.tsx", + "line": " const buttonRef = React.useRef(null);", "reasonCategory": "usageTrusted", - "updated": "2020-11-06T17:43:07.381Z", - "reasonDetail": "used for figuring out clipboard contents" + "updated": "2023-04-22T00:07:56.294Z", + "reasonDetail": "Popper needs to reference the button" + }, + { + "rule": "React-useRef", + "path": "ts/quill/formatting/menu.tsx", + "line": " const timerRef = React.useRef();", + "reasonCategory": "usageTrusted", + "updated": "2023-04-22T00:07:56.294Z", + "reasonDetail": "We need a persistent timer to track long-hovers" }, { "rule": "React-useRef", diff --git a/ts/util/sendStoryMessage.ts b/ts/util/sendStoryMessage.ts index 2a784e3205f3..dc8bfeb7789e 100644 --- a/ts/util/sendStoryMessage.ts +++ b/ts/util/sendStoryMessage.ts @@ -28,11 +28,13 @@ import { isNotNil } from './isNotNil'; import { collect } from './iterables'; import { DurationInSeconds } from './durations'; import { sanitizeLinkPreview } from '../services/LinkPreview'; +import type { DraftBodyRanges } from '../types/BodyRange'; export async function sendStoryMessage( listIds: Array, conversationIds: Array, - attachment: AttachmentType + attachment: AttachmentType, + bodyRanges: DraftBodyRanges | undefined ): Promise { if (getStoriesBlocked()) { log.warn('stories.sendStoryMessage: stories disabled, returning early'); @@ -171,6 +173,7 @@ export async function sendStoryMessage( // on the receiver side. return window.Signal.Migrations.upgradeMessageSchema({ attachments, + bodyRanges, conversationId: ourConversation.id, expireTimer: DurationInSeconds.DAY, expirationStartTimestamp: Date.now(), @@ -277,6 +280,7 @@ export async function sendStoryMessage( const messageAttributes = await window.Signal.Migrations.upgradeMessageSchema({ attachments, + bodyRanges, canReplyToStory: true, conversationId: group.id, expireTimer: DurationInSeconds.DAY,