import React from 'react'; import { Message } from '../../conversation/Message'; import { TimerNotification } from '../../conversation/TimerNotification'; import { SessionScrollButton } from '../SessionScrollButton'; import { ResetSessionNotification } from '../../conversation/ResetSessionNotification'; import { Constants } from '../../../session'; import _ from 'lodash'; import { contextMenu } from 'react-contexify'; import { AttachmentType } from '../../../types/Attachment'; import { GroupNotification } from '../../conversation/GroupNotification'; import { GroupInvitation } from '../../conversation/GroupInvitation'; import { ConversationType } from '../../../state/ducks/conversations'; import { MessageModel } from '../../../../js/models/messages'; import { SessionLastSeenIndicator } from './SessionLastSeedIndicator'; import { VerificationNotification } from '../../conversation/VerificationNotification'; import { ToastUtils } from '../../../session/utils'; interface State { isScrolledToBottom: boolean; showScrollButton: boolean; doneInitialScroll: boolean; } interface Props { selectedMessages: Array; conversationKey: string; messages: Array; conversation: ConversationType; messageContainerRef: React.RefObject; selectMessage: (messageId: string) => void; fetchMessagesForConversation: ({ conversationKey, count, }: { conversationKey: string; count: number; }) => void; replyToMessage: (messageId: number) => Promise; onClickAttachment: (attachment: any, message: any) => void; onDownloadAttachment: ({ attachment }: { attachment: any }) => void; onDeleteSelectedMessages: () => Promise; } export class SessionMessagesList extends React.Component { private readonly messagesEndRef: React.RefObject; private readonly messageContainerRef: React.RefObject; public constructor(props: Props) { super(props); this.state = { isScrolledToBottom: false, showScrollButton: false, doneInitialScroll: false, }; this.renderMessage = this.renderMessage.bind(this); this.handleScroll = this.handleScroll.bind(this); this.scrollToUnread = this.scrollToUnread.bind(this); this.scrollToBottom = this.scrollToBottom.bind(this); this.scrollToQuoteMessage = this.scrollToQuoteMessage.bind(this); this.messagesEndRef = React.createRef(); this.messageContainerRef = this.props.messageContainerRef; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~ LIFECYCLES ~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public componentDidMount() { // Pause thread to wait for rendering to complete setTimeout(this.scrollToUnread, 0); } public componentDidUpdate(prevProps: Props, _prevState: State) { if ( prevProps.conversationKey !== this.props.conversationKey || (prevProps.messages.length === 0 && this.props.messages.length !== 0) ) { // displayed conversation changed. We have a bit of cleaning to do here this.setState( { isScrolledToBottom: false, showScrollButton: false, doneInitialScroll: false, }, this.scrollToUnread ); } else { // Keep scrolled to bottom unless user scrolls up if (this.state.isScrolledToBottom) { this.scrollToBottom(); } } } public render() { const { messages } = this.props; const { showScrollButton } = this.state; return (
{this.renderMessages(messages)}
); } public renderMessages(messages: Array) { const { isScrolledToBottom } = this.state; const { conversation } = this.props; const multiSelectMode = Boolean(this.props.selectedMessages.length); let currentMessageIndex = 0; // find the first unread message in the list of messages. We use this to display the // unread banner so this is at all times at the correct index. // Our messages are marked read, so be sure to skip those. let findFirstUnreadIndex = messages.findIndex( message => !( message?.attributes?.unread && message?.attributes?.unread !== false ) && message?.attributes?.type === 'incoming' ); // if we did not find an unread messsages, but the conversation has some, // we must not have enough messages in memory, so just display the unread banner // at the top of the screen if (findFirstUnreadIndex === -1 && conversation.unreadCount !== 0) { findFirstUnreadIndex = messages.length - 1; } if (conversation.unreadCount === 0) { findFirstUnreadIndex = -1; } const isConvoBlocked = conversation.isBlocked; return ( <> {messages.map((message: MessageModel) => { const messageProps = message.propsForMessage; const timerProps = message.propsForTimerNotification; const resetSessionProps = message.propsForResetSessionNotification; const verificationSessionProps = message.propsForVerificationNotification; const propsForGroupInvitation = message.propsForGroupInvitation; const groupNotificationProps = message.propsForGroupNotification; // if there are some unread messages const showUnreadIndicator = !isScrolledToBottom && findFirstUnreadIndex > 0 && currentMessageIndex === findFirstUnreadIndex; const unreadIndicator = ( ); currentMessageIndex = currentMessageIndex + 1; if (groupNotificationProps) { return ( <> {unreadIndicator} ); } if (propsForGroupInvitation) { return ( <> {unreadIndicator} ); } if (verificationSessionProps) { return ( <> {unreadIndicator} ); } if (resetSessionProps) { return ( <> {unreadIndicator} ); } if (timerProps) { return ( <> {unreadIndicator} ); } // firstMessageOfSeries tells us to render the avatar only for the first message // in a series of messages from the same user return ( <> {this.renderMessage( messageProps, message.firstMessageOfSeries, multiSelectMode )} {unreadIndicator} ); })} ); } public renderMessage( messageProps: any, firstMessageOfSeries: boolean, multiSelectMode: boolean ) { const selected = !!messageProps?.id && this.props.selectedMessages.includes(messageProps.id); messageProps.i18n = window.i18n; messageProps.selected = selected; messageProps.firstMessageOfSeries = firstMessageOfSeries; messageProps.multiSelectMode = multiSelectMode; messageProps.onSelectMessage = (messageId: string) => { this.selectMessage(messageId); }; messageProps.onReply = (messageId: number) => { void this.props.replyToMessage(messageId); }; messageProps.onClickAttachment = (attachment: any) => { this.props.onClickAttachment(attachment, messageProps); }; messageProps.onDownload = (attachment: AttachmentType) => { this.props.onDownloadAttachment({ attachment }); }; if (messageProps.quote) { messageProps.quote.onClick = (options: { quoteAuthor: string; quoteId: any; referencedMessageNotFound: boolean; }) => { void this.scrollToQuoteMessage(options); }; } return ; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~ MESSAGE HANDLING ~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public updateReadMessages() { const { messages, conversationKey } = this.props; const { isScrolledToBottom } = this.state; if (!messages || messages.length === 0) { return; } const conversation = window.ConversationController.getOrThrow( conversationKey ); if (conversation.isBlocked()) { return; } if (isScrolledToBottom) { void conversation.markRead(messages[0].attributes.received_at); } } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~ SCROLLING METHODS ~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public async handleScroll() { const messageContainer = this.messageContainerRef?.current; const { fetchMessagesForConversation, conversationKey } = this.props; if (!messageContainer) { return; } contextMenu.hideAll(); if (!this.state.doneInitialScroll) { return; } const scrollTop = messageContainer.scrollTop; const scrollHeight = messageContainer.scrollHeight; const clientHeight = messageContainer.clientHeight; const scrollButtonViewShowLimit = 0.75; const scrollButtonViewHideLimit = 0.4; const scrollOffsetPx = scrollHeight - scrollTop - clientHeight; const scrollOffsetPc = scrollOffsetPx / clientHeight; // Scroll button appears if you're more than 75% scrolled up if ( scrollOffsetPc > scrollButtonViewShowLimit && !this.state.showScrollButton ) { this.setState({ showScrollButton: true }); } // Scroll button disappears if you're more less than 40% scrolled up if ( scrollOffsetPc < scrollButtonViewHideLimit && this.state.showScrollButton ) { this.setState({ showScrollButton: false }); } // Scrolled to bottom const isScrolledToBottom = scrollOffsetPc === 0; // Pin scroll to bottom on new message, unless user has scrolled up if (this.state.isScrolledToBottom !== isScrolledToBottom) { this.setState({ isScrolledToBottom }, () => { // Mark messages read this.updateReadMessages(); }); } // Fetch more messages when nearing the top of the message list const shouldFetchMoreMessages = scrollTop <= Constants.UI.MESSAGE_CONTAINER_BUFFER_OFFSET_PX; if (shouldFetchMoreMessages) { const { messages } = this.props; const numMessages = this.props.messages.length + Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT; const oldLen = messages.length; const previousTopMessage = messages[oldLen - 1]?.id; fetchMessagesForConversation({ conversationKey, count: numMessages }); if (previousTopMessage && oldLen !== messages.length) { this.scrollToMessage(previousTopMessage); } } } public scrollToUnread() { const { messages, conversation } = this.props; if (conversation.unreadCount > 0) { let message; if (messages.length > conversation.unreadCount) { // if we have enough message to show one more message, show one more to include the unread banner message = messages[conversation.unreadCount]; } else { message = messages[conversation.unreadCount - 1]; } if (message) { this.scrollToMessage(message.id); } } if (!this.state.doneInitialScroll) { this.setState( { doneInitialScroll: true, }, () => { this.updateReadMessages(); } ); } } public scrollToMessage(messageId: string) { const topUnreadMessage = document.getElementById(messageId); topUnreadMessage?.scrollIntoView(); // if the scroll container is not scrollable as it's not tall enough, we have to update // the isScrollToBottom ourself const messageContainer = this.messageContainerRef.current; if (!messageContainer) { return; } const scrollTop = messageContainer.scrollTop; const scrollHeight = messageContainer.scrollHeight; const clientHeight = messageContainer.clientHeight; const scrollOffsetPx = scrollHeight - scrollTop - clientHeight; const scrollOffsetPc = scrollOffsetPx / clientHeight; if (scrollOffsetPc === 0 && this.state.doneInitialScroll) { this.setState({ isScrolledToBottom: true }); } else if ( scrollHeight !== 0 && scrollHeight === clientHeight && !this.state.isScrolledToBottom ) { this.setState({ isScrolledToBottom: true }, () => { // Mark messages read this.updateReadMessages(); }); } } public scrollToBottom() { // FIXME VINCE: Smooth scrolling that isn't slow@! // this.messagesEndRef.current?.scrollIntoView( // { behavior: firstLoad ? 'auto' : 'smooth' } // ); const messageContainer = this.messageContainerRef.current; if (!messageContainer) { return; } messageContainer.scrollTop = messageContainer.scrollHeight - messageContainer.clientHeight; this.updateReadMessages(); } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~ MESSAGE SELECTION ~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public selectMessage(messageId: string) { this.props.selectMessage(messageId); } private async scrollToQuoteMessage(options: any = {}) { const { quoteAuthor, quoteId, referencedMessageNotFound } = options; // For simplicity's sake, we show the 'not found' toast no matter what if we were // not able to find the referenced message when the quote was received. if (referencedMessageNotFound) { ToastUtils.pushOriginalNotFound(); return; } // Look for message in memory first, which would tell us if we could scroll to it const targetMessage = this.props.messages.find(item => { const messageAuthor = item.propsForMessage?.authorPhoneNumber; if (!messageAuthor || quoteAuthor !== messageAuthor) { return false; } if (quoteId !== item.propsForMessage?.timestamp) { return false; } return true; }); // If there's no message already in memory, we won't be scrolling. So we'll gather // some more information then show an informative toast to the user. if (!targetMessage) { const collection = await window.Signal.Data.getMessagesBySentAt(quoteId, { MessageCollection: window.Whisper.MessageCollection, }); const found = Boolean( collection.find((item: MessageModel) => { const messageAuthor = item.propsForMessage?.authorPhoneNumber; return messageAuthor && quoteAuthor === messageAuthor; }) ); if (found) { ToastUtils.pushFoundButNotLoaded(); } else { ToastUtils.pushOriginalNoLongerAvailable(); } return; } const databaseId = targetMessage.id; // const el = this.$(`#${databaseId}`); // if (!el || el.length === 0) { // ToastUtils.pushOriginalNoLongerAvailable(); // window.log.info( // `Error: had target message ${id} in messageCollection, but it was not in DOM` // ); // return; // } // this probably does not work for us as we need to call getMessages before this.scrollToMessage(databaseId); } }