Affordances for really tall messages

This commit is contained in:
Josh Perez 2021-10-20 16:46:42 -04:00 committed by GitHub
parent 2e9eaa855a
commit b32d068e83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 375 additions and 108 deletions

View File

@ -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": {

View File

@ -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 {

View File

@ -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;
}
}
}

View File

@ -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';

View File

@ -46,7 +46,7 @@ export const AtMentionify = ({
if (range) {
results.push(
<span
className={`module-message-body__at-mention module-message-body__at-mention--${direction}`}
className={`MessageBody__at-mention MessageBody__at-mention--${direction}`}
key={range.start}
onClick={() => {
if (openConversation && range.conversationID) {

View File

@ -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<Props, State> {
deletedForEveryone,
direction,
i18n,
onHeightChange,
openConversation,
status,
text,
@ -1252,12 +1253,13 @@ export class Message extends React.PureComponent<Props, State> {
: null
)}
>
<MessageBody
<MessageBodyReadMore
bodyRanges={bodyRanges}
disableLinks={!this.areLinksEnabled()}
direction={direction}
i18n={i18n}
openConversation={openConversation}
onHeightChange={onHeightChange}
text={contents || ''}
textPending={textPending}
/>

View File

@ -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<Props> {
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 (
<AddNewLines
key={key}
text={textWithNewLines}
renderNonNewLine={({ text, key: innerKey }) => (
renderNonNewLine={({ text: innerText, key: innerKey }) => (
<AtMentionify
key={innerKey}
direction={direction}
text={text}
text={innerText}
bodyRanges={bodyRanges}
openConversation={openConversation}
/>
@ -82,62 +101,51 @@ export class MessageBody extends React.Component<Props> {
);
};
public addDownloading(jsx: JSX.Element): JSX.Element {
const { i18n, textPending } = this.props;
return (
<span>
{jsx}
{textPending ? (
<span className="module-message-body__highlight">
{' '}
{i18n('downloading')}
</span>
) : null}
</span>
);
}
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 (
<span>
{disableLinks ? (
renderEmoji({
i18n,
text: textWithPending,
text: processedText,
sizeClass,
key: 0,
renderNonEmoji: this.renderNewLines,
renderNonEmoji: renderNewLines,
})
);
}
return this.addDownloading(
<Linkify
text={textWithPending}
renderNonLink={({ key, text: nonLinkText }) => {
return renderEmoji({
i18n,
text: nonLinkText,
sizeClass,
key,
renderNonEmoji: this.renderNewLines,
});
}}
/>
);
}
) : (
<Linkify
text={processedText}
renderNonLink={({ key, text: nonLinkText }) => {
return renderEmoji({
i18n,
text: nonLinkText,
sizeClass,
key,
renderNonEmoji: renderNewLines,
});
}}
/>
)}
{textPending ? (
<span className="MessageBody__highlight"> {i18n('downloading')}</span>
) : null}
{onIncreaseTextLength ? (
<button
className="MessageBody__read-more"
onClick={() => {
onIncreaseTextLength();
}}
onKeyDown={(ev: KeyboardEvent) => {
if (ev.key === 'Space' || ev.key === 'Enter') {
onIncreaseTextLength();
}
}}
tabIndex={0}
type="button"
>
{' '}
{i18n('MessageBody--read-more')}
</button>
) : null}
</span>
);
}

View File

@ -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> = {}): 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', () => (
<MessageBodyReadMore
{...createProps({
text: `x${'🍰'.repeat(399)}🍒`,
})}
/>
));
story.add('Cherry overflow', () => (
<MessageBodyReadMore
{...createProps({
text: `x${'🍰'.repeat(400)}🍒`,
})}
/>
));
story.add('Excessive amounts of cake', () => (
<MessageBodyReadMore
{...createProps({
text: `x${'🍰'.repeat(20000)}`,
})}
/>
));
story.add('Long text', () => (
<MessageBodyReadMore
{...createProps({
text: `
SCENE I. Rome. A street.
Enter FLAVIUS, MARULLUS, and certain Commoners
FLAVIUS
Hence! home, you idle creatures get you home:
Is this a holiday? what! know you not,
Being mechanical, you ought not walk
Upon a labouring day without the sign
Of your profession? Speak, what trade art thou?
First Commoner
Why, sir, a carpenter.
MARULLUS
Where is thy leather apron and thy rule?
What dost thou with thy best apparel on?
You, sir, what trade are you?
Second Commoner
Truly, sir, in respect of a fine workman, I am but,
as you would say, a cobbler.
MARULLUS
But what trade art thou? answer me directly.
Second Commoner
A trade, sir, that, I hope, I may use with a safe
conscience; which is, indeed, sir, a mender of bad soles.
MARULLUS
What trade, thou knave? thou naughty knave, what trade?
Second Commoner
Nay, I beseech you, sir, be not out with me: yet,
if you be out, sir, I can mend you.
MARULLUS
What meanest thou by that? mend me, thou saucy fellow!
Second Commoner
Why, sir, cobble you.
FLAVIUS
Thou art a cobbler, art thou?
Second Commoner
Truly, sir, all that I live by is with the awl: I
meddle with no tradesman's matters, nor women's
matters, but with awl. I am, indeed, sir, a surgeon
to old shoes; when they are in great danger, I
recover them. As proper men as ever trod upon
neat's leather have gone upon my handiwork.
FLAVIUS
But wherefore art not in thy shop today?
Why dost thou lead these men about the streets?
Second Commoner
Truly, sir, to wear out their shoes, to get myself
into more work. But, indeed, sir, we make holiday,
to see Caesar and to rejoice in his triumph.
MARULLUS
Wherefore rejoice? What conquest brings he home?
What tributaries follow him to Rome,
To grace in captive bonds his chariot-wheels?
You blocks, you stones, you worse than senseless things!
O you hard hearts, you cruel men of Rome,
Knew you not Pompey? Many a time and oft
Have you climb'd up to walls and battlements,
To towers and windows, yea, to chimney-tops,
Your infants in your arms, and there have sat
The livelong day, with patient expectation,
To see great Pompey pass the streets of Rome:
And when you saw his chariot but appear,
Have you not made an universal shout,
That Tiber trembled underneath her banks,
To hear the replication of your sounds
Made in her concave shores?
And do you now put on your best attire?
And do you now cull out a holiday?
And do you now strew flowers in his way
That comes in triumph over Pompey's blood? Be gone!
Run to your houses, fall upon your knees,
Pray to the gods to intermit the plague
That needs must light on this ingratitude.
FLAVIUS
Go, go, good countrymen, and, for this fault,
Assemble all the poor men of your sort;
Draw them to Tiber banks, and weep your tears
Into the channel, till the lowest stream
Do kiss the most exalted shores of all.
Exeunt all the Commoners
See whether their basest metal be not moved;
They vanish tongue-tied in their guiltiness.
Go you down that way towards the Capitol;
This way will I
disrobe the images,
If you do find them deck'd with ceremonies.
MARULLUS
May we do so?
You know it is the feast of Lupercal.
FLAVIUS
It is no matter; let no images
Be hung with Caesar's trophies. I'll about,
And drive away the vulgar from the streets:
So do you too, where you perceive them thick.
These growing feathers pluck'd from Caesar's wing
Will make him fly an ordinary pitch,
Who else would soar above the view of men
And keep us all in servile fearfulness.
`,
})}
/>
));

View File

@ -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 (
<MessageBody
bodyRanges={bodyRanges}
disableLinks={disableLinks}
direction={direction}
i18n={i18n}
onIncreaseTextLength={onIncreaseTextLength}
openConversation={openConversation}
text={slicedText}
textPending={textPending}
/>
);
}

View File

@ -105,7 +105,7 @@ export class MessageBodyHighlight extends React.Component<Props> {
const [, toHighlight] = match;
count += 2;
results.push(
<span className="module-message-body__highlight" key={count - 1}>
<span className="MessageBody__highlight" key={count - 1}>
{renderEmoji({
text: toHighlight,
sizeClass,

View File

@ -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);

View File

@ -32,7 +32,7 @@ const createMockElement = (
const createMockAtMentionElement = (
dataset: Record<string, string>
): HTMLElement => createMockElement('module-message-body__at-mention', dataset);
): HTMLElement => createMockElement('MessageBody__at-mention', dataset);
const createMockMentionBlotElement = (
dataset: Record<string, string>