Skip to content

Commit

Permalink
feat: Group messages in the chat by user and timestamp (#16496)
Browse files Browse the repository at this point in the history
* feat: Group messages in the chat by user and timestamp

* feat: Prepare new version

* improve message asset read indicator

* revert opacity to 0 for not hovered message

* revert expectsReadConfirmation

* clear unecessary styles
  • Loading branch information
przemvs authored Jan 22, 2024
1 parent 3e5cc60 commit b8f359d
Show file tree
Hide file tree
Showing 9 changed files with 259 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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<MessageActions, 'onClickResetSession'> {
Expand Down Expand Up @@ -117,7 +118,7 @@ export const ContentMessageComponent: React.FC<ContentMessageProps> = ({
'quote',
]);

const shouldShowAvatar = (): boolean => {
const shouldShowMessageHeader = (): boolean => {
if (!previousMessage || hasMarker) {
return true;
}
Expand All @@ -126,9 +127,21 @@ export const ContentMessageComponent: React.FC<ContentMessageProps> = ({
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(),
Expand Down Expand Up @@ -168,7 +181,7 @@ export const ContentMessageComponent: React.FC<ContentMessageProps> = ({
}
}}
>
{shouldShowAvatar() && (
{shouldShowMessageHeader() && (
<MessageHeader onClickAvatar={onClickAvatar} message={message} focusTabIndex={messageFocusedTabIndex}>
{was_edited && (
<span className="message-header-label-icon icon-edit" title={message.displayEditedTimestamp()}></span>
Expand All @@ -179,11 +192,12 @@ export const ContentMessageComponent: React.FC<ContentMessageProps> = ({
</MessageTime>
</span>

<ReadReceiptStatus
<ReadIndicator
message={message}
is1to1Conversation={conversation.is1to1()}
isLastDeliveredMessage={isLastDeliveredMessage}
onClickDetails={onClickDetails}
onClick={onClickDetails}
showIconOnly
/>
</MessageHeader>
)}
Expand All @@ -207,6 +221,7 @@ export const ContentMessageComponent: React.FC<ContentMessageProps> = ({
isMessageFocused={msgFocusState}
/>
)}

{assets.map(asset => (
<ContentAsset
key={asset.type}
Expand All @@ -217,8 +232,12 @@ export const ContentMessageComponent: React.FC<ContentMessageProps> = ({
onClickImage={onClickImage}
onClickMessage={onClickMessage}
isMessageFocused={msgFocusState}
is1to1Conversation={conversation.is1to1()}
isLastDeliveredMessage={isLastDeliveredMessage}
onClickDetails={() => onClickDetails(message)}
/>
))}

{failedToSend && (
<PartialFailureToSendWarning
isMessageFocused={msgFocusState}
Expand All @@ -235,9 +254,10 @@ export const ContentMessageComponent: React.FC<ContentMessageProps> = ({
/>
)}
</div>

{!isConversationReadonly && isActionMenuVisible && (
<MessageActionsMenu
isMsgWithHeader={shouldShowAvatar()}
isMsgWithHeader={shouldShowMessageHeader()}
message={message}
handleActionMenuVisibility={setActionMenuVisibility}
contextMenu={contextMenu}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const messageBodyActions: CSSObject = {
minWidth: '40px',
position: 'absolute',
right: '16px',
top: '-20px',
top: '-30px',
userSelect: 'none',
'@media (max-width: @screen-md-min)': {
height: '45px',
Expand Down Expand Up @@ -103,5 +103,5 @@ export const getActionsMenuCSS = (isActive?: boolean): CSSObject => {
};

export const messageWithHeaderTop: CSSObject = {
top: '-53px',
top: '-63px',
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -86,13 +95,27 @@ const ContentAsset = ({
<LinkPreviewAsset message={message} isFocusable={isMessageFocused} />
</div>
))}

<ReadIndicator
message={message}
is1to1Conversation={is1to1Conversation}
isLastDeliveredMessage={isLastDeliveredMessage}
onClick={onClickDetails}
/>
</>
);
case AssetType.FILE:
if ((asset as FileAssetType).isFile()) {
return (
<div className={`message-asset ${isObfuscated ? 'ephemeral-asset-expired icon-file' : ''}`}>
<FileAsset message={message} isFocusable={isMessageFocused} />

<ReadIndicator
message={message}
is1to1Conversation={is1to1Conversation}
isLastDeliveredMessage={isLastDeliveredMessage}
onClick={onClickDetails}
/>
</div>
);
}
Expand All @@ -101,6 +124,13 @@ const ContentAsset = ({
return (
<div className={`message-asset ${isObfuscated ? 'ephemeral-asset-expired' : ''}`}>
<AudioAsset message={message} isFocusable={isMessageFocused} />

<ReadIndicator
message={message}
is1to1Conversation={is1to1Conversation}
isLastDeliveredMessage={isLastDeliveredMessage}
onClick={onClickDetails}
/>
</div>
);
}
Expand All @@ -109,18 +139,32 @@ const ContentAsset = ({
return (
<div className={`message-asset ${isObfuscated ? 'ephemeral-asset-expired icon-movie' : ''}`}>
<VideoAsset message={message} isFocusable={isMessageFocused} />

<ReadIndicator
message={message}
is1to1Conversation={is1to1Conversation}
isLastDeliveredMessage={isLastDeliveredMessage}
onClick={onClickDetails}
/>
</div>
);
}
case AssetType.IMAGE:
return (
<div key={asset.id} className="message-asset">
<div className={`message-asset ${isObfuscated ? 'ephemeral-asset-expired' : ''}`}>
<ImageAsset
asset={asset as MediumImage}
message={message}
onClick={onClickImage}
isFocusable={isMessageFocused}
/>

<ReadIndicator
message={message}
is1to1Conversation={is1to1Conversation}
isLastDeliveredMessage={isLastDeliveredMessage}
onClick={onClickDetails}
/>
</div>
);
case AssetType.LOCATION:
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
},
}),
});
Original file line number Diff line number Diff line change
@@ -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 (
<span css={ReadIndicatorStyles(showIconOnly)} data-uie-name="status-message-read-receipts">
{showDeliveredMessage && (
<span data-uie-name="status-message-read-receipt-delivered">{t('conversationMessageDelivered')}</span>
)}

{showIconOnly && readReceiptText && <Icon.Read />}

{!showIconOnly && !!readReceiptText && (
<div css={ReadReceiptText} data-uie-name="status-message-read-receipt-text">
<Icon.Read /> {readReceiptText}
</div>
)}
</span>
);
}

const readReceiptCount = readReceipts.length;
const showEyeIndicatorOnly = showIconOnly && readReceiptCount > 0;

return (
<button
css={ReadIndicatorStyles(showIconOnly)}
onClick={() => onClick(message)}
className="button-reset-default"
data-uie-name="status-message-read-receipts"
>
{showEyeIndicatorOnly ? (
<Icon.Read />
) : (
!!readReceiptCount && (
<div css={ReadReceiptText} data-uie-name="status-message-read-receipt-count">
<Icon.Read /> {readReceiptCount}
</div>
)
)}
</button>
);
};
Loading

0 comments on commit b8f359d

Please sign in to comment.