Reintroduce inline metadata with full RTL support

This commit is contained in:
Scott Nonnenberg 2022-03-23 13:23:28 -07:00 committed by GitHub
parent 801c70b298
commit bb066d4a84
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 185 additions and 9 deletions

View file

@ -930,6 +930,31 @@ Signal Desktop makes use of the following open source projects.
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
## direction
(The MIT License)
Copyright (c) 2014 Titus Wormer <tituswormer@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## emoji-datasource
The MIT License (MIT)

View file

@ -97,6 +97,7 @@
"copy-text-to-clipboard": "2.1.0",
"dashdash": "1.14.1",
"dicer": "0.3.1",
"direction": "1.0.4",
"emoji-datasource": "7.0.2",
"emoji-datasource-apple": "7.0.2",
"emoji-regex": "9.2.2",
@ -203,6 +204,7 @@
"@types/dashdash": "1.14.0",
"@types/debug": "4.1.7",
"@types/dicer": "0.2.2",
"@types/direction": "1.0.0",
"@types/filesize": "3.6.0",
"@types/fs-extra": "5.0.5",
"@types/google-libphonenumber": "7.4.23",

View file

@ -12,7 +12,7 @@ import { SignalService } from '../../protobuf';
import { ConversationColors } from '../../types/Colors';
import { EmojiPicker } from '../emoji/EmojiPicker';
import type { Props, AudioAttachmentProps } from './Message';
import { Message } from './Message';
import { TextDirection, Message } from './Message';
import {
AUDIO_MP3,
IMAGE_JPEG,
@ -184,6 +184,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
showVisualAttachment: action('showVisualAttachment'),
status: overrideProps.status || 'sent',
text: overrideProps.text || text('text', ''),
textDirection: overrideProps.textDirection || TextDirection.Default,
textPending: boolean('textPending', overrideProps.textPending || false),
theme: ThemeType.light,
timestamp: number('timestamp', overrideProps.timestamp || Date.now()),
@ -228,6 +229,7 @@ story.add('Plain Message', () => {
story.add('Plain RTL Message', () => {
const props = createProps({
text: 'الأسانسير، علشان القطط ماتاكلش منها. وننساها، ونعود الى أوراقنا موصدين الباب بإحكام. نتنحنح، ونقول: البتاع. كلمة تدلّ على لا شيء، وعلى كلّ شيء. وهي مركز أبحاث شعبية كثيرة، تتعجّب من غرابتها والقومية المصرية الخاصة التي تعكسها، الى جانب الشيء الكثير من العفوية وحلاوة الروح. نعم، نحن قرأنا وسمعنا وعرفنا كل هذا. لكنه محلّ اهتمامنا اليوم لأسباب غير تلك الأسباب. كذلك، فإننا لعاقدون عزمنا على أن نتجاوز قضية الفصحى والعامية، وثنائية النخبة والرعاع، التي كثيراً ما ينحو نحوها الحديث عن الكلمة المذكورة. وفوق هذا كله، لسنا بصدد تفسير معاني "البتاع" كما تأتي في قصيدة الحاج أحمد فؤاد نجم، ولا التحذلق والتفذلك في الألغاز والأسرار المكنونة. هذا البتاع - أم هذه البت',
textDirection: TextDirection.RightToLeft,
});
return renderBothDirections(props);

View file

@ -25,6 +25,7 @@ import {
MessageBodyReadMore,
} from './MessageBodyReadMore';
import { MessageMetadata } from './MessageMetadata';
import { MessageTextMetadataSpacer } from './MessageTextMetadataSpacer';
import { ImageGrid } from './ImageGrid';
import { GIF } from './GIF';
import { Image } from './Image';
@ -91,6 +92,19 @@ type Trigger = {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
};
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record<MessageStatusType, number> = {
delivered: 24,
error: 24,
paused: 18,
'partial-sent': 24,
read: 24,
sending: 18,
sent: 24,
viewed: 24,
};
const EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600;
const GROUP_AVATAR_SIZE = AvatarSize.TWENTY_EIGHT;
@ -109,9 +123,17 @@ const SENT_STATUSES = new Set<MessageStatusType>([
enum MetadataPlacement {
NotRendered,
RenderedByMessageAudioComponent,
InlineWithText,
Bottom,
}
export enum TextDirection {
LeftToRight = 'LeftToRight',
RightToLeft = 'RightToLeft',
Default = 'Default',
None = 'None',
}
export const MessageStatuses = [
'delivered',
'error',
@ -161,6 +183,7 @@ export type PropsData = {
conversationId: string;
displayLimit?: number;
text?: string;
textDirection: TextDirection;
textPending?: boolean;
isSticker?: boolean;
isSelected?: boolean;
@ -318,6 +341,8 @@ export type Props = PropsData &
Pick<ReactionPickerProps, 'renderEmojiPicker'>;
type State = {
metadataWidth: number;
expiring: boolean;
expired: boolean;
imageBroken: boolean;
@ -355,6 +380,8 @@ export class Message extends React.PureComponent<Props, State> {
super(props);
this.state = {
metadataWidth: this.guessMetadataWidth(),
expiring: false,
expired: false,
imageBroken: false,
@ -529,8 +556,11 @@ export class Message extends React.PureComponent<Props, State> {
expirationTimestamp,
status,
text,
textDirection,
}: Readonly<Props> = this.props
): MetadataPlacement {
const isRTL = textDirection === TextDirection.RightToLeft;
if (
!expirationLength &&
!expirationTimestamp &&
@ -550,9 +580,39 @@ export class Message extends React.PureComponent<Props, State> {
return MetadataPlacement.Bottom;
}
if (isRTL) {
return MetadataPlacement.Bottom;
}
return MetadataPlacement.InlineWithText;
}
/**
* A lot of the time, we add an invisible inline spacer for messages. This spacer is the
* same size as the message metadata. Unfortunately, we don't know how wide it is until
* we render it.
*
* This will probably guess wrong, but it's valuable to get close to the real value
* because it can reduce layout jumpiness.
*/
private guessMetadataWidth(): number {
const { direction, expirationLength, expirationTimestamp, status } =
this.props;
let result = GUESS_METADATA_WIDTH_TIMESTAMP_SIZE;
const hasExpireTimer = Boolean(expirationLength && expirationTimestamp);
if (hasExpireTimer) {
result += GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE;
}
if (direction === 'outgoing' && status) {
result += GUESS_METADATA_WIDTH_OUTGOING_SIZE[status];
}
return result;
}
public startSelectedTimer(): void {
const { clearSelectedMessage, interactionMode } = this.props;
const { isSelected } = this.state;
@ -672,13 +732,31 @@ export class Message extends React.PureComponent<Props, State> {
);
}
private updateMetadataWidth = (newMetadataWidth: number): void => {
this.setState(({ metadataWidth }) => ({
// We don't want text to jump around if the metadata shrinks, but we want to make
// sure we have enough room.
metadataWidth: Math.max(metadataWidth, newMetadataWidth),
}));
};
private renderMetadata(): ReactNode {
let isInline: boolean;
const metadataPlacement = this.getMetadataPlacement();
if (
metadataPlacement === MetadataPlacement.NotRendered ||
metadataPlacement === MetadataPlacement.RenderedByMessageAudioComponent
) {
switch (metadataPlacement) {
case MetadataPlacement.NotRendered:
case MetadataPlacement.RenderedByMessageAudioComponent:
return null;
case MetadataPlacement.InlineWithText:
isInline = true;
break;
case MetadataPlacement.Bottom:
isInline = false;
break;
default:
log.error(missingCaseError(metadataPlacement));
isInline = false;
break;
}
const {
@ -708,9 +786,11 @@ export class Message extends React.PureComponent<Props, State> {
hasText={Boolean(text)}
i18n={i18n}
id={id}
isInline={isInline}
isShowingImage={this.isShowingImage()}
isSticker={isStickerLike}
isTapToViewExpired={isTapToViewExpired}
onWidthMeasured={isInline ? this.updateMetadataWidth : undefined}
showMessageDetail={showMessageDetail}
status={status}
textPending={textPending}
@ -1378,8 +1458,11 @@ export class Message extends React.PureComponent<Props, State> {
openConversation,
status,
text,
textDirection,
textPending,
} = this.props;
const { metadataWidth } = this.state;
const isRTL = textDirection === TextDirection.RightToLeft;
// eslint-disable-next-line no-nested-ternary
const contents = deletedForEveryone
@ -1401,7 +1484,7 @@ export class Message extends React.PureComponent<Props, State> {
? 'module-message__text--error'
: null
)}
dir="auto"
dir={isRTL ? 'rtl' : undefined}
>
<MessageBodyReadMore
bodyRanges={bodyRanges}
@ -1415,6 +1498,10 @@ export class Message extends React.PureComponent<Props, State> {
text={contents || ''}
textPending={textPending}
/>
{!isRTL &&
this.getMetadataPlacement() === MetadataPlacement.InlineWithText && (
<MessageTextMetadataSpacer metadataWidth={metadataWidth} />
)}
</div>
);
}

View file

@ -8,6 +8,7 @@ import { number } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import type { PropsData as MessageDataPropsType } from './Message';
import { TextDirection } from './Message';
import type { Props } from './MessageDetail';
import { MessageDetail } from './MessageDetail';
import { SendStatus } from '../../messages/MessageSendState';
@ -45,6 +46,7 @@ const defaultMessage: MessageDataPropsType = {
readStatus: ReadStatus.Read,
status: 'sent',
text: 'A message from Max',
textDirection: TextDirection.Default,
timestamp: Date.now(),
};

View file

@ -0,0 +1,13 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } from 'react';
import React from 'react';
const SPACING = 10;
export const MessageTextMetadataSpacer = ({
metadataWidth,
}: Readonly<{ metadataWidth: number }>): ReactElement => (
<span style={{ display: 'inline-block', width: metadataWidth + SPACING }} />
);

View file

@ -11,7 +11,7 @@ import { storiesOf } from '@storybook/react';
import { ConversationColors } from '../../types/Colors';
import { pngUrl } from '../../storybook/Fixtures';
import type { Props as MessagesProps } from './Message';
import { Message } from './Message';
import { Message, TextDirection } from './Message';
import {
AUDIO_MP3,
IMAGE_PNG,
@ -95,6 +95,7 @@ const defaultMessageProps: MessagesProps = {
showVisualAttachment: action('default--showVisualAttachment'),
status: 'sent',
text: 'This is really interesting.',
textDirection: TextDirection.Default,
theme: ThemeType.light,
timestamp: Date.now(),
};

View file

@ -24,6 +24,7 @@ import { ContactSpoofingType } from '../../util/contactSpoofing';
import { ReadStatus } from '../../messages/MessageReadStatus';
import type { WidthBreakpoint } from '../_util';
import { ThemeType } from '../../types/Util';
import { TextDirection } from './Message';
const i18n = setupI18n('en', enMessages);
@ -60,6 +61,7 @@ const items: Record<string, TimelineItemType> = {
previews: [],
readStatus: ReadStatus.Read,
text: '🔥',
textDirection: TextDirection.Default,
timestamp: Date.now(),
},
timestamp: Date.now(),
@ -84,6 +86,7 @@ const items: Record<string, TimelineItemType> = {
previews: [],
readStatus: ReadStatus.Read,
text: 'Hello there from the new world! http://somewhere.com',
textDirection: TextDirection.Default,
timestamp: Date.now(),
},
timestamp: Date.now(),
@ -122,6 +125,7 @@ const items: Record<string, TimelineItemType> = {
previews: [],
readStatus: ReadStatus.Read,
text: 'Hello there from the new world!',
textDirection: TextDirection.Default,
timestamp: Date.now(),
},
timestamp: Date.now(),
@ -222,6 +226,7 @@ const items: Record<string, TimelineItemType> = {
readStatus: ReadStatus.Read,
status: 'sent',
text: '🔥',
textDirection: TextDirection.Default,
timestamp: Date.now(),
},
timestamp: Date.now(),
@ -247,6 +252,7 @@ const items: Record<string, TimelineItemType> = {
readStatus: ReadStatus.Read,
status: 'read',
text: 'Hello there from the new world! http://somewhere.com',
textDirection: TextDirection.Default,
timestamp: Date.now(),
},
timestamp: Date.now(),
@ -272,6 +278,7 @@ const items: Record<string, TimelineItemType> = {
readStatus: ReadStatus.Read,
status: 'sent',
text: 'Hello there from the new world! 🔥',
textDirection: TextDirection.Default,
timestamp: Date.now(),
},
timestamp: Date.now(),
@ -297,6 +304,7 @@ const items: Record<string, TimelineItemType> = {
readStatus: ReadStatus.Read,
status: 'sent',
text: 'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
textDirection: TextDirection.Default,
timestamp: Date.now(),
},
timestamp: Date.now(),
@ -322,6 +330,7 @@ const items: Record<string, TimelineItemType> = {
readStatus: ReadStatus.Read,
status: 'read',
text: 'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
textDirection: TextDirection.Default,
timestamp: Date.now(),
},
timestamp: Date.now(),

View file

@ -4,6 +4,7 @@
import { identity, isEqual, isNumber, isObject, map, omit, pick } from 'lodash';
import { createSelectorCreator } from 'reselect';
import filesize from 'filesize';
import getDirection from 'direction';
import type {
LastMessageStatus,
@ -13,6 +14,7 @@ import type {
import type { TimelineItemType } from '../../components/conversation/TimelineItem';
import type { PropsData } from '../../components/conversation/Message';
import { TextDirection } from '../../components/conversation/Message';
import type { PropsData as TimerNotificationProps } from '../../components/conversation/TimerNotification';
import type { PropsData as ChangeNumberNotificationProps } from '../../components/conversation/ChangeNumberNotification';
import type { PropsData as SafetyNumberNotificationProps } from '../../components/conversation/SafetyNumberNotification';
@ -583,6 +585,7 @@ type ShallowPropsType = Pick<
| 'selectedReaction'
| 'status'
| 'text'
| 'textDirection'
| 'textPending'
| 'timestamp'
>;
@ -668,6 +671,7 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)(
selectedReaction,
status: getMessagePropStatus(message, ourConversationId),
text: message.body,
textDirection: getTextDirection(message.body),
textPending: message.bodyPending,
timestamp: message.sent_at,
};
@ -676,6 +680,27 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)(
(_: unknown, props: ShallowPropsType) => props
);
function getTextDirection(body?: string): TextDirection {
if (!body) {
return TextDirection.None;
}
const direction = getDirection(body);
switch (direction) {
case 'ltr':
return TextDirection.LeftToRight;
case 'rtl':
return TextDirection.RightToLeft;
case 'neutral':
return TextDirection.Default;
default: {
const unexpected: never = direction;
log.warn(`getTextDirection: unexpected direction ${unexpected}`);
return TextDirection.None;
}
}
}
export const getPropsForMessage: (
message: MessageWithUIFieldsType,
options: GetPropsForMessageOptions

View file

@ -1998,6 +1998,11 @@
dependencies:
"@types/node" "*"
"@types/direction@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/direction/-/direction-1.0.0.tgz#6a0962feade8502f9e986e87abe1130b611b13be"
integrity sha512-et1wmqXm/5smJ8lTJfBnwD12/2Y7eVJLKbuaRT0h2xaKAoo1h8Dz2Io22GObDLFwxY1ddXRTLH3Gq5v44Fl/2w==
"@types/eslint-scope@^3.7.0":
version "3.7.0"
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.0.tgz#4792816e31119ebd506902a482caec4951fabd86"
@ -5778,6 +5783,11 @@ dir-glob@^3.0.1:
dependencies:
path-type "^4.0.0"
direction@1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/direction/-/direction-1.0.4.tgz#2b86fb686967e987088caf8b89059370d4837442"
integrity sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==
dmg-builder@23.0.1:
version "23.0.1"
resolved "https://registry.yarnpkg.com/dmg-builder/-/dmg-builder-23.0.1.tgz#fc5d3e6939b4ca7769d83224d48c2e8da453e84d"