diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index a5e4ab44ca..a5582c9aaf 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -330,7 +330,7 @@ padding-right: 12px; padding-left: 12px; padding-top: 10px; - padding-bottom: 10px; + padding-bottom: 7px; min-width: 0px; width: 100%; overflow: hidden; @@ -1046,15 +1046,10 @@ flex-direction: row; align-items: center; margin-top: 3px; - margin-bottom: -3px; &--outgoing { justify-content: flex-end; } - - &--with-reactions { - margin-bottom: -2px; - } } // With an image and no caption, this section needs to be on top of the image overlay @@ -10607,7 +10602,7 @@ $contact-modal-padding: 18px; } &--with-reactions { - margin-bottom: -10px; + margin-bottom: -9px; } &--deleted-for-everyone { diff --git a/stylesheets/components/MessageAudio.scss b/stylesheets/components/MessageAudio.scss index 423bfdd191..b0d2c53bfa 100644 --- a/stylesheets/components/MessageAudio.scss +++ b/stylesheets/components/MessageAudio.scss @@ -1,13 +1,21 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +$audio-attachment-button-size: 36px; +$audio-attachment-button-margin-big: 12px; +$audio-attachment-button-margin-small: 4px; + .module-message__audio-attachment { display: flex; + flex-direction: column; + margin-top: 2px; +} +.module-message__audio-attachment__button-and-waveform { + display: flex; flex-direction: row; align-items: center; - - margin-top: 2px; + margin-bottom: 5px; } /* The separator between audio and text */ @@ -16,7 +24,7 @@ padding-bottom: 12px; margin-bottom: 7px; - .module-message__audio-attachment--incoming & { + &.module-message__audio-attachment--incoming { @include light-theme { border-color: $color-black-alpha-20; } @@ -36,15 +44,20 @@ .module-message__audio-attachment__button, .module-message__audio-attachment__spinner { - flex-shrink: 0; - width: 36px; - height: 36px; - @include button-reset; + flex-shrink: 0; + width: $audio-attachment-button-size; + height: $audio-attachment-button-size; + margin-right: $audio-attachment-button-margin-big; + outline: none; border-radius: 18px; + @media (min-width: 0px) and (max-width: 799px) { + margin-right: $audio-attachment-button-margin-small; + } + &::before { display: block; height: 100%; @@ -93,7 +106,6 @@ .module-message__audio-attachment__waveform { flex-shrink: 0; - margin-left: 12px; display: flex; align-items: center; @@ -154,13 +166,30 @@ } } +.module-message__audio-attachment__metadata { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + .module-message__audio-attachment--outgoing & { + flex-direction: row-reverse; + } + + .module-message__audio-attachment--outgoing &, + .module-message__audio-attachment--with-content-below & { + margin-left: $audio-attachment-button-size + + $audio-attachment-button-margin-big; + @media (min-width: 0px) and (max-width: 799px) { + margin-left: $audio-attachment-button-size + + $audio-attachment-button-margin-small; + } + } +} + .module-message__audio-attachment__countdown { flex-shrink: 1; - /* Prevent text from jumping when countdown changes */ - min-width: 32px; - text-align: right; - user-select: none; @include font-caption; @@ -178,19 +207,3 @@ color: $color-white-alpha-80; } } - -@media (min-width: 0px) and (max-width: 799px) { - .module-message__audio-attachment__waveform { - margin-left: 4px; - } - - /* Clip the countdown text when it is too long on small screens */ - .module-message__audio-attachment__countdown { - margin-left: 4px; - - max-width: 46px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } -} diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index c417ca611e..64a8f9428d 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -16,11 +16,10 @@ import { import { Avatar } from '../Avatar'; import { Spinner } from '../Spinner'; import { MessageBody } from './MessageBody'; -import { ExpireTimer } from './ExpireTimer'; +import { MessageMetadata } from './MessageMetadata'; import { ImageGrid } from './ImageGrid'; import { GIF } from './GIF'; import { Image } from './Image'; -import { Timestamp } from './Timestamp'; import { ContactName } from './ContactName'; import { Quote, QuotedAttachmentType } from './Quote'; import { EmbeddedContact } from './EmbeddedContact'; @@ -88,16 +87,23 @@ export const Directions = ['incoming', 'outgoing'] as const; export type DirectionType = typeof Directions[number]; export type AudioAttachmentProps = { - id: string; renderingContext: string; i18n: LocalizerType; buttonRef: React.RefObject; - direction: DirectionType; theme: ThemeType | undefined; attachment: AttachmentType; withContentAbove: boolean; withContentBelow: boolean; + direction: DirectionType; + expirationLength?: number; + expirationTimestamp?: number; + id: string; + showMessageDetail: (id: string) => void; + status?: MessageStatusType; + textPending?: boolean; + timestamp: number; + kickOffAttachmentDownload(): void; onCorrupted(): void; }; @@ -549,82 +555,9 @@ export class Message extends React.Component { return isMessageRequestAccepted && !isBlocked; } - public renderTimestamp(): JSX.Element { - const { - direction, - i18n, - id, - isSticker, - isTapToViewExpired, - showMessageDetail, - status, - text, - timestamp, - } = this.props; - - const isShowingImage = this.isShowingImage(); - const withImageNoCaption = Boolean(!isSticker && !text && isShowingImage); - - const isError = status === 'error' && direction === 'outgoing'; - const isPartiallySent = - status === 'partial-sent' && direction === 'outgoing'; - const isPaused = status === 'paused'; - - if (isError || isPartiallySent || isPaused) { - let statusInfo: React.ReactChild; - if (isError) { - statusInfo = i18n('sendFailed'); - } else if (isPaused) { - statusInfo = i18n('sendPaused'); - } else { - statusInfo = ( - - ); - } - - return ( - - {statusInfo} - - ); - } - - const metadataDirection = isSticker ? undefined : direction; - - return ( - - ); - } - public renderMetadata(): JSX.Element | null { const { + attachments, collapseMetadata, direction, expirationLength, @@ -632,68 +565,40 @@ export class Message extends React.Component { isSticker, isTapToViewExpired, status, + i18n, text, textPending, + timestamp, + id, + showMessageDetail, } = this.props; if (collapseMetadata) { return null; } - const isShowingImage = this.isShowingImage(); - const withImageNoCaption = Boolean(!isSticker && !text && isShowingImage); - const metadataDirection = isSticker ? undefined : direction; + // The message audio component renders its own metadata because it positions the + // metadata in line with some of its own. + if (isAudio(attachments) && !text) { + return null; + } return ( -
- {this.renderTimestamp()} - {expirationLength && expirationTimestamp ? ( - - ) : null} - {textPending ? ( -
- -
- ) : null} - {!textPending && - direction === 'outgoing' && - status !== 'error' && - status !== 'partial-sent' ? ( -
- ) : null} -
+ ); } @@ -751,19 +656,24 @@ export class Message extends React.Component { collapseMetadata, conversationType, direction, + expirationLength, + expirationTimestamp, i18n, id, - renderingContext, + isSticker, kickOffAttachmentDownload, markAttachmentAsCorrupted, quote, - showVisualAttachment, - isSticker, - text, - theme, reducedMotion, - renderAudioAttachment, + renderingContext, + showMessageDetail, + showVisualAttachment, + status, + text, + textPending, + theme, + timestamp, } = this.props; const { imageBroken } = this.state; @@ -851,14 +761,21 @@ export class Message extends React.Component { return renderAudioAttachment({ i18n, buttonRef: this.audioButtonRef, - id, renderingContext, - direction, theme, attachment: firstAttachment, withContentAbove, withContentBelow, + direction, + expirationLength, + expirationTimestamp, + id, + showMessageDetail, + status, + textPending, + timestamp, + kickOffAttachmentDownload() { kickOffAttachmentDownload({ attachment: firstAttachment, @@ -1698,9 +1615,7 @@ export class Message extends React.Component { return undefined; } - // Messy return here. - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - public isShowingImage() { + public isShowingImage(): boolean { const { isTapToView, attachments, previews } = this.props; const { imageBroken } = this.state; diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx index 6f8f0774d3..23a2fb0621 100644 --- a/ts/components/conversation/MessageAudio.tsx +++ b/ts/components/conversation/MessageAudio.tsx @@ -8,18 +8,28 @@ import { noop } from 'lodash'; import { assert } from '../../util/assert'; import { LocalizerType } from '../../types/Util'; import { hasNotDownloaded, AttachmentType } from '../../types/Attachment'; +import type { DirectionType, MessageStatusType } from './Message'; import { ComputePeaksResult } from '../GlobalAudioContext'; +import { MessageMetadata } from './MessageMetadata'; export type Props = { - direction?: 'incoming' | 'outgoing'; - id: string; renderingContext: string; i18n: LocalizerType; attachment: AttachmentType; withContentAbove: boolean; withContentBelow: boolean; + // Message properties. Many are needed for rendering metadata + direction: DirectionType; + expirationLength?: number; + expirationTimestamp?: number; + id: string; + showMessageDetail: (id: string) => void; + status?: MessageStatusType; + textPending?: boolean; + timestamp: number; + // See: GlobalAudioContext.tsx audio: HTMLAudioElement; @@ -134,13 +144,20 @@ const Button: React.FC = props => { export const MessageAudio: React.FC = (props: Props) => { const { i18n, - id, renderingContext, - direction, attachment, withContentAbove, withContentBelow, + direction, + expirationLength, + expirationTimestamp, + id, + showMessageDetail, + status, + textPending, + timestamp, + buttonRef, kickOffAttachmentDownload, onCorrupted, @@ -492,6 +509,29 @@ export const MessageAudio: React.FC = (props: Props) => { const countDown = duration - currentTime; + const metadata = ( +
+ {!withContentBelow && ( + + )} +
{timeToText(countDown)}
+
+ ); + return (
= (props: Props) => { withContentAbove ? `${CSS_BASE}--with-content-above` : null )} > - {button} - {waveform} -
{timeToText(countDown)}
+
+ {button} + {waveform} +
+ {metadata}
); }; diff --git a/ts/components/conversation/MessageMetadata.tsx b/ts/components/conversation/MessageMetadata.tsx new file mode 100644 index 0000000000..abc8e61fe7 --- /dev/null +++ b/ts/components/conversation/MessageMetadata.tsx @@ -0,0 +1,155 @@ +// Copyright 2018-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent, ReactChild } from 'react'; +import classNames from 'classnames'; + +import { LocalizerType } from '../../types/Util'; +import type { DirectionType, MessageStatusType } from './Message'; +import { ExpireTimer } from './ExpireTimer'; +import { Timestamp } from './Timestamp'; +import { Spinner } from '../Spinner'; + +type PropsType = { + direction: DirectionType; + expirationLength?: number; + expirationTimestamp?: number; + hasText: boolean; + i18n: LocalizerType; + id: string; + isShowingImage: boolean; + isSticker?: boolean; + isTapToViewExpired?: boolean; + showMessageDetail: (id: string) => void; + status?: MessageStatusType; + textPending?: boolean; + timestamp: number; +}; + +export const MessageMetadata: FunctionComponent = props => { + const { + direction, + expirationLength, + expirationTimestamp, + hasText, + i18n, + id, + isShowingImage, + isSticker, + isTapToViewExpired, + showMessageDetail, + status, + textPending, + timestamp, + } = props; + + const withImageNoCaption = Boolean(!isSticker && !hasText && isShowingImage); + const metadataDirection = isSticker ? undefined : direction; + + let timestampNode: ReactChild; + { + const isError = status === 'error' && direction === 'outgoing'; + const isPartiallySent = + status === 'partial-sent' && direction === 'outgoing'; + const isPaused = status === 'paused'; + + if (isError || isPartiallySent || isPaused) { + let statusInfo: React.ReactChild; + if (isError) { + statusInfo = i18n('sendFailed'); + } else if (isPaused) { + statusInfo = i18n('sendPaused'); + } else { + statusInfo = ( + + ); + } + + timestampNode = ( + + {statusInfo} + + ); + } else { + timestampNode = ( + + ); + } + } + + return ( +
+ {timestampNode} + {expirationLength && expirationTimestamp ? ( + + ) : null} + {textPending ? ( +
+ +
+ ) : null} + {!textPending && + direction === 'outgoing' && + status !== 'error' && + status !== 'partial-sent' ? ( +
+ ) : null} +
+ ); +}; diff --git a/ts/state/smart/MessageAudio.tsx b/ts/state/smart/MessageAudio.tsx index 128a1cbd32..d561b01952 100644 --- a/ts/state/smart/MessageAudio.tsx +++ b/ts/state/smart/MessageAudio.tsx @@ -10,18 +10,29 @@ import { mapDispatchToProps } from '../actions'; import { StateType } from '../reducer'; import { LocalizerType } from '../../types/Util'; import { AttachmentType } from '../../types/Attachment'; +import type { + DirectionType, + MessageStatusType, +} from '../../components/conversation/Message'; export type Props = { audio: HTMLAudioElement; - direction?: 'incoming' | 'outgoing'; - id: string; renderingContext: string; i18n: LocalizerType; attachment: AttachmentType; withContentAbove: boolean; withContentBelow: boolean; + direction: DirectionType; + expirationLength?: number; + expirationTimestamp?: number; + id: string; + showMessageDetail: (id: string) => void; + status?: MessageStatusType; + textPending?: boolean; + timestamp: number; + buttonRef: React.RefObject; computePeaks(url: string, barCount: number): Promise; diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 810fc444e8..3f3e7960e1 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -118,31 +118,27 @@ export function getExtensionForDisplay({ return undefined; } -export function isAudio( - attachments?: Array -): boolean | undefined { - return ( +export function isAudio(attachments?: Array): boolean { + return Boolean( attachments && - attachments[0] && - attachments[0].contentType && - !attachments[0].isCorrupted && - MIME.isAudio(attachments[0].contentType) + attachments[0] && + attachments[0].contentType && + !attachments[0].isCorrupted && + MIME.isAudio(attachments[0].contentType) ); } -export function canDisplayImage( - attachments?: Array -): boolean | 0 | undefined { +export function canDisplayImage(attachments?: Array): boolean { const { height, width } = attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 }; - return ( + return Boolean( height && - height > 0 && - height <= 4096 && - width && - width > 0 && - width <= 4096 + height > 0 && + height <= 4096 && + width && + width > 0 && + width <= 4096 ); } @@ -164,14 +160,12 @@ export function getUrl(attachment: AttachmentType): string | undefined { return attachment.url; } -export function isImage( - attachments?: Array -): boolean | undefined { - return ( +export function isImage(attachments?: Array): boolean { + return Boolean( attachments && - attachments[0] && - attachments[0].contentType && - isImageTypeSupported(attachments[0].contentType) + attachments[0] && + attachments[0].contentType && + isImageTypeSupported(attachments[0].contentType) ); } @@ -193,13 +187,11 @@ export function canBeTranscoded( ); } -export function hasImage( - attachments?: Array -): string | boolean | undefined { - return ( +export function hasImage(attachments?: Array): boolean { + return Boolean( attachments && - attachments[0] && - (attachments[0].url || attachments[0].pending || attachments[0].blurHash) + attachments[0] && + (attachments[0].url || attachments[0].pending || attachments[0].blurHash) ); }