From a3be2c347d8e537755ed649f4a21e19943958667 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 20 Jan 2022 16:50:42 +1100 Subject: [PATCH] fixup open convo on search, quote click or hit the bottom --- app/sql.js | 17 +- js/background.js | 7 +- stylesheets/_modules.scss | 27 --- stylesheets/_quote.scss | 19 ++- stylesheets/_theme_dark.scss | 8 - ts/components/basic/MessageBodyHighlight.tsx | 9 +- .../conversation/SessionMessagesList.tsx | 27 +-- .../SessionMessagesListContainer.tsx | 154 +++++------------- .../message-content/MessageContent.tsx | 33 +++- .../MessageContentWithStatus.tsx | 11 +- .../message/message-content/MessageQuote.tsx | 69 +++++--- .../message-item/GenericReadableMessage.tsx | 4 +- .../message/message-item/Message.tsx | 6 - .../message/message-item/ReadableMessage.tsx | 34 +++- ts/components/search/MessageSearchResults.tsx | 44 ++--- ts/components/search/SearchResults.tsx | 24 +-- ts/data/data.ts | 13 +- ts/models/messageType.ts | 7 - ts/receiver/errors.ts | 1 - ts/state/ducks/conversations.ts | 89 ++++++++-- ts/state/ducks/search.ts | 15 +- ts/state/selectors/conversations.ts | 19 +++ ts/state/selectors/search.ts | 29 +--- 23 files changed, 359 insertions(+), 307 deletions(-) diff --git a/app/sql.js b/app/sql.js index 69f66e6d1..36857cb69 100644 --- a/app/sql.js +++ b/app/sql.js @@ -1853,17 +1853,19 @@ function searchConversations(query, { limit } = {}) { return map(rows, row => jsonToObject(row.json)); } -function searchMessages(query, { limit } = {}) { +function searchMessages(query, limit) { + // order by clause is the same as orderByClause but with a table prefix so we cannot reuse it + const rows = globalInstance .prepare( `SELECT - messages.json, - snippet(messages_fts, -1, '<>', '<>', '...', 15) as snippet + ${MESSAGES_TABLE}.json, + snippet(${MESSAGES_FTS_TABLE}, -1, '<>', '<>', '...', 15) as snippet FROM ${MESSAGES_FTS_TABLE} - INNER JOIN ${MESSAGES_TABLE} on messages_fts.id = messages.id + INNER JOIN ${MESSAGES_TABLE} on ${MESSAGES_FTS_TABLE}.id = ${MESSAGES_TABLE}.id WHERE - messages_fts match $query - ORDER BY messages.received_at DESC + ${MESSAGES_FTS_TABLE} match $query + ORDER BY ${MESSAGES_TABLE}.serverTimestamp DESC, ${MESSAGES_TABLE}.serverId DESC, ${MESSAGES_TABLE}.sent_at DESC, ${MESSAGES_TABLE}.received_at DESC LIMIT $limit;` ) .all({ @@ -1877,7 +1879,7 @@ function searchMessages(query, { limit } = {}) { })); } -function searchMessagesInConversation(query, conversationId, { limit } = {}) { +function searchMessagesInConversation(query, conversationId, limit) { const rows = globalInstance .prepare( `SELECT @@ -2241,7 +2243,6 @@ function getMessagesByConversation(conversationId, { messageId = null } = {}) { // If messageId is null, it means we are just opening the convo to the last unread message, or at the bottom const firstUnread = getFirstUnreadMessageIdInConversation(conversationId); - if (messageId || firstUnread) { const messageFound = getMessageById(messageId || firstUnread); diff --git a/js/background.js b/js/background.js index 3c9abcf4c..5b21c7d49 100644 --- a/js/background.js +++ b/js/background.js @@ -326,10 +326,11 @@ window.setCallMediaPermissions(enabled); }; - Whisper.Notifications.on('click', async (id, messageId) => { + Whisper.Notifications.on('click', async conversationKey => { window.showWindow(); - if (id) { - await window.openConversationWithMessages({ conversationKey: id, messageId }); + if (conversationKey) { + // do not put the messageId here so the conversation is loaded on the last unread instead + await window.openConversationWithMessages({ conversationKey, messageId: null }); } else { appView.openInbox({ initialLoadComplete, diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 4d21c583a..0723aec45 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -1425,10 +1425,6 @@ } } -.module-message-search-result--is-selected { - background-color: $color-gray-05; -} - .module-message-search-result__text { flex-grow: 1; margin-inline-start: 12px; @@ -1483,29 +1479,6 @@ font-weight: 300; } -.module-message-search-result__body { - margin-top: 1px; - flex-grow: 1; - flex-shrink: 1; - - font-size: 13px; - - color: var(--color-text-subtle); - - max-height: 3.6em; - - overflow: hidden; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - - // Note: -webkit-line-clamp doesn't work for RTL text, and it forces you to use - // ... as the truncation indicator. That's not a solution that works well for - // all languages. More resources: - // - http://hackingui.com/front-end/a-pure-css-solution-for-multiline-text-truncation/ - // - https://medium.com/mofed/css-line-clamp-the-good-the-bad-and-the-straight-up-broken-865413f16e5 -} - // Module: Left Pane .module-left-pane { diff --git a/stylesheets/_quote.scss b/stylesheets/_quote.scss index f347cf3c2..0d40a2d7e 100644 --- a/stylesheets/_quote.scss +++ b/stylesheets/_quote.scss @@ -271,6 +271,23 @@ } } +$session-highlight-message-shadow: 0px 0px 10px 1px $session-color-green; + +@keyframes remove-box-shadow { + 0% { + box-shadow: $session-highlight-message-shadow; + } + 75% { + box-shadow: $session-highlight-message-shadow; + } + 100% { + box-shadow: none; + } +} + .flash-green-once { - box-shadow: 0px 0px 6px 3px $session-color-green; + animation-name: remove-box-shadow; + animation-timing-function: linear; + animation-duration: 2s; + box-shadow: $session-highlight-message-shadow; } diff --git a/stylesheets/_theme_dark.scss b/stylesheets/_theme_dark.scss index d8ff4f04a..1456b5e36 100644 --- a/stylesheets/_theme_dark.scss +++ b/stylesheets/_theme_dark.scss @@ -423,10 +423,6 @@ } } - .module-message-search-result--is-selected { - background-color: $color-dark-70; - } - .module-message-search-result__header__from { color: $color-gray-05; } @@ -435,10 +431,6 @@ color: $color-gray-25; } - .module-message-search-result__body { - color: $color-gray-05; - } - .module-message__link-preview__icon-container__circle-background { background-color: $color-gray-25; } diff --git a/ts/components/basic/MessageBodyHighlight.tsx b/ts/components/basic/MessageBodyHighlight.tsx index 64bedfeb9..9e30e5b91 100644 --- a/ts/components/basic/MessageBodyHighlight.tsx +++ b/ts/components/basic/MessageBodyHighlight.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import styled from 'styled-components'; import { RenderTextCallbackType } from '../../types/Util'; import { SizeClassType } from '../../util/emoji'; import { AddNewLines } from '../conversation/AddNewLines'; @@ -9,6 +10,10 @@ const renderNewLines: RenderTextCallbackType = ({ text, key }) => ( ); +const SnippetHighlight = styled.span` + font-weight: bold; +`; + const renderEmoji = ({ text, key, @@ -51,14 +56,14 @@ export const MessageBodyHighlight = (props: { text: string }) => { const [, toHighlight] = match; results.push( - + {renderEmoji({ text: toHighlight, sizeClass, key: count++, renderNonEmoji: renderNewLines, })} - + ); // @ts-ignore diff --git a/ts/components/conversation/SessionMessagesList.tsx b/ts/components/conversation/SessionMessagesList.tsx index 766b72415..e196e9241 100644 --- a/ts/components/conversation/SessionMessagesList.tsx +++ b/ts/components/conversation/SessionMessagesList.tsx @@ -2,7 +2,7 @@ import React, { useLayoutEffect } from 'react'; import { useSelector } from 'react-redux'; // tslint:disable-next-line: no-submodule-imports import useKey from 'react-use/lib/useKey'; -import { PropsForDataExtractionNotification, QuoteClickOptions } from '../../models/messageType'; +import { PropsForDataExtractionNotification } from '../../models/messageType'; import { PropsForCallNotification, PropsForExpirationTimer, @@ -10,6 +10,7 @@ import { PropsForGroupUpdate, } from '../../state/ducks/conversations'; import { + getOldBottomMessageId, getOldTopMessageId, getSortedMessagesTypesOfSelectedConversation, } from '../../state/selectors/conversations'; @@ -28,10 +29,9 @@ function isNotTextboxEvent(e: KeyboardEvent) { } export const SessionMessagesList = (props: { - scrollToQuoteMessage: (options: QuoteClickOptions) => Promise; scrollAfterLoadMore: ( messageIdToScrollTo: string, - block: ScrollLogicalPosition | undefined + type: 'load-more-top' | 'load-more-bottom' ) => void; onPageUpPressed: () => void; onPageDownPressed: () => void; @@ -40,6 +40,7 @@ export const SessionMessagesList = (props: { }) => { const messagesProps = useSelector(getSortedMessagesTypesOfSelectedConversation); const oldTopMessageId = useSelector(getOldTopMessageId); + const oldBottomMessageId = useSelector(getOldBottomMessageId); useLayoutEffect(() => { const newTopMessageId = messagesProps.length @@ -47,7 +48,15 @@ export const SessionMessagesList = (props: { : undefined; if (oldTopMessageId !== newTopMessageId && oldTopMessageId && newTopMessageId) { - props.scrollAfterLoadMore(oldTopMessageId, 'start'); + props.scrollAfterLoadMore(oldTopMessageId, 'load-more-top'); + } + + const newBottomMessageId = messagesProps.length + ? messagesProps[0].message.props.messageId + : undefined; + + if (newBottomMessageId !== oldBottomMessageId && oldBottomMessageId && newBottomMessageId) { + props.scrollAfterLoadMore(oldBottomMessageId, 'load-more-bottom'); } }); @@ -123,15 +132,7 @@ export const SessionMessagesList = (props: { return null; } - return [ - , - dateBreak, - unreadIndicator, - ]; + return [, dateBreak, unreadIndicator]; })} ); diff --git a/ts/components/conversation/SessionMessagesListContainer.tsx b/ts/components/conversation/SessionMessagesListContainer.tsx index 7b0fd66a2..a54a5c338 100644 --- a/ts/components/conversation/SessionMessagesListContainer.tsx +++ b/ts/components/conversation/SessionMessagesListContainer.tsx @@ -8,16 +8,12 @@ import { connect, useSelector } from 'react-redux'; import { SessionMessagesList } from './SessionMessagesList'; import styled from 'styled-components'; import autoBind from 'auto-bind'; -import { getMessagesBySentAt } from '../../data/data'; import { ConversationTypeEnum } from '../../models/conversation'; -import { MessageModel } from '../../models/message'; -import { QuoteClickOptions } from '../../models/messageType'; import { getConversationController } from '../../session/conversations'; -import { ToastUtils } from '../../session/utils'; import { - openConversationOnQuoteClick, quotedMessageToAnimate, ReduxConversationType, + resetOldBottomMessageId, resetOldTopMessageId, showScrollToBottomButton, SortedMessageModelProps, @@ -39,6 +35,11 @@ export type SessionMessageListProps = { messageContainerRef: React.RefObject; }; +export const ScrollToLoadedMessageContext = React.createContext( + // tslint:disable-next-line: no-empty + (_loadedMessageIdToScrollTo: string) => {} +); + const SessionUnreadAboveIndicator = styled.div` position: sticky; top: 0; @@ -95,67 +96,23 @@ class SessionMessagesListContainerInner extends React.Component { } } - public componentDidUpdate( - prevProps: Props, - _prevState: any - // snapShot: { - // fakeScrollTop: number; - // realScrollTop: number; - // scrollHeight: number; - // oldTopMessageId?: string; - // } - ) { - // const { oldTopMessageId } = snapShot; - // console.warn('didupdate with oldTopMessageId', oldTopMessageId); + public componentDidUpdate(prevProps: Props, _prevState: any) { // // If you want to mess with this, be my guest. // // just make sure you don't remove that as a bug in chrome makes the column-reverse do bad things // // https://bugs.chromium.org/p/chromium/issues/detail?id=1189195&q=column-reverse&can=2#makechanges const isSameConvo = prevProps.conversationKey === this.props.conversationKey; - // if (isSameConvo && oldTopMessageId) { - // this.scrollToMessage(oldTopMessageId, 'center'); - // // if (messageAddedWasMoreRecentOne) { - // // if (snapShot.scrollHeight - snapShot.realScrollTop < 50) { - // // // consider that we were scrolled to bottom - // // currentRef.scrollTop = 0; - // // } else { - // // currentRef.scrollTop = -(currentRef.scrollHeight - snapShot.realScrollTop); - // // } - // // } else { - // // currentRef.scrollTop = snapShot.fakeScrollTop; - // // } - // } + if ( !isSameConvo && this.props.messagesProps.length && this.props.messagesProps[0].propsForMessage.convoId === this.props.conversationKey ) { - console.info('Not same convo, resetting scrolling posiiton', this.props.messagesProps.length); this.setupTimeoutResetQuotedHighlightedMessage(this.props.animateQuotedMessageId); // displayed conversation changed. We have a bit of cleaning to do here this.initialMessageLoadingPosition(); } } - public getSnapshotBeforeUpdate() { - // const messagePropsBeforeUpdate = this.props.messagesProps; - // const oldTopMessageId = messagePropsBeforeUpdate.length - // ? messagePropsBeforeUpdate[messagePropsBeforeUpdate.length - 1].propsForMessage.id - // : undefined; - // console.warn('oldTopMessageId', oldTopMessageId); - // const messageContainer = this.props.messageContainerRef.current; - // const scrollTop = messageContainer?.scrollTop || undefined; - // const scrollHeight = messageContainer?.scrollHeight || undefined; - // // as we use column-reverse for displaying message list - // // the top is < 0 - // const realScrollTop = scrollHeight && scrollTop ? scrollHeight + scrollTop : undefined; - // return { - // realScrollTop, - // fakeScrollTop: scrollTop, - // scrollHeight: scrollHeight, - // oldTopMessageId, - // }; - } - public render() { const { conversationKey, conversation } = this.props; @@ -187,18 +144,27 @@ class SessionMessagesListContainerInner extends React.Component { key="typing-bubble" /> - { - this.scrollToMessage(...args, { isLoadMoreTop: true }); - }} - onPageDownPressed={this.scrollPgDown} - onPageUpPressed={this.scrollPgUp} - onHomePressed={this.scrollTop} - onEndPressed={this.scrollEnd} - /> - - + + { + const isLoadMoreTop = type === 'load-more-top'; + const isLoadMoreBottom = type === 'load-more-bottom'; + this.scrollToMessage(messageIdToScrollTo, isLoadMoreTop ? 'start' : 'end', { + isLoadMoreTop, + isLoadMoreBottom, + }); + }} + onPageDownPressed={this.scrollPgDown} + onPageUpPressed={this.scrollPgUp} + onHomePressed={this.scrollTop} + onEndPressed={this.scrollEnd} + /> + + + ); } @@ -223,7 +189,7 @@ class SessionMessagesListContainerInner extends React.Component { (conversation.unreadCount && conversation.unreadCount <= 0) || firstUnreadOnOpen === undefined ) { - this.scrollToBottom(); + this.scrollToMostRecentMessage(); } else { // just assume that this need to be shown by default window.inboxStore?.dispatch(showScrollToBottomButton(true)); @@ -275,7 +241,7 @@ class SessionMessagesListContainerInner extends React.Component { private scrollToMessage( messageId: string, block: ScrollLogicalPosition | undefined, - options?: { isLoadMoreTop: boolean | undefined } + options?: { isLoadMoreTop: boolean | undefined; isLoadMoreBottom: boolean | undefined } ) { const messageElementDom = document.getElementById(`msg-${messageId}`); @@ -288,9 +254,13 @@ class SessionMessagesListContainerInner extends React.Component { // reset the oldTopInRedux so that a refresh/new message does not scroll us back here again window.inboxStore?.dispatch(resetOldTopMessageId()); } + if (options?.isLoadMoreBottom) { + // reset the oldBottomInRedux so that a refresh/new message does not scroll us back here again + window.inboxStore?.dispatch(resetOldBottomMessageId()); + } } - private scrollToBottom() { + private scrollToMostRecentMessage() { const messageContainer = this.props.messageContainerRef.current; if (!messageContainer) { return; @@ -340,61 +310,23 @@ class SessionMessagesListContainerInner extends React.Component { messageContainer.scrollTo(0, 0); } - private async scrollToQuoteMessage(options: QuoteClickOptions) { - if (!this.props.conversationKey) { + private scrollToQuoteMessage(loadedQuoteMessageToScrollTo: string) { + if (!this.props.conversationKey || !loadedQuoteMessageToScrollTo) { return; } - 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?.sender; - - 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 = collection.find((item: MessageModel) => { - const messageAuthor = item.getSource(); - - return Boolean(messageAuthor && quoteAuthor === messageAuthor); - }); - - if (found) { - void openConversationOnQuoteClick({ - conversationKey: this.props.conversationKey, - messageIdToNavigateTo: found.get('id'), - }); - ToastUtils.pushFoundButNotLoaded(); - } else { - ToastUtils.pushOriginalNoLongerAvailable(); - } - return; + if (!messagesProps.find(m => m.propsForMessage.id === loadedQuoteMessageToScrollTo)) { + throw new Error('this message is not loaded'); } - const databaseId = targetMessage.propsForMessage.id; - this.scrollToMessage(databaseId, 'center'); + this.scrollToMessage(loadedQuoteMessageToScrollTo, 'start'); // Highlight this message on the UI - window.inboxStore?.dispatch(quotedMessageToAnimate(databaseId)); - this.setupTimeoutResetQuotedHighlightedMessage(databaseId); + window.inboxStore?.dispatch(quotedMessageToAnimate(loadedQuoteMessageToScrollTo)); + this.setupTimeoutResetQuotedHighlightedMessage(loadedQuoteMessageToScrollTo); } } diff --git a/ts/components/conversation/message/message-content/MessageContent.tsx b/ts/components/conversation/message/message-content/MessageContent.tsx index 7888509f5..fb849759e 100644 --- a/ts/components/conversation/message/message-content/MessageContent.tsx +++ b/ts/components/conversation/message/message-content/MessageContent.tsx @@ -1,10 +1,10 @@ import classNames from 'classnames'; import moment from 'moment'; -import React, { createContext, useCallback, useState } from 'react'; +import React, { createContext, useCallback, useContext, useLayoutEffect, useState } from 'react'; import { InView } from 'react-intersection-observer'; import { useSelector } from 'react-redux'; import { isEmpty } from 'lodash'; -import { MessageRenderingProps, QuoteClickOptions } from '../../../../models/messageType'; +import { MessageRenderingProps } from '../../../../models/messageType'; import { getMessageContentSelectorProps, getMessageTextProps, @@ -26,6 +26,7 @@ import { MessageAttachment } from './MessageAttachment'; import { MessagePreview } from './MessagePreview'; import { MessageQuote } from './MessageQuote'; import { MessageText } from './MessageText'; +import { ScrollToLoadedMessageContext } from '../../SessionMessagesListContainer'; export type MessageContentSelectorProps = Pick< MessageRenderingProps, @@ -42,7 +43,6 @@ export type MessageContentSelectorProps = Pick< type Props = { messageId: string; - onQuoteClick?: (quote: QuoteClickOptions) => void; isDetailView?: boolean; }; @@ -97,11 +97,14 @@ function onClickOnMessageInnerContainer(event: React.MouseEvent) export const IsMessageVisibleContext = createContext(false); export const MessageContent = (props: Props) => { + const [flashGreen, setFlashGreen] = useState(false); const contentProps = useSelector(state => getMessageContentSelectorProps(state as any, props.messageId) ); const [isMessageVisible, setMessageIsVisible] = useState(false); + const scrollToMessage = useContext(ScrollToLoadedMessageContext); + const [imageBroken, setImageBroken] = useState(false); const onVisible = (inView: boolean | Object) => { @@ -119,6 +122,24 @@ export const MessageContent = (props: Props) => { setImageBroken(true); }, [setImageBroken]); + const quotedMessageToAnimate = useSelector(getQuotedMessageToAnimate); + const isQuotedMessageToAnimate = quotedMessageToAnimate === props.messageId; + + useLayoutEffect(() => { + if (isQuotedMessageToAnimate) { + if (!flashGreen) { + //scroll to me and flash me + scrollToMessage(props.messageId); + setFlashGreen(true); + } + return; + } + if (flashGreen) { + setFlashGreen(false); + } + return; + }); + if (!contentProps) { return null; } @@ -136,13 +157,11 @@ export const MessageContent = (props: Props) => { } = contentProps; const selectedMsg = useSelector(state => getMessageTextProps(state as any, props.messageId)); - const quotedMessageToAnimate = useSelector(getQuotedMessageToAnimate); let isDeleted = false; if (selectedMsg && selectedMsg.isDeleted !== undefined) { isDeleted = selectedMsg.isDeleted; } - const isQuotedMessageToAnimate = quotedMessageToAnimate === props.messageId; const width = getWidth({ previews, attachments }); const isShowingImage = getIsShowingImage({ attachments, imageBroken, previews, text }); @@ -167,7 +186,7 @@ export const MessageContent = (props: Props) => { lastMessageOfSeries || props.isDetailView ? `module-message__container--${direction}--last-of-series` : '', - isQuotedMessageToAnimate && 'flash-green-once' + flashGreen && 'flash-green-once' )} style={{ width: isShowingImage ? width : undefined, @@ -186,7 +205,7 @@ export const MessageContent = (props: Props) => { {!isDeleted && ( <> - + void; ctxMenuID: string; isDetailView?: boolean; dataTestId?: string; @@ -64,7 +63,7 @@ export const MessageContentWithStatuses = (props: Props) => { } }; - const { messageId, onQuoteClick, ctxMenuID, isDetailView, dataTestId } = props; + const { messageId, ctxMenuID, isDetailView, dataTestId } = props; if (!contentProps) { return null; } @@ -88,11 +87,7 @@ export const MessageContentWithStatuses = (props: Props) => {
- +
void; messageId: string; }; export type MessageQuoteSelectorProps = Pick; export const MessageQuote = (props: Props) => { - const { onQuoteClick: scrollToQuote } = props; - const selected = useSelector(state => getMessageQuoteProps(state as any, props.messageId)); - const dispatch = useDispatch(); const multiSelectMode = useSelector(isMessageSelectionMode); + const isMessageDetailViewMode = useSelector(isMessageDetailView); + + // const scrollToLoadedMessage = useContext(ScrollToLoadedMessageContext); + const quote = selected ? selected.quote : undefined; + const direction = selected ? selected.direction : undefined; const onQuoteClick = useCallback( - (event: React.MouseEvent) => { + async (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); - if (!selected?.quote) { + if (!quote) { window.log.warn('onQuoteClick: quote not valid'); return; } - if (multiSelectMode && props.messageId) { - dispatch(toggleSelectedMessageId(props.messageId)); + if (isMessageDetailViewMode) { + // trying to scroll while in the container while the message detail view is shown has unknown effects + return; + } + + const { + referencedMessageNotFound, + messageId: quotedMessageSentAt, + sender: quoteAuthor, + } = quote; + // 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 || !quotedMessageSentAt || !quoteAuthor) { + ToastUtils.pushOriginalNotFound(); return; } - const { sender, referencedMessageNotFound, messageId } = selected.quote; - const quoteId = _.toNumber(messageId); - scrollToQuote?.({ - quoteAuthor: sender, - quoteId, - referencedMessageNotFound: referencedMessageNotFound || false, + const collection = await getMessagesBySentAt(_.toNumber(quotedMessageSentAt)); + const foundInDb = collection.find((item: MessageModel) => { + const messageAuthor = item.getSource(); + + return Boolean(messageAuthor && quoteAuthor === messageAuthor); }); + + if (!foundInDb) { + ToastUtils.pushOriginalNotFound(); + return; + } + void openConversationToSpecificMessage({ + conversationKey: foundInDb.get('conversationId'), + messageIdToNavigateTo: foundInDb.get('id'), + }); + + // scrollToLoadedMessage?.({ + // quoteAuthor: sender, + // quoteId, + // referencedMessageNotFound: referencedMessageNotFound || false, + // }); }, - [scrollToQuote, selected?.quote, multiSelectMode, props.messageId] + [quote, multiSelectMode, props.messageId] ); if (!selected) { return null; } - const { quote, direction } = selected; - if (!quote || !quote.sender || !quote.messageId) { return null; } diff --git a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx index 1cb527a64..d342aaf76 100644 --- a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx @@ -6,7 +6,7 @@ import { useDispatch, useSelector } from 'react-redux'; import useInterval from 'react-use/lib/useInterval'; import _ from 'lodash'; import { removeMessage } from '../../../../data/data'; -import { MessageRenderingProps, QuoteClickOptions } from '../../../../models/messageType'; +import { MessageRenderingProps } from '../../../../models/messageType'; import { getConversationController } from '../../../../session/conversations'; import { messageExpired } from '../../../../state/ducks/conversations'; import { @@ -94,7 +94,6 @@ function useIsExpired(props: ExpiringProps) { type Props = { messageId: string; - onQuoteClick: (quote: QuoteClickOptions) => void; ctxMenuID: string; isDetailView?: boolean; }; @@ -181,7 +180,6 @@ export const GenericReadableMessage = (props: Props) => { diff --git a/ts/components/conversation/message/message-item/Message.tsx b/ts/components/conversation/message/message-item/Message.tsx index 15b484da9..1da2edd18 100644 --- a/ts/components/conversation/message/message-item/Message.tsx +++ b/ts/components/conversation/message/message-item/Message.tsx @@ -3,7 +3,6 @@ import React from 'react'; import _ from 'lodash'; import uuid from 'uuid'; import { useSelector } from 'react-redux'; -import { QuoteClickOptions } from '../../../../models/messageType'; import { getGenericReadableMessageSelectorProps } from '../../../../state/selectors/conversations'; import { GenericReadableMessage } from './GenericReadableMessage'; @@ -13,7 +12,6 @@ export const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200; type Props = { messageId: string; isDetailView?: boolean; // when the detail is shown for a message, we disble click and some other stuff - onQuoteClick?: (options: QuoteClickOptions) => Promise; }; export const Message = (props: Props) => { @@ -22,9 +20,6 @@ export const Message = (props: Props) => { ); const ctxMenuID = `ctx-menu-message-${uuid()}`; - const onQuoteClick = (quote: QuoteClickOptions) => { - void props.onQuoteClick?.(quote); - }; if (msgProps?.isDeleted && msgProps.direction === 'outgoing') { return null; @@ -32,7 +27,6 @@ export const Message = (props: Props) => { return ( { + (window.inboxStore?.dispatch as any)( + fetchBottomMessagesForConversation({ + conversationKey: selectedConversationKey, + oldBottomMessageId: youngestMessageId, + }) + ); + }, + 100 +); + export const ReadableMessage = (props: ReadableMessageProps) => { const { messageId, onContextMenu, className, receivedAt, isUnread } = props; @@ -51,7 +66,9 @@ export const ReadableMessage = (props: ReadableMessageProps) => { const haveDoneFirstScroll = useSelector(getHaveDoneFirstScroll); const mostRecentMessageId = useSelector(getMostRecentMessageId); const oldestMessageId = useSelector(getOldestMessageId); - const fetchingMore = useSelector(areMoreTopMessagesBeingFetched); + const youngestMessageId = useSelector(getYoungestMessageId); + const fetchingTopMore = useSelector(areMoreTopMessagesBeingFetched); + const fetchingBottomMore = useSelector(areMoreBottomMessagesBeingFetched); const shouldMarkReadWhenVisible = isUnread; const onVisible = useCallback( @@ -85,12 +102,22 @@ export const ReadableMessage = (props: ReadableMessageProps) => { inView === true && isAppFocused && oldestMessageId === messageId && - !fetchingMore && + !fetchingTopMore && selectedConversationKey ) { debouncedTriggerLoadMoreTop(selectedConversationKey, oldestMessageId); } + if ( + inView === true && + isAppFocused && + youngestMessageId === messageId && + !fetchingBottomMore && + selectedConversationKey + ) { + debouncedTriggerLoadMoreBottom(selectedConversationKey, youngestMessageId); + } + // this part is just handling the marking of the message as read if needed if ( (inView === true || @@ -113,7 +140,8 @@ export const ReadableMessage = (props: ReadableMessageProps) => { haveDoneFirstScroll, mostRecentMessageId, oldestMessageId, - fetchingMore, + fetchingTopMore, + fetchingBottomMore, isAppFocused, loadedMessagesLength, receivedAt, diff --git a/ts/components/search/MessageSearchResults.tsx b/ts/components/search/MessageSearchResults.tsx index 74ef8d6fd..5d1dbee62 100644 --- a/ts/components/search/MessageSearchResults.tsx +++ b/ts/components/search/MessageSearchResults.tsx @@ -5,12 +5,13 @@ import { MessageDirection } from '../../models/messageType'; import { getOurPubKeyStrFromCache } from '../../session/utils/User'; import { FindAndFormatContactType, - openConversationWithMessages, + openConversationToSpecificMessage, } from '../../state/ducks/conversations'; import { ContactName } from '../conversation/ContactName'; import { Avatar, AvatarSize } from '../avatar/Avatar'; import { Timestamp } from '../conversation/Timestamp'; import { MessageBodyHighlight } from '../basic/MessageBodyHighlight'; +import styled from 'styled-components'; type PropsHousekeeping = { isSelected?: boolean; @@ -83,17 +84,25 @@ const AvatarItem = (props: { source: string }) => { return ; }; +const ResultBody = styled.div` + margin-top: 1px; + flex-grow: 1; + flex-shrink: 1; + + font-size: 13px; + + color: var(--color-text-subtle); + + max-height: 3.6em; + + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +`; + export const MessageSearchResult = (props: MessageResultProps) => { - const { - isSelected, - id, - conversationId, - receivedAt, - snippet, - destination, - source, - direction, - } = props; + const { id, conversationId, receivedAt, snippet, destination, source, direction } = props; // Some messages miss a source or destination. Doing checks to see if the fields can be derived from other sources. // E.g. if the source is missing but the message is outgoing, the source will be our pubkey @@ -119,15 +128,12 @@ export const MessageSearchResult = (props: MessageResultProps) => { key={`div-msg-searchresult-${id}`} role="button" onClick={async () => { - await openConversationWithMessages({ + await openConversationToSpecificMessage({ conversationKey: conversationId, - messageId: id, + messageIdToNavigateTo: id, }); }} - className={classNames( - 'module-message-search-result', - isSelected ? 'module-message-search-result--is-selected' : null - )} + className={classNames('module-message-search-result')} >
@@ -137,9 +143,9 @@ export const MessageSearchResult = (props: MessageResultProps) => {
-
+ -
+ ); diff --git a/ts/components/search/SearchResults.tsx b/ts/components/search/SearchResults.tsx index aa23a303b..8769eb5c8 100644 --- a/ts/components/search/SearchResults.tsx +++ b/ts/components/search/SearchResults.tsx @@ -9,7 +9,6 @@ export type SearchResultsProps = { contacts: Array; conversations: Array; messages: Array; - hideMessagesHeader: boolean; searchTerm: string; }; @@ -18,14 +17,17 @@ const ContactsItem = (props: { header: string; items: Array
{props.header}
{props.items.map(contact => ( - + ))} ); }; export const SearchResults = (props: SearchResultsProps) => { - const { conversations, contacts, messages, searchTerm, hideMessagesHeader } = props; + const { conversations, contacts, messages, searchTerm } = props; const haveConversations = conversations && conversations.length; const haveContacts = contacts && contacts.length; @@ -45,7 +47,11 @@ export const SearchResults = (props: SearchResultsProps) => { {window.i18n('conversationsHeader')} {conversations.map(conversation => ( - + ))} ) : null} @@ -55,13 +61,11 @@ export const SearchResults = (props: SearchResultsProps) => { {haveMessages ? (
- {hideMessagesHeader ? null : ( -
- {window.i18n('messagesHeader')} -
- )} +
+ {`${window.i18n('messagesHeader')}: ${messages.length}`} +
{messages.map(message => ( - + ))}
) : null} diff --git a/ts/data/data.ts b/ts/data/data.ts index e51db9f59..8a2f63aa3 100644 --- a/ts/data/data.ts +++ b/ts/data/data.ts @@ -587,9 +587,12 @@ export async function searchConversations(query: string): Promise> { return conversations; } -export async function searchMessages(query: string, { limit }: any = {}): Promise> { - const messages = await channels.searchMessages(query, { limit }); - return messages; +export async function searchMessages(query: string, limit: number): Promise> { + const messages = await channels.searchMessages(query, limit); + + return _.uniqWith(messages, (left: { id: string }, right: { id: string }) => { + return left.id === right.id; + }); } /** @@ -598,10 +601,10 @@ export async function searchMessages(query: string, { limit }: any = {}): Promis export async function searchMessagesInConversation( query: string, conversationId: string, - options: { limit: number } | undefined + limit: number ): Promise { const messages = await channels.searchMessagesInConversation(query, conversationId, { - limit: options?.limit, + limit, }); return messages; } diff --git a/ts/models/messageType.ts b/ts/models/messageType.ts index a10928815..2e1c55d93 100644 --- a/ts/models/messageType.ts +++ b/ts/models/messageType.ts @@ -216,12 +216,6 @@ export const fillMessageAttributesWithDefaults = ( return defaulted; }; -export type QuoteClickOptions = { - quoteAuthor: string; - quoteId: number; - referencedMessageNotFound: boolean; -}; - /** * Those props are the one generated from a single Message improved by the one by the app itself. * Some of the one added comes from the MessageList, some from redux, etc.. @@ -235,5 +229,4 @@ export type MessageRenderingProps = PropsForMessageWithConvoProps & { multiSelectMode: boolean; firstMessageOfSeries: boolean; lastMessageOfSeries: boolean; - onQuoteClick?: (options: QuoteClickOptions) => Promise; }; diff --git a/ts/receiver/errors.ts b/ts/receiver/errors.ts index 295e76751..a7c113dc4 100644 --- a/ts/receiver/errors.ts +++ b/ts/receiver/errors.ts @@ -1,7 +1,6 @@ import { initIncomingMessage } from './dataMessage'; import { toNumber } from 'lodash'; import { getConversationController } from '../session/conversations'; -import { actions as conversationActions } from '../state/ducks/conversations'; import { ConversationTypeEnum } from '../models/conversation'; import { toLogFormat } from '../types/attachments/Errors'; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index efbc31222..0949cb1cf 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -273,7 +273,9 @@ export type ConversationsStateType = { lightBox?: LightBoxOptions; quotedMessage?: ReplyingToMessageProps; areMoreTopMessagesBeingFetched: boolean; + areMoreBottomMessagesBeingFetched: boolean; oldTopMessageId: string | null; + oldBottomMessageId: string | null; haveDoneFirstScroll: boolean; showScrollButton: boolean; @@ -316,31 +318,27 @@ export type SortedMessageModelProps = MessageModelPropsWithoutConvoProps & { lastMessageOfSeries: boolean; }; -type FetchedMessageResults = { +type FetchedTopMessageResults = { conversationKey: string; messagesProps: Array; oldTopMessageId: string | null; }; export const fetchTopMessagesForConversation = createAsyncThunk( - 'messages/fetchByConversationKey', + 'messages/fetchTopByConversationKey', async ({ conversationKey, oldTopMessageId, }: { conversationKey: string; oldTopMessageId: string | null; - }): Promise => { + }): Promise => { const beforeTimestamp = Date.now(); - perfStart('fetchTopMessagesForConversation'); const messagesProps = await getMessages({ conversationKey, messageId: oldTopMessageId, }); - const afterTimestamp = Date.now(); - perfEnd('fetchTopMessagesForConversation', 'fetchTopMessagesForConversation'); - - const time = afterTimestamp - beforeTimestamp; + const time = Date.now() - beforeTimestamp; window?.log?.info(`Loading ${messagesProps.length} messages took ${time}ms to load.`); return { @@ -351,6 +349,37 @@ export const fetchTopMessagesForConversation = createAsyncThunk( } ); +type FetchedBottomMessageResults = { + conversationKey: string; + messagesProps: Array; + oldBottomMessageId: string | null; +}; + +export const fetchBottomMessagesForConversation = createAsyncThunk( + 'messages/fetchBottomByConversationKey', + async ({ + conversationKey, + oldBottomMessageId, + }: { + conversationKey: string; + oldBottomMessageId: string | null; + }): Promise => { + const beforeTimestamp = Date.now(); + const messagesProps = await getMessages({ + conversationKey, + messageId: oldBottomMessageId, + }); + const time = Date.now() - beforeTimestamp; + window?.log?.info(`Loading ${messagesProps.length} messages took ${time}ms to load.`); + + return { + conversationKey, + messagesProps, + oldBottomMessageId, + }; + } +); + // Reducer export function getEmptyConversationState(): ConversationsStateType { @@ -361,11 +390,13 @@ export function getEmptyConversationState(): ConversationsStateType { showRightPanel: false, selectedMessageIds: [], areMoreTopMessagesBeingFetched: false, + areMoreBottomMessagesBeingFetched: false, showScrollButton: false, mentionMembers: [], firstUnreadMessageId: undefined, haveDoneFirstScroll: false, oldTopMessageId: null, + oldBottomMessageId: null, }; } @@ -695,6 +726,7 @@ const conversationsSlice = createSlice({ selectedConversation: action.payload.conversationKey, areMoreTopMessagesBeingFetched: false, + areMoreBottomMessagesBeingFetched: false, messages: action.payload.initialMessages, showRightPanel: false, selectedMessageIds: [], @@ -706,6 +738,7 @@ const conversationsSlice = createSlice({ showScrollButton: false, animateQuotedMessageId: undefined, oldTopMessageId: null, + oldBottomMessageId: null, mentionMembers: [], firstUnreadMessageId: action.payload.firstUnreadIdOnOpen, @@ -720,23 +753,26 @@ const conversationsSlice = createSlice({ initialMessages: Array; }> ) { - if (state.selectedConversation !== action.payload.conversationKey) { - return state; - } - return { ...state, + selectedConversation: action.payload.conversationKey, areMoreTopMessagesBeingFetched: false, + areMoreBottomMessagesBeingFetched: false, messages: action.payload.initialMessages, showScrollButton: true, animateQuotedMessageId: action.payload.messageIdToNavigateTo, oldTopMessageId: null, + oldBottomMessageId: null, }; }, resetOldTopMessageId(state: ConversationsStateType) { state.oldTopMessageId = null; return state; }, + resetOldBottomMessageId(state: ConversationsStateType) { + state.oldBottomMessageId = null; + return state; + }, updateHaveDoneFirstScroll(state: ConversationsStateType) { state.haveDoneFirstScroll = true; return state; @@ -786,7 +822,7 @@ const conversationsSlice = createSlice({ // Add reducers for additional action types here, and handle loading state as needed builder.addCase( fetchTopMessagesForConversation.fulfilled, - (state: ConversationsStateType, action: PayloadAction) => { + (state: ConversationsStateType, action: PayloadAction) => { // this is called once the messages are loaded from the db for the currently selected conversation const { messagesProps, conversationKey, oldTopMessageId } = action.payload; // double check that this update is for the shown convo @@ -807,6 +843,32 @@ const conversationsSlice = createSlice({ builder.addCase(fetchTopMessagesForConversation.rejected, (state: ConversationsStateType) => { state.areMoreTopMessagesBeingFetched = false; }); + builder.addCase( + fetchBottomMessagesForConversation.fulfilled, + (state: ConversationsStateType, action: PayloadAction) => { + // this is called once the messages are loaded from the db for the currently selected conversation + const { messagesProps, conversationKey, oldBottomMessageId } = action.payload; + // double check that this update is for the shown convo + if (conversationKey === state.selectedConversation) { + return { + ...state, + oldBottomMessageId, + messages: messagesProps, + areMoreBottomMessagesBeingFetched: false, + }; + } + return state; + } + ); + builder.addCase(fetchBottomMessagesForConversation.pending, (state: ConversationsStateType) => { + state.areMoreBottomMessagesBeingFetched = true; + }); + builder.addCase( + fetchBottomMessagesForConversation.rejected, + (state: ConversationsStateType) => { + state.areMoreBottomMessagesBeingFetched = false; + } + ); }, }); @@ -850,6 +912,7 @@ export const { messageChanged, messagesChanged, resetOldTopMessageId, + resetOldBottomMessageId, updateHaveDoneFirstScroll, markConversationFullyRead, // layout stuff diff --git a/ts/state/ducks/search.ts b/ts/state/ducks/search.ts index 9e93af9c0..2ec05642b 100644 --- a/ts/state/ducks/search.ts +++ b/ts/state/ducks/search.ts @@ -30,7 +30,7 @@ type SearchResultsPayloadType = { conversations: Array; contacts: Array; - messages?: Array; + messages?: Array; }; type SearchResultsKickoffActionType = { @@ -83,17 +83,20 @@ async function doSearch(query: string, options: SearchOptions): Promise = []; if (advancedSearchOptions.from && advancedSearchOptions.from.length > 0) { const senderFilterQuery = await queryConversationsAndContacts( advancedSearchOptions.from, options ); - senderFilter = senderFilterQuery.contacts; + filteredMessages = filterMessages( + filteredMessages, + advancedSearchOptions, + senderFilterQuery.contacts + ); + } else { + filteredMessages = filterMessages(filteredMessages, advancedSearchOptions, []); } - filteredMessages = filterMessages(filteredMessages, advancedSearchOptions, senderFilter); } return { query, @@ -201,7 +204,7 @@ function getAdvancedSearchOptionsFromQuery(query: string): AdvancedSearchOptions async function queryMessages(query: string) { try { const normalized = cleanSearchTerm(query); - return searchMessages(normalized); + return searchMessages(normalized, 1000); } catch (e) { return []; } diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index c9d3503a8..0f7434e35 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -597,6 +597,11 @@ export const areMoreTopMessagesBeingFetched = createSelector( (state: ConversationsStateType): boolean => state.areMoreTopMessagesBeingFetched || false ); +export const areMoreBottomMessagesBeingFetched = createSelector( + getConversations, + (state: ConversationsStateType): boolean => state.areMoreBottomMessagesBeingFetched || false +); + export const getHaveDoneFirstScroll = createSelector( getConversations, (state: ConversationsStateType): boolean => state.haveDoneFirstScroll @@ -696,6 +701,15 @@ export const getOldestMessageId = createSelector( } ); +export const getYoungestMessageId = createSelector( + getSortedMessagesOfSelectedConversation, + (messages: Array): string | undefined => { + const youngest = messages.length > 0 ? messages[0].propsForMessage.id : undefined; + + return youngest; + } +); + export const getLoadedMessagesLength = createSelector( getConversations, (state: ConversationsStateType): number => { @@ -1123,3 +1137,8 @@ export const getOldTopMessageId = createSelector( getConversations, (state: ConversationsStateType): string | null => state.oldTopMessageId || null ); + +export const getOldBottomMessageId = createSelector( + getConversations, + (state: ConversationsStateType): string | null => state.oldBottomMessageId || null +); diff --git a/ts/state/selectors/search.ts b/ts/state/selectors/search.ts index c6abd9b76..fdd680329 100644 --- a/ts/state/selectors/search.ts +++ b/ts/state/selectors/search.ts @@ -11,11 +11,6 @@ export const getSearch = (state: StateType): SearchStateType => state.search; export const getQuery = createSelector(getSearch, (state: SearchStateType): string => state.query); -export const getSelectedMessage = createSelector( - getSearch, - (state: SearchStateType): string | undefined => state.selectedMessage -); - export const isSearching = createSelector(getSearch, (state: SearchStateType) => { const { query } = state; @@ -23,13 +18,8 @@ export const isSearching = createSelector(getSearch, (state: SearchStateType) => }); export const getSearchResults = createSelector( - [getSearch, getConversationLookup, getSelectedConversationKey, getSelectedMessage], - ( - searchState: SearchStateType, - lookup: ConversationLookupType, - selectedConversation?: string, - selectedMessage?: string - ) => { + [getSearch, getConversationLookup, getSelectedConversationKey], + (searchState: SearchStateType, lookup: ConversationLookupType, selectedConversation?: string) => { return { contacts: compact( searchState.contacts.map(id => { @@ -65,20 +55,7 @@ export const getSearchResults = createSelector( return value; }) ), - messages: compact( - searchState.messages?.map(message => { - if (message.id === selectedMessage) { - return { - ...message, - isSelected: true, - }; - } - - return message; - }) - ), - hideMessagesHeader: false, - + messages: compact(searchState.messages), searchTerm: searchState.query, }; }