Skip to content

Commit

Permalink
feat: Improved delivered status indicator WPB-9170
Browse files Browse the repository at this point in the history
  • Loading branch information
Roma Koval committed Jun 11, 2024
1 parent 696c293 commit 37704a7
Show file tree
Hide file tree
Showing 9 changed files with 236 additions and 171 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {QualifiedId} from '@wireapp/api-client/lib/user';
import cx from 'classnames';
import ko from 'knockout';

import {DeliveredIndicator} from 'Components/MessagesList/Message/DeliveredIndicator/DeliveredIndicator';
import {ReadIndicator} from 'Components/MessagesList/Message/ReadIndicator';
import {Conversation} from 'src/script/entity/Conversation';
import {CompositeMessage} from 'src/script/entity/message/CompositeMessage';
Expand All @@ -48,6 +49,7 @@ import {ContextMenuEntry} from '../../../../ui/ContextMenu';
import {EphemeralTimer} from '../EphemeralTimer';
import {MessageTime} from '../MessageTime';
import {useMessageFocusedTabIndex} from '../util';

export interface ContentMessageProps extends Omit<MessageActions, 'onClickResetSession'> {
contextMenu: {entries: ko.Subscribable<ContextMenuEntry[]>};
conversation: Conversation;
Expand Down Expand Up @@ -88,6 +90,7 @@ export const ContentMessageComponent = ({
onClickDetails,
}: ContentMessageProps) => {
const messageRef = useRef<HTMLDivElement | null>(null);
const [deliveryIndicatorRef, setDeliveryIndicatorRef] = useState<HTMLDivElement | null>(null);

// check if current message is focused and its elements focusable
const msgFocusState = useMemo(() => isMsgElementsFocusable && isFocused, [isMsgElementsFocusable, isFocused]);
Expand Down Expand Up @@ -163,134 +166,138 @@ export const ContentMessageComponent = ({
useClickOutside(messageRef, hideActionMenuVisibility);

return (
<div
aria-label={messageAriaLabel}
className="content-message-wrapper"
ref={contentMessageWrapperRef}
onMouseEnter={() => {
// open another floating action menu if none already open
if (!isMenuOpen) {
setActionMenuVisibility(true);
}
}}
onMouseLeave={() => {
// close floating message actions when no active menu is open like context menu/emoji picker
if (!isMenuOpen) {
setActionMenuVisibility(false);
}
}}
>
{(was_edited || !hideHeader) && (
<MessageHeader onClickAvatar={onClickAvatar} message={message} focusTabIndex={messageFocusedTabIndex}>
{was_edited && (
<span className="message-header-label-icon icon-edit" title={message.displayEditedTimestamp()}></span>
)}

<span className="content-message-timestamp">
<MessageTime timestamp={timestamp} data-timestamp-type="normal">
{timeAgo}
</MessageTime>
</span>
</MessageHeader>
)}

<>
<div
className={cx('message-body', {
'message-asset': isAssetMessage,
'message-quoted': !!quote,
'ephemeral-asset-expired': isObfuscated && isAssetMessage,
'icon-file': isObfuscated && isFileMessage,
'icon-movie': isObfuscated && isVideoMessage,
})}
{...(ephemeralCaption && {title: ephemeralCaption})}
aria-label={messageAriaLabel}
className="content-message-wrapper"
css={{
width: `calc(100% - ${deliveryIndicatorRef?.offsetWidth || 0}px)`,
}}
ref={contentMessageWrapperRef}
onMouseEnter={() => {
// open another floating action menu if none already open
if (!isMenuOpen) {
setActionMenuVisibility(true);
}
}}
onMouseLeave={() => {
// close floating message actions when no active menu is open like context menu/emoji picker
if (!isMenuOpen) {
setActionMenuVisibility(false);
}
}}
>
{ephemeral_status === EphemeralStatusType.ACTIVE && (
<div className="message-ephemeral-timer">
<EphemeralTimer message={message} />
</div>
{(was_edited || !hideHeader) && (
<MessageHeader onClickAvatar={onClickAvatar} message={message} focusTabIndex={messageFocusedTabIndex}>
{was_edited && (
<span className="message-header-label-icon icon-edit" title={message.displayEditedTimestamp()}></span>
)}

<span className="content-message-timestamp">
<MessageTime timestamp={timestamp} data-timestamp-type="normal">
{timeAgo}
</MessageTime>
</span>
</MessageHeader>
)}

{quote && (
<Quote
conversation={conversation}
quote={quote}
selfId={selfId}
findMessage={findMessage}
showDetail={onClickImage}
focusMessage={onClickTimestamp}
handleClickOnMessage={onClickMessage}
showUserDetails={onClickAvatar}
<div
className={cx('message-body', {
'message-asset': isAssetMessage,
'message-quoted': !!quote,
'ephemeral-asset-expired': isObfuscated && isAssetMessage,
'icon-file': isObfuscated && isFileMessage,
'icon-movie': isObfuscated && isVideoMessage,
})}
{...(ephemeralCaption && {title: ephemeralCaption})}
>
{ephemeral_status === EphemeralStatusType.ACTIVE && (
<div className="message-ephemeral-timer">
<EphemeralTimer message={message} />
</div>
)}

{quote && (
<Quote
conversation={conversation}
quote={quote}
selfId={selfId}
findMessage={findMessage}
showDetail={onClickImage}
focusMessage={onClickTimestamp}
handleClickOnMessage={onClickMessage}
showUserDetails={onClickAvatar}
isMessageFocused={msgFocusState}
/>
)}

{assets.map(asset => (
<ContentAsset
key={asset.type}
asset={asset}
message={message}
selfId={selfId}
onClickButton={onClickButton}
onClickImage={onClickImage}
onClickMessage={onClickMessage}
isMessageFocused={msgFocusState}
is1to1Conversation={conversation.is1to1()}
onClickDetails={() => onClickDetails(message)}
/>
))}

{isAssetMessage && (
<ReadIndicator message={message} is1to1Conversation={conversation.is1to1()} onClick={onClickDetails} />
)}

{!isConversationReadonly && isActionMenuVisible && (
<MessageActionsMenu
isMsgWithHeader={!hideHeader}
message={message}
handleActionMenuVisibility={setActionMenuVisibility}
contextMenu={contextMenu}
isMessageFocused={msgFocusState}
handleReactionClick={onClickReaction}
reactionsTotalCount={reactions.length}
isRemovedFromConversation={conversation.removed_from_conversation()}
/>
)}
</div>

{[StatusType.FAILED, StatusType.FEDERATION_ERROR].includes(status) && (
<CompleteFailureToSendWarning
{...(status === StatusType.FEDERATION_ERROR && {unreachableDomain: conversation.domain})}
isMessageFocused={msgFocusState}
onRetry={() => onRetry(message)}
/>
)}

{assets.map(asset => (
<ContentAsset
key={asset.type}
asset={asset}
message={message}
selfId={selfId}
onClickButton={onClickButton}
onClickImage={onClickImage}
onClickMessage={onClickMessage}
{failedToSend && (
<PartialFailureToSendWarning
isMessageFocused={msgFocusState}
is1to1Conversation={conversation.is1to1()}
isLastDeliveredMessage={isLastDeliveredMessage}
onClickDetails={() => onClickDetails(message)}
/>
))}

{isAssetMessage && (
<ReadIndicator
message={message}
is1to1Conversation={conversation.is1to1()}
isLastDeliveredMessage={isLastDeliveredMessage}
onClick={onClickDetails}
failedToSend={failedToSend}
knownUsers={conversation.allUserEntities()}
/>
)}

{!isConversationReadonly && isActionMenuVisible && (
<MessageActionsMenu
isMsgWithHeader={!hideHeader}
message={message}
handleActionMenuVisibility={setActionMenuVisibility}
contextMenu={contextMenu}
isMessageFocused={msgFocusState}
{!!reactions.length && (
<MessageReactionsList
reactions={reactions}
selfUserId={selfId}
handleReactionClick={onClickReaction}
reactionsTotalCount={reactions.length}
isMessageFocused={msgFocusState}
onTooltipReactionCountClick={() => onClickReactionDetails(message)}
onLastReactionKeyEvent={() => setActionMenuVisibility(false)}
isRemovedFromConversation={conversation.removed_from_conversation()}
users={conversation.allUserEntities()}
/>
)}
</div>

{[StatusType.FAILED, StatusType.FEDERATION_ERROR].includes(status) && (
<CompleteFailureToSendWarning
{...(status === StatusType.FEDERATION_ERROR && {unreachableDomain: conversation.domain})}
isMessageFocused={msgFocusState}
onRetry={() => onRetry(message)}
/>
)}

{failedToSend && (
<PartialFailureToSendWarning
isMessageFocused={msgFocusState}
failedToSend={failedToSend}
knownUsers={conversation.allUserEntities()}
/>
)}

{!!reactions.length && (
<MessageReactionsList
reactions={reactions}
selfUserId={selfId}
handleReactionClick={onClickReaction}
isMessageFocused={msgFocusState}
onTooltipReactionCountClick={() => onClickReactionDetails(message)}
onLastReactionKeyEvent={() => setActionMenuVisibility(false)}
isRemovedFromConversation={conversation.removed_from_conversation()}
users={conversation.allUserEntities()}
/>
)}
</div>
<DeliveredIndicator
ref={setDeliveryIndicatorRef}
isLastDeliveredMessage={isLastDeliveredMessage}
height={messageRef.current?.offsetHeight}
/>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ interface ContentAssetProps {
selfId: QualifiedId;
isMessageFocused: boolean;
is1to1Conversation: boolean;
isLastDeliveredMessage: boolean;
onClickDetails: () => void;
}

Expand All @@ -67,7 +66,6 @@ const ContentAsset = ({
onClickButton,
isMessageFocused,
is1to1Conversation,
isLastDeliveredMessage,
onClickDetails,
}: ContentAssetProps) => {
const {isObfuscated, status} = useKoSubscribableChildren(message, ['isObfuscated', 'status']);
Expand Down Expand Up @@ -96,25 +94,15 @@ const ContentAsset = ({
)}

{shouldRenderText && (
<ReadIndicator
message={message}
is1to1Conversation={is1to1Conversation}
isLastDeliveredMessage={isLastDeliveredMessage}
onClick={onClickDetails}
/>
<ReadIndicator message={message} is1to1Conversation={is1to1Conversation} onClick={onClickDetails} />
)}

{previews.map(() => (
<div key={asset.id} className="message-asset">
<LinkPreviewAsset message={message} isFocusable={isMessageFocused} />

{!shouldRenderText && (
<ReadIndicator
message={message}
is1to1Conversation={is1to1Conversation}
isLastDeliveredMessage={isLastDeliveredMessage}
onClick={onClickDetails}
/>
<ReadIndicator message={message} is1to1Conversation={is1to1Conversation} onClick={onClickDetails} />
)}
</div>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* 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 DeliveryIndicatorContainerStyles = (height?: number): CSSObject => ({
display: 'flex',
boxSizing: 'border-box',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
top: 0,
right: 0,
overflow: 'unset',
padding: '0 24px 0 16px',
height: height ? `${height}px` : 'min-content',
});

export const DeliveredIndicatorStyles = (isLastDeliveredMessage: boolean): CSSObject => ({
color: 'var(--content-message-timestamp)',
fontSize: 'var(--font-size-small)',
fontWeight: 'var(--font-weight-regular)',
wordWrap: 'normal',
width: 'min-content',
visibility: isLastDeliveredMessage ? 'unset' : 'hidden',
});
Loading

0 comments on commit 37704a7

Please sign in to comment.