diff --git a/background.html b/background.html index 04fc9ebe4..4e6df89d3 100644 --- a/background.html +++ b/background.html @@ -305,8 +305,6 @@ - - diff --git a/background_test.html b/background_test.html index 1158cefe9..147e90d6b 100644 --- a/background_test.html +++ b/background_test.html @@ -308,8 +308,6 @@ - - diff --git a/test/index.html b/test/index.html index ab811b6dc..c30978e3b 100644 --- a/test/index.html +++ b/test/index.html @@ -317,8 +317,6 @@ - - diff --git a/ts/components/session/ActionsPanel.tsx b/ts/components/session/ActionsPanel.tsx index 6a4dfa7d9..8852e1a8d 100644 --- a/ts/components/session/ActionsPanel.tsx +++ b/ts/components/session/ActionsPanel.tsx @@ -44,27 +44,28 @@ class ActionsPanelPrivate extends React.Component { // tslint:disable-next-line: no-backbone-get-set-outside-model const ourNumber = window.storage.get('primaryDevicePubKey'); - window.ConversationController.getOrCreateAndWait(ourNumber, 'private').then( - (conversation: any) => { - this.setState({ - avatarPath: conversation.getAvatarPath(), - }); - // When our primary device updates its avatar, we will need for a message sync to know about that. - // Once we get the avatar update, we need to refresh this react component. - // So we listen to changes on our profile avatar and use the updated avatarPath (done on message received). - this.ourConversation = conversation; + void window.ConversationController.getOrCreateAndWait( + ourNumber, + 'private' + ).then((conversation: any) => { + this.setState({ + avatarPath: conversation.getAvatarPath(), + }); + // When our primary device updates its avatar, we will need for a message sync to know about that. + // Once we get the avatar update, we need to refresh this react component. + // So we listen to changes on our profile avatar and use the updated avatarPath (done on message received). + this.ourConversation = conversation; - this.ourConversation.on( - 'change', - () => { - this.refreshAvatarCallback(this.ourConversation); - }, - 'refreshAvatarCallback' - ); + this.ourConversation.on( + 'change', + () => { + this.refreshAvatarCallback(this.ourConversation); + }, + 'refreshAvatarCallback' + ); - void this.showLightThemeDialogIfNeeded(); - } - ); + void this.showLightThemeDialogIfNeeded(); + }); } public async showLightThemeDialogIfNeeded() { diff --git a/ts/components/session/conversation/SessionCompositionBox.tsx b/ts/components/session/conversation/SessionCompositionBox.tsx index 059e9e858..3098a7fee 100644 --- a/ts/components/session/conversation/SessionCompositionBox.tsx +++ b/ts/components/session/conversation/SessionCompositionBox.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { debounce } from 'lodash'; +import _, { debounce } from 'lodash'; import { Attachment } from '../../../types/Attachment'; import * as MIME from '../../../types/MIME'; @@ -9,7 +9,6 @@ import TextareaAutosize from 'react-autosize-textarea'; import { SessionIconButton, SessionIconSize, SessionIconType } from '../icon'; import { SessionEmojiPanel } from './SessionEmojiPanel'; import { SessionRecording } from './SessionRecording'; -import { Props as MessageProps } from '../../conversation/Message'; import { SignalService } from '../../../protobuf'; @@ -18,7 +17,6 @@ import { Constants } from '../../../session'; import { toArray } from 'react-emoji-render'; import { SessionQuotedMessageComposition } from './SessionQuotedMessageComposition'; import { Flex } from '../Flex'; -import _ from 'lodash'; export interface ReplyingToMessageProps { convoId: string; @@ -43,6 +41,8 @@ interface Props { dropZoneFiles: FileList; quotedMessageProps?: ReplyingToMessageProps; removeQuotedMessage: () => void; + + textarea: React.RefObject; } interface State { @@ -56,7 +56,6 @@ interface State { } export class SessionCompositionBox extends React.Component { - // private readonly textarea: React.RefObject; private readonly textarea: React.RefObject; private readonly fileInput: React.RefObject; private emojiPanel: any; @@ -72,7 +71,7 @@ export class SessionCompositionBox extends React.Component { showEmojiPanel: false, }; - this.textarea = React.createRef(); + this.textarea = props.textarea; this.fileInput = React.createRef(); // Emojis diff --git a/ts/components/session/conversation/SessionConversation.tsx b/ts/components/session/conversation/SessionConversation.tsx index d488d3d6f..014792d18 100644 --- a/ts/components/session/conversation/SessionConversation.tsx +++ b/ts/components/session/conversation/SessionConversation.tsx @@ -4,19 +4,10 @@ import React from 'react'; import classNames from 'classnames'; -import { - ReplyingToMessageProps, - SessionCompositionBox, -} from './SessionCompositionBox'; -import { SessionProgress } from '../SessionProgress'; - -import { Message, Props as MessageProps } from '../../conversation/Message'; -import { TimerNotification } from '../../conversation/TimerNotification'; +import { SessionCompositionBox } from './SessionCompositionBox'; import { getTimestamp } from './SessionConversationManager'; -import { SessionScrollButton } from '../SessionScrollButton'; -import { ResetSessionNotification } from '../../conversation/ResetSessionNotification'; import { Constants } from '../../../session'; import { SessionKeyVerification } from '../SessionKeyVerification'; import _ from 'lodash'; @@ -26,6 +17,7 @@ import { ConversationHeaderWithDetails } from '../../conversation/ConversationHe import { SessionRightPanelWithDetails } from './SessionRightPanel'; import { SessionTheme } from '../../../state/ducks/SessionTheme'; import { DefaultTheme } from 'styled-components'; +import { SessionConversationMessagesList } from './SessionConversationMessagesList'; interface State { conversationKey: string; @@ -52,7 +44,6 @@ interface State { showOverlay: boolean; showRecordingView: boolean; showOptionsPane: boolean; - showScrollButton: boolean; // For displaying `More Info` on messages, and `Safety Number`, etc. infoViewState?: 'safetyNumber' | 'messageDetails'; @@ -71,8 +62,7 @@ interface Props { } export class SessionConversation extends React.Component { - private readonly messagesEndRef: React.RefObject; - private readonly messageContainerRef: React.RefObject; + private readonly compositionBoxRef: React.RefObject; constructor(props: any) { super(props); @@ -98,22 +88,13 @@ export class SessionConversation extends React.Component { doneInitialScroll: false, displayScrollToBottomButton: false, messageFetchTimestamp: 0, - showOverlay: false, showRecordingView: false, showOptionsPane: false, - showScrollButton: false, - infoViewState: undefined, - dropZoneFiles: undefined, // <-- FileList or something else? }; - - 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.compositionBoxRef = React.createRef(); // Group settings panel this.toggleGroupSettingsPane = this.toggleGroupSettingsPane.bind(this); @@ -135,9 +116,7 @@ export class SessionConversation extends React.Component { this.deleteSelectedMessages = this.deleteSelectedMessages.bind(this); this.replyToMessage = this.replyToMessage.bind(this); - - this.messagesEndRef = React.createRef(); - this.messageContainerRef = React.createRef(); + this.getMessages = this.getMessages.bind(this); // Keyboard navigation this.onKeyDown = this.onKeyDown.bind(this); @@ -146,12 +125,9 @@ export class SessionConversation extends React.Component { this.state.conversationKey ); conversationModel.on('change', () => { - this.setState( - { - messages: conversationModel.messageCollection.models, - }, - this.updateReadMessages - ); + this.setState({ + messages: conversationModel.messageCollection.models, + }); }); } @@ -166,30 +142,11 @@ export class SessionConversation extends React.Component { public componentDidMount() { // Pause thread to wait for rendering to complete - setTimeout(this.scrollToUnread, 0); setTimeout(() => { this.setState({ doneInitialScroll: true, }); }, 100); - - this.updateReadMessages(); - } - - public componentDidUpdate() { - // Keep scrolled to bottom unless user scrolls up - if (this.state.isScrolledToBottom) { - this.scrollToBottom(); - } - - // New messages get from message collection. - const messageCollection = window.ConversationController.get( - this.state.conversationKey - )?.messageCollection; - } - - public async componentWillReceiveProps(nextProps: any) { - return; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -197,15 +154,12 @@ export class SessionConversation extends React.Component { // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ public render() { const { - messages, conversationKey, doneInitialScroll, showRecordingView, showOptionsPane, - showScrollButton, quotedMessageProps, } = this.state; - const loading = !doneInitialScroll; const selectionMode = !!this.state.selectedMessages.length; const conversation = this.props.conversations.conversationLookup[ @@ -225,6 +179,7 @@ export class SessionConversation extends React.Component { const showSafetyNumber = this.state.infoViewState === 'safetyNumber'; const showMessageDetails = this.state.infoViewState === 'messageDetails'; + const messagesListProps = this.getMessagesListProps(); return ( @@ -260,21 +215,8 @@ export class SessionConversation extends React.Component {
- {loading &&
} - -
- {this.renderMessages(messages)} -
-
- - + + {showRecordingView && (
)} @@ -293,6 +235,7 @@ export class SessionConversation extends React.Component { removeQuotedMessage={() => { void this.replyToMessage(undefined); }} + textarea={this.compositionBoxRef} /> )}
@@ -311,92 +254,11 @@ export class SessionConversation extends React.Component { ); } - public renderMessages(messages: any) { - const multiSelectMode = Boolean(this.state.selectedMessages.length); - // FIXME VINCE: IF MESSAGE IS THE TOP OF UNREAD, THEN INSERT AN UNREAD BANNER - - return ( - <> - {messages.map((message: any) => { - const messageProps = message.propsForMessage; - const quoteProps = message.propsForQuote; - - const timerProps = message.propsForTimerNotification && { - i18n: window.i18n, - ...message.propsForTimerNotification, - }; - const resetSessionProps = message.propsForResetSessionNotification && { - i18n: window.i18n, - ...message.propsForResetSessionNotification, - }; - - const attachmentProps = message.propsForAttachment; - const groupNotificationProps = message.propsForGroupNotification; - - 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, - multiSelectMode - ) - : item; - item = quoteProps - ? this.renderMessage( - timerProps, - message.firstMessageOfSeries, - multiSelectMode, - quoteProps - ) - : item; - - item = timerProps ? : item; - item = resetSessionProps ? ( - - ) : ( - item - ); - // item = attachmentProps ? this.renderMessage(timerProps) : item; - - return item; - })} - - ); - } - public renderHeader() { const headerProps = this.getHeaderProps(); return ; } - public renderMessage( - messageProps: any, - firstMessageOfSeries: boolean, - multiSelectMode: boolean, - quoteProps?: any - ) { - const selected = - !!messageProps?.id && - this.state.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.quote = quoteProps || undefined; - messageProps.onReply = (messageId: number) => { - void this.replyToMessage(messageId); - }; - - return ; - } - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~ GETTER METHODS ~~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -427,10 +289,6 @@ export class SessionConversation extends React.Component { const messageFetchTimestamp = Date.now(); this.setState({ messages, messageFetchTimestamp }, () => { - if (this.state.isScrolledToBottom) { - this.updateReadMessages(); - } - // Add new messages to conversation collection conversationModel.messageCollection = messageSet; }); @@ -485,11 +343,7 @@ export class SessionConversation extends React.Component { const previousTopMessage = this.state.messages[0]?.id; const newTopMessage = messages[0]?.id; - this.setState({ messages, messageFetchTimestamp: timestamp }, () => { - if (this.state.isScrolledToBottom) { - this.updateReadMessages(); - } - }); + this.setState({ messages, messageFetchTimestamp: timestamp }); return { newTopMessage, previousTopMessage }; } @@ -602,6 +456,31 @@ export class SessionConversation extends React.Component { return headerProps; } + public getMessagesListProps() { + const { conversationKey } = this.state; + const conversation = window.ConversationController.getOrThrow( + conversationKey + ); + const conversationModel = window.ConversationController.getOrThrow( + conversationKey + ); + + return { + selectedMessages: this.state.selectedMessages, + conversationKey: this.state.conversationKey, + messages: this.state.messages, + resetSelection: this.resetSelection, + initialFetchComplete: this.state.initialFetchComplete, + quotedMessageTimestamp: this.state.quotedMessageTimestamp, + conversationModel: conversationModel, + conversation: conversation, + selectMessage: this.selectMessage, + getMessages: this.getMessages, + replyToMessage: this.replyToMessage, + doneInitialScroll: this.state.doneInitialScroll, + }; + } + public getGroupSettingsProps() { const { conversationKey } = this.state; const conversation = window.ConversationController.getOrThrow( @@ -713,98 +592,6 @@ export class SessionConversation extends React.Component { this.updateSendingProgress(100, -1); } - public updateReadMessages() { - const { isScrolledToBottom, messages, conversationKey } = this.state; - - // If you're not friends, don't mark anything as read. Otherwise - // this will automatically accept friend request. - const conversation = window.ConversationController.getOrThrow( - conversationKey - ); - - if (conversation.isBlocked()) { - return; - } - - let unread; - - if (!messages || messages.length === 0) { - return; - } - - if (isScrolledToBottom) { - unread = messages[messages.length - 1]; - } else { - unread = this.findNewestVisibleUnread(); - } - - if (unread) { - conversation.markRead(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 deleteSelectedMessages() { // Get message objects const selectedMessages = this.state.messages.filter(message => @@ -926,99 +713,6 @@ export class SessionConversation extends React.Component { }); } - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - // ~~~~~~~~~~~~ SCROLLING METHODS ~~~~~~~~~~~~~ - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - public async handleScroll() { - const messageContainer = this.messageContainerRef.current; - if (!messageContainer) { - 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; - - // Mark messages read - 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 = - scrollTop <= Constants.UI.MESSAGE_CONTAINER_BUFFER_OFFSET_PX; - - if (shouldFetchMoreMessages) { - const numMessages = - this.state.messages.length + - Constants.CONVERSATION.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) - )?.previousTopMessage; - - if (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; - } - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ~~~~~~~~~~~~ MESSAGE SELECTION ~~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -1076,7 +770,9 @@ export class SessionConversation extends React.Component { ); } } - this.setState({ quotedMessageTimestamp, quotedMessageProps }); + this.setState({ quotedMessageTimestamp, quotedMessageProps }, () => { + this.compositionBoxRef.current?.focus(); + }); } } @@ -1084,48 +780,42 @@ export class SessionConversation extends React.Component { // ~~~~~~~~~~~ KEYBOARD NAVIGATION ~~~~~~~~~~~~ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 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 = pageHeight * 0.8; - - if (event.key === 'Escape') { - // EXIT MEDIA VIEW - - if (recordingMode) { - // EXIT RECORDING VIEW - } - // EXIT WHAT ELSE? - } - - 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: - } + // 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 = pageHeight * 0.8; + // if (event.key === 'Escape') { + // // EXIT MEDIA VIEW + // if (recordingMode) { + // // EXIT RECORDING VIEW + // } + // // EXIT WHAT ELSE? + // } + // 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: + // } } } diff --git a/ts/components/session/conversation/SessionConversationMessagesList.tsx b/ts/components/session/conversation/SessionConversationMessagesList.tsx new file mode 100644 index 000000000..fb4d5b6c1 --- /dev/null +++ b/ts/components/session/conversation/SessionConversationMessagesList.tsx @@ -0,0 +1,425 @@ +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 { ConversationModel } from '../../../../js/models/conversations'; + +interface State { + isScrolledToBottom: boolean; + showScrollButton: boolean; + doneInitialScroll: boolean; +} + +interface Props { + selectedMessages: Array; + conversationKey: string; + messages: Array; + resetSelection: () => any; + initialFetchComplete: boolean; + conversationModel: ConversationModel; + conversation: any; + selectMessage: (messageId: string) => void; + getMessages: ( + numMessages: number, + interval: number + ) => Promise<{ previousTopMessage: string }>; + replyToMessage: (messageId: number) => Promise; +} + +export class SessionConversationMessagesList extends React.Component< + Props, + State +> { + private readonly messagesEndRef: React.RefObject; + private readonly messageContainerRef: React.RefObject; + + public constructor(props: Props) { + super(props); + + this.state = { + isScrolledToBottom: false, + showScrollButton: true, + 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.messagesEndRef = React.createRef(); + this.messageContainerRef = React.createRef(); + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // ~~~~~~~~~~~~~~~~ LIFECYCLES ~~~~~~~~~~~~~~~~ + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + public componentDidMount() { + // Pause thread to wait for rendering to complete + setTimeout(this.scrollToUnread, 0); + setTimeout(() => { + this.setState({ + doneInitialScroll: true, + }); + }, 100); + + this.updateReadMessages(); + } + + public componentDidUpdate() { + // Keep scrolled to bottom unless user scrolls up + if (this.state.isScrolledToBottom) { + this.scrollToBottom(); + this.updateReadMessages(); + } + + // New messages get from message collection. + const messageCollection = window.ConversationController.get( + this.props.conversationKey + )?.messageCollection; + } + + public async componentWillReceiveProps(nextProps: any) { + return; + } + + public render() { + const { messages } = this.props; + + const { doneInitialScroll, showScrollButton } = this.state; + + if (!doneInitialScroll) { + return
; + } + return ( + <> +
+ {this.renderMessages(messages)} +
+
+ + + ); + } + + public renderMessages(messages: any) { + const multiSelectMode = Boolean(this.props.selectedMessages.length); + // FIXME VINCE: IF MESSAGE IS THE TOP OF UNREAD, THEN INSERT AN UNREAD BANNER + return ( + <> + {messages.map((message: any) => { + const messageProps = message.propsForMessage; + // const quoteProps = messageProps.quote; + // console.warn('propsForQuote', quoteProps); + + const timerProps = message.propsForTimerNotification; + const resetSessionProps = message.propsForResetSessionNotification; + + const attachmentProps = message.propsForAttachment; + const groupNotificationProps = message.propsForGroupNotification; + + 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, + multiSelectMode + ) + : item; + + item = timerProps ? : item; + item = resetSessionProps ? ( + + ) : ( + item + ); + // item = attachmentProps ? this.renderMessage(timerProps) : item; + + return item; + })} + + ); + } + + 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); + }; + + return ; + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // ~~~~~~~~~~~~~ MESSAGE HANDLING ~~~~~~~~~~~~~ + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + public updateReadMessages() { + const { messages, conversationKey } = this.props; + const { isScrolledToBottom } = this.state; + + // If you're not friends, don't mark anything as read. Otherwise + // this will automatically accept friend request. + const conversation = window.ConversationController.getOrThrow( + conversationKey + ); + + if (conversation.isBlocked()) { + return; + } + + let unread; + + if (!messages || messages.length === 0) { + return; + } + + if (isScrolledToBottom) { + unread = messages[messages.length - 1]; + } else { + unread = this.findNewestVisibleUnread(); + } + + if (unread) { + conversation.markRead(unread.attributes.received_at); + } + } + + public findNewestVisibleUnread() { + const messageContainer = this.messageContainerRef.current; + if (!messageContainer) { + return null; + } + + const { messages, conversation } = this.props; + 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 >= conversation.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; + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // ~~~~~~~~~~~~ SCROLLING METHODS ~~~~~~~~~~~~~ + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + public async handleScroll() { + const messageContainer = this.messageContainerRef?.current; + if (!messageContainer) { + 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; + + // Mark messages read + 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 = + scrollTop <= Constants.UI.MESSAGE_CONTAINER_BUFFER_OFFSET_PX; + + if (shouldFetchMoreMessages) { + const numMessages = + this.props.messages.length + + Constants.CONVERSATION.DEFAULT_MESSAGE_FETCH_COUNT; + + // Prevent grabbing messags with scroll more frequently than once per 2s. + const messageFetchInterval = 2; + const previousTopMessage = ( + await this.props.getMessages(numMessages, messageFetchInterval) + )?.previousTopMessage; + + if (previousTopMessage) { + this.scrollToMessage(previousTopMessage); + } + } + } + + public scrollToUnread() { + const { messages, conversation } = this.props; + const message = messages[messages.length - 1 - conversation.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; + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // ~~~~~~~~~~~~ MESSAGE SELECTION ~~~~~~~~~~~~~ + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + public selectMessage(messageId: string) { + this.props.selectMessage(messageId); + } + + public resetSelection() { + this.props.resetSelection(); + } + + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + // ~~~~~~~~~~~ KEYBOARD NAVIGATION ~~~~~~~~~~~~ + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + private onKeyDown(event: any) { + //FIXME Audric + // const messageContainer = this.messageContainerRef.current; + // if (!messageContainer) { + // return; + // } + // const selectionMode = !!this.props.selectedMessages.length; + // const recordingMode = this.props.showRecordingView; + // const pageHeight = messageContainer.clientHeight; + // const arrowScrollPx = 50; + // const pageScrollPx = pageHeight * 0.8; + // if (event.key === 'Escape') { + // // EXIT MEDIA VIEW + // if (recordingMode) { + // // EXIT RECORDING VIEW + // } + // // EXIT WHAT ELSE? + // } + // 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: + // } + } +} diff --git a/ts/receiver/multidevice.ts b/ts/receiver/multidevice.ts index 91588de7f..cadc1ab05 100644 --- a/ts/receiver/multidevice.ts +++ b/ts/receiver/multidevice.ts @@ -378,7 +378,7 @@ async function onContactReceived(details: any) { if (details.profileKey) { const profileKey = StringUtils.decode(details.profileKey, 'base64'); - conversation.setProfileKey(profileKey); + void conversation.setProfileKey(profileKey); } if (details.name && details.name.length) { diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index bac2f1298..ba92de6e0 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -659,105 +659,6 @@ "updated": "2018-09-15T00:38:04.183Z", "reasonDetail": "Hard-coded value" }, - { - "rule": "jQuery-$(", - "path": "js/views/message_list_view.js", - "line": " template: $('#message-list').html(),", - "lineNumber": 13, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "Parameter is a hard-coded string" - }, - { - "rule": "jQuery-html(", - "path": "js/views/message_list_view.js", - "line": " template: $('#message-list').html(),", - "lineNumber": 13, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "This is run at JS load time, which means we control the contents of the target element" - }, - { - "rule": "jQuery-$(", - "path": "js/views/message_list_view.js", - "line": " this.$messages = this.$('.messages');", - "lineNumber": 30, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "Parameter is a hard-coded string" - }, - { - "rule": "jQuery-append(", - "path": "js/views/message_list_view.js", - "line": " this.$messages.append(view.el);", - "lineNumber": 111, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "view.el is a known DOM element" - }, - { - "rule": "jQuery-prepend(", - "path": "js/views/message_list_view.js", - "line": " this.$messages.prepend(view.el);", - "lineNumber": 114, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "view.el is a known DOM element" - }, - { - "rule": "jQuery-$(", - "path": "js/views/message_list_view.js", - "line": " const next = this.$(`#${this.collection.at(index + 1).id}`);", - "lineNumber": 117, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "Message ids are GUIDs, and therefore the resultant string for $() is an id" - }, - { - "rule": "jQuery-insertBefore(", - "path": "js/views/message_list_view.js", - "line": " view.$el.insertBefore(next);", - "lineNumber": 120, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "next is a known DOM element" - }, - { - "rule": "jQuery-insertAfter(", - "path": "js/views/message_list_view.js", - "line": " view.$el.insertAfter(prev);", - "lineNumber": 122, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "prev is a known DOM element" - }, - { - "rule": "jQuery-insertBefore(", - "path": "js/views/message_list_view.js", - "line": " view.$el.insertBefore(elements[i]);", - "lineNumber": 131, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "elements[i] is a known DOM element" - }, - { - "rule": "jQuery-append(", - "path": "js/views/message_list_view.js", - "line": " this.$messages.append(view.el);", - "lineNumber": 136, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "view.el is a known DOM element" - }, - { - "rule": "jQuery-append(", - "path": "js/views/message_view.js", - "line": " this.$el.append(this.childView.el);", - "lineNumber": 122, - "reasonCategory": "usageTrusted", - "updated": "2018-09-19T18:13:29.628Z", - "reasonDetail": "Interacting with already-existing DOM nodes" - }, { "rule": "jQuery-$(", "path": "js/views/phone-input-view.js",