/* * Wire * Copyright (C) 2022 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 {useMemo, useState, useEffect, useRef} from 'react'; import {QualifiedId} from '@wireapp/api-client/lib/user'; import cx from 'classnames'; import ko from 'knockout'; import {OutlineCheck} from '@wireapp/react-ui-kit'; 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'; 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 {t} from 'Util/LocalizerUtil'; import {ContentAsset} from './asset'; import {deliveredMessageIndicator, messageBodyWrapper, messageEphemeralTimer} from './ContentMessage.styles'; import {MessageActionsMenu} from './MessageActions/MessageActions'; import {useMessageActionsState} from './MessageActions/MessageActions.state'; import {MessageReactionsList} from './MessageActions/MessageReactions/MessageReactionsList'; import {MessageHeader} from './MessageHeader'; import {Quote} from './MessageQuote'; import {CompleteFailureToSendWarning, PartialFailureToSendWarning} from './Warnings'; import {MessageActions} from '..'; import type {FileAsset as FileAssetType} from '../../../../entity/message/FileAsset'; import {useClickOutside} from '../../../../hooks/useClickOutside'; import {EphemeralStatusType} from '../../../../message/EphemeralStatusType'; import {ContextMenuEntry} from '../../../../ui/ContextMenu'; import {EphemeralTimer} from '../EphemeralTimer'; import {MessageTime} from '../MessageTime'; import {useMessageFocusedTabIndex} from '../util'; export interface ContentMessageProps extends Omit { contextMenu: {entries: ko.Subscribable}; conversation: Conversation; findMessage: (conversation: Conversation, messageId: string) => Promise; focusMessage?: () => void; /** whether the message should display the user avatar and user name before the actual content */ hideHeader: boolean; hasMarker?: boolean; isFocused: boolean; isLastDeliveredMessage: boolean; message: ContentMessage; onClickButton: (message: CompositeMessage, buttonId: string) => void; onRetry: (message: ContentMessage) => void; quotedMessage?: ContentMessage; selfId: QualifiedId; isMsgElementsFocusable: boolean; onClickReaction: (emoji: string) => void; is1to1?: boolean; } export const ContentMessageComponent = ({ conversation, message, findMessage, selfId, hideHeader, isFocused, isLastDeliveredMessage, contextMenu, onClickAvatar, onClickImage, onClickTimestamp, onClickMessage, onClickReactionDetails, onClickButton, onRetry, isMsgElementsFocusable, onClickReaction, onClickDetails, is1to1, }: ContentMessageProps) => { const messageRef = useRef(null); // check if current message is focused and its elements focusable const msgFocusState = useMemo(() => isMsgElementsFocusable && isFocused, [isMsgElementsFocusable, isFocused]); const messageFocusedTabIndex = useMessageFocusedTabIndex(msgFocusState); const { senderName, timestamp, ephemeralCaption, ephemeral_status, assets, was_edited, failedToSend, reactions, status, quote, isObfuscated, } = useKoSubscribableChildren(message, [ 'senderName', 'timestamp', 'ephemeralCaption', 'ephemeral_status', 'assets', 'was_edited', 'failedToSend', 'reactions', 'status', 'quote', 'isObfuscated', ]); const timeAgo = useRelativeTimestamp(message.timestamp()); const [messageAriaLabel] = getMessageAriaLabel({ assets, displayTimestampShort: message.displayTimestampShort(), senderName, }); const [isActionMenuVisible, setActionMenuVisibility] = useState(false); const isMenuOpen = useMessageActionsState(state => state.isMenuOpen); useEffect(() => { setActionMenuVisibility(isFocused || msgFocusState); }, [msgFocusState, isFocused]); const isConversationReadonly = conversation.readOnlyState() !== null; const contentMessageWrapperRef = (element: HTMLDivElement | null) => { messageRef.current = element; setTimeout(() => { if (element?.parentElement?.querySelector(':hover') === element) { // Trigger the action menu in case the component is rendered with the mouse already hovering over it setActionMenuVisibility(true); } }); }; const asset = assets?.[0] as FileAssetType | undefined; const isFileMessage = !!asset?.isFile(); const isAudioMessage = !!asset?.isAudio(); const isVideoMessage = !!asset?.isVideo(); const isImageMessage = !!asset?.isImage(); const isAssetMessage = isFileMessage || isAudioMessage || isVideoMessage || isImageMessage; const isEphemeralMessage = ephemeral_status === EphemeralStatusType.ACTIVE; const hideActionMenuVisibility = () => { if (isFocused) { setActionMenuVisibility(false); } }; // Closing another ActionMenu on outside click useClickOutside(messageRef, hideActionMenuVisibility); return (
{ // 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) && ( {was_edited && ( )} {timeAgo} )}
{isEphemeralMessage && (
)}
{quote && ( )} {assets.map(asset => ( onClickDetails(message)} /> ))} {isAssetMessage && ( )} {!isConversationReadonly && isActionMenuVisible && ( )}
{message.expectsReadConfirmation && (
{is1to1 && isLastDeliveredMessage && (
)}
)}
{[StatusType.FAILED, StatusType.FEDERATION_ERROR].includes(status) && ( onRetry(message)} /> )} {failedToSend && ( )} {!!reactions.length && ( onClickReactionDetails(message)} onLastReactionKeyEvent={() => setActionMenuVisibility(false)} isRemovedFromConversation={conversation.isSelfUserRemoved()} users={conversation.allUserEntities()} /> )}
); };