From 9cdba8b38ed5ffc0ff37b63f7014c03c1144f440 Mon Sep 17 00:00:00 2001 From: Roma Koval Date: Mon, 22 Apr 2024 22:56:12 +0200 Subject: [PATCH 01/16] feat: Add "Jump to las message" button WPB-6518 --- .../components/Conversation/Conversation.tsx | 26 ++++++++++++++++++- .../components/MessagesList/MessageList.tsx | 2 +- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/script/components/Conversation/Conversation.tsx b/src/script/components/Conversation/Conversation.tsx index 60039ecae5e..de1ca066d76 100644 --- a/src/script/components/Conversation/Conversation.tsx +++ b/src/script/components/Conversation/Conversation.tsx @@ -19,10 +19,12 @@ import {UIEvent, useCallback, useState} from 'react'; +import {amplify} from 'amplify'; import cx from 'classnames'; import {container} from 'tsyringe'; -import {useMatchMedia} from '@wireapp/react-ui-kit'; +import {useMatchMedia, IconButton, ChevronIcon} from '@wireapp/react-ui-kit'; +import {WebAppEvents} from '@wireapp/webapp-events'; import {CallingCell} from 'Components/calling/CallingCell'; import {DropFileArea} from 'Components/DropFileArea'; @@ -460,6 +462,12 @@ export const Conversation = ({ [addReadReceiptToBatch, repositories.conversation, repositories.integration, updateConversationLastRead], ); + const onGoToLastMessage = () => { + activeConversation?.release(); + amplify.publish(WebAppEvents.CONVERSATION.SHOW, activeConversation); + // content.showConversation(activeConversation, {exposeMessage: activeConversation?.lastDeliveredMessage()}); + }; + return ( + + {(!activeConversation.hasLastReceivedMessageLoaded() || true) && ( + + + + )} + {isConversationLoaded && (isReadOnlyConversation ? ( diff --git a/src/script/components/MessagesList/MessageList.tsx b/src/script/components/MessagesList/MessageList.tsx index 2aab694a600..092471b4b07 100644 --- a/src/script/components/MessagesList/MessageList.tsx +++ b/src/script/components/MessagesList/MessageList.tsx @@ -168,7 +168,7 @@ export const MessagesList: FC = ({ scrollHeight.current = newScrollHeight; }, [messagesContainer?.parentElement, loaded, filteredMessages, selfUser?.id]); - // Listen to resizes of the the content element (if it's resized it means something has changed in the message list, link a link preview was generated) + // Listen to resizes of the content element (if it's resized it means something has changed in the message list, link a link preview was generated) useResizeObserver(syncScrollPosition, messagesContainer); // Also listen to the scrolling container resizes (when the window resizes or the inputBar changes) useResizeObserver(syncScrollPosition, messagesContainer?.parentElement); From a37646554e3cd34ed7ef48f432a601ce8fe20d22 Mon Sep 17 00:00:00 2001 From: Roma Koval Date: Fri, 10 May 2024 16:25:05 +0200 Subject: [PATCH 02/16] Show/hide "jump to last message" button on visibility of the last message; Mark all the conversation as read on the "jump to last message" button click; Reload the conversation on the "jump to last message" click if the latest conversation messages were not load; --- .../components/Conversation/Conversation.tsx | 52 +++++++----- .../LastMessageVisibilityTracker.tsx | 79 +++++++++++++++++++ .../LastMessageVisibilityTracker/index.tsx | 20 +++++ .../components/MessagesList/Message/index.tsx | 10 ++- .../components/MessagesList/MessageList.tsx | 4 +- src/script/entity/Conversation.ts | 1 + src/script/view_model/ContentViewModel.ts | 2 +- 7 files changed, 146 insertions(+), 22 deletions(-) create mode 100644 src/script/components/LastMessageVisibilityTracker/LastMessageVisibilityTracker.tsx create mode 100644 src/script/components/LastMessageVisibilityTracker/index.tsx diff --git a/src/script/components/Conversation/Conversation.tsx b/src/script/components/Conversation/Conversation.tsx index de1ca066d76..520126714c9 100644 --- a/src/script/components/Conversation/Conversation.tsx +++ b/src/script/components/Conversation/Conversation.tsx @@ -21,15 +21,17 @@ import {UIEvent, useCallback, useState} from 'react'; import {amplify} from 'amplify'; import cx from 'classnames'; +import ko from 'knockout'; import {container} from 'tsyringe'; -import {useMatchMedia, IconButton, ChevronIcon} from '@wireapp/react-ui-kit'; +import {useMatchMedia} from '@wireapp/react-ui-kit'; import {WebAppEvents} from '@wireapp/webapp-events'; import {CallingCell} from 'Components/calling/CallingCell'; import {DropFileArea} from 'Components/DropFileArea'; import {Giphy} from 'Components/Giphy'; import {InputBar} from 'Components/InputBar'; +import {LastMessageVisibilityTracker, MessageVisibility} from 'Components/LastMessageVisibilityTracker'; import {MessagesList} from 'Components/MessagesList'; import {showDetailViewModal} from 'Components/Modals/DetailViewModal'; import {PrimaryModal} from 'Components/Modals/PrimaryModal'; @@ -120,6 +122,8 @@ export const Conversation = ({ const [isMsgElementsFocusable, setMsgElementsFocusable] = useState(true); + const messageVisibility: ko.Observable = ko.observable(); + // To be changed when design chooses a breakpoint, the conditional can be integrated to the ui-kit directly const smBreakpoint = useMatchMedia('max-width: 640px'); @@ -398,9 +402,14 @@ export const Conversation = ({ } }; + const visibleInViewportCallback = useCallback((isVisible: boolean, messageEntity: Message) => { + messageVisibility({isVisible, message: messageEntity}); + }, []); + const getInViewportCallback = useCallback( (conversationEntity: ConversationEntity, messageEntity: Message) => { const messageTimestamp = messageEntity.timestamp(); + const callbacks: Function[] = []; if (!messageEntity.isEphemeral()) { @@ -463,9 +472,15 @@ export const Conversation = ({ ); const onGoToLastMessage = () => { - activeConversation?.release(); - amplify.publish(WebAppEvents.CONVERSATION.SHOW, activeConversation); - // content.showConversation(activeConversation, {exposeMessage: activeConversation?.lastDeliveredMessage()}); + activeConversation?.setTimestamp( + activeConversation?.last_server_timestamp(), + ConversationEntity.TIMESTAMP_TYPE.LAST_READ, + ); + if (!activeConversation?.hasLastReceivedMessageLoaded()) { + activeConversation?.release(); + amplify.publish(WebAppEvents.CONVERSATION.SHOW, activeConversation, {}); + } + // scroll }; return ( @@ -534,26 +549,27 @@ export const Conversation = ({ onClickMessage={handleClickOnMessage} onLoading={loading => setIsConversationLoaded(!loading)} getVisibleCallback={getInViewportCallback} + getVisibleEachTimeCallback={(isVisible, _conversationEntity, messageEntity) => + visibleInViewportCallback(isVisible, messageEntity) + } isLastReceivedMessage={isLastReceivedMessage} isMsgElementsFocusable={isMsgElementsFocusable} setMsgElementsFocusable={setMsgElementsFocusable} isRightSidebarOpen={isRightSidebarOpen} /> - {(!activeConversation.hasLastReceivedMessageLoaded() || true) && ( - - - - )} + {isConversationLoaded && (isReadOnlyConversation ? ( diff --git a/src/script/components/LastMessageVisibilityTracker/LastMessageVisibilityTracker.tsx b/src/script/components/LastMessageVisibilityTracker/LastMessageVisibilityTracker.tsx new file mode 100644 index 00000000000..2c6d9c83538 --- /dev/null +++ b/src/script/components/LastMessageVisibilityTracker/LastMessageVisibilityTracker.tsx @@ -0,0 +1,79 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {HTMLProps, useState, FC, useEffect} from 'react'; + +import ko from 'knockout'; + +import {ChevronIcon, IconButton} from '@wireapp/react-ui-kit'; + +import {Conversation as ConversationEntity} from '../../entity/Conversation'; +import {Message} from '../../entity/message/Message'; + +export interface MessageVisibility { + message: Message; + isVisible: boolean; +} + +export interface LastMessageVisibilityTrackerProps extends HTMLProps { + onGoToLastMessage: () => void; + messageVisibility: ko.Observable; + conversation: ConversationEntity; +} + +// conversation.last_event_timestamp doesn't contain system messages +const lastMessageId = (conversation: ConversationEntity): string => { + if (!conversation.hasLastReceivedMessageLoaded() || (conversation.messages()?.length || 0) === 0) { + return ''; + } + return conversation.messages()[conversation.messages().length - 1].id; +}; + +export const LastMessageVisibilityTracker: FC = ({ + onGoToLastMessage, + messageVisibility, + conversation, + ...rest +}: LastMessageVisibilityTrackerProps) => { + const [lastMessageShown, setLastMessageShown] = useState(false); + + useEffect(() => { + const subscription = messageVisibility.subscribe(({message, isVisible}: MessageVisibility) => { + if (!conversation.hasLastReceivedMessageLoaded()) { + setLastMessageShown(false); + } else if (message.id === lastMessageId(conversation)) { + setLastMessageShown(isVisible); + } + }); + + return () => { + subscription.dispose(); + }; + }, []); + + if (lastMessageShown) { + return null; + } + + return ( + + + + ); +}; diff --git a/src/script/components/LastMessageVisibilityTracker/index.tsx b/src/script/components/LastMessageVisibilityTracker/index.tsx new file mode 100644 index 00000000000..96796a695c1 --- /dev/null +++ b/src/script/components/LastMessageVisibilityTracker/index.tsx @@ -0,0 +1,20 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +export * from './LastMessageVisibilityTracker'; diff --git a/src/script/components/MessagesList/Message/index.tsx b/src/script/components/MessagesList/Message/index.tsx index a6ba05e002f..1cb395f056c 100644 --- a/src/script/components/MessagesList/Message/index.tsx +++ b/src/script/components/MessagesList/Message/index.tsx @@ -69,6 +69,7 @@ export interface MessageParams extends MessageActions { }; messageRepository: MessageRepository; onVisible?: () => void; + onVisibilityChange?: (isVisible: boolean) => void; selfId: QualifiedId; shouldShowInvitePeople: boolean; teamState?: TeamState; @@ -88,6 +89,7 @@ export const Message: React.FC = p isHighlighted, hideHeader, onVisible, + onVisibilityChange, scrollTo, isFocused, handleFocus, @@ -154,7 +156,13 @@ export const Message: React.FC = p ); const wrappedContent = onVisible ? ( - + {content} ) : ( diff --git a/src/script/components/MessagesList/MessageList.tsx b/src/script/components/MessagesList/MessageList.tsx index 092471b4b07..ad454f3e689 100644 --- a/src/script/components/MessagesList/MessageList.tsx +++ b/src/script/components/MessagesList/MessageList.tsx @@ -126,7 +126,7 @@ export const MessagesList: FC = ({ const groupedMessages = groupMessagesBySenderAndTime(filteredMessages, conversationLastReadTimestamp.current); - const [messagesContainer, setMessageContainer] = useState(null); + const [messagesContainer, setMessagesContainer] = useState(null); const shouldShowInvitePeople = isActiveParticipant && inTeam && (isGuestRoom || isGuestAndServicesRoom); @@ -252,7 +252,7 @@ export const MessagesList: FC = ({ className={cx('message-list', {'is-right-panel-open': isRightSidebarOpen})} tabIndex={TabIndex.UNFOCUSABLE} > -
+
{groupedMessages.flatMap(group => { if (isMarker(group)) { return ( diff --git a/src/script/entity/Conversation.ts b/src/script/entity/Conversation.ts index 31810667864..257b81f0e4c 100644 --- a/src/script/entity/Conversation.ts +++ b/src/script/entity/Conversation.ts @@ -423,6 +423,7 @@ export class Conversation { return message_a.timestamp() - message_b.timestamp(); }), ); + this.lastDeliveredMessage = ko.pureComputed(() => this.getLastDeliveredMessage()); this.incomingMessages = ko.observableArray(); diff --git a/src/script/view_model/ContentViewModel.ts b/src/script/view_model/ContentViewModel.ts index 14edfb6a18a..acb79bbf7a9 100644 --- a/src/script/view_model/ContentViewModel.ts +++ b/src/script/view_model/ContentViewModel.ts @@ -239,7 +239,7 @@ export class ContentViewModel { exposeMessage: exposeMessageEntity, openFirstSelfMention = false, openNotificationSettings = false, - } = options; + } = options || {}; if (!conversation) { return this.handleMissingConversation(); From 12f4b3d427e00e16ccf1b59296c158292445ae51 Mon Sep 17 00:00:00 2001 From: Roma Koval Date: Tue, 28 May 2024 22:17:50 +0200 Subject: [PATCH 03/16] Fix the way last message visibility is tracked; Add re-render of message list on click on button when last message is loaded; --- .../components/Conversation/Conversation.tsx | 23 ++++----- .../LastMessageVisibilityTracker.tsx | 50 +++++++------------ .../components/MessagesList/Message/index.tsx | 6 +-- .../components/MessagesList/MessageList.tsx | 25 +++++++--- src/script/entity/Conversation.ts | 2 + src/script/hooks/useComponentRerenderKey.ts | 30 +++++++++++ 6 files changed, 80 insertions(+), 56 deletions(-) create mode 100644 src/script/hooks/useComponentRerenderKey.ts diff --git a/src/script/components/Conversation/Conversation.tsx b/src/script/components/Conversation/Conversation.tsx index 520126714c9..3ccc45cee7a 100644 --- a/src/script/components/Conversation/Conversation.tsx +++ b/src/script/components/Conversation/Conversation.tsx @@ -21,7 +21,6 @@ import {UIEvent, useCallback, useState} from 'react'; import {amplify} from 'amplify'; import cx from 'classnames'; -import ko from 'knockout'; import {container} from 'tsyringe'; import {useMatchMedia} from '@wireapp/react-ui-kit'; @@ -31,7 +30,7 @@ import {CallingCell} from 'Components/calling/CallingCell'; import {DropFileArea} from 'Components/DropFileArea'; import {Giphy} from 'Components/Giphy'; import {InputBar} from 'Components/InputBar'; -import {LastMessageVisibilityTracker, MessageVisibility} from 'Components/LastMessageVisibilityTracker'; +import {LastMessageVisibilityTracker} from 'Components/LastMessageVisibilityTracker'; import {MessagesList} from 'Components/MessagesList'; import {showDetailViewModal} from 'Components/Modals/DetailViewModal'; import {PrimaryModal} from 'Components/Modals/PrimaryModal'; @@ -39,6 +38,7 @@ import {showWarningModal} from 'Components/Modals/utils/showWarningModal'; import {TitleBar} from 'Components/TitleBar'; import {CallingViewMode, CallState} from 'src/script/calling/CallState'; import {Config} from 'src/script/Config'; +import {useComponentRerenderKey} from 'src/script/hooks/useComponentRerenderKey'; import {PROPERTIES_TYPE} from 'src/script/properties/PropertiesType'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {allowsAllFiles, getFileExtensionOrName, hasAllowedExtension} from 'Util/FileTypeUtil'; @@ -122,7 +122,7 @@ export const Conversation = ({ const [isMsgElementsFocusable, setMsgElementsFocusable] = useState(true); - const messageVisibility: ko.Observable = ko.observable(); + const [messagesListRerenderKey, rerenderMessageList] = useComponentRerenderKey('messages-list'); // To be changed when design chooses a breakpoint, the conditional can be integrated to the ui-kit directly const smBreakpoint = useMatchMedia('max-width: 640px'); @@ -402,10 +402,6 @@ export const Conversation = ({ } }; - const visibleInViewportCallback = useCallback((isVisible: boolean, messageEntity: Message) => { - messageVisibility({isVisible, message: messageEntity}); - }, []); - const getInViewportCallback = useCallback( (conversationEntity: ConversationEntity, messageEntity: Message) => { const messageTimestamp = messageEntity.timestamp(); @@ -476,11 +472,13 @@ export const Conversation = ({ activeConversation?.last_server_timestamp(), ConversationEntity.TIMESTAMP_TYPE.LAST_READ, ); + activeConversation?.initialMessage(undefined); if (!activeConversation?.hasLastReceivedMessageLoaded()) { activeConversation?.release(); amplify.publish(WebAppEvents.CONVERSATION.SHOW, activeConversation, {}); + } else { + rerenderMessageList(); } - // scroll }; return ( @@ -533,6 +531,7 @@ export const Conversation = ({ })} setIsConversationLoaded(!loading)} getVisibleCallback={getInViewportCallback} - getVisibleEachTimeCallback={(isVisible, _conversationEntity, messageEntity) => - visibleInViewportCallback(isVisible, messageEntity) - } isLastReceivedMessage={isLastReceivedMessage} isMsgElementsFocusable={isMsgElementsFocusable} setMsgElementsFocusable={setMsgElementsFocusable} @@ -561,11 +557,10 @@ export const Conversation = ({ { onGoToLastMessage: () => void; - messageVisibility: ko.Observable; conversation: ConversationEntity; } -// conversation.last_event_timestamp doesn't contain system messages -const lastMessageId = (conversation: ConversationEntity): string => { - if (!conversation.hasLastReceivedMessageLoaded() || (conversation.messages()?.length || 0) === 0) { - return ''; - } - return conversation.messages()[conversation.messages().length - 1].id; +export const isLastReceivedMessage = ( + messageEntity: MessageEntity, + conversationEntity: ConversationEntity, +): boolean => { + const messagesLength = conversationEntity.messages()?.length || 0; + return ( + !!messageEntity.timestamp() && + conversationEntity.hasLastReceivedMessageLoaded() && + !!messagesLength && + conversationEntity.messages()[messagesLength - 1].id === messageEntity.id + ); }; export const LastMessageVisibilityTracker: FC = ({ onGoToLastMessage, - messageVisibility, conversation, ...rest }: LastMessageVisibilityTrackerProps) => { - const [lastMessageShown, setLastMessageShown] = useState(false); - - useEffect(() => { - const subscription = messageVisibility.subscribe(({message, isVisible}: MessageVisibility) => { - if (!conversation.hasLastReceivedMessageLoaded()) { - setLastMessageShown(false); - } else if (message.id === lastMessageId(conversation)) { - setLastMessageShown(isVisible); - } - }); - - return () => { - subscription.dispose(); - }; - }, []); + const {isLastMessageVisible} = useKoSubscribableChildren(conversation, ['isLastMessageVisible']); - if (lastMessageShown) { + if (isLastMessageVisible) { return null; } diff --git a/src/script/components/MessagesList/Message/index.tsx b/src/script/components/MessagesList/Message/index.tsx index 1cb395f056c..da6dab41014 100644 --- a/src/script/components/MessagesList/Message/index.tsx +++ b/src/script/components/MessagesList/Message/index.tsx @@ -69,7 +69,7 @@ export interface MessageParams extends MessageActions { }; messageRepository: MessageRepository; onVisible?: () => void; - onVisibilityChange?: (isVisible: boolean) => void; + onVisibilityLost?: () => void; selfId: QualifiedId; shouldShowInvitePeople: boolean; teamState?: TeamState; @@ -89,7 +89,7 @@ export const Message: React.FC = p isHighlighted, hideHeader, onVisible, - onVisibilityChange, + onVisibilityLost, scrollTo, isFocused, handleFocus, @@ -161,7 +161,7 @@ export const Message: React.FC = p allowBiggerThanViewport checkOverlay onVisible={onVisible} - onVisibilityChange={onVisibilityChange} + onVisibilityLost={onVisibilityLost} > {content} diff --git a/src/script/components/MessagesList/MessageList.tsx b/src/script/components/MessagesList/MessageList.tsx index ad454f3e689..668487f5da1 100644 --- a/src/script/components/MessagesList/MessageList.tsx +++ b/src/script/components/MessagesList/MessageList.tsx @@ -23,6 +23,7 @@ import {TabIndex} from '@wireapp/react-ui-kit/lib/types/enums'; import cx from 'classnames'; import {FadingScrollbar} from 'Components/FadingScrollbar'; +import {isLastReceivedMessage} from 'Components/LastMessageVisibilityTracker'; import {filterMessages} from 'Components/MessagesList/utils/messagesFilter'; import {ConversationRepository} from 'src/script/conversation/ConversationRepository'; import {MessageRepository} from 'src/script/conversation/MessageRepository'; @@ -43,7 +44,7 @@ import {ScrollToElement} from './Message/types'; import {groupMessagesBySenderAndTime, isMarker} from './utils/messagesGroup'; import {updateScroll, FocusedElement} from './utils/scrollUpdater'; -import {Conversation as ConversationEntity, Conversation} from '../../entity/Conversation'; +import {Conversation} from '../../entity/Conversation'; import {isContentMessage} from '../../guards/Message'; interface MessagesListParams { @@ -66,7 +67,6 @@ interface MessagesListParams { showMessageReactions: (message: MessageEntity, showReactions?: boolean) => void; showParticipants: (users: User[]) => void; showUserDetails: (user: User | ServiceEntity) => void; - isLastReceivedMessage: (messageEntity: MessageEntity, conversationEntity: ConversationEntity) => boolean; isMsgElementsFocusable: boolean; setMsgElementsFocusable: (isMsgElementsFocusable: boolean) => void; isRightSidebarOpen?: boolean; @@ -89,7 +89,6 @@ export const MessagesList: FC = ({ invitePeople, messageActions, onLoading, - isLastReceivedMessage, isMsgElementsFocusable, setMsgElementsFocusable, isRightSidebarOpen = false, @@ -253,7 +252,7 @@ export const MessagesList: FC = ({ tabIndex={TabIndex.UNFOCUSABLE} >
- {groupedMessages.flatMap(group => { + {groupedMessages.flatMap((group, groupIndex) => { if (isMarker(group)) { return ( @@ -261,10 +260,23 @@ export const MessagesList: FC = ({ } const {messages, firstMessageTimestamp} = group; - return messages.map(message => { + return messages.map((message, messageIndex) => { const isLastDeliveredMessage = lastDeliveredMessage?.id === message.id; + const isLastLoadedMessage = + groupIndex === groupedMessages.length - 1 && messageIndex === messages.length - 1; - const visibleCallback = getVisibleCallback(conversation, message); + const isLastMessage = isLastLoadedMessage && isLastReceivedMessage(message, conversation); + + const visibleCallback = () => { + getVisibleCallback(conversation, message)?.(); + if (isLastMessage) { + conversation.isLastMessageVisible(true); + } + }; + + const lastMessageInvisibleCallback = isLastMessage + ? () => conversation.isLastMessageVisible(false) + : undefined; const key = `${message.id || 'message'}-${message.timestamp()}`; @@ -275,6 +287,7 @@ export const MessagesList: FC = ({ ; public readonly removed_from_conversation: ko.PureComputed; public readonly roles: ko.Observable>; + public readonly isLastMessageVisible: ko.Observable; public readonly selfUser: ko.Observable; public readonly servicesCount: ko.PureComputed; public readonly showNotificationsEverything: ko.PureComputed; @@ -206,6 +207,7 @@ export class Conversation { this.teamId = undefined; this.type = ko.observable(); + this.isLastMessageVisible = ko.observable(true); this.isLoadingMessages = ko.observable(false); this.isTextInputReady = ko.observable(false); diff --git a/src/script/hooks/useComponentRerenderKey.ts b/src/script/hooks/useComponentRerenderKey.ts new file mode 100644 index 00000000000..bd7dfefdd30 --- /dev/null +++ b/src/script/hooks/useComponentRerenderKey.ts @@ -0,0 +1,30 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {useState, Key} from 'react'; + +export const useComponentRerenderKey = (baseKey?: Key) => { + const [rerenderCount, setRerenderCount] = useState(0); + + const rerender = () => setRerenderCount(prev => prev + 1); + + const key = baseKey ? `${baseKey}-rerender-${rerenderCount}` : `rerender-${rerenderCount}`; + + return [key, rerender] as const; +}; From e0167f6f15e2f712d1ffb490e76a0b41f6eb1127 Mon Sep 17 00:00:00 2001 From: Virgile <78490891+V-Gira@users.noreply.github.com> Date: Wed, 29 May 2024 12:31:22 +0200 Subject: [PATCH 04/16] runfix: add breakpoint to bottom positioning (#17483) --- src/script/components/Conversation/Conversation.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/script/components/Conversation/Conversation.tsx b/src/script/components/Conversation/Conversation.tsx index 3ccc45cee7a..d9c4facb8f7 100644 --- a/src/script/components/Conversation/Conversation.tsx +++ b/src/script/components/Conversation/Conversation.tsx @@ -126,6 +126,7 @@ export const Conversation = ({ // To be changed when design chooses a breakpoint, the conditional can be integrated to the ui-kit directly const smBreakpoint = useMatchMedia('max-width: 640px'); + const mdBreakpoint = useMatchMedia('max-width: 768px'); const {addReadReceiptToBatch} = useReadReceiptSender(repositories.message); @@ -559,7 +560,7 @@ export const Conversation = ({ conversation={activeConversation} css={{ position: 'absolute', - bottom: '56px', + bottom: mdBreakpoint ? '100px' : '56px', right: '10px', height: '40px', borderRadius: '100%', From 21436850346a5f2428c157251eaec480bbcf0f4e Mon Sep 17 00:00:00 2001 From: Roma Koval Date: Wed, 29 May 2024 14:25:36 +0200 Subject: [PATCH 05/16] Fix the way conversation is marked as read; Make isLastReceivedMessage independent function and use it for last message visibility; Fix tests; --- setupTests.js | 7 +++ .../components/Conversation/Conversation.tsx | 61 +++++++++++------- .../JumpToLastMessageButton.test.tsx | 47 ++++++++++++++ .../LastMessageVisibilityTracker.tsx | 63 ------------------- .../LastMessageVisibilityTracker/index.tsx | 20 ------ .../MessagesList/MessageList.test.tsx | 1 - .../components/MessagesList/MessageList.tsx | 5 +- src/script/entity/Conversation.ts | 4 ++ 8 files changed, 99 insertions(+), 109 deletions(-) create mode 100644 src/script/components/Conversation/JumpToLastMessageButton.test.tsx delete mode 100644 src/script/components/LastMessageVisibilityTracker/LastMessageVisibilityTracker.tsx delete mode 100644 src/script/components/LastMessageVisibilityTracker/index.tsx diff --git a/setupTests.js b/setupTests.js index 8bd74c2829b..8066e685a75 100644 --- a/setupTests.js +++ b/setupTests.js @@ -68,5 +68,12 @@ window.z = {userPermission: {}}; window.URL.createObjectURL = jest.fn(); window.URL.revokeObjectURL = jest.fn(); +Object.defineProperty(document, 'elementFromPoint', { + writable: true, + value: jest.fn().mockImplementation((x, y) => { + return null; + }), +}); + const testLib = require('@testing-library/react'); testLib.configure({testIdAttribute: 'data-uie-name'}); diff --git a/src/script/components/Conversation/Conversation.tsx b/src/script/components/Conversation/Conversation.tsx index d9c4facb8f7..9388b49260d 100644 --- a/src/script/components/Conversation/Conversation.tsx +++ b/src/script/components/Conversation/Conversation.tsx @@ -17,20 +17,19 @@ * */ -import {UIEvent, useCallback, useState} from 'react'; +import {FC, HTMLProps, UIEvent, useCallback, useState} from 'react'; import {amplify} from 'amplify'; import cx from 'classnames'; import {container} from 'tsyringe'; -import {useMatchMedia} from '@wireapp/react-ui-kit'; +import {ChevronIcon, IconButton, useMatchMedia} from '@wireapp/react-ui-kit'; import {WebAppEvents} from '@wireapp/webapp-events'; import {CallingCell} from 'Components/calling/CallingCell'; import {DropFileArea} from 'Components/DropFileArea'; import {Giphy} from 'Components/Giphy'; import {InputBar} from 'Components/InputBar'; -import {LastMessageVisibilityTracker} from 'Components/LastMessageVisibilityTracker'; import {MessagesList} from 'Components/MessagesList'; import {showDetailViewModal} from 'Components/Modals/DetailViewModal'; import {PrimaryModal} from 'Components/Modals/PrimaryModal'; @@ -53,7 +52,7 @@ import {ReadOnlyConversationMessage} from './ReadOnlyConversationMessage'; import {checkFileSharingPermission} from './utils/checkFileSharingPermission'; import {ConversationState} from '../../conversation/ConversationState'; -import {Conversation as ConversationEntity} from '../../entity/Conversation'; +import {Conversation as ConversationEntity, isLastReceivedMessage} from '../../entity/Conversation'; import {ContentMessage} from '../../entity/message/ContentMessage'; import {DecryptErrorMessage} from '../../entity/message/DecryptErrorMessage'; import {MemberMessage} from '../../entity/message/MemberMessage'; @@ -388,16 +387,13 @@ export const Conversation = ({ } }; - const isLastReceivedMessage = (messageEntity: Message, conversationEntity: ConversationEntity): boolean => { - return !!messageEntity.timestamp() && messageEntity.timestamp() >= conversationEntity.last_event_timestamp(); - }; - - const updateConversationLastRead = (conversationEntity: ConversationEntity, messageEntity: Message): void => { + const updateConversationLastRead = (conversationEntity: ConversationEntity, messageEntity?: Message): void => { const conversationLastRead = conversationEntity.last_read_timestamp(); const lastKnownTimestamp = conversationEntity.getLastKnownTimestamp(repositories.serverTime.toServerTimestamp()); const needsUpdate = conversationLastRead < lastKnownTimestamp; - if (needsUpdate && isLastReceivedMessage(messageEntity, conversationEntity)) { + // if no message provided it means we need to jump to the last message + if (needsUpdate && (!messageEntity || isLastReceivedMessage(messageEntity, conversationEntity))) { conversationEntity.setTimestamp(lastKnownTimestamp, ConversationEntity.TIMESTAMP_TYPE.LAST_READ); repositories.message.markAsRead(conversationEntity); } @@ -469,16 +465,15 @@ export const Conversation = ({ ); const onGoToLastMessage = () => { - activeConversation?.setTimestamp( - activeConversation?.last_server_timestamp(), - ConversationEntity.TIMESTAMP_TYPE.LAST_READ, - ); - activeConversation?.initialMessage(undefined); - if (!activeConversation?.hasLastReceivedMessageLoaded()) { - activeConversation?.release(); - amplify.publish(WebAppEvents.CONVERSATION.SHOW, activeConversation, {}); - } else { - rerenderMessageList(); + if (activeConversation) { + activeConversation.initialMessage(undefined); + if (!activeConversation.hasLastReceivedMessageLoaded()) { + updateConversationLastRead(activeConversation); + activeConversation.release(); + amplify.publish(WebAppEvents.CONVERSATION.SHOW, activeConversation, {}); + } else { + rerenderMessageList(); + } } }; @@ -549,13 +544,12 @@ export const Conversation = ({ onClickMessage={handleClickOnMessage} onLoading={loading => setIsConversationLoaded(!loading)} getVisibleCallback={getInViewportCallback} - isLastReceivedMessage={isLastReceivedMessage} isMsgElementsFocusable={isMsgElementsFocusable} setMsgElementsFocusable={setMsgElementsFocusable} isRightSidebarOpen={isRightSidebarOpen} /> - ); }; + +interface JumpToLastMessageButtonProps extends HTMLProps { + onGoToLastMessage: () => void; + conversation: ConversationEntity; +} + +export const JumpToLastMessageButton: FC = ({ + onGoToLastMessage, + conversation, + ...rest +}: JumpToLastMessageButtonProps) => { + const {isLastMessageVisible} = useKoSubscribableChildren(conversation, ['isLastMessageVisible']); + + if (isLastMessageVisible) { + return null; + } + + return ( + + + + ); +}; diff --git a/src/script/components/Conversation/JumpToLastMessageButton.test.tsx b/src/script/components/Conversation/JumpToLastMessageButton.test.tsx new file mode 100644 index 00000000000..93b78bbddc9 --- /dev/null +++ b/src/script/components/Conversation/JumpToLastMessageButton.test.tsx @@ -0,0 +1,47 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {render} from '@testing-library/react'; + +import {JumpToLastMessageButton} from 'Components/Conversation/Conversation'; + +import {generateConversation} from '../../../../test/helper/ConversationGenerator'; +import {withTheme} from '../../auth/util/test/TestUtil'; + +describe('JumpToLastMessageButton', () => { + const conversation = generateConversation(); + + it('visible when last message is not shown', () => { + conversation.isLastMessageVisible(false); + const {getByTestId} = render( + withTheme(), + ); + + expect(getByTestId('jump-to-last-message-button')).toBeTruthy(); + }); + + it('hidden when last message is shown', () => { + conversation.isLastMessageVisible(true); + const {queryByTestId} = render( + withTheme(), + ); + + expect(queryByTestId('jump-to-last-message-button')).toBeNull(); + }); +}); diff --git a/src/script/components/LastMessageVisibilityTracker/LastMessageVisibilityTracker.tsx b/src/script/components/LastMessageVisibilityTracker/LastMessageVisibilityTracker.tsx deleted file mode 100644 index e1f55ff1958..00000000000 --- a/src/script/components/LastMessageVisibilityTracker/LastMessageVisibilityTracker.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - * - */ - -import {HTMLProps, FC} from 'react'; - -import {ChevronIcon, IconButton} from '@wireapp/react-ui-kit'; - -import {useKoSubscribableChildren} from 'Util/ComponentUtil'; - -import {Conversation as ConversationEntity} from '../../entity/Conversation'; -import {Message as MessageEntity} from '../../entity/message/Message'; - -export interface LastMessageVisibilityTrackerProps extends HTMLProps { - onGoToLastMessage: () => void; - conversation: ConversationEntity; -} - -export const isLastReceivedMessage = ( - messageEntity: MessageEntity, - conversationEntity: ConversationEntity, -): boolean => { - const messagesLength = conversationEntity.messages()?.length || 0; - return ( - !!messageEntity.timestamp() && - conversationEntity.hasLastReceivedMessageLoaded() && - !!messagesLength && - conversationEntity.messages()[messagesLength - 1].id === messageEntity.id - ); -}; - -export const LastMessageVisibilityTracker: FC = ({ - onGoToLastMessage, - conversation, - ...rest -}: LastMessageVisibilityTrackerProps) => { - const {isLastMessageVisible} = useKoSubscribableChildren(conversation, ['isLastMessageVisible']); - - if (isLastMessageVisible) { - return null; - } - - return ( - - - - ); -}; diff --git a/src/script/components/LastMessageVisibilityTracker/index.tsx b/src/script/components/LastMessageVisibilityTracker/index.tsx deleted file mode 100644 index 96796a695c1..00000000000 --- a/src/script/components/LastMessageVisibilityTracker/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - * - */ - -export * from './LastMessageVisibilityTracker'; diff --git a/src/script/components/MessagesList/MessageList.test.tsx b/src/script/components/MessagesList/MessageList.test.tsx index b40a9fc1a50..387d9eac20f 100644 --- a/src/script/components/MessagesList/MessageList.test.tsx +++ b/src/script/components/MessagesList/MessageList.test.tsx @@ -45,7 +45,6 @@ const getDefaultParams = (): React.ComponentProps => { } as any, getVisibleCallback: jest.fn(), invitePeople: jest.fn(), - isLastReceivedMessage: jest.fn(), messageActions: { deleteMessage: jest.fn(), deleteMessageEveryone: jest.fn(), diff --git a/src/script/components/MessagesList/MessageList.tsx b/src/script/components/MessagesList/MessageList.tsx index 668487f5da1..6d5fa6cd5aa 100644 --- a/src/script/components/MessagesList/MessageList.tsx +++ b/src/script/components/MessagesList/MessageList.tsx @@ -23,7 +23,6 @@ import {TabIndex} from '@wireapp/react-ui-kit/lib/types/enums'; import cx from 'classnames'; import {FadingScrollbar} from 'Components/FadingScrollbar'; -import {isLastReceivedMessage} from 'Components/LastMessageVisibilityTracker'; import {filterMessages} from 'Components/MessagesList/utils/messagesFilter'; import {ConversationRepository} from 'src/script/conversation/ConversationRepository'; import {MessageRepository} from 'src/script/conversation/MessageRepository'; @@ -44,7 +43,7 @@ import {ScrollToElement} from './Message/types'; import {groupMessagesBySenderAndTime, isMarker} from './utils/messagesGroup'; import {updateScroll, FocusedElement} from './utils/scrollUpdater'; -import {Conversation} from '../../entity/Conversation'; +import {Conversation, isLastReceivedMessage} from '../../entity/Conversation'; import {isContentMessage} from '../../guards/Message'; interface MessagesListParams { @@ -265,7 +264,7 @@ export const MessagesList: FC = ({ const isLastLoadedMessage = groupIndex === groupedMessages.length - 1 && messageIndex === messages.length - 1; - const isLastMessage = isLastLoadedMessage && isLastReceivedMessage(message, conversation); + const isLastMessage = isLastLoadedMessage && conversation.hasLastReceivedMessageLoaded(); const visibleCallback = () => { getVisibleCallback(conversation, message)?.(); diff --git a/src/script/entity/Conversation.ts b/src/script/entity/Conversation.ts index 5c3e6c89c75..6fa18330840 100644 --- a/src/script/entity/Conversation.ts +++ b/src/script/entity/Conversation.ts @@ -82,6 +82,10 @@ enum TIMESTAMP_TYPE { MUTED = 'mutedTimestamp', } +export const isLastReceivedMessage = (messageEntity: Message, conversationEntity: Conversation): boolean => { + return messageEntity.timestamp() >= conversationEntity.last_event_timestamp(); +}; + export class Conversation { private readonly teamState: TeamState; public readonly archivedState: ko.Observable; From 60cffd0e741ad8f4e71ad0b9411e1a52f1457b34 Mon Sep 17 00:00:00 2001 From: Roma Koval Date: Thu, 30 May 2024 12:34:01 +0200 Subject: [PATCH 06/16] Move JumpToLastMessageButton into a separate file --- .../components/Conversation/Conversation.tsx | 28 ++--------- .../JumpToLastMessageButton.test.tsx | 2 +- .../Conversation/JumpToLastMessageButton.tsx | 48 +++++++++++++++++++ 3 files changed, 52 insertions(+), 26 deletions(-) create mode 100644 src/script/components/Conversation/JumpToLastMessageButton.tsx diff --git a/src/script/components/Conversation/Conversation.tsx b/src/script/components/Conversation/Conversation.tsx index 9388b49260d..378426f1b04 100644 --- a/src/script/components/Conversation/Conversation.tsx +++ b/src/script/components/Conversation/Conversation.tsx @@ -17,16 +17,17 @@ * */ -import {FC, HTMLProps, UIEvent, useCallback, useState} from 'react'; +import {UIEvent, useCallback, useState} from 'react'; import {amplify} from 'amplify'; import cx from 'classnames'; import {container} from 'tsyringe'; -import {ChevronIcon, IconButton, useMatchMedia} from '@wireapp/react-ui-kit'; +import {useMatchMedia} from '@wireapp/react-ui-kit'; import {WebAppEvents} from '@wireapp/webapp-events'; import {CallingCell} from 'Components/calling/CallingCell'; +import {JumpToLastMessageButton} from 'Components/Conversation/JumpToLastMessageButton'; import {DropFileArea} from 'Components/DropFileArea'; import {Giphy} from 'Components/Giphy'; import {InputBar} from 'Components/InputBar'; @@ -596,26 +597,3 @@ export const Conversation = ({ ); }; - -interface JumpToLastMessageButtonProps extends HTMLProps { - onGoToLastMessage: () => void; - conversation: ConversationEntity; -} - -export const JumpToLastMessageButton: FC = ({ - onGoToLastMessage, - conversation, - ...rest -}: JumpToLastMessageButtonProps) => { - const {isLastMessageVisible} = useKoSubscribableChildren(conversation, ['isLastMessageVisible']); - - if (isLastMessageVisible) { - return null; - } - - return ( - - - - ); -}; diff --git a/src/script/components/Conversation/JumpToLastMessageButton.test.tsx b/src/script/components/Conversation/JumpToLastMessageButton.test.tsx index 93b78bbddc9..0e9398ee641 100644 --- a/src/script/components/Conversation/JumpToLastMessageButton.test.tsx +++ b/src/script/components/Conversation/JumpToLastMessageButton.test.tsx @@ -19,7 +19,7 @@ import {render} from '@testing-library/react'; -import {JumpToLastMessageButton} from 'Components/Conversation/Conversation'; +import {JumpToLastMessageButton} from 'Components/Conversation/JumpToLastMessageButton'; import {generateConversation} from '../../../../test/helper/ConversationGenerator'; import {withTheme} from '../../auth/util/test/TestUtil'; diff --git a/src/script/components/Conversation/JumpToLastMessageButton.tsx b/src/script/components/Conversation/JumpToLastMessageButton.tsx new file mode 100644 index 00000000000..5493a2ab9b9 --- /dev/null +++ b/src/script/components/Conversation/JumpToLastMessageButton.tsx @@ -0,0 +1,48 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {FC, HTMLProps} from 'react'; + +import {ChevronIcon, IconButton} from '@wireapp/react-ui-kit'; + +import {useKoSubscribableChildren} from 'Util/ComponentUtil'; + +import {Conversation as ConversationEntity} from '../../entity/Conversation'; + +export interface JumpToLastMessageButtonProps extends HTMLProps { + onGoToLastMessage: () => void; + conversation: ConversationEntity; +} + +export const JumpToLastMessageButton: FC = ({ + onGoToLastMessage, + conversation, +}: JumpToLastMessageButtonProps) => { + const {isLastMessageVisible} = useKoSubscribableChildren(conversation, ['isLastMessageVisible']); + + if (isLastMessageVisible) { + return null; + } + + return ( + + + + ); +}; From b0b8687dcbc0400d225fc99407f5623a0dc2aa13 Mon Sep 17 00:00:00 2001 From: Roma Koval Date: Thu, 30 May 2024 12:39:52 +0200 Subject: [PATCH 07/16] Add comment for a non-trivial usage of `key` to enforce component re-render; Add comments for jump to the last message function; Make options optional in `showConversation`; Fix CSS for the button and disable eslint for the WithConditionalCSSProp impert; --- src/script/components/Conversation/Conversation.tsx | 9 ++++++--- .../Conversation/JumpToLastMessageButton.tsx | 13 +++++++++++-- src/script/page/AppMain.tsx | 2 +- src/script/view_model/ContentViewModel.ts | 8 ++++---- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/script/components/Conversation/Conversation.tsx b/src/script/components/Conversation/Conversation.tsx index 378426f1b04..755d78aa387 100644 --- a/src/script/components/Conversation/Conversation.tsx +++ b/src/script/components/Conversation/Conversation.tsx @@ -122,6 +122,7 @@ export const Conversation = ({ const [isMsgElementsFocusable, setMsgElementsFocusable] = useState(true); + // by changing the key of MessageList we can enforce it to re-render const [messagesListRerenderKey, rerenderMessageList] = useComponentRerenderKey('messages-list'); // To be changed when design chooses a breakpoint, the conditional can be integrated to the ui-kit directly @@ -465,14 +466,17 @@ export const Conversation = ({ [addReadReceiptToBatch, repositories.conversation, repositories.integration, updateConversationLastRead], ); - const onGoToLastMessage = () => { + const jumpToLastMessage = () => { if (activeConversation) { + // clean up anything like search result activeConversation.initialMessage(undefined); + // if there are unloaded messages, the conversation should be marked as read and reloaded if (!activeConversation.hasLastReceivedMessageLoaded()) { updateConversationLastRead(activeConversation); activeConversation.release(); amplify.publish(WebAppEvents.CONVERSATION.SHOW, activeConversation, {}); } else { + // else we just need to scroll down, by re-rendering MessageList rerenderMessageList(); } } @@ -551,14 +555,13 @@ export const Conversation = ({ /> diff --git a/src/script/components/Conversation/JumpToLastMessageButton.tsx b/src/script/components/Conversation/JumpToLastMessageButton.tsx index 5493a2ab9b9..30f649cd213 100644 --- a/src/script/components/Conversation/JumpToLastMessageButton.tsx +++ b/src/script/components/Conversation/JumpToLastMessageButton.tsx @@ -19,13 +19,16 @@ import {FC, HTMLProps} from 'react'; +// eslint-disable-next-line import/no-unresolved +import {WithConditionalCSSProp} from '@emotion/react/types/jsx-namespace'; + import {ChevronIcon, IconButton} from '@wireapp/react-ui-kit'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {Conversation as ConversationEntity} from '../../entity/Conversation'; -export interface JumpToLastMessageButtonProps extends HTMLProps { +export interface JumpToLastMessageButtonProps extends WithConditionalCSSProp> { onGoToLastMessage: () => void; conversation: ConversationEntity; } @@ -33,6 +36,7 @@ export interface JumpToLastMessageButtonProps extends HTMLProps { export const JumpToLastMessageButton: FC = ({ onGoToLastMessage, conversation, + ...rest }: JumpToLastMessageButtonProps) => { const {isLastMessageVisible} = useKoSubscribableChildren(conversation, ['isLastMessageVisible']); @@ -41,7 +45,12 @@ export const JumpToLastMessageButton: FC = ({ } return ( - + ); diff --git a/src/script/page/AppMain.tsx b/src/script/page/AppMain.tsx index 6bd6282dec7..4b285beb443 100644 --- a/src/script/page/AppMain.tsx +++ b/src/script/page/AppMain.tsx @@ -159,7 +159,7 @@ export const AppMain: FC = ({ configureRoutes({ '/': showMostRecentConversation, '/conversation/:conversationId(/:domain)': (conversationId: string, domain: string = apiContext.domain ?? '') => - mainView.content.showConversation({id: conversationId, domain}, {}), + mainView.content.showConversation({id: conversationId, domain}), '/preferences/about': () => mainView.list.openPreferencesAbout(), '/preferences/account': () => mainView.list.openPreferencesAccount(), '/preferences/av': () => mainView.list.openPreferencesAudioVideo(), diff --git a/src/script/view_model/ContentViewModel.ts b/src/script/view_model/ContentViewModel.ts index acb79bbf7a9..8e9a98b7f09 100644 --- a/src/script/view_model/ContentViewModel.ts +++ b/src/script/view_model/ContentViewModel.ts @@ -56,8 +56,8 @@ interface ShowConversationOptions { } interface ShowConversationOverload { - (conversation: Conversation | undefined, options: ShowConversationOptions): Promise; - (conversationId: QualifiedId, options: ShowConversationOptions): Promise; + (conversation: Conversation | undefined, options?: ShowConversationOptions): Promise; + (conversationId: QualifiedId, options?: ShowConversationOptions): Promise; } export class ContentViewModel { @@ -92,7 +92,7 @@ export class ContentViewModel { const showMostRecentConversation = () => { const mostRecentConversation = this.conversationState.getMostRecentConversation(); - this.showConversation(mostRecentConversation, {}); + this.showConversation(mostRecentConversation); }; this.userState.connectRequests.subscribe(requests => { @@ -233,7 +233,7 @@ export class ContentViewModel { */ readonly showConversation: ShowConversationOverload = async ( conversation: Conversation | QualifiedId | undefined, - options: ShowConversationOptions, + options?: ShowConversationOptions, ) => { const { exposeMessage: exposeMessageEntity, From d901309032fea519c8708708e084039115587784 Mon Sep 17 00:00:00 2001 From: Roma Koval Date: Mon, 3 Jun 2024 11:33:24 +0200 Subject: [PATCH 08/16] Move jump to last message into MessageList; Make JumpToLastMessageButton part of MessageList file to not let it be used outside; Use debounce to avoid too often button visibility change; Make onVisibilityLost call on component unmount configurable; --- .../components/Conversation/Conversation.tsx | 37 +-- .../JumpToLastMessageButton.test.tsx | 47 ---- .../Conversation/JumpToLastMessageButton.tsx | 57 ---- .../components/MessagesList/MessageList.tsx | 247 ++++++++++++------ src/script/components/utils/InViewport.tsx | 6 +- src/script/hooks/useComponentRerenderKey.ts | 30 --- 6 files changed, 167 insertions(+), 257 deletions(-) delete mode 100644 src/script/components/Conversation/JumpToLastMessageButton.test.tsx delete mode 100644 src/script/components/Conversation/JumpToLastMessageButton.tsx delete mode 100644 src/script/hooks/useComponentRerenderKey.ts diff --git a/src/script/components/Conversation/Conversation.tsx b/src/script/components/Conversation/Conversation.tsx index 755d78aa387..b656c0d965b 100644 --- a/src/script/components/Conversation/Conversation.tsx +++ b/src/script/components/Conversation/Conversation.tsx @@ -19,15 +19,12 @@ import {UIEvent, useCallback, useState} from 'react'; -import {amplify} from 'amplify'; import cx from 'classnames'; import {container} from 'tsyringe'; import {useMatchMedia} from '@wireapp/react-ui-kit'; -import {WebAppEvents} from '@wireapp/webapp-events'; import {CallingCell} from 'Components/calling/CallingCell'; -import {JumpToLastMessageButton} from 'Components/Conversation/JumpToLastMessageButton'; import {DropFileArea} from 'Components/DropFileArea'; import {Giphy} from 'Components/Giphy'; import {InputBar} from 'Components/InputBar'; @@ -38,7 +35,6 @@ import {showWarningModal} from 'Components/Modals/utils/showWarningModal'; import {TitleBar} from 'Components/TitleBar'; import {CallingViewMode, CallState} from 'src/script/calling/CallState'; import {Config} from 'src/script/Config'; -import {useComponentRerenderKey} from 'src/script/hooks/useComponentRerenderKey'; import {PROPERTIES_TYPE} from 'src/script/properties/PropertiesType'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {allowsAllFiles, getFileExtensionOrName, hasAllowedExtension} from 'Util/FileTypeUtil'; @@ -122,12 +118,8 @@ export const Conversation = ({ const [isMsgElementsFocusable, setMsgElementsFocusable] = useState(true); - // by changing the key of MessageList we can enforce it to re-render - const [messagesListRerenderKey, rerenderMessageList] = useComponentRerenderKey('messages-list'); - // To be changed when design chooses a breakpoint, the conditional can be integrated to the ui-kit directly const smBreakpoint = useMatchMedia('max-width: 640px'); - const mdBreakpoint = useMatchMedia('max-width: 768px'); const {addReadReceiptToBatch} = useReadReceiptSender(repositories.message); @@ -466,22 +458,6 @@ export const Conversation = ({ [addReadReceiptToBatch, repositories.conversation, repositories.integration, updateConversationLastRead], ); - const jumpToLastMessage = () => { - if (activeConversation) { - // clean up anything like search result - activeConversation.initialMessage(undefined); - // if there are unloaded messages, the conversation should be marked as read and reloaded - if (!activeConversation.hasLastReceivedMessageLoaded()) { - updateConversationLastRead(activeConversation); - activeConversation.release(); - amplify.publish(WebAppEvents.CONVERSATION.SHOW, activeConversation, {}); - } else { - // else we just need to scroll down, by re-rendering MessageList - rerenderMessageList(); - } - } - }; - return ( - - {isConversationLoaded && diff --git a/src/script/components/Conversation/JumpToLastMessageButton.test.tsx b/src/script/components/Conversation/JumpToLastMessageButton.test.tsx deleted file mode 100644 index 0e9398ee641..00000000000 --- a/src/script/components/Conversation/JumpToLastMessageButton.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - * - */ - -import {render} from '@testing-library/react'; - -import {JumpToLastMessageButton} from 'Components/Conversation/JumpToLastMessageButton'; - -import {generateConversation} from '../../../../test/helper/ConversationGenerator'; -import {withTheme} from '../../auth/util/test/TestUtil'; - -describe('JumpToLastMessageButton', () => { - const conversation = generateConversation(); - - it('visible when last message is not shown', () => { - conversation.isLastMessageVisible(false); - const {getByTestId} = render( - withTheme(), - ); - - expect(getByTestId('jump-to-last-message-button')).toBeTruthy(); - }); - - it('hidden when last message is shown', () => { - conversation.isLastMessageVisible(true); - const {queryByTestId} = render( - withTheme(), - ); - - expect(queryByTestId('jump-to-last-message-button')).toBeNull(); - }); -}); diff --git a/src/script/components/Conversation/JumpToLastMessageButton.tsx b/src/script/components/Conversation/JumpToLastMessageButton.tsx deleted file mode 100644 index 30f649cd213..00000000000 --- a/src/script/components/Conversation/JumpToLastMessageButton.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - * - */ - -import {FC, HTMLProps} from 'react'; - -// eslint-disable-next-line import/no-unresolved -import {WithConditionalCSSProp} from '@emotion/react/types/jsx-namespace'; - -import {ChevronIcon, IconButton} from '@wireapp/react-ui-kit'; - -import {useKoSubscribableChildren} from 'Util/ComponentUtil'; - -import {Conversation as ConversationEntity} from '../../entity/Conversation'; - -export interface JumpToLastMessageButtonProps extends WithConditionalCSSProp> { - onGoToLastMessage: () => void; - conversation: ConversationEntity; -} - -export const JumpToLastMessageButton: FC = ({ - onGoToLastMessage, - conversation, - ...rest -}: JumpToLastMessageButtonProps) => { - const {isLastMessageVisible} = useKoSubscribableChildren(conversation, ['isLastMessageVisible']); - - if (isLastMessageVisible) { - return null; - } - - return ( - - - - ); -}; diff --git a/src/script/components/MessagesList/MessageList.tsx b/src/script/components/MessagesList/MessageList.tsx index 6d5fa6cd5aa..14fc1eb75ce 100644 --- a/src/script/components/MessagesList/MessageList.tsx +++ b/src/script/components/MessagesList/MessageList.tsx @@ -17,10 +17,15 @@ * */ -import React, {FC, useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; +import React, {FC, HTMLProps, useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; import {TabIndex} from '@wireapp/react-ui-kit/lib/types/enums'; +import {amplify} from 'amplify'; import cx from 'classnames'; +import {debounce} from 'underscore'; + +import {ChevronIcon, IconButton, useMatchMedia} from '@wireapp/react-ui-kit'; +import {WebAppEvents} from '@wireapp/webapp-events'; import {FadingScrollbar} from 'Components/FadingScrollbar'; import {filterMessages} from 'Components/MessagesList/utils/messagesFilter'; @@ -69,6 +74,7 @@ interface MessagesListParams { isMsgElementsFocusable: boolean; setMsgElementsFocusable: (isMsgElementsFocusable: boolean) => void; isRightSidebarOpen?: boolean; + updateConversationLastRead: (conversation: Conversation) => void; } export const MessagesList: FC = ({ @@ -91,6 +97,7 @@ export const MessagesList: FC = ({ isMsgElementsFocusable, setMsgElementsFocusable, isRightSidebarOpen = false, + updateConversationLastRead, }) => { const { messages: allMessages, @@ -243,92 +250,160 @@ export const MessagesList: FC = ({ syncScrollPosition(); }; + const jumpToLastMessage = () => { + if (conversation) { + // clean up anything like search result + conversation.initialMessage(undefined); + // if there are unloaded messages, the conversation should be marked as read and reloaded + if (!conversation.hasLastReceivedMessageLoaded()) { + updateConversationLastRead(conversation); + conversation.release(); + amplify.publish(WebAppEvents.CONVERSATION.SHOW, conversation, {}); + } else { + // else we just need to scroll down + messageListRef.current?.scrollTo?.({behavior: 'smooth', top: messageListRef.current.scrollHeight}); + } + } + }; + return ( - -
- {groupedMessages.flatMap((group, groupIndex) => { - if (isMarker(group)) { - return ( - - ); - } - const {messages, firstMessageTimestamp} = group; - - return messages.map((message, messageIndex) => { - const isLastDeliveredMessage = lastDeliveredMessage?.id === message.id; - const isLastLoadedMessage = - groupIndex === groupedMessages.length - 1 && messageIndex === messages.length - 1; - - const isLastMessage = isLastLoadedMessage && conversation.hasLastReceivedMessageLoaded(); - - const visibleCallback = () => { - getVisibleCallback(conversation, message)?.(); - if (isLastMessage) { - conversation.isLastMessageVisible(true); - } - }; - - const lastMessageInvisibleCallback = isLastMessage - ? () => conversation.isLastMessageVisible(false) - : undefined; - - const key = `${message.id || 'message'}-${message.timestamp()}`; - - const isHighlighted = !!highlightedMessage && highlightedMessage === message.id; - const isFocused = !!focusedId && focusedId === message.id; - - return ( - invitePeople(conversation)} - onClickReactionDetails={message => showMessageReactions(message, true)} - onClickMessage={onClickMessage} - onClickParticipants={showParticipants} - onClickDetails={message => showMessageDetails(message)} - onClickResetSession={resetSession} - onClickTimestamp={async function (messageId: string) { - setHighlightedMessage(messageId); - setTimeout(() => setHighlightedMessage(undefined), 5000); - const messageIsLoaded = conversation.getMessage(messageId); - - if (!messageIsLoaded) { - const messageEntity = await messageRepository.getMessageInConversationById(conversation, messageId); - conversation.removeMessages(); - conversationRepository.getMessagesWithOffset(conversation, messageEntity); + <> + +
+ {groupedMessages.flatMap((group, groupIndex) => { + if (isMarker(group)) { + return ( + + ); + } + const {messages, firstMessageTimestamp} = group; + + return messages.map((message, messageIndex) => { + const isLastDeliveredMessage = lastDeliveredMessage?.id === message.id; + const isLastLoadedMessage = + groupIndex === groupedMessages.length - 1 && messageIndex === messages.length - 1; + + const isLastMessage = isLastLoadedMessage && conversation.hasLastReceivedMessageLoaded(); + + const visibleCallback = () => { + getVisibleCallback(conversation, message)?.(); + if (isLastMessage) { + conversation.isLastMessageVisible(true); + } + }; + + const lastMessageInvisibleCallback = isLastMessage + ? () => { + conversation.isLastMessageVisible(false); } - }} - selfId={selfUser.qualifiedId} - shouldShowInvitePeople={shouldShowInvitePeople} - isFocused={isFocused} - handleFocus={setFocusedId} - handleArrowKeyDown={handleKeyDown} - isMsgElementsFocusable={isMsgElementsFocusable} - setMsgElementsFocusable={setMsgElementsFocusable} - /> - ); - }); - })} -
-
+ : undefined; + + const key = `${message.id || 'message'}-${message.timestamp()}`; + + const isHighlighted = !!highlightedMessage && highlightedMessage === message.id; + const isFocused = !!focusedId && focusedId === message.id; + + return ( + invitePeople(conversation)} + onClickReactionDetails={message => showMessageReactions(message, true)} + onClickMessage={onClickMessage} + onClickParticipants={showParticipants} + onClickDetails={message => showMessageDetails(message)} + onClickResetSession={resetSession} + onClickTimestamp={async function (messageId: string) { + setHighlightedMessage(messageId); + setTimeout(() => setHighlightedMessage(undefined), 5000); + const messageIsLoaded = conversation.getMessage(messageId); + + if (!messageIsLoaded) { + const messageEntity = await messageRepository.getMessageInConversationById( + conversation, + messageId, + ); + conversation.removeMessages(); + conversationRepository.getMessagesWithOffset(conversation, messageEntity); + } + }} + selfId={selfUser.qualifiedId} + shouldShowInvitePeople={shouldShowInvitePeople} + isFocused={isFocused} + handleFocus={setFocusedId} + handleArrowKeyDown={handleKeyDown} + isMsgElementsFocusable={isMsgElementsFocusable} + setMsgElementsFocusable={setMsgElementsFocusable} + /> + ); + }); + })} +
+
+ + + ); +}; + +export interface JumpToLastMessageButtonProps extends HTMLProps { + onGoToLastMessage: () => void; + conversation: Conversation; +} + +export const JumpToLastMessageButton: FC = ({ + onGoToLastMessage, + conversation, +}: JumpToLastMessageButtonProps) => { + // To be changed when design chooses a breakpoint, the conditional can be integrated to the ui-kit directly + const mdBreakpoint = useMatchMedia('max-width: 768px'); + + const [isLastMessageVisible, setIsLastMessageVisible] = useState(true); + + useEffect(() => { + const subscription = conversation.isLastMessageVisible.subscribe( + debounce(value => { + setIsLastMessageVisible(value); + }, 100), + ); + return () => subscription.dispose(); + }, [conversation]); + + if (isLastMessageVisible) { + return null; + } + + return ( + + + ); }; diff --git a/src/script/components/utils/InViewport.tsx b/src/script/components/utils/InViewport.tsx index dd77695419a..9b8cc424934 100644 --- a/src/script/components/utils/InViewport.tsx +++ b/src/script/components/utils/InViewport.tsx @@ -25,6 +25,7 @@ import {viewportObserver} from 'Util/DOM/viewportObserver'; interface InViewportParams { onVisible: () => void; onVisibilityLost?: () => void; + callVisibilityLostOnUnmount?: boolean; requireFullyInView?: boolean; allowBiggerThanViewport?: boolean; /** Will check if the element is overlayed by something else. Can be used to be sure the user could actually see the element. Should not be used to do lazy loading as the overlayObserver has quite a long debounce time */ @@ -38,6 +39,7 @@ const InViewport: React.FC> = requireFullyInView = false, checkOverlay = false, allowBiggerThanViewport = false, + callVisibilityLostOnUnmount = false, ...props }) => { const domNode = useRef(null); @@ -101,7 +103,9 @@ const InViewport: React.FC> = } return () => { // If the element is unmounted, we can trigger the onVisibilityLost callback and release the trackers - onVisibilityLost?.(); + if (callVisibilityLostOnUnmount) { + onVisibilityLost?.(); + } releaseTrackers(); }; }, [allowBiggerThanViewport, requireFullyInView, checkOverlay, onVisible, onVisibilityLost]); diff --git a/src/script/hooks/useComponentRerenderKey.ts b/src/script/hooks/useComponentRerenderKey.ts deleted file mode 100644 index bd7dfefdd30..00000000000 --- a/src/script/hooks/useComponentRerenderKey.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - * - */ - -import {useState, Key} from 'react'; - -export const useComponentRerenderKey = (baseKey?: Key) => { - const [rerenderCount, setRerenderCount] = useState(0); - - const rerender = () => setRerenderCount(prev => prev + 1); - - const key = baseKey ? `${baseKey}-rerender-${rerenderCount}` : `rerender-${rerenderCount}`; - - return [key, rerender] as const; -}; From 9f8eebef492ac713c8eaa89a948ad9f791174154 Mon Sep 17 00:00:00 2001 From: Roma Koval Date: Mon, 3 Jun 2024 18:54:35 +0200 Subject: [PATCH 09/16] Fix behavior for the corner case of search result being in the last messages batch --- .../components/MessagesList/MessageList.tsx | 22 ++++++++++++++----- .../MessagesList/utils/scrollUpdater.ts | 3 +++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/script/components/MessagesList/MessageList.tsx b/src/script/components/MessagesList/MessageList.tsx index 14fc1eb75ce..865c7678a5e 100644 --- a/src/script/components/MessagesList/MessageList.tsx +++ b/src/script/components/MessagesList/MessageList.tsx @@ -209,6 +209,11 @@ export const MessagesList: FC = ({ setTimeout(() => { setLoaded(true); onLoading(false); + // if new conversation is loaded but there are unread messages, previous conversation + // last message visibility might not be cleaned as this conversation last message is not loaded yet + if (!conversation.hasLastReceivedMessageLoaded()) { + conversation.isLastMessageVisible(false); + } }, 10); }); return () => conversation.release(); @@ -253,14 +258,19 @@ export const MessagesList: FC = ({ const jumpToLastMessage = () => { if (conversation) { // clean up anything like search result - conversation.initialMessage(undefined); + setHighlightedMessage(undefined); + focusedElement.current = null; // if there are unloaded messages, the conversation should be marked as read and reloaded if (!conversation.hasLastReceivedMessageLoaded()) { updateConversationLastRead(conversation); conversation.release(); amplify.publish(WebAppEvents.CONVERSATION.SHOW, conversation, {}); + } else if (conversation.initialMessage()) { + // if there was a search result, conversation should be reloaded (as not all messages in last batch are usually + // loaded when showing search result), then scrollUpdater will do the job in the right moment after reload + conversation.initialMessage(undefined); } else { - // else we just need to scroll down + // we just need to scroll down messageListRef.current?.scrollTo?.({behavior: 'smooth', top: messageListRef.current.scrollHeight}); } } @@ -376,15 +386,17 @@ export const JumpToLastMessageButton: FC = ({ // To be changed when design chooses a breakpoint, the conditional can be integrated to the ui-kit directly const mdBreakpoint = useMatchMedia('max-width: 768px'); - const [isLastMessageVisible, setIsLastMessageVisible] = useState(true); + const [isLastMessageVisible, setIsLastMessageVisible] = useState(conversation.isLastMessageVisible()); useEffect(() => { const subscription = conversation.isLastMessageVisible.subscribe( debounce(value => { setIsLastMessageVisible(value); - }, 100), + }, 200), ); - return () => subscription.dispose(); + return () => { + subscription.dispose(); + }; }, [conversation]); if (isLastMessageVisible) { diff --git a/src/script/components/MessagesList/utils/scrollUpdater.ts b/src/script/components/MessagesList/utils/scrollUpdater.ts index bf53b42edba..d3362eae058 100644 --- a/src/script/components/MessagesList/utils/scrollUpdater.ts +++ b/src/script/components/MessagesList/utils/scrollUpdater.ts @@ -61,6 +61,9 @@ export function updateScroll( } else if (lastMessage && lastMessage.status() === StatusType.SENDING && lastMessage.user().id === selfUserId) { // The self user just sent a message, we scroll straight to the bottom container.scrollTo?.({behavior: 'smooth', top: container.scrollHeight}); + } else { + // jump to last message was called + container.scrollTo?.({behavior: 'smooth', top: container.scrollHeight}); } return container.scrollHeight; } From e816ba3d30b51dcf71692774beb1c565fbf578b4 Mon Sep 17 00:00:00 2001 From: Roma Koval Date: Mon, 3 Jun 2024 19:32:40 +0200 Subject: [PATCH 10/16] Add test for JumpToLastMessageButton, fix MessageList test --- .../JumpToLastMessageButton.test.tsx | 47 +++++++++++++++++++ .../MessagesList/MessageList.test.tsx | 1 + 2 files changed, 48 insertions(+) create mode 100644 src/script/components/MessagesList/JumpToLastMessageButton.test.tsx diff --git a/src/script/components/MessagesList/JumpToLastMessageButton.test.tsx b/src/script/components/MessagesList/JumpToLastMessageButton.test.tsx new file mode 100644 index 00000000000..96d04fae1a8 --- /dev/null +++ b/src/script/components/MessagesList/JumpToLastMessageButton.test.tsx @@ -0,0 +1,47 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {render} from '@testing-library/react'; + +import {JumpToLastMessageButton} from 'Components/MessagesList/MessageList'; + +import {generateConversation} from '../../../../test/helper/ConversationGenerator'; +import {withTheme} from '../../auth/util/test/TestUtil'; + +describe('JumpToLastMessageButton', () => { + const conversation = generateConversation(); + + it('visible when last message is not shown', () => { + conversation.isLastMessageVisible(false); + const {getByTestId} = render( + withTheme(), + ); + + expect(getByTestId('jump-to-last-message-button')).toBeTruthy(); + }); + + it('hidden when last message is shown', () => { + conversation.isLastMessageVisible(true); + const {queryByTestId} = render( + withTheme(), + ); + + expect(queryByTestId('jump-to-last-message-button')).toBeNull(); + }); +}); diff --git a/src/script/components/MessagesList/MessageList.test.tsx b/src/script/components/MessagesList/MessageList.test.tsx index 387d9eac20f..f29d45ef935 100644 --- a/src/script/components/MessagesList/MessageList.test.tsx +++ b/src/script/components/MessagesList/MessageList.test.tsx @@ -64,6 +64,7 @@ const getDefaultParams = (): React.ComponentProps => { isMsgElementsFocusable: true, setMsgElementsFocusable: jest.fn(), showMessageReactions: jest.fn(), + updateConversationLastRead: jest.fn(), }; }; From 702115d4d9e8669377192142eed0f2e1e122e19a Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Tue, 4 Jun 2024 09:55:33 +0200 Subject: [PATCH 11/16] fix: Avoid listening to initialMessage change. Initial message is a static property that is read only when a conversation is loaded. Once loaded this value should not be read again and should not trigger re-rendering --- src/script/components/MessagesList/MessageList.tsx | 14 ++++---------- .../components/MessagesList/utils/scrollUpdater.ts | 3 --- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/script/components/MessagesList/MessageList.tsx b/src/script/components/MessagesList/MessageList.tsx index 865c7678a5e..2408bc3cf34 100644 --- a/src/script/components/MessagesList/MessageList.tsx +++ b/src/script/components/MessagesList/MessageList.tsx @@ -108,7 +108,6 @@ export const MessagesList: FC = ({ inTeam, isLoadingMessages, hasAdditionalMessages, - initialMessage, } = useKoSubscribableChildren(conversation, [ 'inTeam', 'isActiveParticipant', @@ -118,12 +117,11 @@ export const MessagesList: FC = ({ 'isGuestAndServicesRoom', 'isLoadingMessages', 'hasAdditionalMessages', - 'initialMessage', ]); const messageListRef = useRef(null); const [loaded, setLoaded] = useState(false); - const [highlightedMessage, setHighlightedMessage] = useState(initialMessage?.id); + const [highlightedMessage, setHighlightedMessage] = useState(conversation.initialMessage()?.id); const conversationLastReadTimestamp = useRef(conversation.last_read_timestamp()); const filteredMessages = filterMessages(allMessages); @@ -205,7 +203,7 @@ export const MessagesList: FC = ({ onLoading(true); setLoaded(false); conversationLastReadTimestamp.current = conversation.last_read_timestamp(); - loadConversation(conversation, initialMessage).then(() => { + loadConversation(conversation, conversation.initialMessage()).then(() => { setTimeout(() => { setLoaded(true); onLoading(false); @@ -217,7 +215,7 @@ export const MessagesList: FC = ({ }, 10); }); return () => conversation.release(); - }, [conversation, initialMessage]); + }, [conversation]); useLayoutEffect(() => { if (loaded && messageListRef.current) { @@ -258,17 +256,13 @@ export const MessagesList: FC = ({ const jumpToLastMessage = () => { if (conversation) { // clean up anything like search result - setHighlightedMessage(undefined); + //setHighlightedMessage(undefined); focusedElement.current = null; // if there are unloaded messages, the conversation should be marked as read and reloaded if (!conversation.hasLastReceivedMessageLoaded()) { updateConversationLastRead(conversation); conversation.release(); amplify.publish(WebAppEvents.CONVERSATION.SHOW, conversation, {}); - } else if (conversation.initialMessage()) { - // if there was a search result, conversation should be reloaded (as not all messages in last batch are usually - // loaded when showing search result), then scrollUpdater will do the job in the right moment after reload - conversation.initialMessage(undefined); } else { // we just need to scroll down messageListRef.current?.scrollTo?.({behavior: 'smooth', top: messageListRef.current.scrollHeight}); diff --git a/src/script/components/MessagesList/utils/scrollUpdater.ts b/src/script/components/MessagesList/utils/scrollUpdater.ts index d3362eae058..bf53b42edba 100644 --- a/src/script/components/MessagesList/utils/scrollUpdater.ts +++ b/src/script/components/MessagesList/utils/scrollUpdater.ts @@ -61,9 +61,6 @@ export function updateScroll( } else if (lastMessage && lastMessage.status() === StatusType.SENDING && lastMessage.user().id === selfUserId) { // The self user just sent a message, we scroll straight to the bottom container.scrollTo?.({behavior: 'smooth', top: container.scrollHeight}); - } else { - // jump to last message was called - container.scrollTo?.({behavior: 'smooth', top: container.scrollHeight}); } return container.scrollHeight; } From 531f01b90b3629a7b31dac5f445110b116ada850 Mon Sep 17 00:00:00 2001 From: Thomas Belin Date: Tue, 4 Jun 2024 09:59:43 +0200 Subject: [PATCH 12/16] fixup! fix: Avoid listening to initialMessage change. --- src/script/components/MessagesList/MessageList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/script/components/MessagesList/MessageList.tsx b/src/script/components/MessagesList/MessageList.tsx index 2408bc3cf34..d1e13317716 100644 --- a/src/script/components/MessagesList/MessageList.tsx +++ b/src/script/components/MessagesList/MessageList.tsx @@ -256,7 +256,7 @@ export const MessagesList: FC = ({ const jumpToLastMessage = () => { if (conversation) { // clean up anything like search result - //setHighlightedMessage(undefined); + setHighlightedMessage(undefined); focusedElement.current = null; // if there are unloaded messages, the conversation should be marked as read and reloaded if (!conversation.hasLastReceivedMessageLoaded()) { From fdf3ae43c1bd6545edffa9aa54346f98cd66c273 Mon Sep 17 00:00:00 2001 From: Roma Koval Date: Tue, 4 Jun 2024 11:01:59 +0200 Subject: [PATCH 13/16] Final fix of jump to last message animation --- src/script/components/MessagesList/MessageList.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/script/components/MessagesList/MessageList.tsx b/src/script/components/MessagesList/MessageList.tsx index d1e13317716..e2549255013 100644 --- a/src/script/components/MessagesList/MessageList.tsx +++ b/src/script/components/MessagesList/MessageList.tsx @@ -20,12 +20,10 @@ import React, {FC, HTMLProps, useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; import {TabIndex} from '@wireapp/react-ui-kit/lib/types/enums'; -import {amplify} from 'amplify'; import cx from 'classnames'; import {debounce} from 'underscore'; import {ChevronIcon, IconButton, useMatchMedia} from '@wireapp/react-ui-kit'; -import {WebAppEvents} from '@wireapp/webapp-events'; import {FadingScrollbar} from 'Components/FadingScrollbar'; import {filterMessages} from 'Components/MessagesList/utils/messagesFilter'; @@ -257,12 +255,13 @@ export const MessagesList: FC = ({ if (conversation) { // clean up anything like search result setHighlightedMessage(undefined); + conversation.initialMessage(undefined); focusedElement.current = null; // if there are unloaded messages, the conversation should be marked as read and reloaded if (!conversation.hasLastReceivedMessageLoaded()) { updateConversationLastRead(conversation); conversation.release(); - amplify.publish(WebAppEvents.CONVERSATION.SHOW, conversation, {}); + loadConversation(conversation); } else { // we just need to scroll down messageListRef.current?.scrollTo?.({behavior: 'smooth', top: messageListRef.current.scrollHeight}); From 53c99e167539a990aae3c8800279e98a443e8630 Mon Sep 17 00:00:00 2001 From: Roma Koval Date: Tue, 4 Jun 2024 11:16:35 +0200 Subject: [PATCH 14/16] Extract styles into separate file --- .../MessagesList/MessageList.styles.ts | 28 +++++++++++++++++++ .../components/MessagesList/MessageList.tsx | 9 ++---- 2 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 src/script/components/MessagesList/MessageList.styles.ts diff --git a/src/script/components/MessagesList/MessageList.styles.ts b/src/script/components/MessagesList/MessageList.styles.ts new file mode 100644 index 00000000000..0fcdcbeae50 --- /dev/null +++ b/src/script/components/MessagesList/MessageList.styles.ts @@ -0,0 +1,28 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {CSSObject} from '@emotion/react'; + +export const jumpToLastMessageButtonStyles: (mdBreakpoint: boolean) => CSSObject = (mdBreakpoint: boolean) => ({ + position: 'absolute', + bottom: mdBreakpoint ? '100px' : '56px', + right: '10px', + height: '40px', + borderRadius: '100%', +}); diff --git a/src/script/components/MessagesList/MessageList.tsx b/src/script/components/MessagesList/MessageList.tsx index e2549255013..c1eb9eff8de 100644 --- a/src/script/components/MessagesList/MessageList.tsx +++ b/src/script/components/MessagesList/MessageList.tsx @@ -26,6 +26,7 @@ import {debounce} from 'underscore'; import {ChevronIcon, IconButton, useMatchMedia} from '@wireapp/react-ui-kit'; import {FadingScrollbar} from 'Components/FadingScrollbar'; +import {jumpToLastMessageButtonStyles} from 'Components/MessagesList/MessageList.styles'; import {filterMessages} from 'Components/MessagesList/utils/messagesFilter'; import {ConversationRepository} from 'src/script/conversation/ConversationRepository'; import {MessageRepository} from 'src/script/conversation/MessageRepository'; @@ -400,13 +401,7 @@ export const JumpToLastMessageButton: FC = ({ From 3f2f8cfc8166b864fdcad11265b6e13873c754ab Mon Sep 17 00:00:00 2001 From: Roma Koval Date: Tue, 4 Jun 2024 14:55:39 +0200 Subject: [PATCH 15/16] Fix the chewron color in jump to last message button; Fix responsive positioning of the button; --- .../components/MessagesList/MessageList.styles.ts | 12 ++++++++---- src/script/components/MessagesList/MessageList.tsx | 14 ++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/script/components/MessagesList/MessageList.styles.ts b/src/script/components/MessagesList/MessageList.styles.ts index 0fcdcbeae50..00bb619ed3a 100644 --- a/src/script/components/MessagesList/MessageList.styles.ts +++ b/src/script/components/MessagesList/MessageList.styles.ts @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2023 Wire Swiss GmbH + * Copyright (C) 2024 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,10 +19,14 @@ import {CSSObject} from '@emotion/react'; -export const jumpToLastMessageButtonStyles: (mdBreakpoint: boolean) => CSSObject = (mdBreakpoint: boolean) => ({ +export const jumpToLastMessageButtonStyles: CSSObject = { position: 'absolute', - bottom: mdBreakpoint ? '100px' : '56px', right: '10px', height: '40px', borderRadius: '100%', -}); + bottom: '56px', + + '@media (max-width: 768px)': { + bottom: '100px', + }, +}; diff --git a/src/script/components/MessagesList/MessageList.tsx b/src/script/components/MessagesList/MessageList.tsx index c1eb9eff8de..acfea5c43c5 100644 --- a/src/script/components/MessagesList/MessageList.tsx +++ b/src/script/components/MessagesList/MessageList.tsx @@ -23,7 +23,7 @@ import {TabIndex} from '@wireapp/react-ui-kit/lib/types/enums'; import cx from 'classnames'; import {debounce} from 'underscore'; -import {ChevronIcon, IconButton, useMatchMedia} from '@wireapp/react-ui-kit'; +import {ChevronIcon, IconButton} from '@wireapp/react-ui-kit'; import {FadingScrollbar} from 'Components/FadingScrollbar'; import {jumpToLastMessageButtonStyles} from 'Components/MessagesList/MessageList.styles'; @@ -373,13 +373,7 @@ export interface JumpToLastMessageButtonProps extends HTMLProps { conversation: Conversation; } -export const JumpToLastMessageButton: FC = ({ - onGoToLastMessage, - conversation, -}: JumpToLastMessageButtonProps) => { - // To be changed when design chooses a breakpoint, the conditional can be integrated to the ui-kit directly - const mdBreakpoint = useMatchMedia('max-width: 768px'); - +export const JumpToLastMessageButton = ({onGoToLastMessage, conversation}: JumpToLastMessageButtonProps) => { const [isLastMessageVisible, setIsLastMessageVisible] = useState(conversation.isLastMessageVisible()); useEffect(() => { @@ -401,9 +395,9 @@ export const JumpToLastMessageButton: FC = ({ - + ); }; From 10cf84d941d067135e47c33ed35111ff6dbea2ae Mon Sep 17 00:00:00 2001 From: Roma Koval Date: Tue, 4 Jun 2024 20:19:02 +0200 Subject: [PATCH 16/16] Move JumpToLastMessageButton to separate file; Move isLastReceivedMessage to utils; Move all CSS for button into the styles file; --- .../components/Conversation/Conversation.tsx | 3 +- .../JumpToLastMessageButton.test.tsx | 2 +- .../MessagesList/JumpToLastMessageButton.tsx | 65 +++++++++++++++++++ .../MessagesList/MessageList.styles.ts | 9 +++ .../components/MessagesList/MessageList.tsx | 44 ++----------- src/script/entity/Conversation.ts | 4 -- src/script/util/conversationMessages.ts | 6 ++ 7 files changed, 87 insertions(+), 46 deletions(-) create mode 100644 src/script/components/MessagesList/JumpToLastMessageButton.tsx diff --git a/src/script/components/Conversation/Conversation.tsx b/src/script/components/Conversation/Conversation.tsx index b656c0d965b..b9bb032d725 100644 --- a/src/script/components/Conversation/Conversation.tsx +++ b/src/script/components/Conversation/Conversation.tsx @@ -37,6 +37,7 @@ import {CallingViewMode, CallState} from 'src/script/calling/CallState'; import {Config} from 'src/script/Config'; import {PROPERTIES_TYPE} from 'src/script/properties/PropertiesType'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; +import {isLastReceivedMessage} from 'Util/conversationMessages'; import {allowsAllFiles, getFileExtensionOrName, hasAllowedExtension} from 'Util/FileTypeUtil'; import {isHittingUploadLimit} from 'Util/isHittingUploadLimit'; import {t} from 'Util/LocalizerUtil'; @@ -49,7 +50,7 @@ import {ReadOnlyConversationMessage} from './ReadOnlyConversationMessage'; import {checkFileSharingPermission} from './utils/checkFileSharingPermission'; import {ConversationState} from '../../conversation/ConversationState'; -import {Conversation as ConversationEntity, isLastReceivedMessage} from '../../entity/Conversation'; +import {Conversation as ConversationEntity} from '../../entity/Conversation'; import {ContentMessage} from '../../entity/message/ContentMessage'; import {DecryptErrorMessage} from '../../entity/message/DecryptErrorMessage'; import {MemberMessage} from '../../entity/message/MemberMessage'; diff --git a/src/script/components/MessagesList/JumpToLastMessageButton.test.tsx b/src/script/components/MessagesList/JumpToLastMessageButton.test.tsx index 96d04fae1a8..4db6216490c 100644 --- a/src/script/components/MessagesList/JumpToLastMessageButton.test.tsx +++ b/src/script/components/MessagesList/JumpToLastMessageButton.test.tsx @@ -19,7 +19,7 @@ import {render} from '@testing-library/react'; -import {JumpToLastMessageButton} from 'Components/MessagesList/MessageList'; +import {JumpToLastMessageButton} from 'Components/MessagesList/JumpToLastMessageButton'; import {generateConversation} from '../../../../test/helper/ConversationGenerator'; import {withTheme} from '../../auth/util/test/TestUtil'; diff --git a/src/script/components/MessagesList/JumpToLastMessageButton.tsx b/src/script/components/MessagesList/JumpToLastMessageButton.tsx new file mode 100644 index 00000000000..5ae1d31b68c --- /dev/null +++ b/src/script/components/MessagesList/JumpToLastMessageButton.tsx @@ -0,0 +1,65 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {HTMLProps, useEffect, useState} from 'react'; + +import {debounce} from 'underscore'; + +import {ChevronIcon, IconButton} from '@wireapp/react-ui-kit'; + +import { + jumpToLastMessageButtonStyles, + jumpToLastMessageChevronStyles, +} from 'Components/MessagesList/MessageList.styles'; + +import {Conversation} from '../../entity/Conversation'; + +export interface JumpToLastMessageButtonProps extends HTMLProps { + onGoToLastMessage: () => void; + conversation: Conversation; +} + +export const JumpToLastMessageButton = ({onGoToLastMessage, conversation}: JumpToLastMessageButtonProps) => { + const [isLastMessageVisible, setIsLastMessageVisible] = useState(conversation.isLastMessageVisible()); + + useEffect(() => { + const subscription = conversation.isLastMessageVisible.subscribe( + debounce(value => { + setIsLastMessageVisible(value); + }, 200), + ); + return () => { + subscription.dispose(); + }; + }, [conversation]); + + if (isLastMessageVisible) { + return null; + } + + return ( + + + + ); +}; diff --git a/src/script/components/MessagesList/MessageList.styles.ts b/src/script/components/MessagesList/MessageList.styles.ts index 00bb619ed3a..6b9054b1638 100644 --- a/src/script/components/MessagesList/MessageList.styles.ts +++ b/src/script/components/MessagesList/MessageList.styles.ts @@ -30,3 +30,12 @@ export const jumpToLastMessageButtonStyles: CSSObject = { bottom: '100px', }, }; + +export const jumpToLastMessageChevronStyles: CSSObject = { + rotate: '90deg', + height: 16, + width: 16, + path: { + fill: 'var(--accent-color)', + }, +}; diff --git a/src/script/components/MessagesList/MessageList.tsx b/src/script/components/MessagesList/MessageList.tsx index acfea5c43c5..e063d7912e9 100644 --- a/src/script/components/MessagesList/MessageList.tsx +++ b/src/script/components/MessagesList/MessageList.tsx @@ -17,16 +17,13 @@ * */ -import React, {FC, HTMLProps, useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; +import React, {FC, useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; import {TabIndex} from '@wireapp/react-ui-kit/lib/types/enums'; import cx from 'classnames'; -import {debounce} from 'underscore'; - -import {ChevronIcon, IconButton} from '@wireapp/react-ui-kit'; import {FadingScrollbar} from 'Components/FadingScrollbar'; -import {jumpToLastMessageButtonStyles} from 'Components/MessagesList/MessageList.styles'; +import {JumpToLastMessageButton} from 'Components/MessagesList/JumpToLastMessageButton'; import {filterMessages} from 'Components/MessagesList/utils/messagesFilter'; import {ConversationRepository} from 'src/script/conversation/ConversationRepository'; import {MessageRepository} from 'src/script/conversation/MessageRepository'; @@ -38,6 +35,7 @@ import {User} from 'src/script/entity/User'; import {useRoveFocus} from 'src/script/hooks/useRoveFocus'; import {ServiceEntity} from 'src/script/integration/ServiceEntity'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; +import {isLastReceivedMessage} from 'Util/conversationMessages'; import {onHitTopOrBottom} from 'Util/DOM/onHitTopOrBottom'; import {useResizeObserver} from 'Util/DOM/resizeObserver'; @@ -47,7 +45,7 @@ import {ScrollToElement} from './Message/types'; import {groupMessagesBySenderAndTime, isMarker} from './utils/messagesGroup'; import {updateScroll, FocusedElement} from './utils/scrollUpdater'; -import {Conversation, isLastReceivedMessage} from '../../entity/Conversation'; +import {Conversation} from '../../entity/Conversation'; import {isContentMessage} from '../../guards/Message'; interface MessagesListParams { @@ -367,37 +365,3 @@ export const MessagesList: FC = ({ ); }; - -export interface JumpToLastMessageButtonProps extends HTMLProps { - onGoToLastMessage: () => void; - conversation: Conversation; -} - -export const JumpToLastMessageButton = ({onGoToLastMessage, conversation}: JumpToLastMessageButtonProps) => { - const [isLastMessageVisible, setIsLastMessageVisible] = useState(conversation.isLastMessageVisible()); - - useEffect(() => { - const subscription = conversation.isLastMessageVisible.subscribe( - debounce(value => { - setIsLastMessageVisible(value); - }, 200), - ); - return () => { - subscription.dispose(); - }; - }, [conversation]); - - if (isLastMessageVisible) { - return null; - } - - return ( - - - - ); -}; diff --git a/src/script/entity/Conversation.ts b/src/script/entity/Conversation.ts index 6fa18330840..5c3e6c89c75 100644 --- a/src/script/entity/Conversation.ts +++ b/src/script/entity/Conversation.ts @@ -82,10 +82,6 @@ enum TIMESTAMP_TYPE { MUTED = 'mutedTimestamp', } -export const isLastReceivedMessage = (messageEntity: Message, conversationEntity: Conversation): boolean => { - return messageEntity.timestamp() >= conversationEntity.last_event_timestamp(); -}; - export class Conversation { private readonly teamState: TeamState; public readonly archivedState: ko.Observable; diff --git a/src/script/util/conversationMessages.ts b/src/script/util/conversationMessages.ts index 68f09de51e0..4350246ea3a 100644 --- a/src/script/util/conversationMessages.ts +++ b/src/script/util/conversationMessages.ts @@ -21,6 +21,8 @@ import {Asset} from 'src/script/entity/message/Asset'; import type {FileAsset as FileAssetType} from 'src/script/entity/message/FileAsset'; import {AssetType} from '../assets/AssetType'; +import {Conversation} from '../entity/Conversation'; +import type {Message} from '../entity/message/Message'; interface MessageDataType { senderName: string; @@ -55,3 +57,7 @@ export function getMessageAriaLabel({senderName, displayTimestampShort, assets}: } }); } + +export const isLastReceivedMessage = (messageEntity: Message, conversationEntity: Conversation): boolean => { + return messageEntity.timestamp() >= conversationEntity.last_event_timestamp(); +};