From e72885944b21d8b14b403a6f12efdb1e05719121 Mon Sep 17 00:00:00 2001 From: audric Date: Wed, 21 Jul 2021 17:14:14 +1000 Subject: [PATCH] use selector to sort and add first of serie flag --- ts/components/UserDetailsDialog.tsx | 2 +- ts/components/conversation/Message.tsx | 16 +- .../session/SessionClosableOverlay.tsx | 2 +- .../session/SessionJoinableDefaultRooms.tsx | 15 +- .../conversation/SessionCompositionBox.tsx | 1 - .../conversation/SessionConversation.tsx | 9 - .../conversation/SessionMessagesList.tsx | 55 +++--- .../conversation/SessionRightPanel.tsx | 2 - ts/models/conversation.ts | 2 - ts/models/message.ts | 2 - ts/receiver/queuedJob.ts | 20 ++- ts/session/utils/WindowUtils.ts | 8 + ts/state/ducks/conversations.ts | 159 ++---------------- ts/state/selectors/conversations.ts | 128 +++++++++++++- ts/state/smart/SessionConversation.tsx | 4 +- ts/window.d.ts | 1 - 16 files changed, 221 insertions(+), 205 deletions(-) create mode 100644 ts/session/utils/WindowUtils.ts diff --git a/ts/components/UserDetailsDialog.tsx b/ts/components/UserDetailsDialog.tsx index a5419cc61..51081632c 100644 --- a/ts/components/UserDetailsDialog.tsx +++ b/ts/components/UserDetailsDialog.tsx @@ -34,7 +34,7 @@ export const UserDetailsDialog = (props: Props) => { ConversationTypeEnum.PRIVATE ); - window.inboxStore?.dispatch(openConversationExternal(conversation.id)); + window.inboxStore?.dispatch(openConversationExternal({ id: conversation.id })); closeDialog(); } diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 126ad67b4..900c5c0ca 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -40,11 +40,17 @@ import { getMessageById } from '../../data/data'; import { connect } from 'react-redux'; import { StateType } from '../../state/reducer'; import { getSelectedMessageIds } from '../../state/selectors/conversations'; -import { showLightBox, toggleSelectedMessageId } from '../../state/ducks/conversations'; +import { + messageExpired, + showLightBox, + toggleSelectedMessageId, +} from '../../state/ducks/conversations'; import { saveAttachmentToDisk } from '../../util/attachmentsUtil'; import { LightBoxOptions } from '../session/conversation/SessionConversation'; import { MessageContextMenu } from './MessageContextMenu'; import { ReadableMessage } from './ReadableMessage'; +import { remote } from 'electron'; +import { isElectronWindowFocused } from '../../session/utils/WindowUtils'; // Same as MIN_WIDTH in ImageGrid.tsx const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200; @@ -161,6 +167,9 @@ class MessageInner extends React.PureComponent { this.setState({ expired: true, }); + window.inboxStore?.dispatch( + messageExpired({ messageId: this.props.id, conversationKey: this.props.convoId }) + ); }; this.expiredTimeout = setTimeout(setExpired, EXPIRED_DELAY); } @@ -597,14 +606,13 @@ class MessageInner extends React.PureComponent { } const onVisible = async (inView: boolean | Object) => { - if (inView === true && shouldMarkReadWhenVisible && window.isFocused()) { + if (inView === true && shouldMarkReadWhenVisible && isElectronWindowFocused()) { const found = await getMessageById(id); if (found && Boolean(found.get('unread'))) { - console.warn('marking as read: ', found.id); // mark the message as read. // this will trigger the expire timer. - void found?.markRead(Date.now()); + void found.markRead(Date.now()); } } }; diff --git a/ts/components/session/SessionClosableOverlay.tsx b/ts/components/session/SessionClosableOverlay.tsx index c1bae19c0..ea7c3c715 100644 --- a/ts/components/session/SessionClosableOverlay.tsx +++ b/ts/components/session/SessionClosableOverlay.tsx @@ -217,7 +217,7 @@ export class SessionClosableOverlay extends React.Component { {descriptionLong &&
{descriptionLong}
} {isMessageView && false &&

{window.i18n('or')}

} {/* FIXME enable back those two items when they are working */} - {isOpenGroupView && } + {isOpenGroupView && } {isMessageView && false && ( { ); }; -export const SessionJoinableRooms = () => { +export const SessionJoinableRooms = (props: { onRoomClicked: () => void }) => { const joinableRooms = useSelector((state: StateType) => state.defaultRooms); + const onRoomClicked = useCallback( + (loading: boolean) => { + if (loading) { + props.onRoomClicked(); + } + }, + [props.onRoomClicked] + ); + if (!joinableRooms.inProgress && !joinableRooms.rooms?.length) { window?.log?.info('no default joinable rooms yet and not in progress'); return <>; @@ -101,7 +110,7 @@ export const SessionJoinableRooms = () => { roomId={r.id} base64Data={r.base64Data} onClick={completeUrl => { - void joinOpenGroupV2WithUIEvents(completeUrl, true, false); + void joinOpenGroupV2WithUIEvents(completeUrl, true, false, onRoomClicked); }} /> ); diff --git a/ts/components/session/conversation/SessionCompositionBox.tsx b/ts/components/session/conversation/SessionCompositionBox.tsx index 2617759c1..9fb4a0c6f 100644 --- a/ts/components/session/conversation/SessionCompositionBox.tsx +++ b/ts/components/session/conversation/SessionCompositionBox.tsx @@ -837,7 +837,6 @@ class SessionCompositionBoxInner extends React.Component { const { quotedMessageProps } = this.props; - console.warn('quotedMessageProps', quotedMessageProps); const { stagedLinkPreview } = this.state; // Send message diff --git a/ts/components/session/conversation/SessionConversation.tsx b/ts/components/session/conversation/SessionConversation.tsx index 8933c884c..cf7b04f06 100644 --- a/ts/components/session/conversation/SessionConversation.tsx +++ b/ts/components/session/conversation/SessionConversation.tsx @@ -33,11 +33,7 @@ import { getPubkeysInPublicConversation } from '../../../data/data'; import autoBind from 'auto-bind'; interface State { - unreadCount: number; - - showOverlay: boolean; showRecordingView: boolean; - stagedAttachments: Array; isDraggingFile: boolean; } @@ -70,10 +66,7 @@ export class SessionConversation extends React.Component { constructor(props: any) { super(props); - const unreadCount = this.props.selectedConversation?.unreadCount || 0; this.state = { - unreadCount, - showOverlay: false, showRecordingView: false, stagedAttachments: [], isDraggingFile: false, @@ -136,7 +129,6 @@ export class SessionConversation extends React.Component { if (newConversationKey !== oldConversationKey) { void this.loadInitialMessages(); this.setState({ - showOverlay: false, showRecordingView: false, stagedAttachments: [], isDraggingFile: false, @@ -350,7 +342,6 @@ export class SessionConversation extends React.Component { media.length > 1 ? media.findIndex(mediaMessage => mediaMessage.attachment.path === attachment.path) : 0; - console.warn('renderLightBox', { media, attachment }); return ; } diff --git a/ts/components/session/conversation/SessionMessagesList.tsx b/ts/components/session/conversation/SessionMessagesList.tsx index 3a62957c3..358dca5c7 100644 --- a/ts/components/session/conversation/SessionMessagesList.tsx +++ b/ts/components/session/conversation/SessionMessagesList.tsx @@ -37,15 +37,17 @@ import { DataExtractionNotification } from '../../conversation/DataExtractionNot import { StateType } from '../../../state/reducer'; import { connect, useSelector } from 'react-redux'; import { - areMoreMessagesBeingFetched, - getMessagesOfSelectedConversation, + getSortedMessagesOfSelectedConversation, getNextMessageToPlayIndex, getQuotedMessageToAnimate, getSelectedConversation, getSelectedConversationKey, getShowScrollButton, isMessageSelectionMode, + getFirstUnreadMessageIndex, + areMoreMessagesBeingFetched, } from '../../../state/selectors/conversations'; +import { isElectronWindowFocused } from '../../../session/utils/WindowUtils'; export type SessionMessageListProps = { messageContainerRef: React.RefObject; @@ -58,6 +60,7 @@ type Props = SessionMessageListProps & { conversation?: ReduxConversationType; showScrollButton: boolean; animateQuotedMessageId: string | undefined; + areMoreMessagesBeingFetched: boolean; }; const UnreadIndicator = (props: { messageId: string; show: boolean }) => { @@ -170,14 +173,13 @@ const MessageList = (props: { scrollToQuoteMessage: (options: QuoteClickOptions) => Promise; playNextMessage?: (value: number) => void; }) => { - const messagesProps = useSelector(getMessagesOfSelectedConversation); - const isFetchingMore = useSelector(areMoreMessagesBeingFetched); - + const messagesProps = useSelector(getSortedMessagesOfSelectedConversation); + const firstUnreadMessageIndex = useSelector(getFirstUnreadMessageIndex); let playableMessageIndex = 0; return ( <> - {messagesProps.map((messageProps: SortedMessageModelProps) => { + {messagesProps.map((messageProps: SortedMessageModelProps, index: number) => { const timerProps = messageProps.propsForTimerNotification; const propsForGroupInvitation = messageProps.propsForGroupInvitation; const propsForDataExtractionNotification = messageProps.propsForDataExtractionNotification; @@ -187,7 +189,8 @@ const MessageList = (props: { // IF we found the first unread message // AND we are not scrolled all the way to the bottom // THEN, show the unread banner for the current message - const showUnreadIndicator = Boolean(messageProps.firstUnread); + const showUnreadIndicator = + Boolean(firstUnreadMessageIndex) && firstUnreadMessageIndex === index; if (groupNotificationProps) { return ( @@ -375,7 +378,7 @@ class SessionMessagesListInner extends React.Component { return; } - if (this.getScrollOffsetBottomPx() === 0 && window.isFocused()) { + if (this.getScrollOffsetBottomPx() === 0 && isElectronWindowFocused()) { void conversation.markRead(messagesProps[0].propsForMessage.receivedAt || 0); } } @@ -449,7 +452,9 @@ class SessionMessagesListInner extends React.Component { } // Fetch more messages when nearing the top of the message list - const shouldFetchMoreMessagesTop = scrollTop <= Constants.UI.MESSAGE_CONTAINER_BUFFER_OFFSET_PX; + const shouldFetchMoreMessagesTop = + scrollTop <= Constants.UI.MESSAGE_CONTAINER_BUFFER_OFFSET_PX && + !this.props.areMoreMessagesBeingFetched; if (shouldFetchMoreMessagesTop) { const { messagesProps } = this.props; @@ -475,11 +480,17 @@ class SessionMessagesListInner extends React.Component { return; } if (conversation.unreadCount > 0 && messagesProps.length) { - // just scroll to the middle of the loaded messages list. so the user can chosse to go up or down from there - - const middle = messagesProps.length / 2; - messagesProps[middle].propsForMessage.id; - this.scrollToMessage(messagesProps[middle].propsForMessage.id); + 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) { @@ -530,13 +541,6 @@ class SessionMessagesListInner extends React.Component { if (!messageContainer) { return; } - - const scrollHeight = messageContainer.scrollHeight; - const clientHeight = messageContainer.clientHeight; - - if (scrollHeight !== 0 && scrollHeight === clientHeight) { - this.updateReadMessages(); - } } private scrollToBottom() { @@ -551,8 +555,10 @@ class SessionMessagesListInner extends React.Component { return; } - const conversation = getConversationController().getOrThrow(conversationKey); - void conversation.markRead(messagesProps[0].propsForMessage.receivedAt || 0); + const conversation = getConversationController().get(conversationKey); + if (isElectronWindowFocused()) { + void conversation.markRead(messagesProps[0].propsForMessage.receivedAt || 0); + } } private async scrollToQuoteMessage(options: QuoteClickOptions) { @@ -623,9 +629,10 @@ const mapStateToProps = (state: StateType) => { return { conversationKey: getSelectedConversationKey(state), conversation: getSelectedConversation(state), - messagesProps: getMessagesOfSelectedConversation(state), + messagesProps: getSortedMessagesOfSelectedConversation(state), showScrollButton: getShowScrollButton(state), animateQuotedMessageId: getQuotedMessageToAnimate(state), + areMoreMessagesBeingFetched: areMoreMessagesBeingFetched(state), }; }; diff --git a/ts/components/session/conversation/SessionRightPanel.tsx b/ts/components/session/conversation/SessionRightPanel.tsx index 2daf87eb4..33bf1e699 100644 --- a/ts/components/session/conversation/SessionRightPanel.tsx +++ b/ts/components/session/conversation/SessionRightPanel.tsx @@ -184,8 +184,6 @@ export const SessionRightPanelWithDetails = () => { if (isShowing && selectedConversation) { void getMediaGalleryProps(selectedConversation.id).then(results => { - console.warn('results2', results); - if (isRunning) { if (!_.isEqual(documents, results.documents)) { setDocuments(results.documents); diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 558d70251..0bae96abd 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -192,7 +192,6 @@ export class ConversationModel extends Backbone.Model { this.throttledBumpTyping = _.throttle(this.bumpTyping, 300); this.updateLastMessage = _.throttle(this.bouncyUpdateLastMessage.bind(this), 1000, { trailing: true, - leading: true, }); this.triggerUIRefresh = _.throttle(this.triggerUIRefresh, 1000, { trailing: true, @@ -212,7 +211,6 @@ export class ConversationModel extends Backbone.Model { this.typingRefreshTimer = null; this.typingPauseTimer = null; this.lastReadTimestamp = 0; - window.inboxStore?.dispatch(conversationChanged({ id: this.id, data: this.getProps() })); } diff --git a/ts/models/message.ts b/ts/models/message.ts index 2adb2479f..899799302 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -1088,8 +1088,6 @@ export class MessageModel extends Backbone.Model { public async markRead(readAt: number) { this.markReadNoCommit(readAt); - // this.getConversation()?.markRead(this.attributes.received_at); - await this.commit(); } diff --git a/ts/receiver/queuedJob.ts b/ts/receiver/queuedJob.ts index 1ee9316c3..809025a57 100644 --- a/ts/receiver/queuedJob.ts +++ b/ts/receiver/queuedJob.ts @@ -434,15 +434,7 @@ export async function handleMessageJob( const id = await message.commit(); message.set({ id }); - // this updates the redux store. - // if the convo on which this message should become visible, - // it will be shown to the user, and might as well be read right away - window.inboxStore?.dispatch( - conversationActions.messageAdded({ - conversationKey: conversation.id, - messageModelProps: message.getProps(), - }) - ); + getMessageController().register(message.id, message); // Note that this can save the message again, if jobs were queued. We need to @@ -481,6 +473,16 @@ export async function handleMessageJob( window?.log?.warn('handleDataMessage: Message', message.idForLogging(), 'was deleted'); } + // this updates the redux store. + // if the convo on which this message should become visible, + // it will be shown to the user, and might as well be read right away + window.inboxStore?.dispatch( + conversationActions.messageAdded({ + conversationKey: conversation.id, + messageModelProps: message.getProps(), + }) + ); + if (message.get('unread')) { await conversation.throttledNotify(message); } diff --git a/ts/session/utils/WindowUtils.ts b/ts/session/utils/WindowUtils.ts new file mode 100644 index 000000000..95f6a0edf --- /dev/null +++ b/ts/session/utils/WindowUtils.ts @@ -0,0 +1,8 @@ +import { remote } from 'electron'; + +export function isElectronWindowFocused() { + const [yourBrowserWindow] = remote.BrowserWindow.getAllWindows(); + const isFocused = yourBrowserWindow?.isFocused() || false; + + return isFocused; +} diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index a519c5138..ebbdbef1b 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -236,7 +236,7 @@ export type ConversationLookupType = { export type ConversationsStateType = { conversationLookup: ConversationLookupType; selectedConversation?: string; - messages: Array; + messages: Array; messageDetailProps?: MessagePropsDetails; showRightPanel: boolean; selectedMessageIds: Array; @@ -258,17 +258,15 @@ export type MentionsMembersType = Array<{ async function getMessages( conversationKey: string, - numMessages: number -): Promise> { + numMessagesToFetch: number +): Promise> { const conversation = getConversationController().get(conversationKey); if (!conversation) { // no valid conversation, early return window?.log?.error('Failed to get convo on reducer.'); return []; } - const unreadCount = await conversation.getUnreadCount(); - let msgCount = - numMessages || Number(Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT) + unreadCount; + let msgCount = numMessagesToFetch; msgCount = msgCount > Constants.CONVERSATION.MAX_MESSAGE_FETCH_COUNT ? Constants.CONVERSATION.MAX_MESSAGE_FETCH_COUNT @@ -282,80 +280,17 @@ async function getMessages( limit: msgCount, }); - // Set first member of series here. - const messageModelsProps: Array = []; - messageSet.models.forEach(m => { - messageModelsProps.push({ ...m.getProps(), firstMessageOfSeries: true }); - }); - - const isPublic = conversation.isPublic(); - - const sortedMessageProps = sortMessages(messageModelsProps, isPublic); - - // no need to do that `firstMessageOfSeries` on a private chat - if (conversation.isPrivate()) { - return sortedMessageProps; - } - return updateFirstMessageOfSeriesAndUnread(sortedMessageProps); + const messageProps: Array = messageSet.models.map(m => m.getProps()); + return messageProps; } export type SortedMessageModelProps = MessageModelProps & { firstMessageOfSeries: boolean; - firstUnread?: boolean; -}; - -const updateFirstMessageOfSeriesAndUnread = ( - messageModelsProps: Array -): Array => { - // messages are got from the more recent to the oldest, so we need to check if - // the next messages in the list is still the same author. - // The message is the first of the series if the next message is not from the same author - const sortedMessageProps: Array = []; - const firstUnreadIndex = getFirstMessageUnreadIndex(messageModelsProps); - - for (let i = 0; i < messageModelsProps.length; i++) { - // Handle firstMessageOfSeries for conditional avatar rendering - let firstMessageOfSeries = true; - let firstUnread = false; - const currentSender = messageModelsProps[i].propsForMessage?.authorPhoneNumber; - const nextSender = - i < messageModelsProps.length - 1 - ? messageModelsProps[i + 1].propsForMessage?.authorPhoneNumber - : undefined; - if (i >= 0 && currentSender === nextSender) { - firstMessageOfSeries = false; - } - if (i === firstUnreadIndex) { - firstUnread = true; - } - - sortedMessageProps.push({ ...messageModelsProps[i], firstMessageOfSeries, firstUnread }); - } - return sortedMessageProps; }; type FetchedMessageResults = { conversationKey: string; - messagesProps: Array; -}; - -const getFirstMessageUnreadIndex = (messages: Array) => { - if (!messages || messages.length === 0) { - return -1; - } - - // iterate over the incoming messages from the oldest one. the first one with isUnread !== undefined is our first unread - for (let index = messages.length - 1; index > 0; index--) { - const message = messages[index]; - if ( - message.propsForMessage.direction === 'incoming' && - message.propsForMessage.isUnread === true - ) { - return index; - } - } - - return -1; + messagesProps: Array; }; export const fetchMessagesForConversation = createAsyncThunk( @@ -370,30 +305,15 @@ export const fetchMessagesForConversation = createAsyncThunk( const beforeTimestamp = Date.now(); console.time('fetchMessagesForConversation'); const messagesProps = await getMessages(conversationKey, count); - const firstUnreadIndex = getFirstMessageUnreadIndex(messagesProps); const afterTimestamp = Date.now(); console.timeEnd('fetchMessagesForConversation'); const time = afterTimestamp - beforeTimestamp; window?.log?.info(`Loading ${messagesProps.length} messages took ${time}ms to load.`); - const mapped = messagesProps.map((m, index) => { - if (index === firstUnreadIndex) { - return { - ...m, - firstMessageOfSeries: true, - firstUnread: true, - }; - } - return { - ...m, - firstMessageOfSeries: true, - firstUnread: false, - }; - }); return { conversationKey, - messagesProps: mapped, + messagesProps, }; } ); @@ -413,32 +333,6 @@ function getEmptyState(): ConversationsStateType { }; } -function sortMessages( - messages: Array, - isPublic: boolean -): Array { - // we order by serverTimestamp for public convos - // be sure to update the sorting order to fetch messages from the DB too at getMessagesByConversation - if (isPublic) { - return messages.sort((a, b) => { - return (b.propsForMessage.serverTimestamp || 0) - (a.propsForMessage.serverTimestamp || 0); - }); - } - if (messages.some(n => !n.propsForMessage.timestamp && !n.propsForMessage.receivedAt)) { - throw new Error('Found some messages without any timestamp set'); - } - - // for non public convos, we order by sent_at or received_at timestamp. - // we assume that a message has either a sent_at or a received_at field set. - const messagesSorted = messages.sort( - (a, b) => - (b.propsForMessage.timestamp || b.propsForMessage.receivedAt || 0) - - (a.propsForMessage.timestamp || a.propsForMessage.receivedAt || 0) - ); - - return messagesSorted; -} - function handleMessageAdded( state: ConversationsStateType, action: PayloadAction<{ @@ -449,32 +343,21 @@ function handleMessageAdded( const { messages } = state; const { conversationKey, messageModelProps: addedMessageProps } = action.payload; if (conversationKey === state.selectedConversation) { - const messagesWithNewMessage = [ - ...messages, - { ...addedMessageProps, firstMessageOfSeries: true }, - ]; - const convo = state.conversationLookup[state.selectedConversation]; - const isPublic = convo?.isPublic || false; + const messagesWithNewMessage = [...messages, addedMessageProps]; - if (convo) { - const sortedMessage = sortMessages(messagesWithNewMessage, isPublic); - const updatedWithFirstMessageOfSeries = updateFirstMessageOfSeriesAndUnread(sortedMessage); - - return { - ...state, - messages: updatedWithFirstMessageOfSeries, - }; - } + return { + ...state, + messages: messagesWithNewMessage, + }; } return state; } -function handleMessageChanged(state: ConversationsStateType, payload: MessageModelProps) { +function handleMessageChanged(state: ConversationsStateType, changedMessage: MessageModelProps) { const messageInStoreIndex = state?.messages?.findIndex( - m => m.propsForMessage.id === payload.propsForMessage.id + m => m.propsForMessage.id === changedMessage.propsForMessage.id ); if (messageInStoreIndex >= 0) { - const changedMessage = { ...payload, firstMessageOfSeries: true }; // we cannot edit the array directly, so slice the first part, insert our edited message, and slice the second part const editedMessages = [ ...state.messages.slice(0, messageInStoreIndex), @@ -482,15 +365,9 @@ function handleMessageChanged(state: ConversationsStateType, payload: MessageMod ...state.messages.slice(messageInStoreIndex + 1), ]; - const convo = state.conversationLookup[payload.propsForMessage.convoId]; - const isPublic = convo?.isPublic || false; - // reorder the messages depending on the timestamp (we might have an updated serverTimestamp now) - const sortedMessage = sortMessages(editedMessages, isPublic); - const updatedWithFirstMessageOfSeries = updateFirstMessageOfSeriesAndUnread(sortedMessage); - return { ...state, - messages: updatedWithFirstMessageOfSeries, + messages: editedMessages, }; } @@ -526,15 +403,13 @@ function handleMessageExpiredOrDeleted( ...state.messages.slice(messageInStoreIndex + 1), ]; - const updatedWithFirstMessageOfSeries = updateFirstMessageOfSeriesAndUnread(editedMessages); - // FIXME two other thing we have to do: // * update the last message text if the message deleted was the last one // * update the unread count of the convo if the message was the one counted as an unread return { ...state, - messages: updatedWithFirstMessageOfSeries, + messages: editedMessages, }; } diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index c17e32983..bf0322b97 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -5,6 +5,7 @@ import { ConversationLookupType, ConversationsStateType, MentionsMembersType, + MessageModelProps, MessagePropsDetails, ReduxConversationType, SortedMessageModelProps, @@ -21,6 +22,7 @@ import { import { LightBoxOptions } from '../../components/session/conversation/SessionConversation'; import { ReplyingToMessageProps } from '../../components/session/conversation/SessionCompositionBox'; import { createSlice } from '@reduxjs/toolkit'; +import { getConversationController } from '../../session/conversations'; export const getConversations = (state: StateType): ConversationsStateType => state.conversations; @@ -53,9 +55,32 @@ export const getOurPrimaryConversation = createSelector( state.conversationLookup[window.storage.get('primaryDevicePubKey')] ); -export const getMessagesOfSelectedConversation = createSelector( +const getMessagesOfSelectedConversation = createSelector( getConversations, - (state: ConversationsStateType): Array => state.messages + (state: ConversationsStateType): Array => state.messages +); + +// Redux recommends to do filtered and deriving state in a selector rather than ourself +export const getSortedMessagesOfSelectedConversation = createSelector( + getMessagesOfSelectedConversation, + (messages: Array): Array => { + if (messages.length === 0) { + return []; + } + + const convoId = messages[0].propsForMessage.convoId; + const convo = getConversationController().get(convoId); + + if (!convo) { + return []; + } + + const isPublic = convo.isPublic() || false; + const isPrivate = convo.isPrivate() || false; + const sortedMessage = sortMessages(messages, isPublic); + + return updateFirstMessageOfSeries(sortedMessage, { isPublic, isPrivate }); + } ); function getConversationTitle( @@ -314,3 +339,102 @@ export const getMentionsInput = createSelector( getConversations, (state: ConversationsStateType): MentionsMembersType => state.mentionMembers ); + +/// Those calls are just related to ordering messages in the redux store. + +function updateFirstMessageOfSeries( + messageModelsProps: Array, + convoOpts: { isPrivate: boolean; isPublic: boolean } +): Array { + // messages are got from the more recent to the oldest, so we need to check if + // the next messages in the list is still the same author. + // The message is the first of the series if the next message is not from the same author + const sortedMessageProps: Array = []; + + if (convoOpts.isPrivate) { + // we don't really care do do that logic for private chats + return messageModelsProps.map(p => { + return { ...p, firstMessageOfSeries: true }; + }); + } + + for (let i = 0; i < messageModelsProps.length; i++) { + const currentSender = messageModelsProps[i].propsForMessage?.authorPhoneNumber; + const nextSender = + i < messageModelsProps.length - 1 + ? messageModelsProps[i + 1].propsForMessage?.authorPhoneNumber + : undefined; + + // Handle firstMessageOfSeries for conditional avatar rendering + + if (i >= 0 && currentSender === nextSender) { + sortedMessageProps.push({ ...messageModelsProps[i], firstMessageOfSeries: false }); + } else { + sortedMessageProps.push({ ...messageModelsProps[i], firstMessageOfSeries: true }); + } + } + return sortedMessageProps; +} + +function sortMessages( + messages: Array, + isPublic: boolean +): Array { + // we order by serverTimestamp for public convos + // be sure to update the sorting order to fetch messages from the DB too at getMessagesByConversation + if (isPublic) { + return messages.slice().sort((a, b) => { + return (b.propsForMessage.serverTimestamp || 0) - (a.propsForMessage.serverTimestamp || 0); + }); + } + if (messages.some(n => !n.propsForMessage.timestamp && !n.propsForMessage.receivedAt)) { + throw new Error('Found some messages without any timestamp set'); + } + + // for non public convos, we order by sent_at or received_at timestamp. + // we assume that a message has either a sent_at or a received_at field set. + const messagesSorted = messages.sort( + (a, b) => + (b.propsForMessage.timestamp || b.propsForMessage.receivedAt || 0) - + (a.propsForMessage.timestamp || a.propsForMessage.receivedAt || 0) + ); + + return messagesSorted; +} + +export const getFirstUnreadMessageIndex = createSelector( + getSortedMessagesOfSelectedConversation, + (messageModelsProps: Array): number | undefined => { + const firstUnreadIndex = getFirstMessageUnreadIndex(messageModelsProps); + return firstUnreadIndex; + } +); + +function getFirstMessageUnreadIndex(messages: Array) { + if (!messages || messages.length === 0) { + return -1; + } + + // this is to handle the case where 50 messages are loaded, some of them are already read at the top, but some not loaded yet are still unread. + if ( + messages.length < + getConversationController() + .get(messages[0].propsForMessage.convoId) + ?.get('unreadCount') + ) { + return -2; + } + + // iterate over the incoming messages from the oldest one. the first one with isUnread !== undefined is our first unread + for (let index = messages.length - 1; index > 0; index--) { + const message = messages[index]; + if ( + message.propsForMessage.direction === 'incoming' && + message.propsForMessage.isUnread === true + ) { + return index; + } + } + + return -1; +} diff --git a/ts/state/smart/SessionConversation.tsx b/ts/state/smart/SessionConversation.tsx index ec3ae9637..7e2c40881 100644 --- a/ts/state/smart/SessionConversation.tsx +++ b/ts/state/smart/SessionConversation.tsx @@ -5,7 +5,7 @@ import { StateType } from '../reducer'; import { getTheme } from '../selectors/theme'; import { getLightBoxOptions, - getMessagesOfSelectedConversation, + getSortedMessagesOfSelectedConversation, getSelectedConversation, getSelectedConversationKey, getSelectedMessageIds, @@ -19,7 +19,7 @@ const mapStateToProps = (state: StateType) => { selectedConversation: getSelectedConversation(state), selectedConversationKey: getSelectedConversationKey(state), theme: getTheme(state), - messagesProps: getMessagesOfSelectedConversation(state), + messagesProps: getSortedMessagesOfSelectedConversation(state), ourNumber: getOurNumber(state), showMessageDetails: isMessageDetailView(state), isRightPanelShowing: isRightPanelShowing(state), diff --git a/ts/window.d.ts b/ts/window.d.ts index 72029c1bd..2fdc03466 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -41,7 +41,6 @@ declare global { getFriendsFromContacts: any; getSettingValue: any; i18n: LocalizerType; - isFocused: () => boolean; libsignal: LibsignalProtocol; log: any; lokiFeatureFlags: {