diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index ed778ea2fd..2702294e06 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -68,6 +68,7 @@ const useProps = (overrideProps: Partial = {}): Props => ({ clearQuotedMessage: action('clearQuotedMessage'), getPreferredBadge: () => undefined, getQuotedMessage: action('getQuotedMessage'), + scrollToBottom: action('scrollToBottom'), sortedGroupMembers: [], // EmojiButton onPickEmoji: action('onPickEmoji'), diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 5baa1679f9..1ab63f89b1 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -123,6 +123,7 @@ export type OwnProps = Readonly<{ setQuotedMessage(message: undefined): unknown; shouldSendHighQualityAttachments: boolean; startRecording: () => unknown; + scrollToBottom: (converstionId: string) => unknown; theme: ThemeType; }>; @@ -202,6 +203,7 @@ export const CompositionArea = ({ clearQuotedMessage, getPreferredBadge, getQuotedMessage, + scrollToBottom, sortedGroupMembers, // EmojiButton onPickEmoji, @@ -628,13 +630,14 @@ export const CompositionArea = ({ {!large ? leftHandSideButtonsFragment : null}
= {}): Props => ({ i18n, + conversationId: 'conversation-id', disabled: boolean('disabled', overrideProps.disabled || false), onSubmit: action('onSubmit'), onEditorStateChange: action('onEditorStateChange'), @@ -32,6 +33,7 @@ const useProps = (overrideProps: Partial = {}): Props => ({ getQuotedMessage: action('getQuotedMessage'), onPickEmoji: action('onPickEmoji'), large: boolean('large', overrideProps.large || false), + scrollToBottom: action('scrollToBottom'), sortedGroupMembers: overrideProps.sortedGroupMembers || [], skinTone: select( 'skinTone', diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index 834b24b5aa..f106150ed6 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -62,6 +62,7 @@ export type InputApi = { export type Props = { readonly i18n: LocalizerType; + readonly conversationId: string; readonly disabled?: boolean; readonly getPreferredBadge: PreferredBadgeSelectorType; readonly large?: boolean; @@ -87,6 +88,7 @@ export type Props = { ): unknown; getQuotedMessage(): unknown; clearQuotedMessage(): unknown; + scrollToBottom: (converstionId: string) => unknown; }; const MAX_LENGTH = 64 * 1024; @@ -95,6 +97,7 @@ const BASE_CLASS_NAME = 'module-composition-input'; export function CompositionInput(props: Props): React.ReactElement { const { i18n, + conversationId, disabled, large, inputApi, @@ -107,6 +110,7 @@ export function CompositionInput(props: Props): React.ReactElement { getPreferredBadge, getQuotedMessage, clearQuotedMessage, + scrollToBottom, sortedGroupMembers, theme, } = props; @@ -237,6 +241,7 @@ export function CompositionInput(props: Props): React.ReactElement { `CompositionInput: Submitting message ${timestamp} with ${mentions.length} mentions` ); onSubmit(text, mentions, timestamp); + scrollToBottom(conversationId); }; if (inputApi) { diff --git a/ts/components/ForwardMessageModal.stories.tsx b/ts/components/ForwardMessageModal.stories.tsx index beb8f3daff..c5a29a4cbe 100644 --- a/ts/components/ForwardMessageModal.stories.tsx +++ b/ts/components/ForwardMessageModal.stories.tsx @@ -44,6 +44,7 @@ const candidateConversations = Array.from(Array(100), () => const useProps = (overrideProps: Partial = {}): PropsType => ({ attachments: overrideProps.attachments, + conversationId: 'conversation-id', candidateConversations, doForwardMessage: action('doForwardMessage'), getPreferredBadge: () => undefined, diff --git a/ts/components/ForwardMessageModal.tsx b/ts/components/ForwardMessageModal.tsx index c924da6dea..65bc724275 100644 --- a/ts/components/ForwardMessageModal.tsx +++ b/ts/components/ForwardMessageModal.tsx @@ -41,6 +41,7 @@ import { useAnimated } from '../hooks/useAnimated'; export type DataPropsType = { attachments?: Array; candidateConversations: ReadonlyArray; + conversationId: string; doForwardMessage: ( selectedContacts: Array, messageBody?: string, @@ -76,6 +77,7 @@ const MAX_FORWARD = 5; export const ForwardMessageModal: FunctionComponent = ({ attachments, candidateConversations, + conversationId, doForwardMessage, getPreferredBadge, i18n, @@ -186,10 +188,10 @@ export const ForwardMessageModal: FunctionComponent = ({ }, [candidateConversations]); const toggleSelectedConversation = useCallback( - (conversationId: string) => { + (selectedConversationId: string) => { let removeContact = false; const nextSelectedContacts = selectedContacts.filter(contact => { - if (contact.id === conversationId) { + if (contact.id === selectedConversationId) { removeContact = true; return false; } @@ -199,7 +201,7 @@ export const ForwardMessageModal: FunctionComponent = ({ setSelectedContacts(nextSelectedContacts); return; } - const selectedContact = contactLookup.get(conversationId); + const selectedContact = contactLookup.get(selectedConversationId); if (selectedContact) { if (selectedContact.announcementsOnly && !selectedContact.areWeAdmin) { setCannotMessage(true); @@ -335,6 +337,7 @@ export const ForwardMessageModal: FunctionComponent = ({ ) : null}
= ({ inputApi={inputApiRef} large moduleClassName="module-ForwardMessageModal__input" + scrollToBottom={noop} onEditorStateChange={( messageText, bodyRanges, @@ -399,7 +403,7 @@ export const ForwardMessageModal: FunctionComponent = ({ i18n={i18n} onClickArchiveButton={shouldNeverBeCalled} onClickContactCheckbox={( - conversationId: string, + selectedConversationId: string, disabledReason: | undefined | ContactCheckboxDisabledReason @@ -408,7 +412,9 @@ export const ForwardMessageModal: FunctionComponent = ({ disabledReason !== ContactCheckboxDisabledReason.MaximumContactsSelected ) { - toggleSelectedConversation(conversationId); + toggleSelectedConversation( + selectedConversationId + ); } }} onSelectConversation={shouldNeverBeCalled} diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 2fdf7798b6..7c645e4ec3 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -473,7 +473,10 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ overrideProps.isLoadingMessages === false ), items: overrideProps.items || Object.keys(items), + loadCountdownStart: undefined, + messageHeightChangeIndex: undefined, resetCounter: 0, + scrollToBottomCounter: 0, scrollToIndex: overrideProps.scrollToIndex, scrollToIndexCounter: 0, totalUnread: number('totalUnread', overrideProps.totalUnread || 0), @@ -485,6 +488,7 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ warning: overrideProps.warning, id: uuid(), + isNearBottom: false, renderItem, renderLastSeenIndicator, renderHeroRow, diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index f86ff18959..fd234152c2 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -78,13 +78,14 @@ export type PropsDataType = { haveNewest: boolean; haveOldest: boolean; isLoadingMessages: boolean; - isNearBottom?: boolean; + isNearBottom: boolean; items: ReadonlyArray; - loadCountdownStart?: number; - messageHeightChangeIndex?: number; - oldestUnreadIndex?: number; + loadCountdownStart: number | undefined; + messageHeightChangeIndex: number | undefined; + oldestUnreadIndex: number | undefined; resetCounter: number; - scrollToIndex?: number; + scrollToBottomCounter: number; + scrollToIndex: number | undefined; scrollToIndexCounter: number; totalUnread: number; }; @@ -959,7 +960,7 @@ export class Timeline extends React.PureComponent { this.scrollDown(false); }; - public scrollDown = (setFocus?: boolean): void => { + public scrollDown = (setFocus?: boolean, forceScrollDown?: boolean): void => { const { haveNewest, id, @@ -976,7 +977,7 @@ export class Timeline extends React.PureComponent { const lastId = items[items.length - 1]; const lastSeenIndicatorRow = this.getLastSeenIndicatorRow(); - if (!this.visibleRows) { + if (!this.visibleRows || forceScrollDown) { if (haveNewest) { this.scrollToBottom(setFocus); } else if (!isLoadingMessages) { @@ -1033,6 +1034,7 @@ export class Timeline extends React.PureComponent { messageHeightChangeIndex, oldestUnreadIndex, resetCounter, + scrollToBottomCounter, scrollToIndex, typingContactId, } = this.props; @@ -1050,6 +1052,10 @@ export class Timeline extends React.PureComponent { this.resizeHeroRow(); } + if (scrollToBottomCounter !== prevProps.scrollToBottomCounter) { + this.scrollDown(false, true); + } + // There are a number of situations which can necessitate that we forget about row // heights previously calculated. We reset the minimum number of rows to minimize // unexpected changes to the scroll position. Those changes happen because diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 5416b0b5c0..ec5bab4c3d 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -101,7 +101,6 @@ import { getAvatarData } from '../util/getAvatarData'; import { createIdenticon } from '../util/createIdenticon'; import * as log from '../logging/log'; import * as Errors from '../types/errors'; -import { isMessageUnread } from '../util/isMessageUnread'; /* eslint-disable more/no-then */ window.Whisper = window.Whisper || {}; @@ -117,13 +116,7 @@ const { upgradeMessageSchema, writeNewAttachmentData, } = window.Signal.Migrations; -const { - addStickerPackReference, - getOlderMessagesByConversation, - getMessageMetricsForConversation, - getMessageById, - getNewerMessagesByConversation, -} = window.Signal.Data; +const { addStickerPackReference } = window.Signal.Data; const THREE_HOURS = durations.HOUR * 3; const FIVE_MINUTES = durations.MINUTE * 5; @@ -131,8 +124,6 @@ const FIVE_MINUTES = durations.MINUTE * 5; const JOB_REPORTING_THRESHOLD_MS = 25; const SEND_REPORTING_THRESHOLD_MS = 25; -const MESSAGE_LOAD_CHUNK_SIZE = 30; - const ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE = new Set([ 'profileLastFetchedAt', 'needsStorageServiceSync', @@ -172,7 +163,7 @@ export class ConversationModel extends window.Backbone inProgressFetch?: Promise; - newMessageQueue?: typeof window.PQueueType; + incomingMessageQueue?: typeof window.PQueueType; jobQueue?: typeof window.PQueueType; @@ -1312,340 +1303,34 @@ export class ConversationModel extends window.Backbone this.debouncedUpdateLastMessage!(); } - addIncomingMessage(message: MessageModel): void { - this.addSingleMessage(message); + addSingleMessage(message: MessageModel): void { + const { messagesAdded } = window.reduxActions.conversations; + const isNewMessage = true; + messagesAdded( + this.id, + [{ ...message.attributes }], + isNewMessage, + window.isActive() + ); } - // New messages might arrive while we're in the middle of a bulk fetch from the - // database. We'll wait until that is done before moving forward. - async addSingleMessage( - message: MessageModel, - { isJustSent }: { isJustSent: boolean } = { isJustSent: false } - ): Promise { - if (!this.newMessageQueue) { - this.newMessageQueue = new window.PQueue({ + // For incoming messages, they might arrive while we're in the middle of a bulk fetch + // from the database. We'll wait until that is done to process this newly-arrived + // message. + addIncomingMessage(message: MessageModel): void { + if (!this.incomingMessageQueue) { + this.incomingMessageQueue = new window.PQueue({ concurrency: 1, timeout: 1000 * 60 * 2, }); } // We use a queue here to ensure messages are added to the UI in the order received - await this.newMessageQueue.add(async () => { + this.incomingMessageQueue.add(async () => { await this.inProgressFetch; + + this.addSingleMessage(message); }); - - const { messagesAdded } = window.reduxActions.conversations; - const { conversations } = window.reduxStore.getState(); - const { messagesByConversation } = conversations; - - const conversationId = this.id; - const existingConversation = messagesByConversation[conversationId]; - const newestId = existingConversation?.metrics?.newest?.id; - const messageIds = existingConversation?.messageIds; - - const isLatestInMemory = - newestId && messageIds && messageIds[messageIds.length - 1] === newestId; - - if (!isJustSent || isLatestInMemory) { - messagesAdded({ - conversationId, - messages: [{ ...message.attributes }], - isActive: window.isActive(), - isJustSent, - isNewMessage: true, - }); - } - - await this.loadNewestMessages(undefined, undefined); - } - - setInProgressFetch(): () => unknown { - let resolvePromise: (value?: unknown) => void; - this.inProgressFetch = new Promise(resolve => { - resolvePromise = resolve; - }); - - const finish = () => { - resolvePromise(); - this.inProgressFetch = undefined; - }; - - return finish; - } - - async loadNewestMessages( - newestMessageId: string | undefined, - setFocus: boolean | undefined - ): Promise { - const { messagesReset, setMessagesLoading } = - window.reduxActions.conversations; - const conversationId = this.id; - - setMessagesLoading(conversationId, true); - const finish = this.setInProgressFetch(); - - try { - let scrollToLatestUnread = true; - - if (newestMessageId) { - const newestInMemoryMessage = await getMessageById(newestMessageId, { - Message: window.Whisper.Message, - }); - if (newestInMemoryMessage) { - // If newest in-memory message is unread, scrolling down would mean going to - // the very bottom, not the oldest unread. - if (isMessageUnread(newestInMemoryMessage.attributes)) { - scrollToLatestUnread = false; - } - } else { - log.warn( - `loadNewestMessages: did not find message ${newestMessageId}` - ); - } - } - - const metrics = await getMessageMetricsForConversation(conversationId); - - // If this is a message request that has not yet been accepted, we always show the - // oldest messages, to ensure that the ConversationHero is shown. We don't want to - // scroll directly to the oldest message, because that could scroll the hero off - // the screen. - if (!newestMessageId && !this.getAccepted() && metrics.oldest) { - this.loadAndScroll(metrics.oldest.id, { disableScroll: true }); - return; - } - - if (scrollToLatestUnread && metrics.oldestUnread) { - this.loadAndScroll(metrics.oldestUnread.id, { - disableScroll: !setFocus, - }); - return; - } - - const messages = await getOlderMessagesByConversation(conversationId, { - limit: MESSAGE_LOAD_CHUNK_SIZE, - MessageCollection: window.Whisper.MessageCollection, - }); - - const cleaned: Array = await this.cleanModels(messages); - const scrollToMessageId = - setFocus && metrics.newest ? metrics.newest.id : undefined; - - // Because our `getOlderMessages` fetch above didn't specify a receivedAt, we got - // the most recent N messages in the conversation. If it has a conflict with - // metrics, fetched a bit before, that's likely a race condition. So we tell our - // reducer to trust the message set we just fetched for determining if we have - // the newest message loaded. - const unboundedFetch = true; - messagesReset( - conversationId, - cleaned.map((messageModel: MessageModel) => ({ - ...messageModel.attributes, - })), - metrics, - scrollToMessageId, - unboundedFetch - ); - } catch (error) { - setMessagesLoading(conversationId, false); - throw error; - } finally { - finish(); - } - } - async loadOlderMessages(oldestMessageId: string): Promise { - const { messagesAdded, setMessagesLoading, repairOldestMessage } = - window.reduxActions.conversations; - const conversationId = this.id; - - setMessagesLoading(conversationId, true); - const finish = this.setInProgressFetch(); - - try { - const message = await getMessageById(oldestMessageId, { - Message: window.Whisper.Message, - }); - if (!message) { - throw new Error( - `loadOlderMessages: failed to load message ${oldestMessageId}` - ); - } - - const receivedAt = message.get('received_at'); - const sentAt = message.get('sent_at'); - const models = await getOlderMessagesByConversation(conversationId, { - receivedAt, - sentAt, - messageId: oldestMessageId, - limit: MESSAGE_LOAD_CHUNK_SIZE, - MessageCollection: window.Whisper.MessageCollection, - }); - - if (models.length < 1) { - log.warn('loadOlderMessages: requested, but loaded no messages'); - repairOldestMessage(conversationId); - return; - } - - const cleaned = await this.cleanModels(models); - messagesAdded({ - conversationId, - messages: cleaned.map((messageModel: MessageModel) => ({ - ...messageModel.attributes, - })), - isActive: window.isActive(), - isJustSent: false, - isNewMessage: false, - }); - } catch (error) { - setMessagesLoading(conversationId, true); - throw error; - } finally { - finish(); - } - } - - async loadNewerMessages(newestMessageId: string): Promise { - const { messagesAdded, setMessagesLoading, repairNewestMessage } = - window.reduxActions.conversations; - const conversationId = this.id; - - setMessagesLoading(conversationId, true); - const finish = this.setInProgressFetch(); - - try { - const message = await getMessageById(newestMessageId, { - Message: window.Whisper.Message, - }); - if (!message) { - throw new Error( - `loadNewerMessages: failed to load message ${newestMessageId}` - ); - } - - const receivedAt = message.get('received_at'); - const sentAt = message.get('sent_at'); - const models = await getNewerMessagesByConversation(conversationId, { - receivedAt, - sentAt, - limit: MESSAGE_LOAD_CHUNK_SIZE, - MessageCollection: window.Whisper.MessageCollection, - }); - - if (models.length < 1) { - log.warn('loadNewerMessages: requested, but loaded no messages'); - repairNewestMessage(conversationId); - return; - } - - const cleaned = await this.cleanModels(models); - messagesAdded({ - conversationId, - messages: cleaned.map((messageModel: MessageModel) => ({ - ...messageModel.attributes, - })), - isActive: window.isActive(), - isJustSent: false, - isNewMessage: false, - }); - } catch (error) { - setMessagesLoading(conversationId, false); - throw error; - } finally { - finish(); - } - } - - async loadAndScroll( - messageId: string, - options?: { disableScroll?: boolean } - ): Promise { - const { messagesReset, setMessagesLoading } = - window.reduxActions.conversations; - const conversationId = this.id; - - setMessagesLoading(conversationId, true); - const finish = this.setInProgressFetch(); - - try { - const message = await getMessageById(messageId, { - Message: window.Whisper.Message, - }); - if (!message) { - throw new Error( - `loadMoreAndScroll: failed to load message ${messageId}` - ); - } - - const receivedAt = message.get('received_at'); - const sentAt = message.get('sent_at'); - const older = await getOlderMessagesByConversation(conversationId, { - limit: MESSAGE_LOAD_CHUNK_SIZE, - receivedAt, - sentAt, - messageId, - MessageCollection: window.Whisper.MessageCollection, - }); - const newer = await getNewerMessagesByConversation(conversationId, { - limit: MESSAGE_LOAD_CHUNK_SIZE, - receivedAt, - sentAt, - MessageCollection: window.Whisper.MessageCollection, - }); - const metrics = await getMessageMetricsForConversation(conversationId); - - const all = [...older.models, message, ...newer.models]; - - const cleaned: Array = await this.cleanModels(all); - const scrollToMessageId = - options && options.disableScroll ? undefined : messageId; - - messagesReset( - conversationId, - cleaned.map((messageModel: MessageModel) => ({ - ...messageModel.attributes, - })), - metrics, - scrollToMessageId - ); - } catch (error) { - setMessagesLoading(conversationId, false); - throw error; - } finally { - finish(); - } - } - - async cleanModels( - collection: MessageModelCollectionType | Array - ): Promise> { - const result = collection - .filter((message: MessageModel) => Boolean(message.id)) - .map((message: MessageModel) => - window.MessageController.register(message.id, message) - ); - - const eliminated = collection.length - result.length; - if (eliminated > 0) { - log.warn(`cleanModels: Eliminated ${eliminated} messages without an id`); - } - - for (let max = result.length, i = 0; i < max; i += 1) { - const message = result[i]; - const { attributes } = message; - const { schemaVersion } = attributes; - - if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) { - // Yep, we really do want to wait for each of these - // eslint-disable-next-line no-await-in-loop - const upgradedMessage = await upgradeMessageSchema(attributes); - message.set(upgradedMessage); - // eslint-disable-next-line no-await-in-loop - await window.Signal.Data.saveMessage(upgradedMessage); - } - } - - return result; } format(): ConversationType { @@ -3974,7 +3659,7 @@ export class ConversationModel extends window.Backbone const enableProfileSharing = Boolean( mandatoryProfileSharingEnabled && !this.get('profileSharing') ); - this.addSingleMessage(model, { isJustSent: true }); + this.addSingleMessage(model); const draftProperties = dontClearDraft ? {} diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index d20a360340..fad8fa11c7 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -248,6 +248,7 @@ export type ConversationMessageType = { messageIds: Array; metrics: MessageMetricsType; resetCounter: number; + scrollToBottomCounter: number; scrollToMessageId?: string; scrollToMessageCounter: number; }; @@ -551,10 +552,9 @@ export type MessagesAddedActionType = { type: 'MESSAGES_ADDED'; payload: { conversationId: string; - isActive: boolean; - isJustSent: boolean; - isNewMessage: boolean; messages: Array; + isNewMessage: boolean; + isActive: boolean; }; }; @@ -611,6 +611,12 @@ export type SetSelectedConversationPanelDepthActionType = { type: 'SET_SELECTED_CONVERSATION_PANEL_DEPTH'; payload: { panelDepth: number }; }; +export type ScrollToBpttomActionType = { + type: 'SCROLL_TO_BOTTOM'; + payload: { + conversationId: string; + }; +}; export type ScrollToMessageActionType = { type: 'SCROLL_TO_MESSAGE'; payload: { @@ -767,6 +773,7 @@ export type ConversationActionType = | ReplaceAvatarsActionType | ReviewGroupMemberNameCollisionActionType | ReviewMessageRequestNameCollisionActionType + | ScrollToBpttomActionType | ScrollToMessageActionType | SelectedConversationChangedActionType | SetComposeGroupAvatarActionType @@ -838,6 +845,7 @@ export const actions = { reviewMessageRequestNameCollision, saveAvatarToDisk, saveUsername, + scrollToBottom, scrollToMessage, selectMessage, setComposeGroupAvatar, @@ -1548,27 +1556,19 @@ function messageSizeChanged( }, }; } -function messagesAdded({ - conversationId, - isActive, - isJustSent, - isNewMessage, - messages, -}: { - conversationId: string; - isActive: boolean; - isJustSent: boolean; - isNewMessage: boolean; - messages: Array; -}): MessagesAddedActionType { +function messagesAdded( + conversationId: string, + messages: Array, + isNewMessage: boolean, + isActive: boolean +): MessagesAddedActionType { return { type: 'MESSAGES_ADDED', payload: { conversationId, - isActive, - isJustSent, - isNewMessage, messages, + isNewMessage, + isActive, }, }; } @@ -1734,6 +1734,15 @@ function closeMaximumGroupSizeModal(): CloseMaximumGroupSizeModalActionType { function closeRecommendedGroupSizeModal(): CloseRecommendedGroupSizeModalActionType { return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' }; } +function scrollToBottom(conversationId: string): ScrollToBpttomActionType { + return { + type: 'SCROLL_TO_BOTTOM', + payload: { + conversationId, + }, + }; +} + function scrollToMessage( conversationId: string, messageId: string @@ -2648,6 +2657,9 @@ export function reducer( scrollToMessageCounter: existingConversation ? existingConversation.scrollToMessageCounter + 1 : 0, + scrollToBottomCounter: existingConversation + ? existingConversation.scrollToBottomCounter + : 0, messageIds, metrics: { ...metrics, @@ -2727,6 +2739,28 @@ export function reducer( }, }; } + if (action.type === 'SCROLL_TO_BOTTOM') { + const { payload } = action; + const { conversationId } = payload; + const { messagesByConversation } = state; + const existingConversation = messagesByConversation[conversationId]; + + if (!existingConversation) { + return state; + } + + return { + ...state, + messagesByConversation: { + ...messagesByConversation, + [conversationId]: { + ...existingConversation, + scrollToBottomCounter: existingConversation.scrollToBottomCounter + 1, + }, + }, + }; + } + if (action.type === 'SCROLL_TO_MESSAGE') { const { payload } = action; const { conversationId, messageId } = payload; @@ -2913,8 +2947,7 @@ export function reducer( } if (action.type === 'MESSAGES_ADDED') { - const { conversationId, isActive, isJustSent, isNewMessage, messages } = - action.payload; + const { conversationId, isActive, isNewMessage, messages } = action.payload; const { messagesByConversation, messagesLookup } = state; const existingConversation = messagesByConversation[conversationId]; @@ -2961,12 +2994,6 @@ export function reducer( // won't add new messages to our message list. const haveLatest = newest && newest.id === lastMessageId; if (!haveLatest) { - if (isJustSent) { - log.warn( - 'reducer/MESSAGES_ADDED: isJustSent is true, but haveLatest is false' - ); - } - return state; } } @@ -3028,7 +3055,7 @@ export function reducer( isLoadingMessages: false, messageIds, heightChangeMessageIds, - scrollToMessageId: isJustSent ? last.id : undefined, + scrollToMessageId: undefined, metrics: { ...existingConversation.metrics, newest, diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index a0addd6abf..28f54ddc0c 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -849,6 +849,7 @@ export function _conversationMessagesSelector( messageIds, metrics, resetCounter, + scrollToBottomCounter, scrollToMessageId, scrollToMessageCounter, } = conversation; @@ -887,7 +888,7 @@ export function _conversationMessagesSelector( isLoadingMessages, loadCountdownStart, items, - isNearBottom, + isNearBottom: isNearBottom || false, messageHeightChangeIndex: isNumber(messageHeightChangeIndex) && messageHeightChangeIndex >= 0 ? messageHeightChangeIndex @@ -897,6 +898,7 @@ export function _conversationMessagesSelector( ? oldestUnreadIndex : undefined, resetCounter, + scrollToBottomCounter, scrollToIndex: isNumber(scrollToIndex) && scrollToIndex >= 0 ? scrollToIndex : undefined, scrollToIndexCounter: scrollToMessageCounter, @@ -932,10 +934,16 @@ export const getConversationMessagesSelector = createSelector( haveNewest: false, haveOldest: false, isLoadingMessages: false, + isNearBottom: false, + items: [], + loadCountdownStart: undefined, + messageHeightChangeIndex: undefined, + oldestUnreadIndex: undefined, resetCounter: 0, + scrollToBottomCounter: 0, + scrollToIndex: undefined, scrollToIndexCounter: 0, totalUnread: 0, - items: [], }; } diff --git a/ts/state/smart/ForwardMessageModal.tsx b/ts/state/smart/ForwardMessageModal.tsx index c3525ff5b1..14f8da2e0f 100644 --- a/ts/state/smart/ForwardMessageModal.tsx +++ b/ts/state/smart/ForwardMessageModal.tsx @@ -18,6 +18,7 @@ import type { AttachmentDraftType } from '../../types/Attachment'; export type SmartForwardMessageModalProps = { attachments?: Array; + conversationId: string; doForwardMessage: ( selectedContacts: Array, messageBody?: string, @@ -41,6 +42,7 @@ const mapStateToProps = ( ): DataPropsType => { const { attachments, + conversationId, doForwardMessage, isSticker, messageBody, @@ -57,6 +59,7 @@ const mapStateToProps = ( return { attachments, candidateConversations, + conversationId, doForwardMessage, getPreferredBadge: getPreferredBadgeSelector(state), i18n: getIntl(state), diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 0a65ff48b0..bda711b419 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -337,6 +337,7 @@ describe('both/state/ducks/conversations', () => { totalUnread: 0, }, resetCounter: 0, + scrollToBottomCounter: 0, scrollToMessageCounter: 0, }; } @@ -839,6 +840,7 @@ describe('both/state/ducks/conversations', () => { messageIds: [messageId], metrics: { totalUnread: 0 }, resetCounter: 0, + scrollToBottomCounter: 0, scrollToMessageCounter: 0, }, }, diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 7b67dc8b44..9929a1a69e 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -19,6 +19,7 @@ import { sniffImageMimeType } from '../util/sniffImageMimeType'; import type { ConversationModel } from '../models/conversations'; import type { GroupV2PendingMemberType, + MessageModelCollectionType, MessageAttributesType, ConversationModelCollectionType, QuotedMessageType, @@ -48,6 +49,7 @@ import { isOutgoing, isTapToView, } from '../state/selectors/message'; +import { isMessageUnread } from '../util/isMessageUnread'; import { getConversationSelector, getMessagesByConversation, @@ -136,7 +138,13 @@ const { upgradeMessageSchema, } = window.Signal.Migrations; -const { getMessageById, getMessagesBySentAt } = window.Signal.Data; +const { + getOlderMessagesByConversation, + getMessageMetricsForConversation, + getMessageById, + getMessagesBySentAt, + getNewerMessagesByConversation, +} = window.Signal.Data; type MessageActionsType = { deleteMessage: (messageId: string) => unknown; @@ -466,6 +474,107 @@ export class ConversationView extends window.Backbone.View { this.scrollToMessage(message.id); }; + const loadOlderMessages = async (oldestMessageId: string) => { + const { messagesAdded, setMessagesLoading, repairOldestMessage } = + window.reduxActions.conversations; + const conversationId = this.model.id; + + setMessagesLoading(conversationId, true); + const finish = this.setInProgressFetch(); + + try { + const message = await getMessageById(oldestMessageId, { + Message: Whisper.Message, + }); + if (!message) { + throw new Error( + `loadOlderMessages: failed to load message ${oldestMessageId}` + ); + } + + const receivedAt = message.get('received_at'); + const sentAt = message.get('sent_at'); + const models = await getOlderMessagesByConversation(conversationId, { + receivedAt, + sentAt, + messageId: oldestMessageId, + limit: 30, + MessageCollection: Whisper.MessageCollection, + }); + + if (models.length < 1) { + log.warn('loadOlderMessages: requested, but loaded no messages'); + repairOldestMessage(conversationId); + return; + } + + const cleaned = await this.cleanModels(models); + const isNewMessage = false; + messagesAdded( + this.model.id, + cleaned.map((messageModel: MessageModel) => ({ + ...messageModel.attributes, + })), + isNewMessage, + window.isActive() + ); + } catch (error) { + setMessagesLoading(conversationId, true); + throw error; + } finally { + finish(); + } + }; + const loadNewerMessages = async (newestMessageId: string) => { + const { messagesAdded, setMessagesLoading, repairNewestMessage } = + window.reduxActions.conversations; + const conversationId = this.model.id; + + setMessagesLoading(conversationId, true); + const finish = this.setInProgressFetch(); + + try { + const message = await getMessageById(newestMessageId, { + Message: Whisper.Message, + }); + if (!message) { + throw new Error( + `loadNewerMessages: failed to load message ${newestMessageId}` + ); + } + + const receivedAt = message.get('received_at'); + const sentAt = message.get('sent_at'); + const models = await getNewerMessagesByConversation(conversationId, { + receivedAt, + sentAt, + limit: 30, + MessageCollection: Whisper.MessageCollection, + }); + + if (models.length < 1) { + log.warn('loadNewerMessages: requested, but loaded no messages'); + repairNewestMessage(conversationId); + return; + } + + const cleaned = await this.cleanModels(models); + const isNewMessage = false; + messagesAdded( + conversationId, + cleaned.map((messageModel: MessageModel) => ({ + ...messageModel.attributes, + })), + isNewMessage, + window.isActive() + ); + } catch (error) { + setMessagesLoading(conversationId, false); + throw error; + } finally { + finish(); + } + }; const markMessageRead = async (messageId: string) => { if (!window.isActive()) { return; @@ -506,10 +615,10 @@ export class ConversationView extends window.Backbone.View { }, contactSupport, learnMoreAboutDeliveryIssue, - loadNewerMessages: this.model.loadNewerMessages.bind(this.model), - loadNewestMessages: this.model.loadNewestMessages.bind(this.model), - loadAndScroll: this.model.loadAndScroll.bind(this.model), - loadOlderMessages: this.model.loadOlderMessages.bind(this.model), + loadNewerMessages, + loadNewestMessages: this.loadNewestMessages.bind(this), + loadAndScroll: this.loadAndScroll.bind(this), + loadOlderMessages, markMessageRead, onBlock: createMessageRequestResponseHandler( 'onBlock', @@ -882,6 +991,38 @@ export class ConversationView extends window.Backbone.View { }; } + async cleanModels( + collection: MessageModelCollectionType | Array + ): Promise> { + const result = collection + .filter((message: MessageModel) => Boolean(message.id)) + .map((message: MessageModel) => + window.MessageController.register(message.id, message) + ); + + const eliminated = collection.length - result.length; + if (eliminated > 0) { + log.warn(`cleanModels: Eliminated ${eliminated} messages without an id`); + } + + for (let max = result.length, i = 0; i < max; i += 1) { + const message = result[i]; + const { attributes } = message; + const { schemaVersion } = attributes; + + if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) { + // Yep, we really do want to wait for each of these + // eslint-disable-next-line no-await-in-loop + const upgradedMessage = await upgradeMessageSchema(attributes); + message.set(upgradedMessage); + // eslint-disable-next-line no-await-in-loop + await window.Signal.Data.saveMessage(upgradedMessage); + } + } + + return result; + } + async scrollToMessage(messageId: string): Promise { const message = await getMessageById(messageId, { Message: Whisper.Message, @@ -912,7 +1053,162 @@ export class ConversationView extends window.Backbone.View { return; } - this.model.loadAndScroll(messageId); + this.loadAndScroll(messageId); + } + + setInProgressFetch(): () => unknown { + let resolvePromise: (value?: unknown) => void; + this.model.inProgressFetch = new Promise(resolve => { + resolvePromise = resolve; + }); + + const finish = () => { + resolvePromise(); + this.model.inProgressFetch = undefined; + }; + + return finish; + } + + async loadAndScroll( + messageId: string, + options?: { disableScroll?: boolean } + ): Promise { + const { messagesReset, setMessagesLoading } = + window.reduxActions.conversations; + const conversationId = this.model.id; + + setMessagesLoading(conversationId, true); + const finish = this.setInProgressFetch(); + + try { + const message = await getMessageById(messageId, { + Message: Whisper.Message, + }); + if (!message) { + throw new Error( + `loadMoreAndScroll: failed to load message ${messageId}` + ); + } + + const receivedAt = message.get('received_at'); + const sentAt = message.get('sent_at'); + const older = await getOlderMessagesByConversation(conversationId, { + limit: 30, + receivedAt, + sentAt, + messageId, + MessageCollection: Whisper.MessageCollection, + }); + const newer = await getNewerMessagesByConversation(conversationId, { + limit: 30, + receivedAt, + sentAt, + MessageCollection: Whisper.MessageCollection, + }); + const metrics = await getMessageMetricsForConversation(conversationId); + + const all = [...older.models, message, ...newer.models]; + + const cleaned: Array = await this.cleanModels(all); + const scrollToMessageId = + options && options.disableScroll ? undefined : messageId; + + messagesReset( + conversationId, + cleaned.map((messageModel: MessageModel) => ({ + ...messageModel.attributes, + })), + metrics, + scrollToMessageId + ); + } catch (error) { + setMessagesLoading(conversationId, false); + throw error; + } finally { + finish(); + } + } + + async loadNewestMessages( + newestMessageId: string | undefined, + setFocus: boolean | undefined + ): Promise { + const { messagesReset, setMessagesLoading } = + window.reduxActions.conversations; + const conversationId = this.model.id; + + setMessagesLoading(conversationId, true); + const finish = this.setInProgressFetch(); + + try { + let scrollToLatestUnread = true; + + if (newestMessageId) { + const newestInMemoryMessage = await getMessageById(newestMessageId, { + Message: Whisper.Message, + }); + if (newestInMemoryMessage) { + // If newest in-memory message is unread, scrolling down would mean going to + // the very bottom, not the oldest unread. + if (isMessageUnread(newestInMemoryMessage.attributes)) { + scrollToLatestUnread = false; + } + } else { + log.warn( + `loadNewestMessages: did not find message ${newestMessageId}` + ); + } + } + + const metrics = await getMessageMetricsForConversation(conversationId); + + // If this is a message request that has not yet been accepted, we always show the + // oldest messages, to ensure that the ConversationHero is shown. We don't want to + // scroll directly to the oldest message, because that could scroll the hero off + // the screen. + if (!newestMessageId && !this.model.getAccepted() && metrics.oldest) { + this.loadAndScroll(metrics.oldest.id, { disableScroll: true }); + return; + } + + if (scrollToLatestUnread && metrics.oldestUnread) { + this.loadAndScroll(metrics.oldestUnread.id, { + disableScroll: !setFocus, + }); + return; + } + + const messages = await getOlderMessagesByConversation(conversationId, { + limit: 30, + MessageCollection: Whisper.MessageCollection, + }); + + const cleaned: Array = await this.cleanModels(messages); + const scrollToMessageId = + setFocus && metrics.newest ? metrics.newest.id : undefined; + + // Because our `getOlderMessages` fetch above didn't specify a receivedAt, we got + // the most recent 30 messages in the conversation. If it has a conflict with + // metrics, fetched a bit before, that's likely a race condition. So we tell our + // reducer to trust the message set we just fetched for determining if we have + // the newest message loaded. + const unboundedFetch = true; + messagesReset( + conversationId, + cleaned.map((messageModel: MessageModel) => ({ + ...messageModel.attributes, + })), + metrics, + scrollToMessageId, + unboundedFetch + ); + } catch (error) { + setMessagesLoading(conversationId, false); + throw error; + } finally { + finish(); + } } async startMigrationToGV2(): Promise { @@ -1206,7 +1502,7 @@ export class ConversationView extends window.Backbone.View { }); if (message) { - this.model.loadAndScroll(messageId); + this.loadAndScroll(messageId); return; } @@ -1218,7 +1514,7 @@ export class ConversationView extends window.Backbone.View { await retryPlaceholders.findByConversationAndMarkOpened(this.model.id); } - this.model.loadNewestMessages(undefined, undefined); + this.loadNewestMessages(undefined, undefined); this.model.updateLastMessage(); this.focusMessageField(); @@ -1286,6 +1582,7 @@ export class ConversationView extends window.Backbone.View { window.reduxStore, { attachments: draftAttachments, + conversationId: this.model.id, doForwardMessage: async ( conversationIds: Array, messageBody?: string,