diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 1ab98801c5..6813abf4d3 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2711,6 +2711,10 @@ "message": "Cancel", "description": "Appears on the cancel button in confirmation dialogs." }, + "MessageBody--read-more": { + "message": "Read more", + "description": "When a message is too long this is the affordance to expand the message" + }, "Message--unsupported-message": { "message": "$contact$ sent you a message that can't be processed or displayed because it uses a new Signal feature.", "placeholders": { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 5242981f7a..f33a4cd9e6 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -4518,48 +4518,6 @@ button.module-image__border-overlay:focus { background-color: $color-white; } -// Module: Highlighted Message Body - -.module-message-body__highlight { - font-weight: bold; -} - -.module-message-body__at-mention { - border-radius: 4px; - cursor: pointer; - display: inline-block; - padding-left: 4px; - padding-right: 4px; - border: 1px solid transparent; - - @include light-theme { - background-color: $color-gray-20; - } - - @include dark-theme { - background-color: $color-black-alpha-40; - } - - &:focus { - border: 1px solid $color-black; - outline: none; - } -} - -.module-message-body__at-mention--incoming { - @include light-theme { - background-color: $color-gray-20; - } - - @include dark-theme { - background-color: $color-gray-60; - } -} - -.module-message-body__at-mention--outgoing { - background-color: $color-black-alpha-40; -} - // Module: Reaction Viewer .module-reaction-viewer { diff --git a/stylesheets/components/MessageBody.scss b/stylesheets/components/MessageBody.scss new file mode 100644 index 0000000000..74435d8622 --- /dev/null +++ b/stylesheets/components/MessageBody.scss @@ -0,0 +1,53 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.MessageBody { + &__highlight { + font-weight: bold; + } + + &__read-more { + @include button-reset; + font-weight: bold; + + &:focus { + color: $color-ultramarine; + } + } + + &__at-mention { + border-radius: 4px; + cursor: pointer; + display: inline-block; + padding-left: 4px; + padding-right: 4px; + border: 1px solid transparent; + + @include light-theme { + background-color: $color-gray-20; + } + + @include dark-theme { + background-color: $color-black-alpha-40; + } + + &:focus { + border: 1px solid $color-black; + outline: none; + } + + &--incoming { + @include light-theme { + background-color: $color-gray-20; + } + + @include dark-theme { + background-color: $color-gray-60; + } + } + + &--outgoing { + background-color: $color-black-alpha-40; + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index b8e8ea13d7..9f4013c93f 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -68,6 +68,7 @@ @import './components/Lightbox.scss'; @import './components/MediaQualitySelector.scss'; @import './components/MessageAudio.scss'; +@import './components/MessageBody.scss'; @import './components/MessageDetail.scss'; @import './components/Modal.scss'; @import './components/PermissionsPopup.scss'; diff --git a/ts/components/conversation/AtMentionify.tsx b/ts/components/conversation/AtMentionify.tsx index 3c3726bbb4..877d0074b9 100644 --- a/ts/components/conversation/AtMentionify.tsx +++ b/ts/components/conversation/AtMentionify.tsx @@ -46,7 +46,7 @@ export const AtMentionify = ({ if (range) { results.push( { if (openConversation && range.conversationID) { diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 3f17781b24..9dd2299d3f 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -17,7 +17,7 @@ import { import { ReadStatus } from '../../messages/MessageReadStatus'; import { Avatar } from '../Avatar'; import { Spinner } from '../Spinner'; -import { MessageBody } from './MessageBody'; +import { MessageBodyReadMore } from './MessageBodyReadMore'; import { MessageMetadata } from './MessageMetadata'; import { ImageGrid } from './ImageGrid'; import { GIF } from './GIF'; @@ -1224,6 +1224,7 @@ export class Message extends React.PureComponent { deletedForEveryone, direction, i18n, + onHeightChange, openConversation, status, text, @@ -1252,12 +1253,13 @@ export class Message extends React.PureComponent { : null )} > - diff --git a/ts/components/conversation/MessageBody.tsx b/ts/components/conversation/MessageBody.tsx index 47bca72826..db24ba65ec 100644 --- a/ts/components/conversation/MessageBody.tsx +++ b/ts/components/conversation/MessageBody.tsx @@ -1,7 +1,7 @@ -// Copyright 2018-2020 Signal Messenger, LLC +// Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { KeyboardEvent } from 'react'; import { getSizeClass, SizeClassType } from '../emoji/lib'; import { AtMentionify } from './AtMentionify'; @@ -30,6 +30,7 @@ export type Props = { disableLinks?: boolean; i18n: LocalizerType; bodyRanges?: BodyRangesType; + onIncreaseTextLength?: () => unknown; openConversation?: OpenConversationActionType; }; @@ -59,21 +60,39 @@ const renderEmoji = ({ * configurable with their `renderXXX` props, this component will assemble all three of * them for you. */ -export class MessageBody extends React.Component { - private readonly renderNewLines: RenderTextCallbackType = ({ +export function MessageBody({ + bodyRanges, + direction, + disableJumbomoji, + disableLinks, + i18n, + onIncreaseTextLength, + openConversation, + text, + textPending, +}: Props): JSX.Element { + const hasReadMore = Boolean(onIncreaseTextLength); + const textWithSuffix = textPending || hasReadMore ? `${text}...` : text; + + const sizeClass = disableJumbomoji ? undefined : getSizeClass(text); + const processedText = AtMentionify.preprocessMentions( + textWithSuffix, + bodyRanges + ); + + const renderNewLines: RenderTextCallbackType = ({ text: textWithNewLines, key, }) => { - const { bodyRanges, direction, openConversation } = this.props; return ( ( + renderNonNewLine={({ text: innerText, key: innerKey }) => ( @@ -82,62 +101,51 @@ export class MessageBody extends React.Component { ); }; - public addDownloading(jsx: JSX.Element): JSX.Element { - const { i18n, textPending } = this.props; - - return ( - - {jsx} - {textPending ? ( - - {' '} - {i18n('downloading')} - - ) : null} - - ); - } - - public render(): JSX.Element { - const { - bodyRanges, - text, - textPending, - disableJumbomoji, - disableLinks, - i18n, - } = this.props; - const sizeClass = disableJumbomoji ? undefined : getSizeClass(text); - const textWithPending = AtMentionify.preprocessMentions( - textPending ? `${text}...` : text, - bodyRanges - ); - - if (disableLinks) { - return this.addDownloading( + return ( + + {disableLinks ? ( renderEmoji({ i18n, - text: textWithPending, + text: processedText, sizeClass, key: 0, - renderNonEmoji: this.renderNewLines, + renderNonEmoji: renderNewLines, }) - ); - } - - return this.addDownloading( - { - return renderEmoji({ - i18n, - text: nonLinkText, - sizeClass, - key, - renderNonEmoji: this.renderNewLines, - }); - }} - /> - ); - } + ) : ( + { + return renderEmoji({ + i18n, + text: nonLinkText, + sizeClass, + key, + renderNonEmoji: renderNewLines, + }); + }} + /> + )} + {textPending ? ( + {i18n('downloading')} + ) : null} + {onIncreaseTextLength ? ( + + ) : null} + + ); } diff --git a/ts/components/conversation/MessageBodyReadMore.stories.tsx b/ts/components/conversation/MessageBodyReadMore.stories.tsx new file mode 100644 index 0000000000..c378ea7ba2 --- /dev/null +++ b/ts/components/conversation/MessageBodyReadMore.stories.tsx @@ -0,0 +1,153 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { action } from '@storybook/addon-actions'; +import { text } from '@storybook/addon-knobs'; +import { storiesOf } from '@storybook/react'; + +import { MessageBodyReadMore, Props } from './MessageBodyReadMore'; +import { setupI18n } from '../../util/setupI18n'; +import enMessages from '../../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf('Components/Conversation/MessageBodyReadMore', module); + +const createProps = (overrideProps: Partial = {}): Props => ({ + bodyRanges: overrideProps.bodyRanges, + direction: 'incoming', + i18n, + onHeightChange: action('onHeightChange'), + text: text('text', overrideProps.text || ''), +}); + +story.add('Lots of cake with a cherry on top', () => ( + +)); + +story.add('Cherry overflow', () => ( + +)); + +story.add('Excessive amounts of cake', () => ( + +)); + +story.add('Long text', () => ( + +)); diff --git a/ts/components/conversation/MessageBodyReadMore.tsx b/ts/components/conversation/MessageBodyReadMore.tsx new file mode 100644 index 0000000000..e6e7fa9929 --- /dev/null +++ b/ts/components/conversation/MessageBodyReadMore.tsx @@ -0,0 +1,88 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState } from 'react'; + +import { MessageBody, Props as MessageBodyPropsType } from './MessageBody'; + +export type Props = Pick< + MessageBodyPropsType, + | 'direction' + | 'text' + | 'textPending' + | 'disableLinks' + | 'i18n' + | 'bodyRanges' + | 'openConversation' +> & { + onHeightChange: () => unknown; +}; + +const INITIAL_LENGTH = 800; +const INCREMENT_COUNT = 3000; + +function graphemeAwareSlice( + str: string, + length: number +): { + hasReadMore: boolean; + text: string; +} { + if (str.length <= length) { + return { text: str, hasReadMore: false }; + } + + let text: string | undefined; + + for (const { index } of new Intl.Segmenter().segment(str)) { + if (!text && index >= length) { + text = str.slice(0, index); + } + if (text && index > length) { + return { + text, + hasReadMore: true, + }; + } + } + + return { + text: str, + hasReadMore: false, + }; +} + +export function MessageBodyReadMore({ + bodyRanges, + direction, + disableLinks, + i18n, + onHeightChange, + openConversation, + text, + textPending, +}: Props): JSX.Element { + const [maxLength, setMaxLength] = useState(INITIAL_LENGTH); + + const { hasReadMore, text: slicedText } = graphemeAwareSlice(text, maxLength); + + const onIncreaseTextLength = hasReadMore + ? () => { + setMaxLength(oldMaxLength => oldMaxLength + INCREMENT_COUNT); + onHeightChange(); + } + : undefined; + + return ( + + ); +} diff --git a/ts/components/conversationList/MessageBodyHighlight.tsx b/ts/components/conversationList/MessageBodyHighlight.tsx index af3d727eab..50fb166386 100644 --- a/ts/components/conversationList/MessageBodyHighlight.tsx +++ b/ts/components/conversationList/MessageBodyHighlight.tsx @@ -105,7 +105,7 @@ export class MessageBodyHighlight extends React.Component { const [, toHighlight] = match; count += 2; results.push( - + {renderEmoji({ text: toHighlight, sizeClass, diff --git a/ts/quill/mentions/matchers.ts b/ts/quill/mentions/matchers.ts index a5b1dd7c15..2c52db7b5e 100644 --- a/ts/quill/mentions/matchers.ts +++ b/ts/quill/mentions/matchers.ts @@ -13,7 +13,7 @@ export const matchMention = ( if (memberRepository) { const { title } = node.dataset; - if (node.classList.contains('module-message-body__at-mention')) { + if (node.classList.contains('MessageBody__at-mention')) { const { id } = node.dataset; const conversation = memberRepository.getMemberById(id); diff --git a/ts/test-node/quill/mentions/matchers_test.ts b/ts/test-node/quill/mentions/matchers_test.ts index 7fc9e44001..dd48dfcae8 100644 --- a/ts/test-node/quill/mentions/matchers_test.ts +++ b/ts/test-node/quill/mentions/matchers_test.ts @@ -32,7 +32,7 @@ const createMockElement = ( const createMockAtMentionElement = ( dataset: Record -): HTMLElement => createMockElement('module-message-body__at-mention', dataset); +): HTMLElement => createMockElement('MessageBody__at-mention', dataset); const createMockMentionBlotElement = ( dataset: Record