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 { ConversationModel } from '../../../../js/models/conversations'; import { SessionFileDropzone } from './SessionFileDropzone'; interface State { isScrolledToBottom: boolean; showScrollButton: boolean; doneInitialScroll: boolean; } interface Props { selectedMessages: Array; conversationKey: string; messages: Array; resetSelection: () => any; initialFetchComplete: boolean; conversationModel: ConversationModel; conversation: any; selectMessage: (messageId: string) => void; getMessages: ( numMessages: number, interval: number ) => Promise<{ previousTopMessage: string }>; replyToMessage: (messageId: number) => Promise; onClickAttachment: (attachment: any, message: any) => void; handleFilesDropped: (droppedFiles: FileList) => void; } export class SessionConversationMessagesList extends React.Component< Props, State > { private readonly messagesEndRef: React.RefObject; private readonly messageContainerRef: React.RefObject; public constructor(props: Props) { super(props); this.state = { isScrolledToBottom: false, showScrollButton: true, 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.messagesEndRef = React.createRef(); this.messageContainerRef = React.createRef(); } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~ LIFECYCLES ~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public componentDidMount() { // Pause thread to wait for rendering to complete setTimeout(this.scrollToUnread, 0); setTimeout(() => { this.setState({ doneInitialScroll: true, }); }, 100); this.updateReadMessages(); } public componentDidUpdate() { // Keep scrolled to bottom unless user scrolls up if (this.state.isScrolledToBottom) { this.scrollToBottom(); this.updateReadMessages(); } // New messages get from message collection. const messageCollection = window.ConversationController.get( this.props.conversationKey )?.messageCollection; } public async componentWillReceiveProps(nextProps: any) { return; } public render() { const { messages } = this.props; const { doneInitialScroll, showScrollButton } = this.state; if (!doneInitialScroll) { return
; } return ( <>
{this.renderMessages(messages)}
); } public renderMessages(messages: any) { const multiSelectMode = Boolean(this.props.selectedMessages.length); // FIXME VINCE: IF MESSAGE IS THE TOP OF UNREAD, THEN INSERT AN UNREAD BANNER return ( <> {messages.map((message: any) => { const messageProps = message.propsForMessage; // const quoteProps = messageProps.quote; // console.warn('propsForQuote', quoteProps); const timerProps = message.propsForTimerNotification; const resetSessionProps = message.propsForResetSessionNotification; const attachmentProps = message.propsForAttachment; const groupNotificationProps = message.propsForGroupNotification; let item; // firstMessageOfSeries tells us to render the avatar only for the first message // in a series of messages from the same user item = messageProps ? this.renderMessage( message, messageProps, message.firstMessageOfSeries, multiSelectMode ) : item; item = timerProps ? : item; item = resetSessionProps ? ( ) : ( item ); // item = attachmentProps ? this.renderMessage(timerProps) : item; return item; })} ); } public renderMessage( message: any, 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 = () return ; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~ MESSAGE HANDLING ~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public updateReadMessages() { const { messages, conversationKey } = this.props; const { isScrolledToBottom } = this.state; // If you're not friends, don't mark anything as read. Otherwise // this will automatically accept friend request. const conversation = window.ConversationController.getOrThrow( conversationKey ); if (conversation.isBlocked()) { return; } let unread; if (!messages || messages.length === 0) { return; } if (isScrolledToBottom) { unread = messages[messages.length - 1]; } else { unread = this.findNewestVisibleUnread(); } if (unread) { conversation.markRead(unread.attributes.received_at); } } public findNewestVisibleUnread() { const messageContainer = this.messageContainerRef.current; if (!messageContainer) { return null; } const { messages, conversation } = this.props; const { length } = messages; const viewportBottom = messageContainer?.clientHeight + messageContainer?.scrollTop || 0; // Start with the most recent message, search backwards in time let foundUnread = 0; for (let i = length - 1; i >= 0; i -= 1) { // Search the latest 30, then stop if we believe we've covered all known // unread messages. The unread should be relatively recent. // Why? local notifications can be unread but won't be reflected the // conversation's unread count. if (i > 30 && foundUnread >= conversation.unreadCount) { return null; } const message = messages[i]; if (!message.attributes.unread) { // eslint-disable-next-line no-continue continue; } foundUnread += 1; const el = document.getElementById(`${message.id}`); if (!el) { // eslint-disable-next-line no-continue continue; } const top = el.offsetTop; // If the bottom fits on screen, we'll call it visible. Even if the // message is really tall. const height = el.offsetHeight; const bottom = top + height; // We're fully below the viewport, continue searching up. if (top > viewportBottom) { // eslint-disable-next-line no-continue continue; } if (bottom <= viewportBottom) { return message; } // Continue searching up. } return null; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~ SCROLLING METHODS ~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public async handleScroll() { const messageContainer = this.messageContainerRef?.current; if (!messageContainer) { 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; // Mark messages read this.updateReadMessages(); // Pin scroll to bottom on new message, unless user has scrolled up if (this.state.isScrolledToBottom !== isScrolledToBottom) { this.setState({ isScrolledToBottom }); } // Fetch more messages when nearing the top of the message list const shouldFetchMoreMessages = scrollTop <= Constants.UI.MESSAGE_CONTAINER_BUFFER_OFFSET_PX; if (shouldFetchMoreMessages) { const numMessages = this.props.messages.length + Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT; // Prevent grabbing messags with scroll more frequently than once per 2s. const messageFetchInterval = 2; const previousTopMessage = ( await this.props.getMessages(numMessages, messageFetchInterval) )?.previousTopMessage; if (previousTopMessage) { this.scrollToMessage(previousTopMessage); } } } public scrollToUnread() { const { messages, conversation } = this.props; const message = messages[messages.length - 1 - conversation.unreadCount]; if (message) { this.scrollToMessage(message.id); } } public scrollToMessage(messageId: string) { const topUnreadMessage = document.getElementById(messageId); topUnreadMessage?.scrollIntoView(); } 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; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~ MESSAGE SELECTION ~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public selectMessage(messageId: string) { this.props.selectMessage(messageId); } public resetSelection() { this.props.resetSelection(); } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~ KEYBOARD NAVIGATION ~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ private onKeyDown(event: any) { //FIXME Audric // const messageContainer = this.messageContainerRef.current; // if (!messageContainer) { // return; // } // const selectionMode = !!this.props.selectedMessages.length; // const recordingMode = this.props.showRecordingView; // const pageHeight = messageContainer.clientHeight; // const arrowScrollPx = 50; // const pageScrollPx = pageHeight * 0.8; // if (event.key === 'Escape') { // // EXIT MEDIA VIEW // if (recordingMode) { // // EXIT RECORDING VIEW // } // // EXIT WHAT ELSE? // } // switch (event.key) { // case 'Escape': // if (selectionMode) { // this.resetSelection(); // } // break; // // Scrolling // case 'ArrowUp': // messageContainer.scrollBy(0, -arrowScrollPx); // break; // case 'ArrowDown': // messageContainer.scrollBy(0, arrowScrollPx); // break; // case 'PageUp': // messageContainer.scrollBy(0, -pageScrollPx); // break; // case 'PageDown': // messageContainer.scrollBy(0, pageScrollPx); // break; // default: // } } }