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.
		
		
		
		
		
			
		
			
				
	
	
		
			501 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			501 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			TypeScript
		
	
import React from 'react';
 | 
						|
 | 
						|
import { Message } from '../../conversation/Message';
 | 
						|
import { TimerNotification } from '../../conversation/TimerNotification';
 | 
						|
 | 
						|
import { SessionScrollButton } from '../SessionScrollButton';
 | 
						|
import { ResetSessionNotification } from '../../conversation/ResetSessionNotification';
 | 
						|
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 { MessageModel } from '../../../../js/models/messages';
 | 
						|
import { SessionLastSeenIndicator } from './SessionLastSeedIndicator';
 | 
						|
import { VerificationNotification } from '../../conversation/VerificationNotification';
 | 
						|
import { ToastUtils } from '../../../session/utils';
 | 
						|
 | 
						|
interface State {
 | 
						|
  isScrolledToBottom: boolean;
 | 
						|
  showScrollButton: boolean;
 | 
						|
  doneInitialScroll: boolean;
 | 
						|
}
 | 
						|
 | 
						|
interface Props {
 | 
						|
  selectedMessages: Array<string>;
 | 
						|
  conversationKey: string;
 | 
						|
  messages: Array<MessageModel>;
 | 
						|
  conversation: ConversationType;
 | 
						|
  messageContainerRef: React.RefObject<any>;
 | 
						|
  selectMessage: (messageId: string) => void;
 | 
						|
  getMessages: (numMessages: number) => Promise<void>;
 | 
						|
  replyToMessage: (messageId: number) => Promise<void>;
 | 
						|
  onClickAttachment: (attachment: any, message: any) => void;
 | 
						|
  onDownloadAttachment: ({ attachment }: { attachment: any }) => void;
 | 
						|
  onDeleteSelectedMessages: () => Promise<void>;
 | 
						|
}
 | 
						|
 | 
						|
export class SessionConversationMessagesList extends React.Component<
 | 
						|
  Props,
 | 
						|
  State
 | 
						|
