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.
619 lines
21 KiB
TypeScript
619 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 { AttachmentType } from '../../../types/Attachment';
|
|
import { GroupNotification } from '../../conversation/GroupNotification';
|
|
import { GroupInvitation } from '../../conversation/GroupInvitation';
|
|
import { ConversationType } 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 } from '../../../models/messageType';
|
|
import { getMessagesBySentAt } from '../../../data/data';
|
|
import autoBind from 'auto-bind';
|
|
import { ConversationTypeEnum } from '../../../models/conversation';
|
|
import { DataExtractionNotification } from '../../conversation/DataExtractionNotification';
|
|
|
|
interface State {
|
|
showScrollButton: boolean;
|
|
animateQuotedMessageId?: string;
|
|
nextMessageToPlay: number | null;
|
|
}
|
|
|
|
interface Props {
|
|
selectedMessages: Array<string>;
|
|
conversationKey: string;
|
|
messages: Array<MessageModel>;
|
|
conversation: ConversationType;
|
|
ourPrimary: string;
|
|
messageContainerRef: React.RefObject<any>;
|
|
selectMessage: (messageId: string) => void;
|
|
deleteMessage: (messageId: string) => void;
|
|
fetchMessagesForConversation: ({
|
|
conversationKey,
|
|
count,
|
|
}: {
|
|
conversationKey: string;
|
|
count: number;
|
|
}) => void;
|
|
replyToMessage: (messageId: number) => Promise<void>;
|
|
showMessageDetails: (messageProps: any) => void;
|
|
onClickAttachment: (attachment: any, message: any) => void;
|
|
onDownloadAttachment: ({
|
|
attachment,
|
|
messageTimestamp,
|
|
}: {
|
|
attachment: any;
|
|
messageTimestamp: number;
|
|
messageSender: string;
|
|
}) => void;
|
|
onDeleteSelectedMessages: () => Promise<void>;
|
|
}
|
|
|
|
export class SessionMessagesList extends React.Component<Props, State> {
|
|
private readonly messageContainerRef: React.RefObject<any>;
|
|
private scrollOffsetBottomPx: number = Number.MAX_VALUE;
|
|
private ignoreScrollEvents: boolean;
|
|
private timeoutResetQuotedScroll: NodeJS.Timeout | null = null;
|
|
|
|
public constructor(props: Props) {
|
|
super(props);
|
|
|
|
this.state = {
|
|
showScrollButton: false,
|
|
nextMessageToPlay: null,
|
|
};
|
|
autoBind(this);
|
|
|
|
this.messageContainerRef = this.props.messageContainerRef;
|
|
this.ignoreScrollEvents = true;
|
|
}
|
|
|
|
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// ~~~~~~~~~~~~~~~~ LIFECYCLES ~~~~~~~~~~~~~~~~
|
|
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
|
|
public componentDidMount() {
|
|
// Pause thread to wait for rendering to complete
|
|
setTimeout(this.scrollToUnread, 0);
|
|
}
|
|
|
|
public componentWillUnmount() {
|
|
if (this.timeoutResetQuotedScroll) {
|
|
clearTimeout(this.timeoutResetQuotedScroll);
|
|
}
|
|
}
|
|
|
|
public componentDidUpdate(prevProps: Props, _prevState: State) {
|
|
const isSameConvo = prevProps.conversationKey === this.props.conversationKey;
|
|
const messageLengthChanged = prevProps.messages.length !== this.props.messages.length;
|
|
if (!isSameConvo || (prevProps.messages.length === 0 && this.props.messages.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.setState(
|
|
{
|
|
showScrollButton: false,
|
|
animateQuotedMessageId: undefined,
|
|
},
|
|
this.scrollToUnread
|
|
);
|
|
} 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.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, messages } = this.props;
|
|
const { showScrollButton } = this.state;
|
|
|
|
let displayedName = null;
|
|
if (conversation.type === ConversationTypeEnum.PRIVATE) {
|
|
displayedName = getConversationController().getContactProfileNameOrShortenedPubKey(
|
|
conversationKey
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="messages-container"
|
|
onScroll={this.handleScroll}
|
|
ref={this.messageContainerRef}
|
|
>
|
|
<TypingBubble
|
|
phoneNumber={conversationKey}
|
|
conversationType={conversation.type}
|
|
displayedName={displayedName}
|
|
isTyping={conversation.isTyping}
|
|
/>
|
|
|
|
{this.renderMessages(messages)}
|
|
|
|
<SessionScrollButton show={showScrollButton} onClick={this.scrollToBottom} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
private displayUnreadBannerIndex(messages: Array<MessageModel>) {
|
|
const { conversation } = this.props;
|
|
if (conversation.unreadCount === 0) {
|
|
return -1;
|
|
}
|
|
// conversation.unreadCount is the number of messages we incoming we did not read yet.
|
|
// also, unreacCount is updated only when the conversation is marked as read.
|
|
// So we can have an unreadCount for the conversation not correct based on the real number of unread messages.
|
|
// some of the messages we have in "messages" are ones we sent ourself (or from another device).
|
|
// those messages should not be counted to display the unread banner.
|
|
|
|
let findFirstUnreadIndex = -1;
|
|
let incomingMessagesSoFar = 0;
|
|
const { unreadCount } = conversation;
|
|
|
|
// Basically, count the number of incoming messages from the most recent one.
|
|
for (let index = 0; index <= messages.length - 1; index++) {
|
|
const message = messages[index];
|
|
if (message.attributes.type === 'incoming') {
|
|
incomingMessagesSoFar++;
|
|
// message.attributes.unread is !== undefined if the message is unread.
|
|
if (message.attributes.unread !== undefined && incomingMessagesSoFar >= unreadCount) {
|
|
findFirstUnreadIndex = index;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
//
|
|
if (findFirstUnreadIndex === -1 && conversation.unreadCount >= 0) {
|
|
return conversation.unreadCount - 1;
|
|
}
|
|
return findFirstUnreadIndex;
|
|
}
|
|
|
|
private renderMessages(messages: Array<MessageModel>) {
|
|
const { conversation, ourPrimary, selectedMessages } = this.props;
|
|
const multiSelectMode = Boolean(selectedMessages.length);
|
|
let currentMessageIndex = 0;
|
|
let playableMessageIndex = 0;
|
|
const displayUnreadBannerIndex = this.displayUnreadBannerIndex(messages);
|
|
|
|
return (
|
|
<>
|
|
{messages.map((message: MessageModel) => {
|
|
const messageProps = message.propsForMessage;
|
|
|
|
const timerProps = message.propsForTimerNotification;
|
|
const propsForGroupInvitation = message.propsForGroupInvitation;
|
|
const propsForDataExtractionNotification = message.propsForDataExtractionNotification;
|
|
|
|
const groupNotificationProps = message.propsForGroupNotification;
|
|
|
|
// IF there are some unread messages
|
|
// AND we found the last read message
|
|
// AND we are not scrolled all the way to the bottom
|
|
// THEN, show the unread banner for the current message
|
|
const showUnreadIndicator =
|
|
displayUnreadBannerIndex >= 0 &&
|
|
currentMessageIndex === displayUnreadBannerIndex &&
|
|
this.getScrollOffsetBottomPx() !== 0;
|
|
const unreadIndicator = (
|
|
<SessionLastSeenIndicator
|
|
count={displayUnreadBannerIndex + 1} // count is used for the 118n of the string
|
|
show={showUnreadIndicator}
|
|
key={`unread-indicator-${message.id}`}
|
|
/>
|
|
);
|
|
|
|
currentMessageIndex = currentMessageIndex + 1;
|
|
|
|
if (groupNotificationProps) {
|
|
return (
|
|
<>
|
|
<GroupNotification {...groupNotificationProps} key={message.id} />
|
|
{unreadIndicator}
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (propsForGroupInvitation) {
|
|
return (
|
|
<>
|
|
<GroupInvitation {...propsForGroupInvitation} key={message.id} />
|
|
{unreadIndicator}
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (propsForDataExtractionNotification) {
|
|
return (
|
|
<>
|
|
<DataExtractionNotification
|
|
{...propsForDataExtractionNotification}
|
|
key={message.id}
|
|
/>
|
|
{unreadIndicator}
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (timerProps) {
|
|
return (
|
|
<>
|
|
<TimerNotification {...timerProps} key={message.id} />
|
|
{unreadIndicator}
|
|
</>
|
|
);
|
|
}
|
|
if (!messageProps) {
|
|
return;
|
|
}
|
|
|
|
if (messageProps) {
|
|
messageProps.nextMessageToPlay = this.state.nextMessageToPlay;
|
|
messageProps.playableMessageIndex = playableMessageIndex;
|
|
messageProps.playNextMessage = this.playNextMessage;
|
|
}
|
|
playableMessageIndex++;
|
|
|
|
if (messageProps.conversationType === ConversationTypeEnum.GROUP) {
|
|
messageProps.weAreAdmin = conversation.groupAdmins?.includes(ourPrimary);
|
|
}
|
|
// a message is deletable if
|
|
// either we sent it,
|
|
// or the convo is not a public one (in this case, we will only be able to delete for us)
|
|
// or the convo is public and we are an admin
|
|
const isDeletable =
|
|
messageProps.authorPhoneNumber === this.props.ourPrimary ||
|
|
!conversation.isPublic ||
|
|
(conversation.isPublic && !!messageProps.weAreAdmin);
|
|
|
|
messageProps.isDeletable = isDeletable;
|
|
messageProps.isAdmin = conversation.groupAdmins?.includes(messageProps.authorPhoneNumber);
|
|
|
|
// firstMessageOfSeries tells us to render the avatar only for the first message
|
|
// in a series of messages from the same user
|
|
return (
|
|
<>
|
|
{this.renderMessage(
|
|
messageProps,
|
|
messageProps.firstMessageOfSeries,
|
|
multiSelectMode,
|
|
message
|
|
)}
|
|
{unreadIndicator}
|
|
</>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
private renderMessage(
|
|
messageProps: MessageRegularProps,
|
|
firstMessageOfSeries: boolean,
|
|
multiSelectMode: boolean,
|
|
message: MessageModel
|
|
) {
|
|
const selected = !!messageProps?.id && this.props.selectedMessages.includes(messageProps.id);
|
|
|
|
messageProps.selected = selected;
|
|
messageProps.firstMessageOfSeries = firstMessageOfSeries;
|
|
|
|
messageProps.multiSelectMode = multiSelectMode;
|
|
messageProps.onSelectMessage = this.props.selectMessage;
|
|
messageProps.onDeleteMessage = this.props.deleteMessage;
|
|
messageProps.onReply = this.props.replyToMessage;
|
|
messageProps.onShowDetail = async () => {
|
|
const messageDetailsProps = await message.getPropsForMessageDetail();
|
|
this.props.showMessageDetails(messageDetailsProps);
|
|
};
|
|
|
|
messageProps.onClickAttachment = (attachment: AttachmentType) => {
|
|
this.props.onClickAttachment(attachment, messageProps);
|
|
};
|
|
messageProps.onDownload = (attachment: AttachmentType) => {
|
|
this.props.onDownloadAttachment({
|
|
attachment,
|
|
messageTimestamp: messageProps.timestamp,
|
|
messageSender: messageProps.authorPhoneNumber,
|
|
});
|
|
};
|
|
|
|
messageProps.isQuotedMessageToAnimate = messageProps.id === this.state.animateQuotedMessageId;
|
|
|
|
if (messageProps.quote) {
|
|
messageProps.quote.onClick = (options: {
|
|
quoteAuthor: string;
|
|
quoteId: any;
|
|
referencedMessageNotFound: boolean;
|
|
}) => {
|
|
void this.scrollToQuoteMessage(options);
|
|
};
|
|
}
|
|
|
|
return <Message {...messageProps} key={messageProps.id} />;
|
|
}
|
|
|
|
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// ~~~~~~~~~~~~~ MESSAGE HANDLING ~~~~~~~~~~~~~
|
|
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
private updateReadMessages() {
|
|
const { messages, conversationKey } = this.props;
|
|
|
|
if (!messages || messages.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const conversation = getConversationController().getOrThrow(conversationKey);
|
|
|
|
if (conversation.isBlocked()) {
|
|
return;
|
|
}
|
|
|
|
if (this.ignoreScrollEvents) {
|
|
return;
|
|
}
|
|
|
|
if (this.getScrollOffsetBottomPx() === 0 && window.isFocused()) {
|
|
void conversation.markRead(messages[0].attributes.received_at);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the targeted index for the next
|
|
* @param index index of message that just completed
|
|
*/
|
|
private readonly playNextMessage = (index: any) => {
|
|
const { messages } = this.props;
|
|
let nextIndex: number | null = index - 1;
|
|
|
|
// to prevent autoplaying as soon as a message is received.
|
|
const latestMessagePlayed = index <= 0 || messages.length < index - 1;
|
|
if (latestMessagePlayed) {
|
|
nextIndex = null;
|
|
this.setState({
|
|
nextMessageToPlay: nextIndex,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// stop auto-playing when the audio messages change author.
|
|
const prevAuthorNumber = messages[index].propsForMessage.authorPhoneNumber;
|
|
const nextAuthorNumber = messages[index - 1].propsForMessage.authorPhoneNumber;
|
|
const differentAuthor = prevAuthorNumber !== nextAuthorNumber;
|
|
if (differentAuthor) {
|
|
nextIndex = null;
|
|
}
|
|
|
|
this.setState({
|
|
nextMessageToPlay: nextIndex,
|
|
});
|
|
};
|
|
|
|
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// ~~~~~~~~~~~~ SCROLLING METHODS ~~~~~~~~~~~~~
|
|
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
private async handleScroll() {
|
|
const messageContainer = this.messageContainerRef?.current;
|
|
|
|
const { fetchMessagesForConversation, conversationKey } = this.props;
|
|
if (!messageContainer) {
|
|
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.state.showScrollButton) {
|
|
this.setState({ showScrollButton: true });
|
|
}
|
|
// Scroll button disappears if you're more less than 40% scrolled up
|
|
if (scrollOffsetPc < scrollButtonViewHideLimit && this.state.showScrollButton) {
|
|
this.setState({ showScrollButton: 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 shouldFetchMoreMessages = scrollTop <= Constants.UI.MESSAGE_CONTAINER_BUFFER_OFFSET_PX;
|
|
|
|
if (shouldFetchMoreMessages) {
|
|
const { messages } = this.props;
|
|
const numMessages =
|
|
this.props.messages.length + Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT;
|
|
const oldLen = messages.length;
|
|
const previousTopMessage = messages[oldLen - 1]?.id;
|
|
|
|
fetchMessagesForConversation({ conversationKey, count: numMessages });
|
|
if (previousTopMessage && oldLen !== messages.length) {
|
|
this.scrollToMessage(previousTopMessage);
|
|
}
|
|
}
|
|
}
|
|
|
|
private scrollToUnread() {
|
|
const { messages, conversation } = this.props;
|
|
if (conversation.unreadCount > 0) {
|
|
let message;
|
|
if (messages.length > conversation.unreadCount) {
|
|
// if we have enough message to show one more message, show one more to include the unread banner
|
|
message = messages[conversation.unreadCount - 1];
|
|
} else {
|
|
message = messages[conversation.unreadCount - 1];
|
|
}
|
|
|
|
if (message) {
|
|
this.scrollToMessage(message.id);
|
|
}
|
|
}
|
|
|
|
if (this.ignoreScrollEvents && messages.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.state.animateQuotedMessageId !== undefined) {
|
|
this.timeoutResetQuotedScroll = global.setTimeout(() => {
|
|
this.setState({ animateQuotedMessageId: undefined });
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
private scrollToMessage(messageId: string, smooth: boolean = false) {
|
|
const topUnreadMessage = document.getElementById(messageId);
|
|
topUnreadMessage?.scrollIntoView({
|
|
behavior: smooth ? 'smooth' : '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) {
|
|
this.setState(
|
|
{ animateQuotedMessageId: messageId },
|
|
this.setupTimeoutResetQuotedHighlightedMessage
|
|
);
|
|
}
|
|
|
|
const messageContainer = this.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.messageContainerRef.current;
|
|
if (!messageContainer) {
|
|
return;
|
|
}
|
|
messageContainer.scrollTop = messageContainer.scrollHeight - messageContainer.clientHeight;
|
|
this.updateReadMessages();
|
|
}
|
|
|
|
private async scrollToQuoteMessage(options: any = {}) {
|
|
const { quoteAuthor, quoteId, referencedMessageNotFound } = options;
|
|
|
|
// 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 = this.props.messages.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.propsForMessage?.authorPhoneNumber;
|
|
|
|
return messageAuthor && quoteAuthor === messageAuthor;
|
|
})
|
|
);
|
|
|
|
if (found) {
|
|
ToastUtils.pushFoundButNotLoaded();
|
|
} else {
|
|
ToastUtils.pushOriginalNoLongerAvailable();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const databaseId = targetMessage.id;
|
|
this.scrollToMessage(databaseId, true);
|
|
}
|
|
|
|
// basically the offset in px from the bottom of the view (most recent message)
|
|
private getScrollOffsetBottomPx() {
|
|
const messageContainer = this.messageContainerRef?.current;
|
|
|
|
if (!messageContainer) {
|
|
return Number.MAX_VALUE;
|
|
}
|
|
|
|
const scrollTop = messageContainer.scrollTop;
|
|
const scrollHeight = messageContainer.scrollHeight;
|
|
const clientHeight = messageContainer.clientHeight;
|
|
return scrollHeight - scrollTop - clientHeight;
|
|
}
|
|
}
|