fixup open convo on search, quote click or hit the bottom

pull/2142/head
Audric Ackermann 2 years ago
parent 2b0a2cff46
commit a3be2c347d
No known key found for this signature in database
GPG Key ID: 999F434D76324AD4

@ -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, '<<left>>', '<<right>>', '...', 15) as snippet
${MESSAGES_TABLE}.json,
snippet(${MESSAGES_FTS_TABLE}, -1, '<<left>>', '<<right>>', '...', 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);

@ -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,

@ -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 {

@ -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;
}

@ -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;
}

@ -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 }) => (
<AddNewLines key={key} text={text} />
);
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(
<span className="module-message-body__highlight" key={count++}>
<SnippetHighlight key={count++}>
{renderEmoji({
text: toHighlight,
sizeClass,
key: count++,
renderNonEmoji: renderNewLines,
})}
</span>
</SnippetHighlight>
);
// @ts-ignore

@ -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<void>;
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 [
<Message
messageId={messageId}
onQuoteClick={props.scrollToQuoteMessage}
key={messageId}
/>,
dateBreak,
unreadIndicator,
];
return [<Message messageId={messageId} key={messageId} />, dateBreak, unreadIndicator];
})}
</>
);

@ -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<HTMLDivElement>;
};
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<Props> {
}
}
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<Props> {
key="typing-bubble"
/>
<SessionMessagesList
scrollToQuoteMessage={this.scrollToQuoteMessage}
scrollAfterLoadMore={(...args) => {
this.scrollToMessage(...args, { isLoadMoreTop: true });
}}
onPageDownPressed={this.scrollPgDown}
onPageUpPressed={this.scrollPgUp}
onHomePressed={this.scrollTop}
onEndPressed={this.scrollEnd}
/>
<SessionScrollButton onClick={this.scrollToBottom} key="scroll-down-button" />
<ScrollToLoadedMessageContext.Provider value={this.scrollToQuoteMessage}>
<SessionMessagesList
scrollAfterLoadMore={(
messageIdToScrollTo: string,
type: 'load-more-top' | 'load-more-bottom'
) => {
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}
/>
</ScrollToLoadedMessageContext.Provider>
<SessionScrollButton onClick={this.scrollToMostRecentMessage} key="scroll-down-button" />
</div>
);
}
@ -223,7 +189,7 @@ class SessionMessagesListContainerInner extends React.Component<Props> {
(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<Props> {
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<Props> {
// 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<Props> {
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);
}
}

@ -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<HTMLDivElement>)
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) => {
<IsMessageVisibleContext.Provider value={isMessageVisible}>
{!isDeleted && (
<>
<MessageQuote messageId={props.messageId} onQuoteClick={props.onQuoteClick} />
<MessageQuote messageId={props.messageId} />
<MessageAttachment
messageId={props.messageId}
imageBroken={imageBroken}

@ -2,7 +2,7 @@ import classNames from 'classnames';
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { replyToMessage } from '../../../../interactions/conversationInteractions';
import { MessageRenderingProps, QuoteClickOptions } from '../../../../models/messageType';
import { MessageRenderingProps } from '../../../../models/messageType';
import { toggleSelectedMessageId } from '../../../../state/ducks/conversations';
import {
getMessageContentWithStatusesSelectorProps,
@ -21,7 +21,6 @@ export type MessageContentWithStatusSelectorProps = Pick<
type Props = {
messageId: string;
onQuoteClick: (quote: QuoteClickOptions) => 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) => {
<div>
<MessageAuthorText messageId={messageId} />
<MessageContent
messageId={messageId}
isDetailView={isDetailView}
onQuoteClick={onQuoteClick}
/>
<MessageContent messageId={messageId} isDetailView={isDetailView} />
</div>
<MessageStatus
dataTestId="msg-status-outgoing"

@ -1,62 +1,91 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import _ from 'lodash';
import { MessageRenderingProps, QuoteClickOptions } from '../../../../models/messageType';
import { MessageRenderingProps } from '../../../../models/messageType';
import { PubKey } from '../../../../session/types';
import { toggleSelectedMessageId } from '../../../../state/ducks/conversations';
import { openConversationToSpecificMessage } from '../../../../state/ducks/conversations';
import {
getMessageQuoteProps,
isMessageDetailView,
isMessageSelectionMode,
} from '../../../../state/selectors/conversations';
import { Quote } from './Quote';
import { ToastUtils } from '../../../../session/utils';
import { getMessagesBySentAt } from '../../../../data/data';
import { MessageModel } from '../../../../models/message';
// tslint:disable: use-simple-attributes
type Props = {
onQuoteClick?: (quote: QuoteClickOptions) => void;
messageId: string;
};
export type MessageQuoteSelectorProps = Pick<MessageRenderingProps, 'quote' | 'direction'>;
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<HTMLDivElement>) => {
async (event: React.MouseEvent<HTMLDivElement>) => {
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;
}

@ -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) => {
<MessageContentWithStatuses
ctxMenuID={props.ctxMenuID}
messageId={messageId}
onQuoteClick={props.onQuoteClick}
isDetailView={isDetailView}
dataTestId={`message-content-${messageId}`}
/>

@ -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<void>;
};
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 (
<GenericReadableMessage
onQuoteClick={onQuoteClick}
ctxMenuID={ctxMenuID}
messageId={props.messageId}
isDetailView={props.isDetailView}

@ -5,17 +5,20 @@ import { useDispatch, useSelector } from 'react-redux';
import { getMessageById } from '../../../../data/data';
import { getConversationController } from '../../../../session/conversations';
import {
fetchBottomMessagesForConversation,
fetchTopMessagesForConversation,
markConversationFullyRead,
showScrollToBottomButton,
} from '../../../../state/ducks/conversations';
import {
areMoreBottomMessagesBeingFetched,
areMoreTopMessagesBeingFetched,
getHaveDoneFirstScroll,
getLoadedMessagesLength,
getMostRecentMessageId,
getOldestMessageId,
getSelectedConversationKey,
getYoungestMessageId,
} from '../../../../state/selectors/conversations';
import { getIsAppFocused } from '../../../../state/selectors/section';
@ -40,6 +43,18 @@ const debouncedTriggerLoadMoreTop = _.debounce(
100
);
const debouncedTriggerLoadMoreBottom = _.debounce(
(selectedConversationKey: string, youngestMessageId: string) => {
(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,

@ -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 <Avatar size={AvatarSize.S} pubkey={source} />;
};
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')}
>
<AvatarItem source={effectiveSource} />
<div className="module-message-search-result__text">
@ -137,9 +143,9 @@ export const MessageSearchResult = (props: MessageResultProps) => {
<Timestamp timestamp={receivedAt} />
</div>
</div>
<div className="module-message-search-result__body">
<ResultBody>
<MessageBodyHighlight text={snippet || ''} />
</div>
</ResultBody>
</div>
</div>
);

@ -9,7 +9,6 @@ export type SearchResultsProps = {
contacts: Array<ConversationListItemProps>;
conversations: Array<ConversationListItemProps>;
messages: Array<MessageResultProps>;
hideMessagesHeader: boolean;
searchTerm: string;
};
@ -18,14 +17,17 @@ const ContactsItem = (props: { header: string; items: Array<ConversationListItem
<div className="module-search-results__contacts">
<div className="module-search-results__contacts-header">{props.header}</div>
{props.items.map(contact => (
<MemoConversationListItemWithDetails {...contact} />
<MemoConversationListItemWithDetails
{...contact}
key={`search-result-contact-${contact.id}`}
/>
))}
</div>
);
};
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')}
</div>
{conversations.map(conversation => (
<MemoConversationListItemWithDetails {...conversation} />
<MemoConversationListItemWithDetails
{...conversation}
mentionedUs={false}
key={`search-result-convo-${conversation.id}`}
/>
))}
</div>
) : null}
@ -55,13 +61,11 @@ export const SearchResults = (props: SearchResultsProps) => {
{haveMessages ? (
<div className="module-search-results__messages">
{hideMessagesHeader ? null : (
<div className="module-search-results__messages-header">
{window.i18n('messagesHeader')}
</div>
)}
<div className="module-search-results__messages-header">
{`${window.i18n('messagesHeader')}: ${messages.length}`}
</div>
{messages.map(message => (
<MessageSearchResult key={`search-result-${message.id}`} {...message} />
<MessageSearchResult key={`search-result-message-${message.id}`} {...message} />
))}
</div>
) : null}

@ -587,9 +587,12 @@ export async function searchConversations(query: string): Promise<Array<any>> {
return conversations;
}
export async function searchMessages(query: string, { limit }: any = {}): Promise<Array<any>> {
const messages = await channels.searchMessages(query, { limit });
return messages;
export async function searchMessages(query: string, limit: number): Promise<Array<Object>> {
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<Object> {
const messages = await channels.searchMessagesInConversation(query, conversationId, {
limit: options?.limit,
limit,
});
return messages;
}

@ -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<void>;
};

@ -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';

@ -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<MessageModelPropsWithoutConvoProps>;
oldTopMessageId: string | null;
};
export const fetchTopMessagesForConversation = createAsyncThunk(
'messages/fetchByConversationKey',
'messages/fetchTopByConversationKey',
async ({
conversationKey,
oldTopMessageId,
}: {
conversationKey: string;
oldTopMessageId: string | null;
}): Promise<FetchedMessageResults> => {
}): Promise<FetchedTopMessageResults> => {
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<MessageModelPropsWithoutConvoProps>;
oldBottomMessageId: string | null;
};
export const fetchBottomMessagesForConversation = createAsyncThunk(
'messages/fetchBottomByConversationKey',
async ({
conversationKey,
oldBottomMessageId,
}: {
conversationKey: string;
oldBottomMessageId: string | null;
}): Promise<FetchedBottomMessageResults> => {
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<MessageModelPropsWithoutConvoProps>;
}>
) {
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<FetchedMessageResults>) => {
(state: ConversationsStateType, action: PayloadAction<FetchedTopMessageResults>) => {
// 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<FetchedBottomMessageResults>) => {
// 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

@ -30,7 +30,7 @@ type SearchResultsPayloadType = {
conversations: Array<string>;
contacts: Array<string>;
messages?: Array<string>;
messages?: Array<Object>;
};
type SearchResultsKickoffActionType = {
@ -83,17 +83,20 @@ async function doSearch(query: string, options: SearchOptions): Promise<SearchRe
]);
const { conversations, contacts } = discussions;
let filteredMessages = _.compact(messages);
if (isAdvancedQuery) {
let senderFilter: Array<string> = [];
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 [];
}

@ -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<MessageModelPropsWithoutConvoProps>): 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
);

@ -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,
};
}

Loading…
Cancel
Save