import React from 'react'; import classNames from 'classnames'; import { ConversationHeader } from '../../conversation/ConversationHeader'; import { SessionCompositionBox } from './SessionCompositionBox'; import { SessionProgress } from '../SessionProgress' import { Message } from '../../conversation/Message'; import { FriendRequest } from '../../conversation/FriendRequest'; import { TimerNotification } from '../../conversation/TimerNotification'; import { getTimestamp } from './SessionConversationManager'; import { SessionScrollButton } from '../SessionScrollButton'; interface State { conversationKey: string; sendingProgess: number; prevSendingProgess: number; unreadCount: number; messages: Array<any>; selectedMessages: Array<string>; isScrolledToBottom: boolean; doneInitialScroll: boolean; displayScrollToBottomButton: boolean; messageFetchTimestamp: number; showRecordingView: boolean; } export class SessionConversation extends React.Component<any, State> { private messagesEndRef: React.RefObject<HTMLDivElement>; private messageContainerRef: React.RefObject<HTMLDivElement>; constructor(props: any) { super(props); console.log(`[conv] Props:`, props); const conversationKey = this.props.conversations.selectedConversation; const conversation = this.props.conversations.conversationLookup[conversationKey]; const unreadCount = conversation.unreadCount; this.state = { sendingProgess: 0, prevSendingProgess: 0, conversationKey, unreadCount, messages: [], selectedMessages: [], isScrolledToBottom: !unreadCount, doneInitialScroll: false, displayScrollToBottomButton: false, messageFetchTimestamp: 0, showRecordingView: false, }; this.handleScroll = this.handleScroll.bind(this); this.scrollToUnread = this.scrollToUnread.bind(this); this.scrollToBottom = this.scrollToBottom.bind(this); this.renderMessage = this.renderMessage.bind(this); this.renderTimerNotification = this.renderTimerNotification.bind(this); this.renderFriendRequest = this.renderFriendRequest.bind(this); // Recording View render and unrender this.onLoadVoiceNoteView = this.onLoadVoiceNoteView.bind(this); this.onExitVoiceNoteView = this.onExitVoiceNoteView.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.selectMessage = this.selectMessage.bind(this); this.resetSelection = this.resetSelection.bind(this); this.messagesEndRef = React.createRef(); this.messageContainerRef = React.createRef(); } public componentDidMount() { this.getMessages().then(() => { // Pause thread to wait for rendering to complete setTimeout(() => { this.scrollToUnread(); }, 0); setTimeout(() => { this.setState({ doneInitialScroll: true, }); }, 100); }); } public componentDidUpdate(){ // Keep scrolled to bottom unless user scrolls up if (this.state.isScrolledToBottom){ this.scrollToBottom(); } console.log(`[update] Props: `, this.props); } public async componentWillReceiveProps() { const timestamp = getTimestamp(); // If we have pulled messages in the last second, don't bother rescanning // This avoids getting messages on every re-render. if (timestamp > this.state.messageFetchTimestamp) { await this.getMessages(); } } public render() { console.log(`[vince][info] Props`, this.props); const { messages, conversationKey, doneInitialScroll, showRecordingView } = this.state; const loading = !doneInitialScroll || messages.length === 0; const selectionMode = !!this.state.selectedMessages.length; const conversation = this.props.conversations.conversationLookup[conversationKey]; const conversationModel = window.getConversationByKey(conversationKey); const isRss = conversation.isRss; const sendMessageFn = conversationModel.sendMessage.bind(conversationModel); return ( <div className={classNames('conversation-item', selectionMode && 'selection-mode')} tabIndex={0} onKeyDown={this.onKeyDown} > <div className="conversation-header"> {this.renderHeader()} </div> <SessionProgress visible={true} value={this.state.sendingProgess} prevValue={this.state.prevSendingProgess} /> <div className="messages-wrapper"> { loading && ( <div className="messages-container__loading"></div> )} <div className="messages-container" onScroll={this.handleScroll} ref={this.messageContainerRef} > {this.renderMessages()} <div ref={this.messagesEndRef} /> </div> <SessionScrollButton display={true} onClick={this.scrollToBottom}/> { showRecordingView && ( <div className="messages-wrapper--blocking-overlay"></div> )} </div> { !isRss && ( <SessionCompositionBox sendMessage={sendMessageFn} onLoadVoiceNoteView={this.onLoadVoiceNoteView} onExitVoiceNoteView={this.onExitVoiceNoteView} /> )} </div> ); } public renderMessages() { const { messages } = this.state; // FIXME VINCE: IF MESSAGE IS THE TOP OF UNREAD, THEN INSERT AN UNREAD BANNER return ( <>{ messages.map((message: any) => { const messageProps = message.propsForMessage; const timerProps = message.propsForTimerNotification; const friendRequestProps = message.propsForFriendRequest; const attachmentProps = message.propsForAttachment; const groupNotificationProps = message.propsForGroupNotification; const quoteProps = message.propsForQuote; let item; // firstMessageOfSeries tells us to render the avatar only for the first message // in a series of messages from the same user item = messageProps ? this.renderMessage(messageProps, message.firstMessageOfSeries) : item; item = timerProps ? this.renderTimerNotification(timerProps) : item; item = quoteProps ? this.renderMessage(timerProps, message.firstMessageOfSeries, quoteProps) : item; item = friendRequestProps ? this.renderFriendRequest(friendRequestProps): item; // item = attachmentProps ? this.renderMessage(timerProps) : item; return item; }) }</> ); } public renderHeader() { const headerProps = this.getHeaderProps(); console.log(`[header] Headerprops: `, headerProps); return ( <ConversationHeader id={headerProps.id} phoneNumber={headerProps.phoneNumber} isVerified={headerProps.isVerified} isMe={headerProps.isMe} isFriend={headerProps.isFriend} i18n={window.i18n} isGroup={headerProps.isGroup} isArchived={headerProps.isArchived} isPublic={headerProps.isPublic} isRss={headerProps.isRss} amMod={headerProps.amMod} members={headerProps.members} showBackButton={headerProps.showBackButton} timerOptions={headerProps.timerOptions} isBlocked={headerProps.isBlocked} hasNickname={headerProps.hasNickname} isFriendRequestPending={headerProps.isFriendRequestPending} isOnline={headerProps.isOnline} selectedMessages={headerProps.selectedMessages} onUpdateGroupName={headerProps.onUpdateGroupName} onSetDisappearingMessages={headerProps.onSetDisappearingMessages} onDeleteMessages={headerProps.onDeleteMessages} onDeleteContact={headerProps.onDeleteContact} onResetSession={headerProps.onResetSession} onCloseOverlay={headerProps.onCloseOverlay} onDeleteSelectedMessages={headerProps.onDeleteSelectedMessages} onArchive={headerProps.onArchive} onMoveToInbox={headerProps.onMoveToInbox} onShowSafetyNumber={headerProps.onShowSafetyNumber} onShowAllMedia={headerProps.onShowAllMedia} onShowGroupMembers={headerProps.onShowGroupMembers} onGoBack={headerProps.onGoBack} onBlockUser={headerProps.onBlockUser} onUnblockUser={headerProps.onUnblockUser} onClearNickname={headerProps.onClearNickname} onChangeNickname={headerProps.onChangeNickname} onCopyPublicKey={headerProps.onCopyPublicKey} onLeaveGroup={headerProps.onLeaveGroup} onAddModerators={headerProps.onAddModerators} onRemoveModerators={headerProps.onRemoveModerators} onInviteFriends={headerProps.onInviteFriends} /> ); } public renderMessage(messageProps: any, firstMessageOfSeries: boolean, quoteProps?: any) { const selected = !! messageProps?.id && this.state.selectedMessages.includes(messageProps.id); return ( <Message i18n = {window.i18n} text = {messageProps?.text} direction = {messageProps?.direction} selected = {selected} timestamp = {messageProps?.timestamp} attachments = {messageProps?.attachments} authorAvatarPath = {messageProps?.authorAvatarPath} authorColor = {messageProps?.authorColor} authorName = {messageProps?.authorName} authorPhoneNumber = {messageProps?.authorPhoneNumber} firstMessageOfSeries = {firstMessageOfSeries} authorProfileName = {messageProps?.authorProfileName} contact = {messageProps?.contact} conversationType = {messageProps?.conversationType} convoId = {messageProps?.convoId} expirationLength = {messageProps?.expirationLength} expirationTimestamp = {messageProps?.expirationTimestamp} id = {messageProps?.id} isDeletable = {messageProps?.isDeletable} isExpired = {messageProps?.isExpired} isModerator = {messageProps?.isModerator} isPublic = {messageProps?.isPublic} isRss = {messageProps?.isRss} multiSelectMode = {messageProps?.multiSelectMode} onBanUser = {messageProps?.onBanUser} onClickAttachment = {messageProps?.onClickAttachment} onClickLinkPreview = {messageProps?.onClickLinkPreview} onCopyPubKey = {messageProps?.onCopyPubKey} onCopyText = {messageProps?.onCopyText} onDelete = {messageProps?.onDelete} onDownload = {messageProps?.onDownload} onReply = {messageProps?.onReply} onRetrySend = {messageProps?.onRetrySend} onSelectMessage = {messageId => this.selectMessage(messageId)} onSelectMessageUnchecked = {messageProps?.onSelectMessageUnchecked} onShowDetail = {messageProps?.onShowDetail} onShowUserDetails = {messageProps?.onShowUserDetails} previews = {messageProps?.previews} quote = {quoteProps || undefined} senderIsModerator = {messageProps?.senderIsModerator} status = {messageProps?.status} textPending = {messageProps?.textPending} /> ); } public renderTimerNotification(timerProps: any) { return ( <TimerNotification type={timerProps.type} phoneNumber={timerProps.phoneNumber} profileName={timerProps.profileName} name={timerProps.name} disabled={timerProps.disabled} timespan={timerProps.timespan} i18n={window.i18n} /> ); } public renderFriendRequest(friendRequestProps: any){ return ( <FriendRequest text={friendRequestProps.text} direction={friendRequestProps.direction} status={friendRequestProps.status} friendStatus={friendRequestProps.friendStatus} i18n={window.i18n} isBlocked={friendRequestProps.isBlocked} timestamp={friendRequestProps.timestamp} onAccept={friendRequestProps.onAccept} onDecline={friendRequestProps.onDecline} onDeleteConversation={friendRequestProps.onDeleteConversation} onRetrySend={friendRequestProps.onRetrySend} onBlockUser={friendRequestProps.onBlockUser} onUnblockUser={friendRequestProps.onUnblockUser} /> ); } public async getMessages(numMessages?: number, fetchInterval = window.CONSTANTS.MESSAGE_FETCH_INTERVAL, loopback = false){ const { conversationKey, messageFetchTimestamp } = this.state; const timestamp = getTimestamp(); // If we have pulled messages in the last interval, don't bother rescanning // This avoids getting messages on every re-render. const timeBuffer = timestamp - messageFetchTimestamp; if (timeBuffer < fetchInterval) { // Loopback gets messages after time has elapsed, // rather than completely cancelling the fetch. // if (loopback) { // setTimeout(() => { // this.getMessages(numMessages, fetchInterval, false); // }, timeBuffer * 1000); // } return { newTopMessage: undefined, previousTopMessage: undefined }; } let msgCount = numMessages || window.CONSTANTS.DEFAULT_MESSAGE_FETCH_COUNT + this.state.unreadCount; msgCount = msgCount > window.CONSTANTS.MAX_MESSAGE_FETCH_COUNT ? window.CONSTANTS.MAX_MESSAGE_FETCH_COUNT : msgCount; const messageSet = await window.Signal.Data.getMessagesByConversation( conversationKey, { limit: msgCount, MessageCollection: window.Whisper.MessageCollection }, ); // Set first member of series here. const messageModels = messageSet.models; const messages = []; let previousSender; for (let i = 0; i < messageModels.length; i++){ // Handle firstMessageOfSeries for conditional avatar rendering let firstMessageOfSeries = true; if (i > 0 && previousSender === messageModels[i].authorPhoneNumber){ firstMessageOfSeries = false; } messages.push({...messageModels[i], firstMessageOfSeries}); previousSender = messageModels[i].authorPhoneNumber; } const previousTopMessage = this.state.messages[0]?.id; const newTopMessage = messages[0]?.id; this.setState({ messages, messageFetchTimestamp: timestamp }, () => { if (this.state.isScrolledToBottom) { this.updateReadMessages(); } }); return { newTopMessage, previousTopMessage }; } public updateReadMessages() { const { isScrolledToBottom, messages, conversationKey } = this.state; let unread; if (!messages || messages.length === 0) { return; } if (isScrolledToBottom) { unread = messages[messages.length - 1]; } else { unread = this.findNewestVisibleUnread(); } if (unread) { const model = window.ConversationController.get(conversationKey); model.markRead.bind(model)(unread.attributes.received_at); } } public findNewestVisibleUnread() { const messageContainer = this.messageContainerRef.current; if (!messageContainer) return null; const { messages, unreadCount } = this.state; const { length } = messages; const viewportBottom = (messageContainer?.clientHeight + messageContainer?.scrollTop) || 0; // Start with the most recent message, search backwards in time let foundUnread = 0; for (let i = length - 1; i >= 0; i -= 1) { // Search the latest 30, then stop if we believe we've covered all known // unread messages. The unread should be relatively recent. // Why? local notifications can be unread but won't be reflected the // conversation's unread count. if (i > 30 && foundUnread >= unreadCount) { return null; } const message = messages[i]; if (!message.attributes.unread) { // eslint-disable-next-line no-continue continue; } foundUnread += 1; const el = document.getElementById(`${message.id}`); if (!el) { // eslint-disable-next-line no-continue continue; } const top = el.offsetTop; // If the bottom fits on screen, we'll call it visible. Even if the // message is really tall. const height = el.offsetHeight; const bottom = top + height; // We're fully below the viewport, continue searching up. if (top > viewportBottom) { // eslint-disable-next-line no-continue continue; } if (bottom <= viewportBottom) { return message; } // Continue searching up. } return null; } public async handleScroll() { const messageContainer = this.messageContainerRef.current; if (!messageContainer) return; const isScrolledToBottom = messageContainer.scrollHeight - messageContainer.clientHeight <= messageContainer.scrollTop + 1; // FIXME VINCE: Update unread count // In models/conversations // Update unread count by geting all divs of .session-message // which are currently in view. this.updateReadMessages(); // Pin scroll to bottom on new message, unless user has scrolled up if (this.state.isScrolledToBottom !== isScrolledToBottom){ this.setState({ isScrolledToBottom }); } // Fetch more messages when nearing the top of the message list const shouldFetchMoreMessages = messageContainer.scrollTop <= window.CONSTANTS.MESSAGE_CONTAINER_BUFFER_OFFSET_PX; if (shouldFetchMoreMessages){ const numMessages = this.state.messages.length + window.CONSTANTS.DEFAULT_MESSAGE_FETCH_COUNT; // Prevent grabbing messags with scroll more frequently than once per 5s. const messageFetchInterval = 2; const previousTopMessage = (await this.getMessages(numMessages, messageFetchInterval, true))?.previousTopMessage; previousTopMessage && this.scrollToMessage(previousTopMessage); } } public scrollToUnread() { const { messages, unreadCount } = this.state; const message = messages[(messages.length - 1) - unreadCount]; if(message) this.scrollToMessage(message.id); } public scrollToMessage(messageId: string) { const topUnreadMessage = document.getElementById(messageId); topUnreadMessage?.scrollIntoView(); } 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; } public getHeaderProps() { const {conversationKey} = this.state; const conversation = window.getConversationByKey(conversationKey); console.log(`[header] Conversation`, conversation); const expireTimer = conversation.get('expireTimer'); const expirationSettingName = expireTimer ? window.Whisper.ExpirationTimerOptions.getName(expireTimer || 0) : null; const members = conversation.get('members') || []; return { id: conversation.id, name: conversation.getName(), phoneNumber: conversation.getNumber(), profileName: conversation.getProfileName(), color: conversation.getColor(), avatarPath: conversation.getAvatarPath(), isVerified: conversation.isVerified(), isFriendRequestPending: conversation.isPendingFriendRequest(), isFriend: conversation.isFriend(), isMe: conversation.isMe(), isClosable: conversation.isClosable(), isBlocked: conversation.isBlocked(), isGroup: !conversation.isPrivate(), isOnline: conversation.isOnline(), isArchived: conversation.get('isArchived'), isPublic: conversation.isPublic(), isRss: conversation.isRss(), amMod: conversation.isModerator( window.storage.get('primaryDevicePubKey') ), members, subscriberCount: conversation.get('subscriberCount'), selectedMessages: this.state.selectedMessages, expirationSettingName, showBackButton: Boolean(conversation.panels && conversation.panels.length), timerOptions: window.Whisper.ExpirationTimerOptions.map((item: any) => ({ name: item.getName(), value: item.get('seconds'), })), hasNickname: !!conversation.getNickname(), onSetDisappearingMessages: (seconds: any) => conversation.setDisappearingMessages(seconds), onDeleteMessages: () => conversation.destroyMessages(), onDeleteSelectedMessages: () => conversation.deleteSelectedMessages(), onCloseOverlay: () => conversation.resetMessageSelection(), onDeleteContact: () => conversation.deleteContact(), onResetSession: () => this.resetSelection(), // These are view only and don't update the Conversation model, so they // need a manual update call. onShowSafetyNumber: () => { conversation.showSafetyNumber(); }, onShowAllMedia: async () => { conversation.updateHeader(); }, onUpdateGroupName: () => { conversation.onUpdateGroupName(); }, onShowGroupMembers: async () => { await conversation.showMembers(); conversation.updateHeader(); }, onGoBack: () => { conversation.resetPanel(); conversation.updateHeader(); }, onBlockUser: () => { conversation.block(); }, onUnblockUser: () => { conversation.unblock(); }, onChangeNickname: () => { conversation.changeNickname(); }, onClearNickname: () => { conversation.setNickname(null); }, onCopyPublicKey: () => { conversation.copyPublicKey(); }, onArchive: () => { conversation.unload('archive'); conversation.setArchived(true); }, onMoveToInbox: () => { conversation.setArchived(false); }, onLeaveGroup: () => { window.Whisper.events.trigger('leaveGroup', conversation); }, onInviteFriends: () => { window.Whisper.events.trigger('inviteFriends', conversation); }, onAddModerators: () => { window.Whisper.events.trigger('addModerators', conversation); }, onRemoveModerators: () => { window.Whisper.events.trigger('removeModerators', conversation); }, onAvatarClick: (pubkey: any) => { if (conversation.isPrivate()) { window.Whisper.events.trigger('onShowUserDetails', { userPubKey: pubkey, }); } else if (!conversation.isRss()) { conversation.showGroupSettings(); } }, }; }; public selectMessage(messageId: string) { const selectedMessages = this.state.selectedMessages.includes(messageId) // Add to array if not selected. Else remove. ? this.state.selectedMessages.filter(id => id !== messageId) : [...this.state.selectedMessages, messageId]; this.setState({ selectedMessages }, () => console.log(`[vince] SelectedMessages: `, this.state.selectedMessages) ); } public resetSelection(){ this.setState({selectedMessages: []}); } public getGroupSettingsProps() { const {conversationKey} = this.state; const conversation = window.getConversationByKey[conversationKey]; const ourPK = window.textsecure.storage.user.getNumber(); const members = conversation.get('members') || []; return { id: conversation.id, name: conversation.getName(), phoneNumber: conversation.getNumber(), profileName: conversation.getProfileName(), color: conversation.getColor(), avatarPath: conversation.getAvatarPath(), isGroup: !conversation.isPrivate(), isPublic: conversation.isPublic(), isAdmin: conversation.get('groupAdmins').includes(ourPK), isRss: conversation.isRss(), memberCount: members.length, timerOptions: window.Whisper.ExpirationTimerOptions.map((item: any) => ({ name: item.getName(), value: item.get('seconds'), })), onSetDisappearingMessages: (seconds: any) => conversation.setDisappearingMessages(seconds), onGoBack: () => { conversation.hideConversationRight(); }, onUpdateGroupName: () => { window.Whisper.events.trigger('updateGroupName', conversation); }, onUpdateGroupMembers: () => { window.Whisper.events.trigger('updateGroupMembers', conversation); }, onLeaveGroup: () => { window.Whisper.events.trigger('leaveGroup', conversation); }, onInviteFriends: () => { window.Whisper.events.trigger('inviteFriends', conversation); }, onShowLightBox: (lightBoxOptions = {}) => { conversation.showChannelLightbox(lightBoxOptions); }, }; }; private onLoadVoiceNoteView() { this.setState({ showRecordingView: true, selectedMessages: [], }) } private onExitVoiceNoteView() { this.setState({ showRecordingView: false, }); console.log(`[vince] Stopped recording entirely`); } private onKeyDown(event: any) { const messageContainer = this.messageContainerRef.current; if (!messageContainer) return; const selectionMode = !!this.state.selectedMessages.length; const recordingMode = this.state.showRecordingView; const pageHeight = messageContainer.clientHeight; const arrowScrollPx = 50; const pageScrollPx = 0.80 * pageHeight; console.log(`[vince][key] event: `, event); console.log(`[vince][key] key: `, event.key); console.log(`[vince][key] key: `, event.keyCode); if (event.key === 'Escape') { // } switch(event.key){ case 'Escape': if (selectionMode) this.resetSelection(); break; // Scrolling case 'ArrowUp': messageContainer.scrollBy(0, -arrowScrollPx); break; case 'ArrowDown': messageContainer.scrollBy(0, arrowScrollPx); break; case 'PageUp': messageContainer.scrollBy(0, -pageScrollPx); break; case 'PageDown': messageContainer.scrollBy(0, pageScrollPx); break; default: break; } } }