Affordances for really tall messages
This commit is contained in:
parent
2e9eaa855a
commit
b32d068e83
12 changed files with 375 additions and 108 deletions
|
@ -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": {
|
||||
|
|
|
@ -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 {
|
||||
|
|
53
stylesheets/components/MessageBody.scss
Normal file
53
stylesheets/components/MessageBody.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
153
ts/components/conversation/MessageBodyReadMore.stories.tsx
Normal file
153
ts/components/conversation/MessageBodyReadMore.stories.tsx
Normal 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.
|
||||
`,
|
||||
})}
|
||||
/>
|
||||
));
|
88
ts/components/conversation/MessageBodyReadMore.tsx
Normal file
88
ts/components/conversation/MessageBodyReadMore.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue