diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 96084e0c2..b64e145b2 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -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 + + 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) diff --git a/package.json b/package.json index 6914aca63..1a25af0e9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index 55dcd467f..452e46661 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -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 => ({ 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); diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 7d8497b04..e936311d0 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -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) => void; }; +const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 10; +const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18; +const GUESS_METADATA_WIDTH_OUTGOING_SIZE: Record = { + 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([ 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; type State = { + metadataWidth: number; + expiring: boolean; expired: boolean; imageBroken: boolean; @@ -355,6 +380,8 @@ export class Message extends React.PureComponent { super(props); this.state = { + metadataWidth: this.guessMetadataWidth(), + expiring: false, expired: false, imageBroken: false, @@ -529,8 +556,11 @@ export class Message extends React.PureComponent { expirationTimestamp, status, text, + textDirection, }: Readonly = this.props ): MetadataPlacement { + const isRTL = textDirection === TextDirection.RightToLeft; + if ( !expirationLength && !expirationTimestamp && @@ -550,7 +580,37 @@ export class Message extends React.PureComponent { return MetadataPlacement.Bottom; } - 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 { @@ -672,13 +732,31 @@ export class Message extends React.PureComponent { ); } + 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 - ) { - return null; + 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 { 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 { 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 { ? 'module-message__text--error' : null )} - dir="auto" + dir={isRTL ? 'rtl' : undefined} > { text={contents || ''} textPending={textPending} /> + {!isRTL && + this.getMetadataPlacement() === MetadataPlacement.InlineWithText && ( + + )} ); } diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index 1fde8c383..d1f6e6258 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -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(), }; diff --git a/ts/components/conversation/MessageTextMetadataSpacer.tsx b/ts/components/conversation/MessageTextMetadataSpacer.tsx new file mode 100644 index 000000000..9c3ec4eae --- /dev/null +++ b/ts/components/conversation/MessageTextMetadataSpacer.tsx @@ -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 => ( + +); diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index 37074e7f6..eb25a84a0 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -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(), }; diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 97b609738..4ecc52104 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -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 = { previews: [], readStatus: ReadStatus.Read, text: '🔥', + textDirection: TextDirection.Default, timestamp: Date.now(), }, timestamp: Date.now(), @@ -84,6 +86,7 @@ const items: Record = { 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 = { 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 = { readStatus: ReadStatus.Read, status: 'sent', text: '🔥', + textDirection: TextDirection.Default, timestamp: Date.now(), }, timestamp: Date.now(), @@ -247,6 +252,7 @@ const items: Record = { 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 = { 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 = { 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 = { 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(), diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 9b6cfc8d1..28a374129 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -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 diff --git a/yarn.lock b/yarn.lock index d2192390b..e41bdf811 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"