From 8766cf3f8a46b2da8ed3fa202ef54cc905d87cc2 Mon Sep 17 00:00:00 2001 From: audric Date: Thu, 22 Jul 2021 14:40:35 +1000 Subject: [PATCH] store offset before refresh of messagesList and restore it --- _locales/en/messages.json | 3 +- .../conversation/SessionConversation.tsx | 37 +- .../conversation/SessionMessagesList.tsx | 565 +----------------- .../SessionMessagesListContainer.tsx | 461 ++++++++++++++ .../conversation/SessionMessagesTypes.tsx | 124 ++++ ts/models/message.ts | 1 - ...onversation.tsx => SessionConversation.ts} | 0 7 files changed, 633 insertions(+), 558 deletions(-) create mode 100644 ts/components/session/conversation/SessionMessagesListContainer.tsx create mode 100644 ts/components/session/conversation/SessionMessagesTypes.tsx rename ts/state/smart/{SessionConversation.tsx => SessionConversation.ts} (100%) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 5e8feb02a..95cdd7b84 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -413,5 +413,6 @@ "pinConversation": "Pin Conversation", "unpinConversation": "Unpin Conversation", "pinConversationLimitTitle": "Pinned conversations limit", - "pinConversationLimitToastDescription": "You can only pin $number$ conversations" + "pinConversationLimitToastDescription": "You can only pin $number$ conversations", + "latestUnreadIsAbove": "First unread message is above" } diff --git a/ts/components/session/conversation/SessionConversation.tsx b/ts/components/session/conversation/SessionConversation.tsx index cf7b04f06..2bb281164 100644 --- a/ts/components/session/conversation/SessionConversation.tsx +++ b/ts/components/session/conversation/SessionConversation.tsx @@ -10,8 +10,8 @@ import { AttachmentUtil, GoogleChrome } from '../../../util'; import { ConversationHeaderWithDetails } from '../../conversation/ConversationHeader'; import { SessionRightPanelWithDetails } from './SessionRightPanel'; import { SessionTheme } from '../../../state/ducks/SessionTheme'; -import { DefaultTheme } from 'styled-components'; -import { SessionMessagesList } from './SessionMessagesList'; +import styled, { DefaultTheme } from 'styled-components'; +import { SessionMessagesListContainer } from './SessionMessagesListContainer'; import { LightboxGallery, MediaItemType } from '../../LightboxGallery'; import { AttachmentType, AttachmentTypeWithPath, save } from '../../../types/Attachment'; @@ -31,6 +31,11 @@ import { MessageDetail } from '../../conversation/MessageDetail'; import { getConversationController } from '../../../session/conversations'; import { getPubkeysInPublicConversation } from '../../../data/data'; import autoBind from 'auto-bind'; +import { useSelector } from 'react-redux'; +import { + isFirstUnreadMessageIdAbove, + getFirstUnreadMessageId, +} from '../../../state/selectors/conversations'; interface State { showRecordingView: boolean; @@ -57,6 +62,30 @@ interface Props { lightBoxOptions?: LightBoxOptions; } +const SessionUnreadAboveIndicator = styled.div` + position: sticky; + top: 0; + margin: 1em; + display: flex; + justify-content: center; + background: ${props => props.theme.colors.sentMessageBackground}; + color: ${props => props.theme.colors.sentMessageText}; +`; + +const UnreadAboveIndicator = () => { + const isFirstUnreadAbove = useSelector(isFirstUnreadMessageIdAbove); + const firstUnreadMessageId = useSelector(getFirstUnreadMessageId) as string; + + if (!isFirstUnreadAbove) { + return null; + } + return ( + + {window.i18n('latestUnreadIsAbove')} + + ); +}; + export class SessionConversation extends React.Component { private readonly messageContainerRef: React.RefObject; private dragCounter: number; @@ -212,7 +241,9 @@ export class SessionConversation extends React.Component { {lightBoxOptions?.media && this.renderLightBox(lightBoxOptions)}
- + + + {showRecordingView &&
} {isDraggingFile && } diff --git a/ts/components/session/conversation/SessionMessagesList.tsx b/ts/components/session/conversation/SessionMessagesList.tsx index ebfbc24c5..952e81c75 100644 --- a/ts/components/session/conversation/SessionMessagesList.tsx +++ b/ts/components/session/conversation/SessionMessagesList.tsx @@ -1,178 +1,21 @@ import React from 'react'; - -import { Message } from '../../conversation/Message'; -import { TimerNotification } from '../../conversation/TimerNotification'; - -import { SessionScrollButton } from '../SessionScrollButton'; -import { Constants } from '../../../session'; -import _ from 'lodash'; -import { contextMenu } from 'react-contexify'; -import { GroupNotification } from '../../conversation/GroupNotification'; -import { GroupInvitation } from '../../conversation/GroupInvitation'; -import { - fetchMessagesForConversation, - PropsForExpirationTimer, - PropsForGroupInvitation, - PropsForGroupUpdate, - quotedMessageToAnimate, - ReduxConversationType, - setNextMessageToPlay, - showScrollToBottomButton, - SortedMessageModelProps, -} from '../../../state/ducks/conversations'; -import { SessionLastSeenIndicator } from './SessionLastSeenIndicator'; -import { ToastUtils } from '../../../session/utils'; -import { TypingBubble } from '../../conversation/TypingBubble'; -import { getConversationController } from '../../../session/conversations'; -import { MessageModel } from '../../../models/message'; -import { - MessageRegularProps, - PropsForDataExtractionNotification, - QuoteClickOptions, -} from '../../../models/messageType'; -import { getMessagesBySentAt } from '../../../data/data'; -import autoBind from 'auto-bind'; -import { ConversationTypeEnum } from '../../../models/conversation'; -import { DataExtractionNotification } from '../../conversation/DataExtractionNotification'; -import { StateType } from '../../../state/reducer'; -import { connect, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; +import { QuoteClickOptions } from '../../../models/messageType'; +import { SortedMessageModelProps } from '../../../state/ducks/conversations'; +import { getSortedMessagesOfSelectedConversation } from '../../../state/selectors/conversations'; import { - getSortedMessagesOfSelectedConversation, - getNextMessageToPlayIndex, - getQuotedMessageToAnimate, - getSelectedConversation, - getSelectedConversationKey, - getShowScrollButton, - isMessageSelectionMode, - areMoreMessagesBeingFetched, - isFirstUnreadMessageIdAbove, - getFirstUnreadMessageId, -} from '../../../state/selectors/conversations'; -import { isElectronWindowFocused } from '../../../session/utils/WindowUtils'; -import useInterval from 'react-use/lib/useInterval'; - -export type SessionMessageListProps = { - messageContainerRef: React.RefObject; -}; - -type Props = SessionMessageListProps & { - conversationKey?: string; - messagesProps: Array; - - conversation?: ReduxConversationType; - showScrollButton: boolean; - animateQuotedMessageId: string | undefined; - areMoreMessagesBeingFetched: boolean; -}; - -const UnreadIndicator = (props: { messageId: string }) => { - const isFirstUnreadOnOpen = useSelector(getFirstUnreadMessageId); - if (!isFirstUnreadOnOpen || isFirstUnreadOnOpen !== props.messageId) { - return null; - } - return ; -}; - -const GroupUpdateItem = (props: { - messageId: string; - groupNotificationProps: PropsForGroupUpdate; -}) => { - return ( - - - - - ); -}; - -const GroupInvitationItem = (props: { - messageId: string; - propsForGroupInvitation: PropsForGroupInvitation; -}) => { - return ( - - - - - - ); -}; - -const DataExtractionNotificationItem = (props: { - messageId: string; - propsForDataExtractionNotification: PropsForDataExtractionNotification; -}) => { - return ( - - - - - - ); -}; - -const TimerNotificationItem = (props: { - messageId: string; - timerProps: PropsForExpirationTimer; -}) => { - return ( - - - - - - ); -}; - -const GenericMessageItem = (props: { - messageId: string; - messageProps: SortedMessageModelProps; - playableMessageIndex?: number; - scrollToQuoteMessage: (options: QuoteClickOptions) => Promise; - playNextMessage?: (value: number) => void; -}) => { - const multiSelectMode = useSelector(isMessageSelectionMode); - const nextMessageToPlay = useSelector(getNextMessageToPlayIndex); - - const messageId = props.messageId; - - const onQuoteClick = props.messageProps.propsForMessage.quote - ? props.scrollToQuoteMessage - : undefined; - - const regularProps: MessageRegularProps = { - ...props.messageProps.propsForMessage, - firstMessageOfSeries: props.messageProps.firstMessageOfSeries, - multiSelectMode, - nextMessageToPlay, - playNextMessage: props.playNextMessage, - onQuoteClick, - }; - - return ( - - - - - ); -}; - -const MessageList = (props: { + GroupUpdateItem, + GroupInvitationItem, + DataExtractionNotificationItem, + TimerNotificationItem, + GenericMessageItem, +} from './SessionMessagesTypes'; + +export const SessionMessagesList = (props: { scrollToQuoteMessage: (options: QuoteClickOptions) => Promise; playNextMessage?: (value: number) => void; }) => { const messagesProps = useSelector(getSortedMessagesOfSelectedConversation); - const isAbove = useSelector(isFirstUnreadMessageIdAbove); - - console.warn('isAbove', isAbove); let playableMessageIndex = 0; return ( @@ -246,387 +89,3 @@ const MessageList = (props: { ); }; - -class SessionMessagesListInner extends React.Component { - private ignoreScrollEvents: boolean; - private timeoutResetQuotedScroll: NodeJS.Timeout | null = null; - - public constructor(props: Props) { - super(props); - autoBind(this); - - this.ignoreScrollEvents = true; - } - - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // ~~~~~~~~~~~~~~~~ LIFECYCLES ~~~~~~~~~~~~~~~~ - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - public componentDidMount() { - // Pause thread to wait for rendering to complete - setTimeout(this.initialMessageLoadingPosition, 0); - } - - public componentWillUnmount() { - if (this.timeoutResetQuotedScroll) { - clearTimeout(this.timeoutResetQuotedScroll); - } - } - - public componentDidUpdate(prevProps: Props) { - const isSameConvo = prevProps.conversationKey === this.props.conversationKey; - const messageLengthChanged = prevProps.messagesProps.length !== this.props.messagesProps.length; - if ( - !isSameConvo || - (prevProps.messagesProps.length === 0 && this.props.messagesProps.length !== 0) - ) { - // displayed conversation changed. We have a bit of cleaning to do here - this.ignoreScrollEvents = true; - this.setupTimeoutResetQuotedHighlightedMessage(this.props.animateQuotedMessageId); - this.initialMessageLoadingPosition(); - } else { - // if we got new message for this convo, and we are scrolled to bottom - if (isSameConvo && messageLengthChanged) { - // Keep scrolled to bottom unless user scrolls up - if (this.getScrollOffsetBottomPx() === 0) { - this.scrollToBottom(); - } - } - } - } - - public render() { - const { conversationKey, conversation } = this.props; - - if (!conversationKey || !conversation) { - return null; - } - - let displayedName = null; - if (conversation.type === ConversationTypeEnum.PRIVATE) { - displayedName = getConversationController().getContactProfileNameOrShortenedPubKey( - conversationKey - ); - } - - return ( -
- - - - - -
- ); - } - - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // ~~~~~~~~~~~~~ MESSAGE HANDLING ~~~~~~~~~~~~~ - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - private updateReadMessages(forceIsOnBottom = false) { - const { messagesProps, conversationKey } = this.props; - - if (!messagesProps || messagesProps.length === 0 || !conversationKey) { - return; - } - - const conversation = getConversationController().getOrThrow(conversationKey); - - if (conversation.isBlocked()) { - return; - } - - if (this.ignoreScrollEvents) { - return; - } - - if ((forceIsOnBottom || this.getScrollOffsetBottomPx() === 0) && isElectronWindowFocused()) { - void conversation.markRead(messagesProps[0].propsForMessage.receivedAt || 0); - } - } - - /** - * Sets the targeted index for the next - * @param index index of message that just completed - */ - private playNextMessage(index: any) { - const { messagesProps } = this.props; - let nextIndex: number | undefined = index - 1; - - // to prevent autoplaying as soon as a message is received. - const latestMessagePlayed = index <= 0 || messagesProps.length < index - 1; - if (latestMessagePlayed) { - nextIndex = undefined; - window.inboxStore?.dispatch(setNextMessageToPlay(nextIndex)); - return; - } - - // stop auto-playing when the audio messages change author. - const prevAuthorNumber = messagesProps[index].propsForMessage.authorPhoneNumber; - const nextAuthorNumber = messagesProps[index - 1].propsForMessage.authorPhoneNumber; - const differentAuthor = prevAuthorNumber !== nextAuthorNumber; - if (differentAuthor) { - nextIndex = undefined; - } - - window.inboxStore?.dispatch(setNextMessageToPlay(nextIndex)); - } - - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // ~~~~~~~~~~~~ SCROLLING METHODS ~~~~~~~~~~~~~ - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - private async handleScroll() { - const messageContainer = this.props.messageContainerRef?.current; - - const { conversationKey } = this.props; - if (!messageContainer || !conversationKey) { - return; - } - contextMenu.hideAll(); - - if (this.ignoreScrollEvents) { - return; - } - // nothing to do if there are no message loaded - if (!this.props.messagesProps || this.props.messagesProps.length === 0) { - return; - } - - // ---- First lets see if we need to show the scroll to bottom button, without using clientHeight (which generates a full layout recalculation) - // get the message the most at the bottom - const bottomMessageId = this.props.messagesProps[0].propsForMessage.id; - const bottomMessageDomElement = document.getElementById(bottomMessageId); - - // get the message the most at the top - const topMessageId = this.props.messagesProps[this.props.messagesProps.length - 1] - .propsForMessage.id; - const topMessageDomElement = document.getElementById(topMessageId); - - const containerTop = messageContainer.getBoundingClientRect().top; - const containerBottom = messageContainer.getBoundingClientRect().bottom; - - // First handle what we gotta handle with the bottom message position - // either the showScrollButton or the markRead of all messages - if (!bottomMessageDomElement) { - window.log.warn('Could not find dom element for handle scroll'); - } else { - const topOfBottomMessage = bottomMessageDomElement.getBoundingClientRect().top; - const bottomOfBottomMessage = bottomMessageDomElement.getBoundingClientRect().bottom; - - // this is our limit for the showScrollDownButton. - const showScrollButton = topOfBottomMessage > window.innerHeight; - window.inboxStore?.dispatch(showScrollToBottomButton(showScrollButton)); - - // trigger markRead if we hit the bottom - const isScrolledToBottom = bottomOfBottomMessage <= containerBottom - 5; - if (isScrolledToBottom) { - // Mark messages read - this.updateReadMessages(true); - } - } - - // Then, see if we need to fetch more messages because the top message it - - if (!topMessageDomElement) { - window.log.warn('Could not find dom top element for handle scroll'); - } else { - const topTopMessage = topMessageDomElement.getBoundingClientRect().top; - - // this is our limit for the showScrollDownButton. - const shouldFetchMore = - topTopMessage > containerTop - 10 && !this.props.areMoreMessagesBeingFetched; - - if (shouldFetchMore) { - const { messagesProps } = this.props; - const numMessages = - messagesProps.length + Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT; - const oldLen = messagesProps.length; - const previousTopMessage = messagesProps[oldLen - 1]?.propsForMessage.id; - - (window.inboxStore?.dispatch as any)( - fetchMessagesForConversation({ conversationKey, count: numMessages }) - ); - if (previousTopMessage && oldLen !== messagesProps.length) { - this.scrollToMessage(previousTopMessage); - } - } - } - } - - /** - * Position the list to the middle of the loaded list if the conversation has unread messages and we have some messages loaded - */ - private initialMessageLoadingPosition() { - const { messagesProps, conversation } = this.props; - if (!conversation) { - return; - } - if (conversation.unreadCount > 0 && messagesProps.length) { - if (conversation.unreadCount < messagesProps.length) { - // if we loaded all unread messages, scroll to the first one unread - const firstUnread = Math.max(conversation.unreadCount, 0); - messagesProps[firstUnread].propsForMessage.id; - this.scrollToMessage(messagesProps[firstUnread].propsForMessage.id); - } else { - // if we did not load all unread messages, just scroll to the middle of the loaded messages list. so the user can choose to go up or down from there - const middle = Math.floor(messagesProps.length / 2); - messagesProps[middle].propsForMessage.id; - this.scrollToMessage(messagesProps[middle].propsForMessage.id); - } - } - - if (this.ignoreScrollEvents && messagesProps.length > 0) { - this.ignoreScrollEvents = false; - this.updateReadMessages(); - } - } - - /** - * Could not find a better name, but when we click on a quoted message, - * the UI takes us there and highlights it. - * If the user clicks again on this message, we want this highlight to be - * shown once again. - * - * So we need to reset the state of of the highlighted message so when the users clicks again, - * the highlight is shown once again - */ - private setupTimeoutResetQuotedHighlightedMessage(messageId: string | undefined) { - if (this.timeoutResetQuotedScroll) { - clearTimeout(this.timeoutResetQuotedScroll); - } - - if (messageId !== undefined) { - this.timeoutResetQuotedScroll = global.setTimeout(() => { - window.inboxStore?.dispatch(quotedMessageToAnimate(undefined)); - }, 2000); // should match .flash-green-once - } - } - - private scrollToMessage(messageId: string, smooth: boolean = false) { - const messageElementDom = document.getElementById(messageId); - messageElementDom?.scrollIntoView({ - behavior: 'auto', - block: 'center', - }); - - // we consider that a `smooth` set to true, means it's a quoted message, so highlight this message on the UI - if (smooth) { - window.inboxStore?.dispatch(quotedMessageToAnimate(messageId)); - this.setupTimeoutResetQuotedHighlightedMessage(messageId); - } - - const messageContainer = this.props.messageContainerRef.current; - if (!messageContainer) { - return; - } - } - - private scrollToBottom() { - const messageContainer = this.props.messageContainerRef.current; - if (!messageContainer) { - return; - } - messageContainer.scrollTop = messageContainer.scrollHeight - messageContainer.clientHeight; - const { messagesProps, conversationKey } = this.props; - - if (!messagesProps || messagesProps.length === 0 || !conversationKey) { - return; - } - - const conversation = getConversationController().get(conversationKey); - if (isElectronWindowFocused()) { - void conversation.markRead(messagesProps[0].propsForMessage.receivedAt || 0); - } - } - - private async scrollToQuoteMessage(options: QuoteClickOptions) { - const { quoteAuthor, quoteId, referencedMessageNotFound } = options; - - const { messagesProps } = this.props; - - // 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 = messagesProps.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 getMessagesBySentAt(quoteId); - const found = Boolean( - collection.find((item: MessageModel) => { - const messageAuthor = item.getSource(); - - return Boolean(messageAuthor && quoteAuthor === messageAuthor); - }) - ); - - if (found) { - ToastUtils.pushFoundButNotLoaded(); - } else { - ToastUtils.pushOriginalNoLongerAvailable(); - } - return; - } - - const databaseId = targetMessage.propsForMessage.id; - this.scrollToMessage(databaseId, true); - } - - // basically the offset in px from the bottom of the view (most recent message) - private getScrollOffsetBottomPx() { - const messageContainer = this.props.messageContainerRef?.current; - - if (!messageContainer) { - return Number.MAX_VALUE; - } - - const scrollTop = messageContainer.scrollTop; - const scrollHeight = messageContainer.scrollHeight; - const clientHeight = messageContainer.clientHeight; - return scrollHeight - scrollTop - clientHeight; - } -} - -const mapStateToProps = (state: StateType) => { - return { - conversationKey: getSelectedConversationKey(state), - conversation: getSelectedConversation(state), - messagesProps: getSortedMessagesOfSelectedConversation(state), - showScrollButton: getShowScrollButton(state), - animateQuotedMessageId: getQuotedMessageToAnimate(state), - areMoreMessagesBeingFetched: areMoreMessagesBeingFetched(state), - }; -}; - -const smart = connect(mapStateToProps); - -export const SessionMessagesList = smart(SessionMessagesListInner); diff --git a/ts/components/session/conversation/SessionMessagesListContainer.tsx b/ts/components/session/conversation/SessionMessagesListContainer.tsx new file mode 100644 index 000000000..bd1879d02 --- /dev/null +++ b/ts/components/session/conversation/SessionMessagesListContainer.tsx @@ -0,0 +1,461 @@ +import React from 'react'; + +import { SessionScrollButton } from '../SessionScrollButton'; +import { Constants } from '../../../session'; +import _ from 'lodash'; +import { contextMenu } from 'react-contexify'; +import { + fetchMessagesForConversation, + quotedMessageToAnimate, + ReduxConversationType, + setNextMessageToPlay, + showScrollToBottomButton, + SortedMessageModelProps, +} from '../../../state/ducks/conversations'; +import { ToastUtils } from '../../../session/utils'; +import { TypingBubble } from '../../conversation/TypingBubble'; +import { getConversationController } from '../../../session/conversations'; +import { MessageModel } from '../../../models/message'; +import { QuoteClickOptions } from '../../../models/messageType'; +import { getMessagesBySentAt } from '../../../data/data'; +import autoBind from 'auto-bind'; +import { ConversationTypeEnum } from '../../../models/conversation'; +import { StateType } from '../../../state/reducer'; +import { connect } from 'react-redux'; +import { + getSortedMessagesOfSelectedConversation, + getQuotedMessageToAnimate, + getSelectedConversation, + getSelectedConversationKey, + getShowScrollButton, + areMoreMessagesBeingFetched, +} from '../../../state/selectors/conversations'; +import { isElectronWindowFocused } from '../../../session/utils/WindowUtils'; +import { SessionMessagesList } from './SessionMessagesList'; + +export type SessionMessageListProps = { + messageContainerRef: React.RefObject; +}; + +type Props = SessionMessageListProps & { + conversationKey?: string; + messagesProps: Array; + + conversation?: ReduxConversationType; + showScrollButton: boolean; + animateQuotedMessageId: string | undefined; + areMoreMessagesBeingFetched: boolean; +}; + +class SessionMessagesListContainerInner extends React.Component { + private ignoreScrollEvents: boolean; + private timeoutResetQuotedScroll: NodeJS.Timeout | null = null; + + public constructor(props: Props) { + super(props); + autoBind(this); + + this.ignoreScrollEvents = true; + this.triggerFetchMoreMessages = _.debounce(this.triggerFetchMoreMessages, 100); + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // ~~~~~~~~~~~~~~~~ LIFECYCLES ~~~~~~~~~~~~~~~~ + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + public componentDidMount() { + // Pause thread to wait for rendering to complete + setTimeout(this.initialMessageLoadingPosition, 0); + } + + public componentWillUnmount() { + if (this.timeoutResetQuotedScroll) { + clearTimeout(this.timeoutResetQuotedScroll); + } + } + + public componentDidUpdate(prevProps: Props, _prevState: any, snapshot: any) { + const isSameConvo = prevProps.conversationKey === this.props.conversationKey; + const messageLengthChanged = prevProps.messagesProps.length !== this.props.messagesProps.length; + if ( + !isSameConvo || + (prevProps.messagesProps.length === 0 && this.props.messagesProps.length !== 0) + ) { + this.setupTimeoutResetQuotedHighlightedMessage(this.props.animateQuotedMessageId); + + // displayed conversation changed. We have a bit of cleaning to do here + this.ignoreScrollEvents = true; + this.initialMessageLoadingPosition(); + this.ignoreScrollEvents = false; + } else { + // if we got new message for this convo, and we are scrolled to bottom + if (isSameConvo && messageLengthChanged) { + // If we have a snapshot value, we've just added new items. + // Adjust scroll so these new items don't push the old ones out of view. + // (snapshot here is the value returned from getSnapshotBeforeUpdate) + if (prevProps.messagesProps.length && snapshot !== null) { + this.ignoreScrollEvents = true; + + const list = this.props.messageContainerRef.current; + list.scrollTop = list.scrollHeight - snapshot; + this.ignoreScrollEvents = false; + } + } + } + } + + public getSnapshotBeforeUpdate(prevProps: Props) { + // Are we adding new items to the list? + // Capture the scroll position so we can adjust scroll later. + if (prevProps.messagesProps.length < this.props.messagesProps.length) { + const list = this.props.messageContainerRef.current; + return list.scrollHeight - list.scrollTop; + } + return null; + } + + public render() { + const { conversationKey, conversation } = this.props; + + if (!conversationKey || !conversation) { + return null; + } + + let displayedName = null; + if (conversation.type === ConversationTypeEnum.PRIVATE) { + displayedName = getConversationController().getContactProfileNameOrShortenedPubKey( + conversationKey + ); + } + + return ( +
+ + + + + +
+ ); + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // ~~~~~~~~~~~~~ MESSAGE HANDLING ~~~~~~~~~~~~~ + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + private updateReadMessages(forceIsOnBottom = false) { + const { messagesProps, conversationKey } = this.props; + + if (!messagesProps || messagesProps.length === 0 || !conversationKey) { + return; + } + + const conversation = getConversationController().getOrThrow(conversationKey); + + if (conversation.isBlocked()) { + return; + } + + if (this.ignoreScrollEvents) { + return; + } + + if ((forceIsOnBottom || this.getScrollOffsetBottomPx() === 0) && isElectronWindowFocused()) { + void conversation.markRead(messagesProps[0].propsForMessage.receivedAt || 0); + } + } + + /** + * Sets the targeted index for the next + * @param index index of message that just completed + */ + private playNextMessage(index: any) { + const { messagesProps } = this.props; + let nextIndex: number | undefined = index - 1; + + // to prevent autoplaying as soon as a message is received. + const latestMessagePlayed = index <= 0 || messagesProps.length < index - 1; + if (latestMessagePlayed) { + nextIndex = undefined; + window.inboxStore?.dispatch(setNextMessageToPlay(nextIndex)); + return; + } + + // stop auto-playing when the audio messages change author. + const prevAuthorNumber = messagesProps[index].propsForMessage.authorPhoneNumber; + const nextAuthorNumber = messagesProps[index - 1].propsForMessage.authorPhoneNumber; + const differentAuthor = prevAuthorNumber !== nextAuthorNumber; + if (differentAuthor) { + nextIndex = undefined; + } + + window.inboxStore?.dispatch(setNextMessageToPlay(nextIndex)); + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // ~~~~~~~~~~~~ SCROLLING METHODS ~~~~~~~~~~~~~ + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + private async handleScroll() { + const messageContainer = this.props.messageContainerRef?.current; + + const { conversationKey } = this.props; + if (!messageContainer || !conversationKey) { + return; + } + contextMenu.hideAll(); + + if (this.ignoreScrollEvents) { + return; + } + // nothing to do if there are no message loaded + if (!this.props.messagesProps || this.props.messagesProps.length === 0) { + return; + } + + // ---- First lets see if we need to show the scroll to bottom button, without using clientHeight (which generates a full layout recalculation) + // get the message the most at the bottom + const bottomMessageId = this.props.messagesProps[0].propsForMessage.id; + const bottomMessageDomElement = document.getElementById(bottomMessageId); + + // get the message the most at the top + const topMessageId = this.props.messagesProps[this.props.messagesProps.length - 1] + .propsForMessage.id; + const topMessageDomElement = document.getElementById(topMessageId); + + const containerTop = messageContainer.getBoundingClientRect().top; + const containerBottom = messageContainer.getBoundingClientRect().bottom; + + // First handle what we gotta handle with the bottom message position + // either the showScrollButton or the markRead of all messages + if (!bottomMessageDomElement) { + window.log.warn('Could not find dom element for handle scroll'); + } else { + const topOfBottomMessage = bottomMessageDomElement.getBoundingClientRect().top; + const bottomOfBottomMessage = bottomMessageDomElement.getBoundingClientRect().bottom; + + // this is our limit for the showScrollDownButton. + const showScrollButton = topOfBottomMessage > window.innerHeight; + window.inboxStore?.dispatch(showScrollToBottomButton(showScrollButton)); + + // trigger markRead if we hit the bottom + const isScrolledToBottom = bottomOfBottomMessage <= containerBottom - 5; + if (isScrolledToBottom) { + // Mark messages read + this.updateReadMessages(true); + } + } + + // Then, see if we need to fetch more messages because the top message it + + if (!topMessageDomElement) { + window.log.warn('Could not find dom top element for handle scroll'); + } else { + const topTopMessage = topMessageDomElement.getBoundingClientRect().top; + + // this is our limit for the showScrollDownButton. + const shouldFetchMore = + topTopMessage > containerTop - 10 && !this.props.areMoreMessagesBeingFetched; + + if (shouldFetchMore) { + console.warn('shouldFetchMore', shouldFetchMore); + const { messagesProps } = this.props; + + const oldLen = messagesProps.length; + const previousTopMessage = messagesProps[oldLen - 1]?.propsForMessage.id; + + this.triggerFetchMoreMessages(); + if (previousTopMessage && oldLen !== messagesProps.length) { + this.scrollToMessage(previousTopMessage); + } + } + } + } + + private triggerFetchMoreMessages() { + const { messagesProps } = this.props; + + const numMessages = messagesProps.length + Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT; + (window.inboxStore?.dispatch as any)( + fetchMessagesForConversation({ + conversationKey: this.props.conversationKey as string, + count: numMessages, + }) + ); + } + + /** + * Position the list to the middle of the loaded list if the conversation has unread messages and we have some messages loaded + */ + private initialMessageLoadingPosition() { + const { messagesProps, conversation } = this.props; + if (!conversation) { + return; + } + if (conversation.unreadCount > 0 && messagesProps.length) { + if (conversation.unreadCount < messagesProps.length) { + // if we loaded all unread messages, scroll to the first one unread + const firstUnread = Math.max(conversation.unreadCount, 0); + messagesProps[firstUnread].propsForMessage.id; + this.scrollToMessage(messagesProps[firstUnread].propsForMessage.id); + } else { + // if we did not load all unread messages, just scroll to the middle of the loaded messages list. so the user can choose to go up or down from there + const middle = Math.floor(messagesProps.length / 2); + messagesProps[middle].propsForMessage.id; + this.scrollToMessage(messagesProps[middle].propsForMessage.id); + } + } + + if (this.ignoreScrollEvents && messagesProps.length > 0) { + this.ignoreScrollEvents = false; + this.updateReadMessages(); + } + } + + /** + * Could not find a better name, but when we click on a quoted message, + * the UI takes us there and highlights it. + * If the user clicks again on this message, we want this highlight to be + * shown once again. + * + * So we need to reset the state of of the highlighted message so when the users clicks again, + * the highlight is shown once again + */ + private setupTimeoutResetQuotedHighlightedMessage(messageId: string | undefined) { + if (this.timeoutResetQuotedScroll) { + clearTimeout(this.timeoutResetQuotedScroll); + } + + if (messageId !== undefined) { + this.timeoutResetQuotedScroll = global.setTimeout(() => { + window.inboxStore?.dispatch(quotedMessageToAnimate(undefined)); + }, 2000); // should match .flash-green-once + } + } + + private scrollToMessage(messageId: string, smooth: boolean = false, alignOnTop = false) { + const messageElementDom = document.getElementById(messageId); + messageElementDom?.scrollIntoView({ + behavior: 'auto', + block: alignOnTop ? 'start' : 'center', + }); + + // we consider that a `smooth` set to true, means it's a quoted message, so highlight this message on the UI + if (smooth) { + window.inboxStore?.dispatch(quotedMessageToAnimate(messageId)); + this.setupTimeoutResetQuotedHighlightedMessage(messageId); + } + + const messageContainer = this.props.messageContainerRef.current; + if (!messageContainer) { + return; + } + } + + private scrollToBottom() { + const messageContainer = this.props.messageContainerRef.current; + if (!messageContainer) { + return; + } + messageContainer.scrollTop = messageContainer.scrollHeight - messageContainer.clientHeight; + const { messagesProps, conversationKey } = this.props; + + if (!messagesProps || messagesProps.length === 0 || !conversationKey) { + return; + } + + const conversation = getConversationController().get(conversationKey); + if (isElectronWindowFocused()) { + void conversation.markRead(messagesProps[0].propsForMessage.receivedAt || 0); + } + } + + private async scrollToQuoteMessage(options: QuoteClickOptions) { + const { quoteAuthor, quoteId, referencedMessageNotFound } = options; + + const { messagesProps } = this.props; + + // 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 = messagesProps.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 getMessagesBySentAt(quoteId); + const found = Boolean( + collection.find((item: MessageModel) => { + const messageAuthor = item.getSource(); + + return Boolean(messageAuthor && quoteAuthor === messageAuthor); + }) + ); + + if (found) { + ToastUtils.pushFoundButNotLoaded(); + } else { + ToastUtils.pushOriginalNoLongerAvailable(); + } + return; + } + + const databaseId = targetMessage.propsForMessage.id; + this.scrollToMessage(databaseId, true); + } + + // basically the offset in px from the bottom of the view (most recent message) + private getScrollOffsetBottomPx() { + const messageContainer = this.props.messageContainerRef?.current; + + if (!messageContainer) { + return Number.MAX_VALUE; + } + + const scrollTop = messageContainer.scrollTop; + const scrollHeight = messageContainer.scrollHeight; + const clientHeight = messageContainer.clientHeight; + return scrollHeight - scrollTop - clientHeight; + } +} + +const mapStateToProps = (state: StateType) => { + return { + conversationKey: getSelectedConversationKey(state), + conversation: getSelectedConversation(state), + messagesProps: getSortedMessagesOfSelectedConversation(state), + showScrollButton: getShowScrollButton(state), + animateQuotedMessageId: getQuotedMessageToAnimate(state), + areMoreMessagesBeingFetched: areMoreMessagesBeingFetched(state), + }; +}; + +const smart = connect(mapStateToProps); + +export const SessionMessagesListContainer = smart(SessionMessagesListContainerInner); diff --git a/ts/components/session/conversation/SessionMessagesTypes.tsx b/ts/components/session/conversation/SessionMessagesTypes.tsx new file mode 100644 index 000000000..84cac731d --- /dev/null +++ b/ts/components/session/conversation/SessionMessagesTypes.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { + PropsForDataExtractionNotification, + QuoteClickOptions, + MessageRegularProps, +} from '../../../models/messageType'; +import { + PropsForGroupUpdate, + PropsForGroupInvitation, + PropsForExpirationTimer, + SortedMessageModelProps, +} from '../../../state/ducks/conversations'; +import { + getFirstUnreadMessageId, + isMessageSelectionMode, + getNextMessageToPlayIndex, +} from '../../../state/selectors/conversations'; +import { DataExtractionNotification } from '../../conversation/DataExtractionNotification'; +import { GroupInvitation } from '../../conversation/GroupInvitation'; +import { GroupNotification } from '../../conversation/GroupNotification'; +import { Message } from '../../conversation/Message'; +import { TimerNotification } from '../../conversation/TimerNotification'; +import { SessionLastSeenIndicator } from './SessionLastSeenIndicator'; + +export const UnreadIndicator = (props: { messageId: string }) => { + const isFirstUnreadOnOpen = useSelector(getFirstUnreadMessageId); + if (!isFirstUnreadOnOpen || isFirstUnreadOnOpen !== props.messageId) { + return null; + } + return ; +}; + +export const GroupUpdateItem = (props: { + messageId: string; + groupNotificationProps: PropsForGroupUpdate; +}) => { + return ( + + + + + ); +}; + +export const GroupInvitationItem = (props: { + messageId: string; + propsForGroupInvitation: PropsForGroupInvitation; +}) => { + return ( + + + + + + ); +}; + +export const DataExtractionNotificationItem = (props: { + messageId: string; + propsForDataExtractionNotification: PropsForDataExtractionNotification; +}) => { + return ( + + + + + + ); +}; + +export const TimerNotificationItem = (props: { + messageId: string; + timerProps: PropsForExpirationTimer; +}) => { + return ( + + + + + + ); +}; + +export const GenericMessageItem = (props: { + messageId: string; + messageProps: SortedMessageModelProps; + playableMessageIndex?: number; + scrollToQuoteMessage: (options: QuoteClickOptions) => Promise; + playNextMessage?: (value: number) => void; +}) => { + const multiSelectMode = useSelector(isMessageSelectionMode); + const nextMessageToPlay = useSelector(getNextMessageToPlayIndex); + + const messageId = props.messageId; + + const onQuoteClick = props.messageProps.propsForMessage.quote + ? props.scrollToQuoteMessage + : undefined; + + const regularProps: MessageRegularProps = { + ...props.messageProps.propsForMessage, + firstMessageOfSeries: props.messageProps.firstMessageOfSeries, + multiSelectMode, + nextMessageToPlay, + playNextMessage: props.playNextMessage, + onQuoteClick, + }; + + return ( + + + + + ); +}; diff --git a/ts/models/message.ts b/ts/models/message.ts index 635ed217b..95b28e920 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -1102,7 +1102,6 @@ export class MessageModel extends Backbone.Model { public markReadNoCommit(readAt: number) { this.set({ unread: 0 }); - console.warn('markReadNoCommit', this.id); if (this.get('expireTimer') && !this.get('expirationStartTimestamp')) { const expirationStartTimestamp = Math.min(Date.now(), readAt || Date.now()); diff --git a/ts/state/smart/SessionConversation.tsx b/ts/state/smart/SessionConversation.ts similarity index 100% rename from ts/state/smart/SessionConversation.tsx rename to ts/state/smart/SessionConversation.ts