diff --git a/src/script/components/MessagesList/Message/ContentMessage/ContentMessage.tsx b/src/script/components/MessagesList/Message/ContentMessage/ContentMessage.tsx index ff04635b302..f8ab63156a2 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/ContentMessage.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/ContentMessage.tsx @@ -21,6 +21,7 @@ import React, {useMemo, useState, useEffect} from 'react'; import {QualifiedId} from '@wireapp/api-client/lib/user'; +import {ReadIndicator} from 'Components/MessagesList/Message/ReadIndicator'; import {Conversation} from 'src/script/entity/Conversation'; import {CompositeMessage} from 'src/script/entity/message/CompositeMessage'; import {ContentMessage} from 'src/script/entity/message/ContentMessage'; @@ -29,6 +30,7 @@ import {useRelativeTimestamp} from 'src/script/hooks/useRelativeTimestamp'; import {StatusType} from 'src/script/message/StatusType'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {getMessageAriaLabel} from 'Util/conversationMessages'; +import {fromUnixTime, TIME_IN_MILLIS} from 'Util/TimeUtil'; import {ContentAsset} from './asset'; import {MessageActionsMenu} from './MessageActions/MessageActions'; @@ -43,7 +45,6 @@ import {EphemeralStatusType} from '../../../../message/EphemeralStatusType'; import {ContextMenuEntry} from '../../../../ui/ContextMenu'; import {EphemeralTimer} from '../EphemeralTimer'; import {MessageTime} from '../MessageTime'; -import {ReadReceiptStatus} from '../ReadReceiptStatus'; import {useMessageFocusedTabIndex} from '../util'; export interface ContentMessageProps extends Omit { @@ -117,7 +118,7 @@ export const ContentMessageComponent: React.FC = ({ 'quote', ]); - const shouldShowAvatar = (): boolean => { + const shouldShowMessageHeader = (): boolean => { if (!previousMessage || hasMarker) { return true; } @@ -126,9 +127,21 @@ export const ContentMessageComponent: React.FC = ({ return true; } + const currentMessageDate = fromUnixTime(message.timestamp() / TIME_IN_MILLIS.SECOND); + const previousMessageDate = fromUnixTime(previousMessage.timestamp() / TIME_IN_MILLIS.SECOND); + + const currentMinute = currentMessageDate.getMinutes(); + const previousMinute = previousMessageDate.getMinutes(); + + if (currentMinute !== previousMinute) { + return true; + } + return !previousMessage.isContent() || previousMessage.user().id !== user.id; }; + const timeAgo = useRelativeTimestamp(message.timestamp()); + const [messageAriaLabel] = getMessageAriaLabel({ assets, displayTimestampShort: message.displayTimestampShort(), @@ -168,7 +181,7 @@ export const ContentMessageComponent: React.FC = ({ } }} > - {shouldShowAvatar() && ( + {shouldShowMessageHeader() && ( {was_edited && ( @@ -179,11 +192,12 @@ export const ContentMessageComponent: React.FC = ({ - )} @@ -207,6 +221,7 @@ export const ContentMessageComponent: React.FC = ({ isMessageFocused={msgFocusState} /> )} + {assets.map(asset => ( = ({ onClickImage={onClickImage} onClickMessage={onClickMessage} isMessageFocused={msgFocusState} + is1to1Conversation={conversation.is1to1()} + isLastDeliveredMessage={isLastDeliveredMessage} + onClickDetails={() => onClickDetails(message)} /> ))} + {failedToSend && ( = ({ /> )} + {!isConversationReadonly && isActionMenuVisible && ( { }; export const messageWithHeaderTop: CSSObject = { - top: '-53px', + top: '-63px', }; diff --git a/src/script/components/MessagesList/Message/ContentMessage/asset/index.tsx b/src/script/components/MessagesList/Message/ContentMessage/asset/index.tsx index a7db6095c6a..6ca98655f37 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/asset/index.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/asset/index.tsx @@ -43,6 +43,20 @@ import {AssetType} from '../../../../../assets/AssetType'; import {Button} from '../../../../../entity/message/Button'; import {CompositeMessage} from '../../../../../entity/message/CompositeMessage'; import {ContentMessage} from '../../../../../entity/message/ContentMessage'; +import {ReadIndicator} from '../../ReadIndicator'; + +interface ContentAssetProps { + asset: Asset; + message: ContentMessage; + onClickButton: (message: CompositeMessage, buttonId: string) => void; + onClickImage: MessageActions['onClickImage']; + onClickMessage: MessageActions['onClickMessage']; + selfId: QualifiedId; + isMessageFocused: boolean; + is1to1Conversation: boolean; + isLastDeliveredMessage: boolean; + onClickDetails: () => void; +} const ContentAsset = ({ asset, @@ -52,15 +66,10 @@ const ContentAsset = ({ onClickMessage, onClickButton, isMessageFocused, -}: { - asset: Asset; - message: ContentMessage; - onClickButton: (message: CompositeMessage, buttonId: string) => void; - onClickImage: MessageActions['onClickImage']; - onClickMessage: MessageActions['onClickMessage']; - selfId: QualifiedId; - isMessageFocused: boolean; -}) => { + is1to1Conversation, + isLastDeliveredMessage, + onClickDetails, +}: ContentAssetProps) => { const {isObfuscated, status} = useKoSubscribableChildren(message, ['isObfuscated', 'status']); switch (asset.type) { @@ -86,6 +95,13 @@ const ContentAsset = ({ ))} + + ); case AssetType.FILE: @@ -93,6 +109,13 @@ const ContentAsset = ({ return (
+ +
); } @@ -101,6 +124,13 @@ const ContentAsset = ({ return (
+ +
); } @@ -109,17 +139,33 @@ const ContentAsset = ({ return (
+ +
); } case AssetType.IMAGE: return ( - +
+ + + +
); case AssetType.LOCATION: return ; diff --git a/src/script/components/MessagesList/Message/ReadIndicator/ReadIndicator.styles.ts b/src/script/components/MessagesList/Message/ReadIndicator/ReadIndicator.styles.ts new file mode 100644 index 00000000000..b6f0990f630 --- /dev/null +++ b/src/script/components/MessagesList/Message/ReadIndicator/ReadIndicator.styles.ts @@ -0,0 +1,58 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {CSSObject} from '@emotion/react'; + +export const ReadReceiptText: CSSObject = { + display: 'inline-flex', + alignItems: 'center', + verticalAlign: 'bottom', +}; + +export const ReadIndicatorStyles = (showIconOnly = false): CSSObject => ({ + color: 'var(--gray-70)', + fontSize: 'var(--font-size-small)', + fontWeight: 'var(--font-weight-medium)', + lineHeight: 'var(--line-height-sm)', + + svg: { + width: '10px', + minHeight: '10px', + marginRight: '4px', + fill: 'currentColor', + }, + + ...(showIconOnly && { + display: 'flex', + alignItems: 'center', + marginLeft: '8px', + }), + + '.message-asset &': { + marginTop: '8px', + marginLeft: '12px', + }, + + ...(!showIconOnly && { + opacity: 0, + '.message:hover &': { + opacity: '1', + }, + }), +}); diff --git a/src/script/components/MessagesList/Message/ReadIndicator/ReadIndicator.tsx b/src/script/components/MessagesList/Message/ReadIndicator/ReadIndicator.tsx new file mode 100644 index 00000000000..37b87c04284 --- /dev/null +++ b/src/script/components/MessagesList/Message/ReadIndicator/ReadIndicator.tsx @@ -0,0 +1,92 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {Icon} from 'Components/Icon'; +import {useKoSubscribableChildren} from 'Util/ComponentUtil'; +import {t} from 'Util/LocalizerUtil'; +import {formatTimeShort} from 'Util/TimeUtil'; + +import {ReadIndicatorStyles, ReadReceiptText} from './ReadIndicator.styles'; + +import {Message} from '../../../../entity/message/Message'; + +interface ReadIndicatorProps { + message: Message; + is1to1Conversation?: boolean; + isLastDeliveredMessage?: boolean; + showIconOnly?: boolean; + onClick: (message: Message) => void; +} + +export const ReadIndicator = ({ + message, + is1to1Conversation = false, + isLastDeliveredMessage = false, + showIconOnly = false, + onClick, +}: ReadIndicatorProps) => { + const {readReceipts} = useKoSubscribableChildren(message, ['readReceipts']); + + if (!message.expectsReadConfirmation) { + return null; + } + + if (is1to1Conversation) { + const readReceiptText = readReceipts.length ? formatTimeShort(readReceipts[0].time) : ''; + const showDeliveredMessage = isLastDeliveredMessage && readReceiptText === ''; + + return ( + + {showDeliveredMessage && ( + {t('conversationMessageDelivered')} + )} + + {showIconOnly && readReceiptText && } + + {!showIconOnly && !!readReceiptText && ( +
+ {readReceiptText} +
+ )} +
+ ); + } + + const readReceiptCount = readReceipts.length; + const showEyeIndicatorOnly = showIconOnly && readReceiptCount > 0; + + return ( + + ); +}; diff --git a/src/script/components/MessagesList/Message/ReadIndicator/index.ts b/src/script/components/MessagesList/Message/ReadIndicator/index.ts new file mode 100644 index 00000000000..c74885f8cc1 --- /dev/null +++ b/src/script/components/MessagesList/Message/ReadIndicator/index.ts @@ -0,0 +1,20 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export * from './ReadIndicator'; diff --git a/src/script/hooks/useRelativeTimestamp.tsx b/src/script/hooks/useRelativeTimestamp.tsx index d664d12df25..d348545666c 100644 --- a/src/script/hooks/useRelativeTimestamp.tsx +++ b/src/script/hooks/useRelativeTimestamp.tsx @@ -23,7 +23,6 @@ import {t} from 'Util/LocalizerUtil'; import { TIME_IN_MILLIS, fromUnixTime, - isYoungerThan2Minutes, isYoungerThan1Hour, isToday, isYesterday, @@ -33,12 +32,13 @@ import { formatLocale, formatDayMonth, isThisYear, + isYoungerThanMinute, } from 'Util/TimeUtil'; export function useRelativeTimestamp(timestamp: number, asDay = false) { const calculateTimestamp = (ts: number, isDay: boolean) => { const date = fromUnixTime(ts / TIME_IN_MILLIS.SECOND); - if (isYoungerThan2Minutes(date)) { + if (isYoungerThanMinute(date)) { return t('conversationJustNow'); } diff --git a/src/script/util/TimeUtil.ts b/src/script/util/TimeUtil.ts index 0236354a05c..6859fc4647e 100644 --- a/src/script/util/TimeUtil.ts +++ b/src/script/util/TimeUtil.ts @@ -285,7 +285,7 @@ export const formatTimestamp = (timestamp: number | string, longFormat: boolean export const getCurrentDate = () => new Date().toISOString().substring(0, 10); export const getUnixTimestamp = () => Math.floor(Date.now() / TIME_IN_MILLIS.SECOND); export const isBeforeToday = (date: FnDate): boolean => isBefore(date, startOfToday()); -export const isYoungerThan2Minutes = (date: FnDate): boolean => differenceInMinutes(new Date(), date) < 2; +export const isYoungerThanMinute = (date: FnDate): boolean => differenceInMinutes(new Date(), date) < 1; export const isYoungerThan1Hour = (date: FnDate) => differenceInHours(new Date(), date) < 1; export const isYoungerThan7Days = (date: FnDate) => differenceInDays(new Date(), date) < 7; diff --git a/src/style/common/variables.less b/src/style/common/variables.less index 1a0dc2d4558..2e70be8e286 100644 --- a/src/style/common/variables.less +++ b/src/style/common/variables.less @@ -233,13 +233,14 @@ body { // ---------------------------------------------------------------------------- // Conversation // ---------------------------------------------------------------------------- -@conversation-max-width: 800px; +@conversation-max-width: 100%; @conversation-message-max-width: 640px; @conversation-message-sender-width: 64px; body { --conversation-message-sender-width: @conversation-message-sender-width; + --conversation-message-asset-width: 800px; --conversation-message-timestamp-width: 140px; @media (max-width: @screen-md-min) { diff --git a/src/style/content/conversation/message-list.less b/src/style/content/conversation/message-list.less index 59f33593199..e5da189e4b1 100644 --- a/src/style/content/conversation/message-list.less +++ b/src/style/content/conversation/message-list.less @@ -312,19 +312,17 @@ padding-left: @conversation-message-sender-width; &-content { - width: calc(100% - var(--conversation-message-timestamp-width)); - max-width: calc( - @conversation-max-width - @conversation-message-sender-width - var(--conversation-message-timestamp-width) - ); + width: 100%; + max-width: calc(@conversation-max-width - var(--conversation-message-sender-width)); } .text { .text-selection; .accent-selection; - display: inline-block; - width: 100%; + display: inline; min-width: 0; + margin-right: 12px; line-height: @line-height-lg; white-space: pre-wrap; word-wrap: break-word; @@ -790,7 +788,9 @@ // MESSAGE SPACING .message-asset { - flex: 1; + display: flex; + max-width: var(--conversation-message-asset-width); + align-items: flex-start; margin-top: 8px; }