import classNames from 'classnames'; import React, { useCallback, useEffect, useState } from 'react'; import { contextMenu } from 'react-contexify'; import { useDispatch, useSelector } from 'react-redux'; import styled, { keyframes } from 'styled-components'; import useInterval from 'react-use/lib/useInterval'; import useMount from 'react-use/lib/useMount'; import { isNil, isString, toNumber } from 'lodash'; import { Data } from '../../../../data/data'; import { MessageRenderingProps } from '../../../../models/messageType'; import { getConversationController } from '../../../../session/conversations'; import { messagesExpired } from '../../../../state/ducks/conversations'; import { getGenericReadableMessageSelectorProps, getIsMessageSelected, isMessageSelectionMode, } from '../../../../state/selectors/conversations'; import { getIncrement } from '../../../../util/timer'; import { ExpireTimer } from '../../ExpireTimer'; import { isOpenOrClosedGroup } from '../../../../models/conversationAttributes'; import { MessageContentWithStatuses } from '../message-content/MessageContentWithStatus'; import { StyledMessageReactionsContainer } from '../message-content/MessageReactions'; import { ReadableMessage } from './ReadableMessage'; export type GenericReadableMessageSelectorProps = Pick< MessageRenderingProps, | 'direction' | 'conversationType' | 'receivedAt' | 'isUnread' | 'expirationLength' | 'expirationTimestamp' | 'isKickedFromGroup' | 'isExpired' | 'convoId' | 'isDeleted' >; type ExpiringProps = { isExpired?: boolean; expirationTimestamp?: number | null; expirationLength?: number | null; convoId?: string; messageId: string; }; const EXPIRATION_CHECK_MINIMUM = 2000; function useIsExpired(props: ExpiringProps) { const { convoId, messageId, expirationLength, expirationTimestamp, isExpired: isExpiredProps, } = props; const dispatch = useDispatch(); const [isExpired] = useState(isExpiredProps); const checkExpired = useCallback(async () => { const now = Date.now(); if (!expirationTimestamp || !expirationLength) { return; } if (isExpired || now >= expirationTimestamp) { await Data.removeMessage(messageId); if (convoId) { dispatch( messagesExpired([ { conversationKey: convoId, messageId, }, ]) ); const convo = getConversationController().get(convoId); convo?.updateLastMessage(); } } }, [dispatch, expirationTimestamp, expirationLength, isExpired, messageId, convoId]); let checkFrequency: number | null = null; if (expirationLength) { const increment = getIncrement(expirationLength || EXPIRATION_CHECK_MINIMUM); checkFrequency = Math.max(EXPIRATION_CHECK_MINIMUM, increment); } useMount(() => { void checkExpired(); }); useInterval(checkExpired, checkFrequency); // check every 2sec or sooner if needed return { isExpired }; } type Props = { messageId: string; ctxMenuID: string; isDetailView?: boolean; }; const highlightedMessageAnimation = keyframes` 1% { background-color: var(--primary-color); } `; const StyledReadableMessage = styled(ReadableMessage)<{ selected: boolean; isRightClicked: boolean; }>` display: flex; align-items: center; width: 100%; letter-spacing: 0.03rem; padding: 0 var(--margins-lg) 0; &.message-highlighted { animation: ${highlightedMessageAnimation} 1s ease-in-out; } ${StyledMessageReactionsContainer} { margin-top: var(--margins-xs); } ${props => props.isRightClicked && ` background-color: var(--conversation-tab-background-selected-color); `} ${props => props.selected && ` &.message-selected { .module-message { &__container { box-shadow: var(--drop-shadow); } } } `} `; export const GenericReadableMessage = (props: Props) => { const { ctxMenuID, messageId, isDetailView } = props; const [enableReactions, setEnableReactions] = useState(true); const msgProps = useSelector(state => getGenericReadableMessageSelectorProps(state as any, props.messageId) ); const expiringProps: ExpiringProps = { convoId: msgProps?.convoId, expirationLength: msgProps?.expirationLength, messageId: props.messageId, expirationTimestamp: msgProps?.expirationTimestamp, isExpired: msgProps?.isExpired, }; const { isExpired } = useIsExpired(expiringProps); const isMessageSelected = useSelector(state => getIsMessageSelected(state as any, props.messageId) ); const multiSelectMode = useSelector(isMessageSelectionMode); const [isRightClicked, setIsRightClicked] = useState(false); const onMessageLoseFocus = useCallback(() => { if (isRightClicked) { setIsRightClicked(false); } }, [isRightClicked]); const handleContextMenu = useCallback( (e: React.MouseEvent) => { const enableContextMenu = !multiSelectMode && !msgProps?.isKickedFromGroup; const attachmentIndexStr = (e?.target as any)?.parentElement?.getAttribute?.( 'data-attachmentindex' ); const attachmentIndex = isString(attachmentIndexStr) && !isNil(toNumber(attachmentIndexStr)) ? toNumber(attachmentIndexStr) : 0; if (enableContextMenu) { contextMenu.hideAll(); contextMenu.show({ id: ctxMenuID, event: e, props: { dataAttachmentIndex: attachmentIndex, }, }); } setIsRightClicked(enableContextMenu); }, [ctxMenuID, multiSelectMode, msgProps?.isKickedFromGroup] ); useEffect(() => { if (msgProps?.convoId) { const conversationModel = getConversationController().get(msgProps?.convoId); if (conversationModel) { setEnableReactions(conversationModel.hasReactions()); } } }, [msgProps?.convoId]); useEffect(() => { document.addEventListener('click', onMessageLoseFocus); return () => { document.removeEventListener('click', onMessageLoseFocus); }; }, [onMessageLoseFocus]); if (!msgProps) { return null; } const { direction, conversationType, receivedAt, isUnread, expirationLength, expirationTimestamp, } = msgProps; if (isExpired) { return null; } const selected = isMessageSelected || false; const isGroup = isOpenOrClosedGroup(conversationType); const isIncoming = direction === 'incoming'; return ( {expirationLength && expirationTimestamp && ( )} {expirationLength && expirationTimestamp && ( )} ); };