You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-desktop/ts/components/session/conversation/SessionMessagesList.tsx

635 lines
21 KiB
TypeScript

import React from 'react';
import { Message } from '../../conversation/Message';
import { TimerNotification } from '../../conversation/TimerNotification';
import { SessionScrollButton } from '../SessionScrollButton';
import { Constants } from '../../../session';
import _ from 'lodash';
import { contextMenu } from 'react-contexify';
import { GroupNotification } from '../../conversation/GroupNotification';
import { GroupInvitation } from '../../conversation/GroupInvitation';
import {
fetchMessagesForConversation,
PropsForExpirationTimer,
PropsForGroupInvitation,
PropsForGroupUpdate,
quotedMessageToAnimate,
ReduxConversationType,
setNextMessageToPlay,
showScrollToBottomButton,
SortedMessageModelProps,
} from '../../../state/ducks/conversations';
import { SessionLastSeenIndicator } from './SessionLastSeenIndicator';
import { ToastUtils } from '../../../session/utils';
import { TypingBubble } from '../../conversation/TypingBubble';
import { getConversationController } from '../../../session/conversations';
import { MessageModel } from '../../../models/message';
import {
MessageRegularProps,
PropsForDataExtractionNotification,
QuoteClickOptions,
} from '../../../models/messageType';
import { getMessagesBySentAt } from '../../../data/data';
import autoBind from 'auto-bind';
import { ConversationTypeEnum } from '../../../models/conversation';
import { DataExtractionNotification } from '../../conversation/DataExtractionNotification';
import { StateType } from '../../../state/reducer';
import { connect, useSelector } from 'react-redux';
import {
areMoreMessagesBeingFetched,
getMessagesOfSelectedConversation,
getNextMessageToPlayIndex,
getQuotedMessageToAnimate,
getSelectedConversation,
getSelectedConversationKey,
getShowScrollButton,
isMessageSelectionMode,
} from '../../../state/selectors/conversations';
export type SessionMessageListProps = {
messageContainerRef: React.RefObject<any>;
};
type Props = SessionMessageListProps & {
conversationKey?: string;
messagesProps: Array<SortedMessageModelProps>;
conversation?: ReduxConversationType;
showScrollButton: boolean;
animateQuotedMessageId: string | undefined;
};
const UnreadIndicator = (props: { messageId: string; show: boolean }) => {
if (!props.show) {
return null;
}
return <SessionLastSeenIndicator key={`unread-indicator-${props.messageId}`} />;
};
const GroupUpdateItem = (props: {
messageId: string;
groupNotificationProps: PropsForGroupUpdate;
showUnreadIndicator: boolean;
}) => {
return (
<React.Fragment key={props.messageId}>
<GroupNotification key={props.messageId} {...props.groupNotificationProps} />
<UnreadIndicator messageId={props.messageId} show={props.showUnreadIndicator} />
</React.Fragment>
);
};
const GroupInvitationItem = (props: {
messageId: string;
propsForGroupInvitation: PropsForGroupInvitation;
showUnreadIndicator: boolean;
}) => {
return (
<React.Fragment key={props.messageId}>
<GroupInvitation key={props.messageId} {...props.propsForGroupInvitation} />
<UnreadIndicator messageId={props.messageId} show={props.showUnreadIndicator} />
</React.Fragment>
);
};
const DataExtractionNotificationItem = (props: {
messageId: string;
propsForDataExtractionNotification: PropsForDataExtractionNotification;
showUnreadIndicator: boolean;
}) => {
return (
<React.Fragment key={props.messageId}>
<DataExtractionNotification
key={props.messageId}
{...props.propsForDataExtractionNotification}
/>
<UnreadIndicator messageId={props.messageId} show={props.showUnreadIndicator} />
</React.Fragment>
);
};
const TimerNotificationItem = (props: {
messageId: string;
timerProps: PropsForExpirationTimer;
showUnreadIndicator: boolean;
}) => {
return (
<React.Fragment key={props.messageId}>
<TimerNotification key={props.messageId} {...props.timerProps} />
<UnreadIndicator messageId={props.messageId} show={props.showUnreadIndicator} />
</React.Fragment>
);
};
const GenericMessageItem = (props: {
messageId: string;
messageProps: SortedMessageModelProps;
playableMessageIndex?: number;
showUnreadIndicator: boolean;
scrollToQuoteMessage: (options: QuoteClickOptions) => Promise<void>;
playNextMessage?: (value: number) => void;
}) => {
const multiSelectMode = useSelector(isMessageSelectionMode);
const quotedMessageToAnimate = useSelector(getQuotedMessageToAnimate);
const nextMessageToPlay = useSelector(getNextMessageToPlayIndex);
const messageId = props.messageId;
const onQuoteClick = props.messageProps.propsForMessage.quote
? props.scrollToQuoteMessage
: undefined;
const regularProps: MessageRegularProps = {
...props.messageProps.propsForMessage,
firstMessageOfSeries: props.messageProps.firstMessageOfSeries,
multiSelectMode,
isQuotedMessageToAnimate: messageId === quotedMessageToAnimate,
nextMessageToPlay,
playNextMessage: props.playNextMessage,
onQuoteClick,
};
return (
<React.Fragment key={props.messageId}>
<Message
{...regularProps}
playableMessageIndex={props.playableMessageIndex}
multiSelectMode={multiSelectMode}
key={messageId}
/>
<UnreadIndicator messageId={props.messageId} show={props.showUnreadIndicator} />
</React.Fragment>
);
};
const MessageList = (props: {
scrollToQuoteMessage: (options: QuoteClickOptions) => Promise<void>;
playNextMessage?: (value: number) => void;
}) => {
const messagesProps = useSelector(getMessagesOfSelectedConversation);
const isFetchingMore = useSelector(areMoreMessagesBeingFetched);
let playableMessageIndex = 0;
return (
<>
{messagesProps.map((messageProps: SortedMessageModelProps) => {
const timerProps = messageProps.propsForTimerNotification;
const propsForGroupInvitation = messageProps.propsForGroupInvitation;
const propsForDataExtractionNotification = messageProps.propsForDataExtractionNotification;
const groupNotificationProps = messageProps.propsForGroupNotification;
// IF we found the first unread message
// AND we are not scrolled all the way to the bottom
// THEN, show the unread banner for the current message
const showUnreadIndicator = Boolean(messageProps.firstUnread);
if (groupNotificationProps) {
return (
<GroupUpdateItem
key={messageProps.propsForMessage.id}
groupNotificationProps={groupNotificationProps}
messageId={messageProps.propsForMessage.id}
showUnreadIndicator={showUnreadIndicator}
/>
);
}
if (propsForGroupInvitation) {
return (
<GroupInvitationItem
key={messageProps.propsForMessage.id}
propsForGroupInvitation={propsForGroupInvitation}
messageId={messageProps.propsForMessage.id}
showUnreadIndicator={showUnreadIndicator}
/>
);
}
if (propsForDataExtractionNotification) {
return (
<DataExtractionNotificationItem
key={messageProps.propsForMessage.id}
propsForDataExtractionNotification={propsForDataExtractionNotification}
messageId={messageProps.propsForMessage.id}
showUnreadIndicator={showUnreadIndicator}
/>
);
}
if (timerProps) {
return (
<TimerNotificationItem
key={messageProps.propsForMessage.id}
timerProps={timerProps}
messageId={messageProps.propsForMessage.id}
showUnreadIndicator={showUnreadIndicator}
/>
);
}
if (!messageProps) {
return;
}
playableMessageIndex++;
// firstMessageOfSeries tells us to render the avatar only for the first message
// in a series of messages from the same user
return (
<GenericMessageItem
key={messageProps.propsForMessage.id}
playableMessageIndex={playableMessageIndex}
messageId={messageProps.propsForMessage.id}
messageProps={messageProps}
showUnreadIndicator={showUnreadIndicator}
scrollToQuoteMessage={props.scrollToQuoteMessage}
playNextMessage={props.playNextMessage}
/>
);
})}
</>
);
};
class SessionMessagesListInner extends React.Component<Props> {
private scrollOffsetBottomPx: number = Number.MAX_VALUE;
private ignoreScrollEvents: boolean;
private timeoutResetQuotedScroll: NodeJS.Timeout | null = null;
public constructor(props: Props) {
super(props);
autoBind(this);
this.ignoreScrollEvents = true;
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~~~~~~~ LIFECYCLES ~~~~~~~~~~~~~~~~
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
public componentDidMount() {
// Pause thread to wait for rendering to complete
setTimeout(this.initialMessageLoadingPosition, 0);
}
public componentWillUnmount() {
if (this.timeoutResetQuotedScroll) {
clearTimeout(this.timeoutResetQuotedScroll);
}
}
public componentDidUpdate(prevProps: Props) {
const isSameConvo = prevProps.conversationKey === this.props.conversationKey;
const messageLengthChanged = prevProps.messagesProps.length !== this.props.messagesProps.length;
if (
!isSameConvo ||
(prevProps.messagesProps.length === 0 && this.props.messagesProps.length !== 0)
) {
// displayed conversation changed. We have a bit of cleaning to do here
this.scrollOffsetBottomPx = Number.MAX_VALUE;
this.ignoreScrollEvents = true;
this.setupTimeoutResetQuotedHighlightedMessage(true);
this.initialMessageLoadingPosition();
} else {
// if we got new message for this convo, and we are scrolled to bottom
if (isSameConvo && messageLengthChanged) {
// Keep scrolled to bottom unless user scrolls up
if (this.getScrollOffsetBottomPx() === 0) {
this.scrollToBottom();
} else {
const messageContainer = this.props.messageContainerRef?.current;
if (messageContainer) {
const scrollHeight = messageContainer.scrollHeight;
const clientHeight = messageContainer.clientHeight;
this.ignoreScrollEvents = true;
messageContainer.scrollTop = scrollHeight - clientHeight - this.scrollOffsetBottomPx;
this.ignoreScrollEvents = false;
}
}
}
}
}
public render() {
const { conversationKey, conversation } = this.props;
if (!conversationKey || !conversation) {
return null;
}
let displayedName = null;
if (conversation.type === ConversationTypeEnum.PRIVATE) {
displayedName = getConversationController().getContactProfileNameOrShortenedPubKey(
conversationKey
);
}
return (
<div
className="messages-container"
onScroll={this.handleScroll}
ref={this.props.messageContainerRef}
>
<TypingBubble
phoneNumber={conversationKey}
conversationType={conversation.type}
displayedName={displayedName}
isTyping={conversation.isTyping}
key="typing-bubble"
/>
<MessageList
scrollToQuoteMessage={this.scrollToQuoteMessage}
playNextMessage={this.playNextMessage}
/>
<SessionScrollButton onClick={this.scrollToBottom} key="scroll-down-button" />
</div>
);
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~~~~ MESSAGE HANDLING ~~~~~~~~~~~~~
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
private updateReadMessages() {
const { messagesProps, conversationKey } = this.props;
if (!messagesProps || messagesProps.length === 0 || !conversationKey) {
return;
}
const conversation = getConversationController().getOrThrow(conversationKey);
if (conversation.isBlocked()) {
return;
}
if (this.ignoreScrollEvents) {
return;
}
if (this.getScrollOffsetBottomPx() === 0 && window.isFocused()) {
void conversation.markRead(messagesProps[0].propsForMessage.receivedAt || 0);
}
}
/**
* Sets the targeted index for the next
* @param index index of message that just completed
*/
private playNextMessage(index: any) {
const { messagesProps } = this.props;
let nextIndex: number | undefined = index - 1;
// to prevent autoplaying as soon as a message is received.
const latestMessagePlayed = index <= 0 || messagesProps.length < index - 1;
if (latestMessagePlayed) {
nextIndex = undefined;
window.inboxStore?.dispatch(setNextMessageToPlay(nextIndex));
return;
}
// stop auto-playing when the audio messages change author.
const prevAuthorNumber = messagesProps[index].propsForMessage.authorPhoneNumber;
const nextAuthorNumber = messagesProps[index - 1].propsForMessage.authorPhoneNumber;
const differentAuthor = prevAuthorNumber !== nextAuthorNumber;
if (differentAuthor) {
nextIndex = undefined;
}
window.inboxStore?.dispatch(setNextMessageToPlay(nextIndex));
}
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// ~~~~~~~~~~~~ SCROLLING METHODS ~~~~~~~~~~~~~
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
private async handleScroll() {
const messageContainer = this.props.messageContainerRef?.current;
const { conversationKey } = this.props;
if (!messageContainer || !conversationKey) {
return;
}
contextMenu.hideAll();
if (this.ignoreScrollEvents) {
return;
}
const scrollTop = messageContainer.scrollTop;
const clientHeight = messageContainer.clientHeight;
const scrollButtonViewShowLimit = 0.75;
const scrollButtonViewHideLimit = 0.4;
this.scrollOffsetBottomPx = this.getScrollOffsetBottomPx();
const scrollOffsetPc = this.scrollOffsetBottomPx / clientHeight;
// Scroll button appears if you're more than 75% scrolled up
if (scrollOffsetPc > scrollButtonViewShowLimit && !this.props.showScrollButton) {
window.inboxStore?.dispatch(showScrollToBottomButton(true));
}
// Scroll button disappears if you're more less than 40% scrolled up
if (scrollOffsetPc < scrollButtonViewHideLimit && this.props.showScrollButton) {
window.inboxStore?.dispatch(showScrollToBottomButton(false));
}
// Scrolled to bottom
const isScrolledToBottom = this.getScrollOffsetBottomPx() === 0;
if (isScrolledToBottom) {
// Mark messages read
this.updateReadMessages();
}
// Fetch more messages when nearing the top of the message list
const shouldFetchMoreMessagesTop = scrollTop <= Constants.UI.MESSAGE_CONTAINER_BUFFER_OFFSET_PX;
if (shouldFetchMoreMessagesTop) {
const { messagesProps } = this.props;
const numMessages = messagesProps.length + Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT;
const oldLen = messagesProps.length;
const previousTopMessage = messagesProps[oldLen - 1]?.propsForMessage.id;
(window.inboxStore?.dispatch as any)(
fetchMessagesForConversation({ conversationKey, count: numMessages })
);
if (previousTopMessage && oldLen !== messagesProps.length) {
this.scrollToMessage(previousTopMessage);
}
}
}
/**
* Position the list to the middle of the loaded list if the conversation has unread messages and we have some messages loaded
*/
private initialMessageLoadingPosition() {
const { messagesProps, conversation } = this.props;
if (!conversation) {
return;
}
if (conversation.unreadCount > 0 && messagesProps.length) {
// just scroll to the middle of the loaded messages list. so the user can chosse to go up or down from there
const middle = messagesProps.length / 2;
messagesProps[middle].propsForMessage.id;
this.scrollToMessage(messagesProps[middle].propsForMessage.id);
}
if (this.ignoreScrollEvents && messagesProps.length > 0) {
this.ignoreScrollEvents = false;
this.updateReadMessages();
}
}
/**
* Could not find a better name, but when we click on a quoted message,
* the UI takes us there and highlights it.
* If the user clicks again on this message, we want this highlight to be
* shown once again.
*
* So we need to reset the state of of the highlighted message so when the users clicks again,
* the highlight is shown once again
*/
private setupTimeoutResetQuotedHighlightedMessage(clearOnly = false) {
if (this.timeoutResetQuotedScroll) {
clearTimeout(this.timeoutResetQuotedScroll);
}
// only clear the timeout, do not schedule once again
if (clearOnly) {
return;
}
if (this.props.animateQuotedMessageId !== undefined) {
this.timeoutResetQuotedScroll = global.setTimeout(() => {
window.inboxStore?.dispatch(quotedMessageToAnimate(undefined));
}, 3000);
}
}
private scrollToMessage(messageId: string, smooth: boolean = false) {
const messageElementDom = document.getElementById(messageId);
messageElementDom?.scrollIntoView({
behavior: 'auto',
block: 'center',
});
// we consider that a `smooth` set to true, means it's a quoted message, so highlight this message on the UI
if (smooth) {
window.inboxStore?.dispatch(quotedMessageToAnimate(messageId));
this.setupTimeoutResetQuotedHighlightedMessage();
}
const messageContainer = this.props.messageContainerRef.current;
if (!messageContainer) {
return;
}
const scrollHeight = messageContainer.scrollHeight;
const clientHeight = messageContainer.clientHeight;
if (scrollHeight !== 0 && scrollHeight === clientHeight) {
this.updateReadMessages();
}
}
private scrollToBottom() {
const messageContainer = this.props.messageContainerRef.current;
if (!messageContainer) {
return;
}
messageContainer.scrollTop = messageContainer.scrollHeight - messageContainer.clientHeight;
const { messagesProps, conversationKey } = this.props;
if (!messagesProps || messagesProps.length === 0 || !conversationKey) {
return;
}
const conversation = getConversationController().getOrThrow(conversationKey);
void conversation.markRead(messagesProps[0].propsForMessage.receivedAt || 0);
}
private async scrollToQuoteMessage(options: QuoteClickOptions) {
const { quoteAuthor, quoteId, referencedMessageNotFound } = options;
const { messagesProps } = this.props;
// For simplicity's sake, we show the 'not found' toast no matter what if we were
// not able to find the referenced message when the quote was received.
if (referencedMessageNotFound) {
ToastUtils.pushOriginalNotFound();
return;
}
// Look for message in memory first, which would tell us if we could scroll to it
const targetMessage = messagesProps.find(item => {
const messageAuthor = item.propsForMessage?.authorPhoneNumber;
if (!messageAuthor || quoteAuthor !== messageAuthor) {
return false;
}
if (quoteId !== item.propsForMessage?.timestamp) {
return false;
}
return true;
});
// If there's no message already in memory, we won't be scrolling. So we'll gather
// some more information then show an informative toast to the user.
if (!targetMessage) {
const collection = await getMessagesBySentAt(quoteId);
const found = Boolean(
collection.find((item: MessageModel) => {
const messageAuthor = item.getSource();
return Boolean(messageAuthor && quoteAuthor === messageAuthor);
})
);
if (found) {
ToastUtils.pushFoundButNotLoaded();
} else {
ToastUtils.pushOriginalNoLongerAvailable();
}
return;
}
const databaseId = targetMessage.propsForMessage.id;
this.scrollToMessage(databaseId, true);
}
// basically the offset in px from the bottom of the view (most recent message)
private getScrollOffsetBottomPx() {
const messageContainer = this.props.messageContainerRef?.current;
if (!messageContainer) {
return Number.MAX_VALUE;
}
const scrollTop = messageContainer.scrollTop;
const scrollHeight = messageContainer.scrollHeight;
const clientHeight = messageContainer.clientHeight;
return scrollHeight - scrollTop - clientHeight;
}
}
const mapStateToProps = (state: StateType) => {
return {
conversationKey: getSelectedConversationKey(state),
conversation: getSelectedConversation(state),
messagesProps: getMessagesOfSelectedConversation(state),
showScrollButton: getShowScrollButton(state),
animateQuotedMessageId: getQuotedMessageToAnimate(state),
};
};
const smart = connect(mapStateToProps);
export const SessionMessagesList = smart(SessionMessagesListInner);