> {
 | 
						|
  private readonly messagesEndRef: React.RefObject<HTMLDivElement>;
 | 
						|
  private readonly messageContainerRef: React.RefObject<any>;
 | 
						|
 | 
						|
  public constructor(props: Props) {
 | 
						|
    super(props);
 | 
						|
 | 
						|
    this.state = {
 | 
						|
      isScrolledToBottom: false,
 | 
						|
      showScrollButton: false,
 | 
						|
      doneInitialScroll: false,
 | 
						|
    };
 | 
						|
    this.renderMessage = this.renderMessage.bind(this);
 | 
						|
    this.handleScroll = this.handleScroll.bind(this);
 | 
						|
    this.scrollToUnread = this.scrollToUnread.bind(this);
 | 
						|
    this.scrollToBottom = this.scrollToBottom.bind(this);
 | 
						|
    this.scrollToQuoteMessage = this.scrollToQuoteMessage.bind(this);
 | 
						|
 | 
						|
    this.messagesEndRef = React.createRef();
 | 
						|
    this.messageContainerRef = this.props.messageContainerRef;
 | 
						|
  }
 | 
						|
 | 
						|
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 | 
						|
  // ~~~~~~~~~~~~~~~~ LIFECYCLES ~~~~~~~~~~~~~~~~
 | 
						|
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 | 
						|
 | 
						|
  public componentDidMount() {
 | 
						|
    // Pause thread to wait for rendering to complete
 | 
						|
    setTimeout(this.scrollToUnread, 0);
 | 
						|
  }
 | 
						|
 | 
						|
  public componentDidUpdate(prevProps: Props, _prevState: State) {
 | 
						|
    if (
 | 
						|
      prevProps.conversationKey !== this.props.conversationKey ||
 | 
						|
      (prevProps.messages.length === 0 && this.props.messages.length !== 0)
 | 
						|
    ) {
 | 
						|
      // displayed conversation changed. We have a bit of cleaning to do here
 | 
						|
      this.setState(
 | 
						|
        {
 | 
						|
          isScrolledToBottom: false,
 | 
						|
          showScrollButton: false,
 | 
						|
          doneInitialScroll: false,
 | 
						|
        },
 | 
						|
        this.scrollToUnread
 | 
						|
      );
 | 
						|
    } else {
 | 
						|
      // Keep scrolled to bottom unless user scrolls up
 | 
						|
      if (this.state.isScrolledToBottom) {
 | 
						|
        this.scrollToBottom();
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  public render() {
 | 
						|
    const { messages } = this.props;
 | 
						|
    const { showScrollButton } = this.state;
 | 
						|
 | 
						|
    return (
 | 
						|
      <div
 | 
						|
        className="messages-container"
 | 
						|
        onScroll={this.handleScroll}
 | 
						|
        ref={this.messageContainerRef}
 | 
						|
      >
 | 
						|
        {this.renderMessages(messages)}
 | 
						|
        <div ref={this.messagesEndRef} />
 | 
						|
 | 
						|
        <SessionScrollButton
 | 
						|
          show={showScrollButton}
 | 
						|
          onClick={this.scrollToBottom}
 | 
						|
        />
 | 
						|
      </div>
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  public renderMessages(messages: Array<MessageModel>) {
 | 
						|
    const { isScrolledToBottom } = this.state;
 | 
						|
    const { conversation } = this.props;
 | 
						|
 | 
						|
    const multiSelectMode = Boolean(this.props.selectedMessages.length);
 | 
						|
    let currentMessageIndex = 0;
 | 
						|
    // find the first unread message in the list of messages. We use this to display the
 | 
						|
    // unread banner so this is at all times at the correct index.
 | 
						|
    // Our messages are marked read, so be sure to skip those.
 | 
						|
    let findFirstUnreadIndex = messages.findIndex(
 | 
						|
      message =>
 | 
						|
        !(
 | 
						|
          message?.attributes?.unread && message?.attributes?.unread !== false
 | 
						|
        ) && message?.attributes?.type === 'incoming'
 | 
						|
    );
 | 
						|
    // if we did not find an unread messsages, but the conversation has some,
 | 
						|
    // we must not have enough messages in memory, so just display the unread banner
 | 
						|
    // at the top of the screen
 | 
						|
    if (findFirstUnreadIndex === -1 && conversation.unreadCount !== 0) {
 | 
						|
      findFirstUnreadIndex = messages.length - 1;
 | 
						|
    }
 | 
						|
    return (
 | 
						|
      <>
 | 
						|
        {messages.map((message: MessageModel) => {
 | 
						|
          const messageProps = message.propsForMessage;
 | 
						|
 | 
						|
          const timerProps = message.propsForTimerNotification;
 | 
						|
          const resetSessionProps = message.propsForResetSessionNotification;
 | 
						|
          const verificationSessionProps =
 | 
						|
            message.propsForVerificationNotification;
 | 
						|
          const propsForGroupInvitation = message.propsForGroupInvitation;
 | 
						|
 | 
						|
          const groupNotificationProps = message.propsForGroupNotification;
 | 
						|
 | 
						|
          // if there are some unread messages
 | 
						|
          const showUnreadIndicator =
 | 
						|
            !isScrolledToBottom &&
 | 
						|
            findFirstUnreadIndex > 0 &&
 | 
						|
            currentMessageIndex === findFirstUnreadIndex;
 | 
						|
          const unreadIndicator = (
 | 
						|
            <SessionLastSeenIndicator
 | 
						|
              count={findFirstUnreadIndex} // count is used for the 118n of the string
 | 
						|
              show={showUnreadIndicator}
 | 
						|
            />
 | 
						|
          );
 | 
						|
 | 
						|
          currentMessageIndex = currentMessageIndex + 1;
 | 
						|
 | 
						|
          if (groupNotificationProps) {
 | 
						|
            return (
 | 
						|
              <>
 | 
						|
                {unreadIndicator}
 | 
						|
                <GroupNotification {...groupNotificationProps} />
 | 
						|
              </>
 | 
						|
            );
 | 
						|
          }
 | 
						|
 | 
						|
          if (propsForGroupInvitation) {
 | 
						|
            return (
 | 
						|
              <>
 | 
						|
                {unreadIndicator}
 | 
						|
                <GroupInvitation {...propsForGroupInvitation} />
 | 
						|
              </>
 | 
						|
            );
 | 
						|
          }
 | 
						|
 | 
						|
          if (verificationSessionProps) {
 | 
						|
            return (
 | 
						|
              <>
 | 
						|
                {unreadIndicator}
 | 
						|
                <VerificationNotification {...verificationSessionProps} />
 | 
						|
              </>
 | 
						|
            );
 | 
						|
          }
 | 
						|
 | 
						|
          if (resetSessionProps) {
 | 
						|
            return (
 | 
						|
              <>
 | 
						|
                {unreadIndicator}
 | 
						|
                <ResetSessionNotification {...resetSessionProps} />
 | 
						|
              </>
 | 
						|
            );
 | 
						|
          }
 | 
						|
 | 
						|
          if (timerProps) {
 | 
						|
            return (
 | 
						|
              <>
 | 
						|
                {unreadIndicator}
 | 
						|
                <TimerNotification {...timerProps} />
 | 
						|
              </>
 | 
						|
            );
 | 
						|
          }
 | 
						|
 | 
						|
          // firstMessageOfSeries tells us to render the avatar only for the first message
 | 
						|
          // in a series of messages from the same user
 | 
						|
          return (
 | 
						|
            <>
 | 
						|
              {unreadIndicator}
 | 
						|
              {this.renderMessage(
 | 
						|
                messageProps,
 | 
						|
                message.firstMessageOfSeries,
 | 
						|
                multiSelectMode
 | 
						|
              )}
 | 
						|
            </>
 | 
						|
          );
 | 
						|
        })}
 | 
						|
      </>
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  public renderMessage(
 | 
						|
    messageProps: any,
 | 
						|
    firstMessageOfSeries: boolean,
 | 
						|
    multiSelectMode: boolean
 | 
						|
  ) {
 | 
						|
    const selected =
 | 
						|
      !!messageProps?.id &&
 | 
						|
      this.props.selectedMessages.includes(messageProps.id);
 | 
						|
 | 
						|
    messageProps.i18n = window.i18n;
 | 
						|
    messageProps.selected = selected;
 | 
						|
    messageProps.firstMessageOfSeries = firstMessageOfSeries;
 | 
						|
    messageProps.multiSelectMode = multiSelectMode;
 | 
						|
    messageProps.onSelectMessage = (messageId: string) => {
 | 
						|
      this.selectMessage(messageId);
 | 
						|
    };
 | 
						|
 | 
						|
    messageProps.onReply = (messageId: number) => {
 | 
						|
      void this.props.replyToMessage(messageId);
 | 
						|
    };
 | 
						|
 | 
						|
    messageProps.onClickAttachment = (attachment: any) => {
 | 
						|
      this.props.onClickAttachment(attachment, messageProps);
 | 
						|
    };
 | 
						|
    messageProps.onDownload = (attachment: AttachmentType) => {
 | 
						|
      this.props.onDownloadAttachment({ attachment });
 | 
						|
    };
 | 
						|
 | 
						|
    if (messageProps.quote) {
 | 
						|
      messageProps.quote.onClick = (options: {
 | 
						|
        quoteAuthor: string;
 | 
						|
        quoteId: any;
 | 
						|
        referencedMessageNotFound: boolean;
 | 
						|
      }) => {
 | 
						|
        void this.scrollToQuoteMessage(options);
 | 
						|
      };
 | 
						|
    }
 | 
						|
 | 
						|
    return <Message {...messageProps} />;
 | 
						|
  }
 | 
						|
 | 
						|
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 | 
						|
  // ~~~~~~~~~~~~~ MESSAGE HANDLING ~~~~~~~~~~~~~
 | 
						|
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 | 
						|
  public updateReadMessages() {
 | 
						|
    const { messages, conversationKey } = this.props;
 | 
						|
    const { isScrolledToBottom } = this.state;
 | 
						|
 | 
						|
    if (!messages || messages.length === 0) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    const conversation = window.ConversationController.getOrThrow(
 | 
						|
      conversationKey
 | 
						|
    );
 | 
						|
 | 
						|
    if (conversation.isBlocked()) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    if (isScrolledToBottom) {
 | 
						|
      void conversation.markRead(messages[0].attributes.received_at);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 | 
						|
  // ~~~~~~~~~~~~ SCROLLING METHODS ~~~~~~~~~~~~~
 | 
						|
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 | 
						|
  public async handleScroll() {
 | 
						|
    const messageContainer = this.messageContainerRef?.current;
 | 
						|
    if (!messageContainer) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
    contextMenu.hideAll();
 | 
						|
 | 
						|
    if (!this.state.doneInitialScroll) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    const scrollTop = messageContainer.scrollTop;
 | 
						|
    const scrollHeight = messageContainer.scrollHeight;
 | 
						|
    const clientHeight = messageContainer.clientHeight;
 | 
						|
 | 
						|
    const scrollButtonViewShowLimit = 0.75;
 | 
						|
    const scrollButtonViewHideLimit = 0.4;
 | 
						|
    const scrollOffsetPx = scrollHeight - scrollTop - clientHeight;
 | 
						|
    const scrollOffsetPc = scrollOffsetPx / 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 = scrollOffsetPc === 0;
 | 
						|
 | 
						|
    // Pin scroll to bottom on new message, unless user has scrolled up
 | 
						|
    if (this.state.isScrolledToBottom !== isScrolledToBottom) {
 | 
						|
      this.setState({ 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;
 | 
						|
 | 
						|
      await this.props.getMessages(numMessages);
 | 
						|
      if (previousTopMessage && oldLen !== messages.length) {
 | 
						|
        this.scrollToMessage(previousTopMessage);
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  public 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];
 | 
						|
      } else {
 | 
						|
        message = messages[conversation.unreadCount - 1];
 | 
						|
      }
 | 
						|
 | 
						|
      if (message) {
 | 
						|
        this.scrollToMessage(message.id);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    if (!this.state.doneInitialScroll) {
 | 
						|
      this.setState(
 | 
						|
        {
 | 
						|
          doneInitialScroll: true,
 | 
						|
        },
 | 
						|
        () => {
 | 
						|
          this.updateReadMessages();
 | 
						|
        }
 | 
						|
      );
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  public scrollToMessage(messageId: string) {
 | 
						|
    const topUnreadMessage = document.getElementById(messageId);
 | 
						|
    topUnreadMessage?.scrollIntoView();
 | 
						|
 | 
						|
    // if the scroll container is not scrollable as it's not tall enough, we have to update
 | 
						|
    // the isScrollToBottom ourself
 | 
						|
 | 
						|
    const messageContainer = this.messageContainerRef.current;
 | 
						|
    if (!messageContainer) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    const scrollTop = messageContainer.scrollTop;
 | 
						|
    const scrollHeight = messageContainer.scrollHeight;
 | 
						|
    const clientHeight = messageContainer.clientHeight;
 | 
						|
 | 
						|
    const scrollOffsetPx = scrollHeight - scrollTop - clientHeight;
 | 
						|
    const scrollOffsetPc = scrollOffsetPx / clientHeight;
 | 
						|
 | 
						|
    if (scrollOffsetPc === 0 && this.state.doneInitialScroll) {
 | 
						|
      this.setState({ isScrolledToBottom: true });
 | 
						|
    } else if (
 | 
						|
      scrollHeight !== 0 &&
 | 
						|
      scrollHeight === clientHeight &&
 | 
						|
      !this.state.isScrolledToBottom
 | 
						|
    ) {
 | 
						|
      this.setState({ isScrolledToBottom: true }, () => {
 | 
						|
        // Mark messages read
 | 
						|
        this.updateReadMessages();
 | 
						|
      });
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  public scrollToBottom() {
 | 
						|
    // FIXME VINCE: Smooth scrolling that isn't slow@!
 | 
						|
    // this.messagesEndRef.current?.scrollIntoView(
 | 
						|
    //   { behavior: firstLoad ? 'auto' : 'smooth' }
 | 
						|
    // );
 | 
						|
 | 
						|
    const messageContainer = this.messageContainerRef.current;
 | 
						|
    if (!messageContainer) {
 | 
						|
      return;
 | 
						|
    }
 | 
						|
    messageContainer.scrollTop =
 | 
						|
      messageContainer.scrollHeight - messageContainer.clientHeight;
 | 
						|
    this.updateReadMessages();
 | 
						|
  }
 | 
						|
 | 
						|
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 | 
						|
  // ~~~~~~~~~~~~ MESSAGE SELECTION ~~~~~~~~~~~~~
 | 
						|
  // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 | 
						|
  public selectMessage(messageId: string) {
 | 
						|
    this.props.selectMessage(messageId);
 | 
						|
  }
 | 
						|
 | 
						|
  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 window.Signal.Data.getMessagesBySentAt(quoteId, {
 | 
						|
        MessageCollection: window.Whisper.MessageCollection,
 | 
						|
      });
 | 
						|
      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;
 | 
						|
    // const el = this.$(`#${databaseId}`);
 | 
						|
    // if (!el || el.length === 0) {
 | 
						|
    //   ToastUtils.pushOriginalNoLongerAvailable();
 | 
						|
    //   window.log.info(
 | 
						|
    //     `Error: had target message ${id} in messageCollection, but it was not in DOM`
 | 
						|
    //   );
 | 
						|
    //   return;
 | 
						|
    // }
 | 
						|
    // this probably does not work for us as we need to call getMessages before
 | 
						|
    this.scrollToMessage(databaseId);
 | 
						|
  }
 | 
						|
}
 |