From 4b3e9e9248bef1f0ad14ed59261b4d5f6de4a3fe Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Wed, 7 Dec 2022 15:55:24 -0300 Subject: [PATCH 01/14] Restrict some types --- .../app/threads/client/flextab/thread.ts | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/apps/meteor/app/threads/client/flextab/thread.ts b/apps/meteor/app/threads/client/flextab/thread.ts index c420fe494501..ae46afd9dbf6 100644 --- a/apps/meteor/app/threads/client/flextab/thread.ts +++ b/apps/meteor/app/threads/client/flextab/thread.ts @@ -1,4 +1,3 @@ -import _ from 'underscore'; import { Meteor } from 'meteor/meteor'; import { Mongo } from 'meteor/mongo'; import { Template } from 'meteor/templating'; @@ -18,16 +17,16 @@ import { getUserPreference } from '../../../utils/client'; import { settings } from '../../../settings/client'; import { callbacks } from '../../../../lib/callbacks'; import { getCommonRoomEvents } from '../../../ui/client/views/app/lib/getCommonRoomEvents'; -import './thread.html'; import type { MessageBoxTemplateInstance } from '../../../ui-message/client/messageBox/messageBox'; import type { MessageContext } from '../../../../client/views/room/contexts/MessageContext'; import type { ChatContext } from '../../../../client/views/room/contexts/ChatContext'; import type MessageHighlightContext from '../../../../client/views/room/MessageList/contexts/MessageHighlightContext'; +import { withDebouncing, withThrottling } from '../../../../lib/utils/highOrderFunctions'; +import './thread.html'; export type ThreadTemplateInstance = Blaze.TemplateInstance<{ mainMessage: IMessage; subscription: ISubscription; - jump: unknown; following: boolean; rid: IRoom['_id']; tabBar: { @@ -68,15 +67,9 @@ const sort = { ts: 1 }; Template.thread.events({ ...dropzoneEvents, ...getCommonRoomEvents(), - 'click .js-close'(e: JQuery.ClickEvent) { - e.preventDefault(); - e.stopPropagation(); - const { close } = this; - return close?.(); - }, - 'scroll .js-scroll-thread': _.throttle(({ currentTarget: e }: JQuery.ScrollEvent, i: ThreadTemplateInstance) => { + 'scroll .js-scroll-thread': withThrottling({ wait: 150 })(({ currentTarget: e }: JQuery.ScrollEvent, i: ThreadTemplateInstance) => { i.atBottom = e.scrollTop >= e.scrollHeight - e.clientHeight; - }, 150), + }), 'click .toggle-hidden'(e: JQuery.ClickEvent) { const id = e.currentTarget.dataset.message; document.querySelector(`#thread-${id}`)?.classList.toggle('message--ignored'); @@ -107,7 +100,7 @@ Template.thread.helpers({ const { state } = Template.instance() as ThreadTemplateInstance; return ['thread-main', state.get('tmid') === state.get('editingMID') ? 'editing' : ''].filter(Boolean).join(' '); }, - _messageContext(this: { mainMessage: IMessage }) { + _messageContext(this: ThreadTemplateInstance['data']) { const result = messageContext.call(this, { rid: this.mainMessage.rid }); return { ...result, @@ -214,7 +207,7 @@ Template.thread.onCreated(async function (this: ThreadTemplateInstance) { sendToChannel = !this.data.mainMessage.tcount; } - const { mainMessage } = Template.currentData(); + const { mainMessage } = Template.currentData() as ThreadTemplateInstance['data']; this.state = new ReactiveDict(undefined, { sendToChannel, @@ -261,12 +254,12 @@ Template.thread.onRendered(function (this: ThreadTemplateInstance) { this.atBottom = true; this.wrapper = this.find('.js-scroll-thread'); - this.sendToBottom = _.throttle(() => { + this.sendToBottom = withThrottling({ wait: 300 })(() => { this.atBottom = true; if (this.wrapper) { this.wrapper.scrollTop = this.wrapper.scrollHeight; } - }, 300); + }); this.sendToBottomIfNecessary = () => { this.atBottom && this.sendToBottom(); @@ -318,12 +311,12 @@ Template.thread.onRendered(function (this: ThreadTemplateInstance) { callbacks.add( 'streamNewMessage', - _.debounce((msg: IEditedMessage) => { + withDebouncing({ wait: 1000 })((msg: IEditedMessage) => { if (Session.get('openedRoom') !== msg.rid || rid !== msg.rid || msg.editedAt || msg.tmid !== tmid) { return; } Meteor.call('readThreads', tmid); - }, 1000), + }), callbacks.priority.MEDIUM, `thread-${rid}`, ); @@ -358,7 +351,7 @@ Template.thread.onRendered(function (this: ThreadTemplateInstance) { this.autorun(() => { FlowRouter.watchPathChange(); const jump = FlowRouter.getQueryParam('jump'); - const { mainMessage } = Template.currentData(); + const { mainMessage } = Template.currentData() as ThreadTemplateInstance['data']; this.state.set({ tmid: mainMessage._id, rid: mainMessage.rid, From c45ed16d467b15f34983db779edce9e900e8cb74 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Wed, 7 Dec 2022 23:28:42 -0300 Subject: [PATCH 02/14] Rearrange top components --- .../room/contextualBar/Threads/Thread.tsx | 106 ++++++++ .../room/contextualBar/Threads/ThreadList.tsx | 254 +++++++++--------- .../room/contextualBar/Threads/ThreadRow.tsx | 62 ----- .../room/contextualBar/Threads/Threads.tsx | 18 ++ .../Threads/components/ThreadListItem.tsx | 95 +++++++ .../Threads/components/ThreadListMessage.tsx | 25 +- .../Threads/components/ThreadSkeleton.tsx | 24 ++ .../Threads/components/ThreadView.tsx | 144 ++++++++++ .../Threads/hooks/useGoToThread.ts | 22 ++ .../Threads/hooks/useGoToThreadList.ts | 18 ++ .../Threads/hooks/useThreadMessage.ts | 43 +++ .../Threads/{ => hooks}/useThreadsList.ts | 12 +- .../views/room/contextualBar/Threads/index.ts | 2 +- .../room/contextualBar/Threads/mapProps.js | 11 - .../room/contextualBar/Threads/withData.tsx | 92 ------- .../views/room/threads/ThreadComponent.tsx | 186 ------------- .../views/room/threads/ThreadSkeleton.tsx | 50 ---- .../client/views/room/threads/ThreadView.tsx | 105 -------- 18 files changed, 615 insertions(+), 654 deletions(-) create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx delete mode 100644 apps/meteor/client/views/room/contextualBar/Threads/ThreadRow.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/Threads.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListItem.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/components/ThreadSkeleton.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/components/ThreadView.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/hooks/useGoToThread.ts create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/hooks/useGoToThreadList.ts create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessage.ts rename apps/meteor/client/views/room/contextualBar/Threads/{ => hooks}/useThreadsList.ts (73%) delete mode 100644 apps/meteor/client/views/room/contextualBar/Threads/mapProps.js delete mode 100644 apps/meteor/client/views/room/contextualBar/Threads/withData.tsx delete mode 100644 apps/meteor/client/views/room/threads/ThreadComponent.tsx delete mode 100644 apps/meteor/client/views/room/threads/ThreadSkeleton.tsx delete mode 100644 apps/meteor/client/views/room/threads/ThreadView.tsx diff --git a/apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx b/apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx new file mode 100644 index 000000000000..c5bf5dacbe87 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx @@ -0,0 +1,106 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { Box, Modal } from '@rocket.chat/fuselage'; +import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import { useEndpoint, useLayoutContextualBarExpanded, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts'; +import type { ReactElement, ReactNode } from 'react'; +import React, { useMemo, useCallback } from 'react'; + +import { normalizeThreadTitle } from '../../../../../app/threads/client/lib/normalizeThreadTitle'; +import VerticalBar from '../../../../components/VerticalBar'; +import { useRoom, useRoomSubscription } from '../../contexts/RoomContext'; +import { useTabBarClose } from '../../contexts/ToolboxContext'; +import ChatProvider from '../../providers/ChatProvider'; +import MessageProvider from '../../providers/MessageProvider'; +import ThreadSkeleton from './components/ThreadSkeleton'; +import ThreadView from './components/ThreadView'; +import { useGoToThreadList } from './hooks/useGoToThreadList'; +import { useThreadMessage } from './hooks/useThreadMessage'; + +type ThreadProps = { + tmid: IMessage['_id']; +}; + +const Thread = ({ tmid }: ThreadProps): ReactElement => { + const room = useRoom(); + const subscription = useRoomSubscription(); + + const goToThreadList = useGoToThreadList(); + const handleGoBack = () => { + goToThreadList(); + }; + + const canExpand = useLayoutContextualBarExpanded(); + const [expanded, setExpanded] = useLocalStorage('expand-threads', false); + + const handleToggleExpand = useCallback(() => { + setExpanded((expanded) => !expanded); + }, [setExpanded]); + + const mainMessage = useThreadMessage(tmid); + const title = useMemo( + (): ReactNode => (mainMessage ? : null), + [mainMessage], + ); + + const uid = useUserId(); + const following = !uid ? false : mainMessage?.replies?.includes(uid) ?? false; + + const followMessage = useEndpoint('POST', '/v1/chat.followMessage'); + const unfollowMessage = useEndpoint('POST', '/v1/chat.unfollowMessage'); + const dispatchToastMessage = useToastMessageDispatch(); + + const handleToggleFollowing = useCallback<() => void>(async () => { + try { + if (!following) { + await followMessage({ mid: tmid }); + return; + } + + await unfollowMessage({ mid: tmid }); + } catch (error: unknown) { + dispatchToastMessage({ + type: 'error', + message: error, + }); + } + }, [dispatchToastMessage, followMessage, following, tmid, unfollowMessage]); + + const closeTabBar = useTabBarClose(); + + const handleBackdropClick = useCallback(() => { + closeTabBar(); + }, [closeTabBar]); + + const handleClose = useCallback(() => { + closeTabBar(); + }, [closeTabBar]); + + return ( + + {canExpand && expanded && } + + + + {mainMessage ? ( + + ) : ( + + )} + + + + + ); +}; + +export default Thread; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx b/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx index ef7251173472..6534e6accbd5 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx @@ -1,94 +1,48 @@ -import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IMessage } from '@rocket.chat/core-typings'; import { Box, Icon, TextInput, Select, Margins, Callout, Throbber } from '@rocket.chat/fuselage'; -import { useResizeObserver, useMutableCallback, useAutoFocus } from '@rocket.chat/fuselage-hooks'; -import { - useRoute, - useCurrentRoute, - useQueryStringParameter, - useSetting, - useTranslation, - useUserSubscription, -} from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; -import React, { useMemo } from 'react'; +import { useResizeObserver, useAutoFocus, useLocalStorage, useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useUserId } from '@rocket.chat/ui-contexts'; +import type { FormEvent, ReactElement } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import { Virtuoso } from 'react-virtuoso'; import ScrollableContentWrapper from '../../../../components/ScrollableContentWrapper'; import VerticalBar from '../../../../components/VerticalBar'; -import { useTabContext } from '../../contexts/ToolboxContext'; -import ChatProvider from '../../providers/ChatProvider'; -import MessageProvider from '../../providers/MessageProvider'; -import ThreadComponent from '../../threads/ThreadComponent'; -import ThreadRow from './ThreadRow'; -import { withData } from './withData'; - -export type ThreadListProps = { - total: number; - threads: IMessage[]; - room: IRoom; - unread?: string[]; - unreadUser?: string[]; - unreadGroup?: string[]; - userId?: IUser['_id'] | null; - - type: 'all' | 'following' | 'unread'; - setType: (type: string) => void; - - loading: boolean; - - error?: Error; +import { useRecordList } from '../../../../hooks/lists/useRecordList'; +import { AsyncStatePhase } from '../../../../lib/asyncState'; +import type { ThreadsListOptions } from '../../../../lib/lists/ThreadsList'; +import { useRoom, useRoomSubscription } from '../../contexts/RoomContext'; +import { useTabBarClose } from '../../contexts/ToolboxContext'; +import ThreadListItem from './components/ThreadListItem'; +import { useGoToThread } from './hooks/useGoToThread'; +import { useThreadsList } from './hooks/useThreadsList'; + +type ThreadType = 'all' | 'following' | 'unread'; + +const ThreadList = (): ReactElement => { + const t = useTranslation(); - text: string; - setText: (text: string) => void; + const closeTabBar = useTabBarClose(); + const handleTabBarCloseButtonClick = useCallback(() => { + closeTabBar(); + }, [closeTabBar]); - onClose: () => void; + const { ref, contentBoxSize: { inlineSize = 378, blockSize = 1 } = {} } = useResizeObserver({ + debounceDelay: 200, + }); - loadMoreItems: (min: number, max: number) => void; -}; + const autoFocusRef = useAutoFocus(true); -const subscriptionFields = {}; - -const ThreadList: FC = function ThreadList({ - total = 10, - threads = [], - room, - unread = [], - unreadUser = [], - unreadGroup = [], - text, - type, - setType, - loadMoreItems, - loading, - onClose, - error, - userId = '', - setText, -}) { - const subscription = useUserSubscription(room._id, subscriptionFields); - - const showRealNames = Boolean(useSetting('UI_Use_Real_Name')); + const [searchText, setSearchText] = useState(''); - const t = useTranslation(); - const inputRef = useAutoFocus(true); - const [name] = useCurrentRoute(); - - if (!name) { - throw new Error('No route name'); - } - - const channelRoute = useRoute(name); - const onClick = useMutableCallback((e) => { - const { id: context } = e.currentTarget.dataset; - channelRoute.push({ - tab: 'thread', - context, - rid: room._id, - ...(room.name && { name: room.name }), - }); - }); + const handleSearchTextChange = useCallback( + (event: FormEvent) => { + setSearchText(event.currentTarget.value); + }, + [setSearchText], + ); - const options: [string, string][] = useMemo( + const typeOptions: (readonly [type: ThreadType, label: string])[] = useMemo( () => [ ['all', t('All')], ['following', t('Following')], @@ -97,104 +51,146 @@ const ThreadList: FC = function ThreadList({ [t], ); - const { ref, contentBoxSize: { inlineSize = 378, blockSize = 1 } = {} } = useResizeObserver({ - debounceDelay: 200, - }); + const [type, setType] = useLocalStorage('thread-list-type', 'all'); + + const handleTypeChange = useCallback( + (type: string) => { + const typeOption = typeOptions.find(([t]) => t === type); + if (typeOption) setType(typeOption[0]); + }, + [setType, typeOptions], + ); + + const room = useRoom(); + const rid = room._id; + const subscription = useRoomSubscription(); + const subscribed = !!subscription; + const uid = useUserId(); + const tunread = subscription?.tunread?.sort().join(','); + const text = useDebouncedValue(searchText, 400); + const options: ThreadsListOptions = useDebouncedValue( + useMemo(() => { + if (type === 'all' || !subscribed || !uid) { + return { + rid, + text, + type: 'all', + }; + } + switch (type) { + case 'following': + return { + rid, + text, + type, + uid, + }; + case 'unread': + return { + rid, + text, + type, + tunread: tunread?.split(','), + }; + } + }, [rid, subscribed, text, tunread, type, uid]), + 300, + ); + + const { threadsList, loadMoreItems } = useThreadsList(options, uid); + const { phase, error, items, itemCount } = useRecordList(threadsList); - const mid = useTabContext(); - const jump = useQueryStringParameter('jump'); + const goToThread = useGoToThread(); + const handleThreadClick = useCallback( + (tmid: IMessage['_id']) => { + goToThread(tmid); + }, + [goToThread], + ); return ( <> {t('Threads')} - + - - + + } - ref={inputRef} + ref={autoFocusRef} + value={searchText} + onChange={handleSearchTextChange} /> - - {loading && ( - - + {phase === AsyncStatePhase.LOADING && ( + + )} {error && ( - + {error.toString()} )} - {!loading && total === 0 && ( - + {phase !== AsyncStatePhase.LOADING && itemCount === 0 && ( + {t('No_Threads')} )} - {!error && total > 0 && threads.length > 0 && ( + {!error && itemCount > 0 && items.length > 0 && ( undefined : (start): unknown => loadMoreItems(start, Math.min(50, total - start))} - overscan={25} - data={threads} - components={{ Scroller: ScrollableContentWrapper as any }} - itemContent={(_index, data: IMessage): FC => - ( - - ) as unknown as FC + totalCount={itemCount} + endReached={ + phase === AsyncStatePhase.LOADING + ? (): void => undefined + : (start): void => { + loadMoreItems(start, Math.min(50, itemCount - start)); + } } + overscan={25} + data={items} + components={{ Scroller: ScrollableContentWrapper }} + itemContent={(_index, data: IMessage): ReactElement => ( + + )} /> )} - - {typeof mid === 'string' && ( - - - - - - - - )} ); }; -export default withData(ThreadList); +export default ThreadList; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/ThreadRow.tsx b/apps/meteor/client/views/room/contextualBar/Threads/ThreadRow.tsx deleted file mode 100644 index e7447dd0857c..000000000000 --- a/apps/meteor/client/views/room/contextualBar/Threads/ThreadRow.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { IMessage } from '@rocket.chat/core-typings'; -import type { MouseEvent, ReactElement } from 'react'; -import React, { memo } from 'react'; - -import { useDecryptedMessage } from '../../../../hooks/useDecryptedMessage'; -import { clickableItem } from '../../../../lib/clickableItem'; -import { normalizeThreadMessage } from '../../../../lib/normalizeThreadMessage'; -import { callWithErrorHandling } from '../../../../lib/utils/callWithErrorHandling'; -import ThreadListMessage from './components/ThreadListMessage'; -import { mapProps } from './mapProps'; - -const Thread = memo(mapProps(clickableItem(ThreadListMessage))); - -const handleFollowButton = (e: MouseEvent, threadId: string): void => { - e.preventDefault(); - e.stopPropagation(); - const { following } = e.currentTarget.dataset; - - following && - callWithErrorHandling(![true, 'true'].includes(following) ? 'followMessage' : 'unfollowMessage', { - mid: threadId, - }); -}; - -type ThreadRowProps = { - thread: IMessage; - showRealNames: boolean; - unread: string[]; - unreadUser: string[]; - unreadGroup: string[]; - userId: string; - onClick: (threadId: string) => void; -}; - -function ThreadRow({ thread, showRealNames, unread, unreadUser, unreadGroup, userId, onClick }: ThreadRowProps): ReactElement { - const decryptedMsg = useDecryptedMessage(thread); - const msg = normalizeThreadMessage({ ...thread, msg: decryptedMsg }); - - const { name = thread.u.username } = thread.u; - - return ( - ): unknown => handleFollowButton(e, thread._id)} - onClick={onClick} - /> - ); -} - -export default memo(ThreadRow); diff --git a/apps/meteor/client/views/room/contextualBar/Threads/Threads.tsx b/apps/meteor/client/views/room/contextualBar/Threads/Threads.tsx new file mode 100644 index 000000000000..fe33cfa89ccc --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/Threads.tsx @@ -0,0 +1,18 @@ +import type { ReactElement } from 'react'; +import React from 'react'; + +import { useTabContext } from '../../contexts/ToolboxContext'; +import Thread from './Thread'; +import ThreadList from './ThreadList'; + +const Threads = (): ReactElement => { + const tmid = useTabContext() as string | undefined; + + if (tmid) { + return ; + } + + return ; +}; + +export default Threads; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListItem.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListItem.tsx new file mode 100644 index 000000000000..44432de2e84f --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListItem.tsx @@ -0,0 +1,95 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import { Palette } from '@rocket.chat/fuselage'; +import { useMethod, useSetting, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts'; +import type { MouseEvent, ReactElement } from 'react'; +import React, { useCallback, memo } from 'react'; + +import { useDecryptedMessage } from '../../../../../hooks/useDecryptedMessage'; +import { normalizeThreadMessage } from '../../../../../lib/normalizeThreadMessage'; +import ThreadListMessage from './ThreadListMessage'; + +type ThreadListItemProps = { + thread: IMessage; + unread: string[]; + unreadUser: string[]; + unreadGroup: string[]; + onClick: (tmid: IMessage['_id']) => void; +}; + +const ThreadListItem = ({ thread, unread, unreadUser, unreadGroup, onClick }: ThreadListItemProps): ReactElement => { + const uid = useUserId() ?? undefined; + const decryptedMsg = useDecryptedMessage(thread); + const msg = normalizeThreadMessage({ ...thread, msg: decryptedMsg }); + + const { name = thread.u.username } = thread.u; + + const following = !!uid && (thread.replies?.includes(uid) ?? false); + + const followMessage = useMethod('followMessage'); + const unfollowMessage = useMethod('unfollowMessage'); + const dispatchToastMessage = useToastMessageDispatch(); + + const toggleFollowMessage = useCallback(async (): Promise => { + try { + if (following) { + await unfollowMessage({ mid: thread._id }); + } else { + await followMessage({ mid: thread._id }); + } + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, [following, unfollowMessage, thread._id, followMessage, dispatchToastMessage]); + + const handleToggleFollowButtonClick = useCallback( + (event: MouseEvent): void => { + event.preventDefault(); + event.stopPropagation(); + toggleFollowMessage(); + }, + [toggleFollowMessage], + ); + + const showRealNames = (useSetting('UI_Use_Real_Name') as boolean | undefined) ?? false; + + const handleListItemClick = useCallback( + (event: MouseEvent): void => { + event.preventDefault(); + event.stopPropagation(); + onClick(thread._id); + }, + [onClick, thread._id], + ); + + return ( + + ); +}; + +export default memo(ThreadListItem); diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMessage.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMessage.tsx index a0ca193f0e27..99128229424f 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMessage.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadListMessage.tsx @@ -17,17 +17,16 @@ type ThreadListMessageProps = { username: IMessage['u']['username']; name?: IMessage['u']['name']; ts: IMessage['ts']; - replies: IMessage['replies']; + replies: ReactNode; participants: ReactNode; handleFollowButton: MouseEventHandler; unread: boolean; - mention: number; + mention: boolean; all: boolean; - tlm: number; - className?: string | string[]; -} & Omit, 'className' | 'is'>; + tlm: Date | undefined; +} & Omit, 'is'>; -function ThreadListMessage({ +const ThreadListMessage = ({ _id, msg, following, @@ -43,7 +42,7 @@ function ThreadListMessage({ tlm, className = [], ...props -}: ThreadListMessageProps): ReactElement { +}: ThreadListMessageProps): ReactElement => { const t = useTranslation(); const formatDate = useTimeAgo(); @@ -73,10 +72,12 @@ function ThreadListMessage({ {participants} - - - {formatDate(tlm)} - + {tlm && ( + + + {formatDate(tlm)} + + )} @@ -99,6 +100,6 @@ function ThreadListMessage({ ); -} +}; export default memo(ThreadListMessage); diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadSkeleton.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadSkeleton.tsx new file mode 100644 index 000000000000..46eff8f1d3a0 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadSkeleton.tsx @@ -0,0 +1,24 @@ +import type { ReactElement } from 'react'; +import React from 'react'; + +import VerticalBar from '../../../../../components/VerticalBar'; + +const ThreadSkeleton = (): ReactElement => { + return ( + + ); +}; + +export default ThreadSkeleton; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadView.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadView.tsx new file mode 100644 index 000000000000..dceef9672ccc --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadView.tsx @@ -0,0 +1,144 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import { useQueryStringParameter, useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactElement, ReactNode } from 'react'; +import React, { useState, useEffect, useContext, useRef } from 'react'; + +import VerticalBar from '../../../../../components/VerticalBar'; +import MessageHighlightContext from '../../../MessageList/contexts/MessageHighlightContext'; +import { ChatContext } from '../../../contexts/ChatContext'; +import { MessageContext } from '../../../contexts/MessageContext'; +import { useRoom, useRoomSubscription } from '../../../contexts/RoomContext'; +import { useTabBarOpenUserInfo } from '../../../contexts/ToolboxContext'; +import { useThreadMessage } from '../hooks/useThreadMessage'; + +type ThreadViewProps = { + tmid: IMessage['_id']; + onGoBack: (e: unknown) => void; + canExpand: boolean; + expanded: boolean; + onToggleExpand: () => void; + title: ReactNode; + following: boolean; + onToggleFollowing: () => void; + onClose: () => void; +}; + +const ThreadView = ({ + tmid, + title, + canExpand, + expanded, + following, + onToggleExpand, + onToggleFollowing, + onClose, + onGoBack, +}: ThreadViewProps): ReactElement => { + const t = useTranslation(); + + const expandLabel = expanded ? t('Collapse') : t('Expand'); + const expandIcon = expanded ? 'arrow-collapse' : 'arrow-expand'; + + const followLabel = following ? t('Following') : t('Not_Following'); + const followIcon = following ? 'bell' : 'bell-off'; + + const expandedThreadStyle = + canExpand && expanded + ? css` + max-width: 855px !important; + @media (min-width: 780px) and (max-width: 1135px) { + max-width: calc(100% - var(--sidebar-width)) !important; + } + ` + : undefined; + + const ref = useRef(null); + + const chatContext = useContext(ChatContext); + const messageContext = useContext(MessageContext); + + const messageHighlightContext = useContext(MessageHighlightContext); + const { current: messageHighlightContextReactiveVar } = useRef(new ReactiveVar(messageHighlightContext)); + useEffect(() => { + messageHighlightContextReactiveVar.set(messageHighlightContext); + }, [messageHighlightContext, messageHighlightContextReactiveVar]); + + const mainMessage = useThreadMessage(tmid); + + const jump = useQueryStringParameter('jump'); + + const room = useRoom(); + const subscription = useRoomSubscription(); + const openRoomInfo = useTabBarOpenUserInfo(); + + const [viewData, setViewData] = useState(() => ({ + mainMessage, + jump, + following, + subscription, + rid: room._id, + tabBar: { openRoomInfo }, + chatContext, + messageContext, + messageHighlightContext: () => messageHighlightContextReactiveVar.get(), + })); + + useEffect(() => { + setViewData((viewData) => { + if (!mainMessage || viewData.mainMessage?._id === mainMessage._id) { + return viewData; + } + + return { + mainMessage, + jump, + following, + subscription, + rid: room._id, + tabBar: { openRoomInfo }, + chatContext, + messageContext, + messageHighlightContext: () => messageHighlightContextReactiveVar.get(), + }; + }); + }, [chatContext, following, jump, messageContext, messageHighlightContextReactiveVar, subscription, mainMessage, room._id, openRoomInfo]); + + useEffect(() => { + if (!ref.current || !viewData.mainMessage) { + return; + } + const view = Blaze.renderWithData(Template.thread, viewData, ref.current); + + return (): void => { + Blaze.remove(view); + }; + }, [viewData]); + + return ( + + + {onGoBack && } + {title} + {canExpand && } + + + + + + + + ); +}; + +export default ThreadView; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useGoToThread.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useGoToThread.ts new file mode 100644 index 000000000000..7901490efe7a --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useGoToThread.ts @@ -0,0 +1,22 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { useCurrentRoute, useRoute } from '@rocket.chat/ui-contexts'; +import { useCallback } from 'react'; + +import { useRoom } from '../../../contexts/RoomContext'; + +export const useGoToThread = (): ((tmid: IMessage['_id']) => void) => { + const room = useRoom(); + const [routeName] = useCurrentRoute(); + + if (!routeName) { + throw new Error('Route name is not defined'); + } + + const roomRoute = useRoute(routeName); + return useCallback( + (tmid) => { + roomRoute.replace({ rid: room._id, ...(room.name && { name: room.name }), tab: 'thread', context: tmid }); + }, + [room._id, room.name, roomRoute], + ); +}; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useGoToThreadList.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useGoToThreadList.ts new file mode 100644 index 000000000000..a83f76bad3e6 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useGoToThreadList.ts @@ -0,0 +1,18 @@ +import { useCurrentRoute, useRoute } from '@rocket.chat/ui-contexts'; +import { useCallback } from 'react'; + +import { useRoom } from '../../../contexts/RoomContext'; + +export const useGoToThreadList = (): (() => void) => { + const room = useRoom(); + const [routeName] = useCurrentRoute(); + + if (!routeName) { + throw new Error('Route name is not defined'); + } + + const roomRoute = useRoute(routeName); + return useCallback(() => { + roomRoute.replace({ rid: room._id, ...(room.name && { name: room.name }), tab: 'thread' }); + }, [room._id, room.name, roomRoute]); +}; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessage.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessage.ts new file mode 100644 index 000000000000..cc7bc2509d32 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessage.ts @@ -0,0 +1,43 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { Tracker } from 'meteor/tracker'; +import { useEffect, useState, useCallback } from 'react'; + +import { ChatMessage } from '../../../../../../app/models/client'; +import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi'; + +export const useThreadMessage = (tmid: string): IMessage | undefined => { + const [message, setMessage] = useState(() => Tracker.nonreactive(() => ChatMessage.findOne({ _id: tmid }))); + const getMessage = useEndpoint('GET', '/v1/chat.getMessage'); + const getMessageParsed = useCallback<(params: { msgId: IMessage['_id'] }) => Promise>( + async (params) => { + const { message } = await getMessage(params); + return mapMessageFromApi(message); + }, + [getMessage], + ); + + useEffect(() => { + const computation = Tracker.autorun(async (computation) => { + const msg = ChatMessage.findOne({ _id: tmid }) || (await getMessageParsed({ msgId: tmid })); + + if (!msg || computation.stopped) { + return; + } + + setMessage((prevMsg) => { + if (!prevMsg || prevMsg._id !== msg._id || prevMsg._updatedAt?.getTime() !== msg._updatedAt?.getTime()) { + return msg; + } + + return prevMsg; + }); + }); + + return (): void => { + computation.stop(); + }; + }, [getMessageParsed, tmid]); + + return message; +}; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/useThreadsList.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadsList.ts similarity index 73% rename from apps/meteor/client/views/room/contextualBar/Threads/useThreadsList.ts rename to apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadsList.ts index 81211a6ec6ef..c84580d545ca 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/useThreadsList.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadsList.ts @@ -2,15 +2,15 @@ import type { IUser } from '@rocket.chat/core-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useCallback, useMemo } from 'react'; -import { useScrollableMessageList } from '../../../../hooks/lists/useScrollableMessageList'; -import { useStreamUpdatesForMessageList } from '../../../../hooks/lists/useStreamUpdatesForMessageList'; -import type { ThreadsListOptions } from '../../../../lib/lists/ThreadsList'; -import { ThreadsList } from '../../../../lib/lists/ThreadsList'; -import { getConfig } from '../../../../lib/utils/getConfig'; +import { useScrollableMessageList } from '../../../../../hooks/lists/useScrollableMessageList'; +import { useStreamUpdatesForMessageList } from '../../../../../hooks/lists/useStreamUpdatesForMessageList'; +import type { ThreadsListOptions } from '../../../../../lib/lists/ThreadsList'; +import { ThreadsList } from '../../../../../lib/lists/ThreadsList'; +import { getConfig } from '../../../../../lib/utils/getConfig'; export const useThreadsList = ( options: ThreadsListOptions, - uid: IUser['_id'], + uid: IUser['_id'] | null, ): { threadsList: ThreadsList; initialItemCount: number; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/index.ts b/apps/meteor/client/views/room/contextualBar/Threads/index.ts index 501741426be2..1cce8747f4d4 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/index.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/index.ts @@ -1 +1 @@ -export { default } from './ThreadList'; +export { default } from './Threads'; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/mapProps.js b/apps/meteor/client/views/room/contextualBar/Threads/mapProps.js deleted file mode 100644 index 0f3782ab331e..000000000000 --- a/apps/meteor/client/views/room/contextualBar/Threads/mapProps.js +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -export function mapProps(Component) { - const WrappedComponent = ({ msg, username, replies = [], tcount, ts, ...props }) => ( - - ); - - WrappedComponent.displayName = `mapProps(${Component.displayName ?? Component.name ?? 'Component'})`; - - return WrappedComponent; -} diff --git a/apps/meteor/client/views/room/contextualBar/Threads/withData.tsx b/apps/meteor/client/views/room/contextualBar/Threads/withData.tsx deleted file mode 100644 index aa380b41c82b..000000000000 --- a/apps/meteor/client/views/room/contextualBar/Threads/withData.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useDebouncedValue, useLocalStorage } from '@rocket.chat/fuselage-hooks'; -import { useUserId, useUserSubscription, useUserRoom } from '@rocket.chat/ui-contexts'; -import type { FC } from 'react'; -import React, { useCallback, useMemo, useState } from 'react'; - -import { useRecordList } from '../../../../hooks/lists/useRecordList'; -import { AsyncStatePhase } from '../../../../hooks/useAsyncState'; -import type { ThreadsListOptions } from '../../../../lib/lists/ThreadsList'; -import { useTabBarClose } from '../../contexts/ToolboxContext'; -import type { ThreadListProps } from './ThreadList'; -import { useThreadsList } from './useThreadsList'; - -const subscriptionFields = { tunread: true, tunreadUser: true, tunreadGroup: true }; -const roomFields = { t: true, name: true }; - -export function withData(Component: FC): FC<{ rid: string }> { - const WrappedComponent: FC<{ rid: string }> = ({ rid, ...props }) => { - const userId = useUserId(); - const onClose = useTabBarClose(); - const room = useUserRoom(rid, roomFields); - const subscription = useUserSubscription(rid, subscriptionFields); - const [type, setType] = useLocalStorage<'all' | 'following' | 'unread'>('thread-list-type', 'all'); - - const [text, setText] = useState(''); - const debouncedText = useDebouncedValue(text, 400); - const options: ThreadsListOptions = useDebouncedValue( - useMemo(() => { - if (type === 'all' || !subscription || !userId) { - return { - rid, - text: debouncedText, - type: 'all', - }; - } - switch (type) { - case 'following': - return { - rid, - text: debouncedText, - type, - uid: userId, - }; - case 'unread': - return { - rid, - text: debouncedText, - type, - tunread: subscription?.tunread, - }; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rid, debouncedText, type, subscription?.tunread?.sort().join(), userId]), - 300, - ); - - const { threadsList, loadMoreItems } = useThreadsList(options, userId as string); - const { phase, error, items: threads, itemCount: totalItemCount } = useRecordList(threadsList); - - const handleTextChange = useCallback((event) => { - setText(event.currentTarget.value); - }, []); - - if (!room) { - throw new Error('No room available'); - } - - return ( - - ); - }; - - WrappedComponent.displayName = `withData(${Component.displayName ?? Component.name ?? 'Component'})`; - - return WrappedComponent; -} diff --git a/apps/meteor/client/views/room/threads/ThreadComponent.tsx b/apps/meteor/client/views/room/threads/ThreadComponent.tsx deleted file mode 100644 index d7ee4afe5f5f..000000000000 --- a/apps/meteor/client/views/room/threads/ThreadComponent.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import type { IMessage, IRoom } from '@rocket.chat/core-typings'; -import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; -import { useToastMessageDispatch, useRoute, useUserId, useUserSubscription, useEndpoint } from '@rocket.chat/ui-contexts'; -import { Blaze } from 'meteor/blaze'; -import { ReactiveVar } from 'meteor/reactive-var'; -import { Template } from 'meteor/templating'; -import { Tracker } from 'meteor/tracker'; -import type { FC } from 'react'; -import React, { useEffect, useRef, useState, useCallback, useMemo, useContext } from 'react'; - -import { ChatMessage } from '../../../../app/models/client'; -import { normalizeThreadTitle } from '../../../../app/threads/client/lib/normalizeThreadTitle'; -import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; -import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi'; -import MessageHighlightContext from '../MessageList/contexts/MessageHighlightContext'; -import { ChatContext } from '../contexts/ChatContext'; -import { MessageContext } from '../contexts/MessageContext'; -import { useTabBarOpenUserInfo } from '../contexts/ToolboxContext'; -import ThreadSkeleton from './ThreadSkeleton'; -import ThreadView from './ThreadView'; - -const subscriptionFields = {}; - -const useThreadMessage = (tmid: string): IMessage | undefined => { - const [message, setMessage] = useState(() => Tracker.nonreactive(() => ChatMessage.findOne({ _id: tmid }))); - const getMessage = useEndpoint('GET', '/v1/chat.getMessage'); - const getMessageParsed = useCallback<(params: { msgId: IMessage['_id'] }) => Promise>( - async (params) => { - const { message } = await getMessage(params); - return mapMessageFromApi(message); - }, - [getMessage], - ); - - useEffect(() => { - const computation = Tracker.autorun(async (computation) => { - const msg = ChatMessage.findOne({ _id: tmid }) || (await getMessageParsed({ msgId: tmid })); - - if (!msg || computation.stopped) { - return; - } - - setMessage((prevMsg) => { - if (!prevMsg || prevMsg._id !== msg._id || prevMsg._updatedAt?.getTime() !== msg._updatedAt?.getTime()) { - return msg; - } - - return prevMsg; - }); - }); - - return (): void => { - computation.stop(); - }; - }, [getMessageParsed, tmid]); - - return message; -}; - -const ThreadComponent: FC<{ - mid: string; - jump: unknown; - room: IRoom; - onClickBack: (e: unknown) => void; -}> = ({ mid, jump, room, onClickBack }) => { - const subscription = useUserSubscription(room._id, subscriptionFields); - const channelRoute = useRoute(roomCoordinator.getRoomTypeConfig(room.t).route.name); - const threadMessage = useThreadMessage(mid); - - const openRoomInfo = useTabBarOpenUserInfo(); - - const ref = useRef(null); - const uid = useUserId(); - - const headerTitle = useMemo(() => (threadMessage ? normalizeThreadTitle(threadMessage) : null), [threadMessage]); - const [expanded, setExpand] = useLocalStorage('expand-threads', false); - const following = !uid ? false : threadMessage?.replies?.includes(uid) ?? false; - - const dispatchToastMessage = useToastMessageDispatch(); - const followMessage = useEndpoint('POST', '/v1/chat.followMessage'); - const unfollowMessage = useEndpoint('POST', '/v1/chat.unfollowMessage'); - - const setFollowing = useCallback<(following: boolean) => void>( - async (following) => { - try { - if (following) { - await followMessage({ mid }); - return; - } - - await unfollowMessage({ mid }); - } catch (error: unknown) { - dispatchToastMessage({ - type: 'error', - message: error, - }); - } - }, - [dispatchToastMessage, followMessage, unfollowMessage, mid], - ); - - const handleClose = useCallback(() => { - channelRoute.push(room.t === 'd' ? { rid: room._id } : { name: room.name || room._id }); - }, [channelRoute, room._id, room.t, room.name]); - - const chatContext = useContext(ChatContext); - const messageContext = useContext(MessageContext); - - const messageHighlightContext = useContext(MessageHighlightContext); - const { current: messageHighlightContextReactiveVar } = useRef(new ReactiveVar(messageHighlightContext)); - useEffect(() => { - messageHighlightContextReactiveVar.set(messageHighlightContext); - }, [messageHighlightContext, messageHighlightContextReactiveVar]); - - const [viewData, setViewData] = useState(() => ({ - mainMessage: threadMessage, - jump, - following, - subscription, - rid: room._id, - tabBar: { openRoomInfo }, - chatContext, - messageContext, - messageHighlightContext: () => messageHighlightContextReactiveVar.get(), - })); - - useEffect(() => { - setViewData((viewData) => { - if (!threadMessage || viewData.mainMessage?._id === threadMessage._id) { - return viewData; - } - - return { - mainMessage: threadMessage, - jump, - following, - subscription, - rid: room._id, - tabBar: { openRoomInfo }, - chatContext, - messageContext, - messageHighlightContext: () => messageHighlightContextReactiveVar.get(), - }; - }); - }, [ - chatContext, - following, - jump, - messageContext, - messageHighlightContextReactiveVar, - openRoomInfo, - room._id, - subscription, - threadMessage, - ]); - - useEffect(() => { - if (!ref.current || !viewData.mainMessage) { - return; - } - const view = Blaze.renderWithData(Template.thread, viewData, ref.current); - - return (): void => { - Blaze.remove(view); - }; - }, [viewData]); - - if (!threadMessage) { - return ; - } - - return ( - setExpand(!expanded)} - onToggleFollow={(following): void => setFollowing(!following)} - onClose={handleClose} - onClickBack={onClickBack} - /> - ); -}; - -export default ThreadComponent; diff --git a/apps/meteor/client/views/room/threads/ThreadSkeleton.tsx b/apps/meteor/client/views/room/threads/ThreadSkeleton.tsx deleted file mode 100644 index 35aa2274eb33..000000000000 --- a/apps/meteor/client/views/room/threads/ThreadSkeleton.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Modal, Box } from '@rocket.chat/fuselage'; -import type { FC } from 'react'; -import React, { useMemo } from 'react'; - -import VerticalBar from '../../../components/VerticalBar'; - -type ThreadSkeletonProps = { - expanded: boolean; - onClose: () => void; -}; - -const ThreadSkeleton: FC = ({ expanded, onClose }) => { - const style = useMemo( - () => - document.dir === 'rtl' - ? { - left: 0, - borderTopRightRadius: 4, - } - : { - right: 0, - borderTopLeftRadius: 4, - }, - [], - ); - - return ( - <> - {expanded && } - - - - - ); -}; - -export default ThreadSkeleton; diff --git a/apps/meteor/client/views/room/threads/ThreadView.tsx b/apps/meteor/client/views/room/threads/ThreadView.tsx deleted file mode 100644 index de446e1d6c64..000000000000 --- a/apps/meteor/client/views/room/threads/ThreadView.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { css } from '@rocket.chat/css-in-js'; -import { Modal, Box } from '@rocket.chat/fuselage'; -import { useLayoutContextualBarExpanded, useTranslation } from '@rocket.chat/ui-contexts'; -import type { ComponentProps } from 'react'; -import React, { useCallback, useMemo, forwardRef } from 'react'; - -import VerticalBar from '../../../components/VerticalBar'; - -type ThreadViewProps = ComponentProps & { - title: string; - expanded: boolean; - following: boolean; - onToggleExpand: (expanded: boolean) => void; - onToggleFollow: (following: boolean) => void; - onClose: () => void; - onClickBack: (e: unknown) => void; -}; - -const ThreadView = forwardRef(function ThreadView( - { title, expanded, following, onToggleExpand, onToggleFollow, onClose, onClickBack }, - ref, -) { - const hasExpand = useLayoutContextualBarExpanded(); - - const style = useMemo( - () => - document.dir === 'rtl' - ? { - left: 0, - borderTopRightRadius: 4, - } - : { - right: 0, - borderTopLeftRadius: 4, - }, - [], - ); - - const t = useTranslation(); - - const expandLabel = expanded ? t('Collapse') : t('Expand'); - const expandIcon = expanded ? 'arrow-collapse' : 'arrow-expand'; - - const handleExpandActionClick = useCallback(() => { - onToggleExpand(expanded); - }, [expanded, onToggleExpand]); - - const followLabel = following ? t('Following') : t('Not_Following'); - const followIcon = following ? 'bell' : 'bell-off'; - - const handleFollowActionClick = useCallback(() => { - onToggleFollow(following); - }, [following, onToggleFollow]); - - const expandedThreadStyle = - hasExpand && expanded - ? css` - max-width: 855px !important; - @media (min-width: 780px) and (max-width: 1135px) { - max-width: calc(100% - var(--sidebar-width)) !important; - } - ` - : undefined; - - return ( - <> - {hasExpand && expanded && } - - - - - {onClickBack && } - - {hasExpand && } - - - - - - - - - - ); -}); - -export default ThreadView; From 7771e593299b15d4c3b09c2e5531d8780e2eed7e Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Mon, 12 Dec 2022 18:17:36 -0300 Subject: [PATCH 03/14] Refactor `Thread` data fetching and state --- apps/meteor/app/models/client/index.ts | 6 +- .../app/threads/client/flextab/thread.ts | 24 ++- .../VerticalBar/VerticalBarAction.tsx | 12 +- apps/meteor/client/lib/chats/data.ts | 13 +- .../room/contextualBar/Threads/Thread.tsx | 151 ++++++++++-------- .../Threads/components/ThreadSkeleton.tsx | 24 ++- .../Threads/components/ThreadTitle.tsx | 16 ++ .../Threads/components/ThreadView.tsx | 77 +-------- .../Threads/hooks/useGetMessageByID.ts | 28 ++++ .../hooks/useThreadMainMessageQuery.ts | 23 +++ .../Threads/hooks/useThreadMessage.ts | 43 ----- .../hooks/useToggleFollowingThreadMutation.ts | 39 +++++ apps/meteor/package.json | 1 + yarn.lock | 57 +++++++ 14 files changed, 296 insertions(+), 218 deletions(-) create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/components/ThreadTitle.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/hooks/useGetMessageByID.ts create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMainMessageQuery.ts delete mode 100644 apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessage.ts create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/hooks/useToggleFollowingThreadMutation.ts diff --git a/apps/meteor/app/models/client/index.ts b/apps/meteor/app/models/client/index.ts index fbcbee481f5c..641f0a00ba82 100644 --- a/apps/meteor/app/models/client/index.ts +++ b/apps/meteor/app/models/client/index.ts @@ -23,9 +23,13 @@ import { WebdavAccounts } from './models/WebdavAccounts'; import CustomSounds from './models/CustomSounds'; import EmojiCustom from './models/EmojiCustom'; +/** @deprecated */ const Users = _.extend({}, users, Meteor.users); +/** @deprecated */ const Subscriptions = _.extend({}, subscriptions, ChatSubscription); -const Messages = _.extend({}, ChatMessage); +/** @deprecated */ +const Messages = _.extend({}, ChatMessage) as typeof ChatMessage; +/** @deprecated */ const Rooms = _.extend({}, ChatRoom); export { diff --git a/apps/meteor/app/threads/client/flextab/thread.ts b/apps/meteor/app/threads/client/flextab/thread.ts index ae46afd9dbf6..758793b4451a 100644 --- a/apps/meteor/app/threads/client/flextab/thread.ts +++ b/apps/meteor/app/threads/client/flextab/thread.ts @@ -25,8 +25,8 @@ import { withDebouncing, withThrottling } from '../../../../lib/utils/highOrderF import './thread.html'; export type ThreadTemplateInstance = Blaze.TemplateInstance<{ - mainMessage: IMessage; - subscription: ISubscription; + mainMessage?: IMessage; + subscription?: ISubscription; following: boolean; rid: IRoom['_id']; tabBar: { @@ -101,7 +101,7 @@ Template.thread.helpers({ return ['thread-main', state.get('tmid') === state.get('editingMID') ? 'editing' : ''].filter(Boolean).join(' '); }, _messageContext(this: ThreadTemplateInstance['data']) { - const result = messageContext.call(this, { rid: this.mainMessage.rid }); + const result = messageContext.call(this, { rid: this.mainMessage?.rid }); return { ...result, settings: { @@ -113,11 +113,7 @@ Template.thread.helpers({ }, messageBoxData(): MessageBoxTemplateInstance['data'] { const instance = Template.instance() as ThreadTemplateInstance; - const { - mainMessage: { rid, _id: tmid }, - subscription, - chatContext, - } = Template.currentData() as ThreadTemplateInstance['data']; + const { mainMessage: { rid, _id: tmid } = {}, subscription, chatContext } = Template.currentData() as ThreadTemplateInstance['data']; if (!chatContext) { throw new Error('chatContext is not defined'); @@ -131,7 +127,7 @@ Template.thread.helpers({ showFormattingTips, tshow: instance.state.get('sendToChannel'), subscription, - rid, + rid: rid ?? '', tmid, onSend: async ( _event: Event, @@ -204,15 +200,15 @@ Template.thread.onCreated(async function (this: ThreadTemplateInstance) { sendToChannel = false; break; default: - sendToChannel = !this.data.mainMessage.tcount; + sendToChannel = !this.data.mainMessage?.tcount ?? true; } const { mainMessage } = Template.currentData() as ThreadTemplateInstance['data']; this.state = new ReactiveDict(undefined, { sendToChannel, - tmid: mainMessage._id, - rid: mainMessage.rid, + tmid: mainMessage?._id, + rid: mainMessage?.rid, }); this.loadMore = async () => { @@ -353,8 +349,8 @@ Template.thread.onRendered(function (this: ThreadTemplateInstance) { const jump = FlowRouter.getQueryParam('jump'); const { mainMessage } = Template.currentData() as ThreadTemplateInstance['data']; this.state.set({ - tmid: mainMessage._id, - rid: mainMessage.rid, + tmid: mainMessage?._id, + rid: mainMessage?.rid, jump, }); }); diff --git a/apps/meteor/client/components/VerticalBar/VerticalBarAction.tsx b/apps/meteor/client/components/VerticalBar/VerticalBarAction.tsx index 5a186cd8e830..afe5b5b15b40 100644 --- a/apps/meteor/client/components/VerticalBar/VerticalBarAction.tsx +++ b/apps/meteor/client/components/VerticalBar/VerticalBarAction.tsx @@ -3,13 +3,15 @@ import { IconButton } from '@rocket.chat/fuselage'; import type { ReactElement, MouseEventHandler, ComponentProps } from 'react'; import React, { memo } from 'react'; -const VerticalBarAction = ({ - name, - ...props -}: { +type VerticalBarActionProps = { name: ComponentProps['name']; title?: string; + disabled?: boolean; onClick?: MouseEventHandler; -}): ReactElement => ; +}; + +const VerticalBarAction = ({ name, ...props }: VerticalBarActionProps): ReactElement => ( + +); export default memo(VerticalBarAction); diff --git a/apps/meteor/client/lib/chats/data.ts b/apps/meteor/client/lib/chats/data.ts index f2cfa17528d0..b0e323cd6ffb 100644 --- a/apps/meteor/client/lib/chats/data.ts +++ b/apps/meteor/client/lib/chats/data.ts @@ -12,7 +12,6 @@ import { call } from '../utils/call'; import { prependReplies } from '../utils/prependReplies'; import type { DataAPI } from './ChatAPI'; -const messagesCollection = Messages as Mongo.Collection; const roomsCollection = Rooms as Mongo.Collection; const subscriptionsCollection = Subscriptions as Mongo.Collection; @@ -38,7 +37,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage }; const findMessageByID = async (mid: IMessage['_id']): Promise => - messagesCollection.findOne({ _id: mid, _hidden: { $ne: true } }, { reactive: false }) ?? call('getSingleMessage', mid); + Messages.findOne({ _id: mid, _hidden: { $ne: true } }, { reactive: false }) ?? call('getSingleMessage', mid); const getMessageByID = async (mid: IMessage['_id']): Promise => { const message = await findMessageByID(mid); @@ -51,7 +50,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage }; const findLastMessage = async (): Promise => - messagesCollection.findOne({ rid, tmid: tmid ?? { $exists: false }, _hidden: { $ne: true } }, { sort: { ts: -1 }, reactive: false }); + Messages.findOne({ rid, tmid: tmid ?? { $exists: false }, _hidden: { $ne: true } }, { sort: { ts: -1 }, reactive: false }); const getLastMessage = async (): Promise => { const message = await findLastMessage(); @@ -70,7 +69,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage return undefined; } - return messagesCollection.findOne( + return Messages.findOne( { rid, 'tmid': tmid ?? { $exists: false }, 'u._id': uid, '_hidden': { $ne: true } }, { sort: { ts: -1 }, reactive: false }, ); @@ -93,7 +92,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage return undefined; } - return messagesCollection.findOne( + return Messages.findOne( { rid, 'tmid': tmid ?? { $exists: false }, 'u._id': uid, '_hidden': { $ne: true }, 'ts': { $lt: message.ts } }, { sort: { ts: -1 }, reactive: false }, ); @@ -116,7 +115,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage return undefined; } - return messagesCollection.findOne( + return Messages.findOne( { rid, 'tmid': tmid ?? { $exists: false }, 'u._id': uid, '_hidden': { $ne: true }, 'ts': { $gt: message.ts } }, { sort: { ts: 1 }, reactive: false }, ); @@ -133,7 +132,7 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage }; const pushEphemeralMessage = async (message: Omit): Promise => { - messagesCollection.upsert({ _id: message._id }, { $set: { ...message, rid, ...(tmid && { tmid }) } }); + Messages.upsert({ _id: message._id }, { $set: { ...message, rid, ...(tmid && { tmid }) } }); }; const canUpdateMessage = async (message: IMessage): Promise => { diff --git a/apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx b/apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx index c5bf5dacbe87..9030c4cf9bbd 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx @@ -1,103 +1,126 @@ import type { IMessage } from '@rocket.chat/core-typings'; -import { Box, Modal } from '@rocket.chat/fuselage'; +import { css } from '@rocket.chat/css-in-js'; +import { Box, Modal, Skeleton } from '@rocket.chat/fuselage'; import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; -import { useEndpoint, useLayoutContextualBarExpanded, useToastMessageDispatch, useUserId } from '@rocket.chat/ui-contexts'; -import type { ReactElement, ReactNode } from 'react'; -import React, { useMemo, useCallback } from 'react'; +import { useLayoutContextualBarExpanded, useToastMessageDispatch, useTranslation, useUserId } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; -import { normalizeThreadTitle } from '../../../../../app/threads/client/lib/normalizeThreadTitle'; import VerticalBar from '../../../../components/VerticalBar'; import { useRoom, useRoomSubscription } from '../../contexts/RoomContext'; import { useTabBarClose } from '../../contexts/ToolboxContext'; import ChatProvider from '../../providers/ChatProvider'; import MessageProvider from '../../providers/MessageProvider'; import ThreadSkeleton from './components/ThreadSkeleton'; +import ThreadTitle from './components/ThreadTitle'; import ThreadView from './components/ThreadView'; import { useGoToThreadList } from './hooks/useGoToThreadList'; -import { useThreadMessage } from './hooks/useThreadMessage'; +import { useThreadMainMessageQuery } from './hooks/useThreadMainMessageQuery'; +import { useToggleFollowingThreadMutation } from './hooks/useToggleFollowingThreadMutation'; type ThreadProps = { tmid: IMessage['_id']; }; const Thread = ({ tmid }: ThreadProps): ReactElement => { + const mainMessageQueryResult = useThreadMainMessageQuery(tmid); + const room = useRoom(); const subscription = useRoomSubscription(); - const goToThreadList = useGoToThreadList(); - const handleGoBack = () => { - goToThreadList(); - }; + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const closeTabBar = useTabBarClose(); + const goToThreadList = useGoToThreadList(); const canExpand = useLayoutContextualBarExpanded(); const [expanded, setExpanded] = useLocalStorage('expand-threads', false); - const handleToggleExpand = useCallback(() => { - setExpanded((expanded) => !expanded); - }, [setExpanded]); - - const mainMessage = useThreadMessage(tmid); - const title = useMemo( - (): ReactNode => (mainMessage ? : null), - [mainMessage], - ); - const uid = useUserId(); - const following = !uid ? false : mainMessage?.replies?.includes(uid) ?? false; - - const followMessage = useEndpoint('POST', '/v1/chat.followMessage'); - const unfollowMessage = useEndpoint('POST', '/v1/chat.unfollowMessage'); - const dispatchToastMessage = useToastMessageDispatch(); - - const handleToggleFollowing = useCallback<() => void>(async () => { - try { - if (!following) { - await followMessage({ mid: tmid }); - return; - } + const following = uid ? mainMessageQueryResult.data?.replies?.includes(uid) ?? false : false; + const toggleFollowingMutation = useToggleFollowingThreadMutation({ + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + }); + + const handleBackdropClick = () => { + closeTabBar(); + }; - await unfollowMessage({ mid: tmid }); - } catch (error: unknown) { - dispatchToastMessage({ - type: 'error', - message: error, - }); - } - }, [dispatchToastMessage, followMessage, following, tmid, unfollowMessage]); + const handleGoBack = () => { + goToThreadList(); + }; - const closeTabBar = useTabBarClose(); + const handleToggleExpand = () => { + setExpanded((expanded) => !expanded); + }; - const handleBackdropClick = useCallback(() => { - closeTabBar(); - }, [closeTabBar]); + const handleToggleFollowing = () => { + toggleFollowingMutation.mutate({ tmid, follow: !following }); + }; - const handleClose = useCallback(() => { + const handleClose = () => { closeTabBar(); - }, [closeTabBar]); + }; return ( {canExpand && expanded && } - - - {mainMessage ? ( - + + + {(mainMessageQueryResult.isLoading && ) || + (mainMessageQueryResult.isSuccess && ) || + null} + + {canExpand && ( + + )} + - ) : ( - - )} - - + + + + + {(mainMessageQueryResult.isLoading && ) || + (mainMessageQueryResult.isSuccess && ( + + + + + + )) || + null} + ); diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadSkeleton.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadSkeleton.tsx index 46eff8f1d3a0..8517a46c8430 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadSkeleton.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadSkeleton.tsx @@ -1,23 +1,17 @@ +import { Box, Skeleton } from '@rocket.chat/fuselage'; import type { ReactElement } from 'react'; import React from 'react'; -import VerticalBar from '../../../../../components/VerticalBar'; - const ThreadSkeleton = (): ReactElement => { return ( - + + + {Array(5) + .fill(5) + .map((_, index) => ( + + ))} + ); }; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadTitle.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadTitle.tsx new file mode 100644 index 000000000000..c2044ba2afb6 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadTitle.tsx @@ -0,0 +1,16 @@ +import type { IThreadMainMessage } from '@rocket.chat/core-typings'; +import React, { useMemo } from 'react'; + +import { normalizeThreadTitle } from '../../../../../../app/threads/client/lib/normalizeThreadTitle'; +import VerticalBar from '../../../../../components/VerticalBar'; + +type ThreadTitleProps = { + mainMessage: IThreadMainMessage; +}; + +const ThreadTitle = ({ mainMessage }: ThreadTitleProps) => { + const innerHTML = useMemo(() => ({ __html: normalizeThreadTitle(mainMessage) }), [mainMessage]); + return ; +}; + +export default ThreadTitle; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadView.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadView.tsx index dceef9672ccc..817c33b4fe8d 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadView.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadView.tsx @@ -1,58 +1,22 @@ -import type { IMessage } from '@rocket.chat/core-typings'; -import { css } from '@rocket.chat/css-in-js'; -import { useQueryStringParameter, useTranslation } from '@rocket.chat/ui-contexts'; -import type { ReactElement, ReactNode } from 'react'; +import type { IThreadMainMessage } from '@rocket.chat/core-typings'; +import { useQueryStringParameter } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; import React, { useState, useEffect, useContext, useRef } from 'react'; +import type { ThreadTemplateInstance } from '../../../../../../app/threads/client/flextab/thread'; import VerticalBar from '../../../../../components/VerticalBar'; import MessageHighlightContext from '../../../MessageList/contexts/MessageHighlightContext'; import { ChatContext } from '../../../contexts/ChatContext'; import { MessageContext } from '../../../contexts/MessageContext'; import { useRoom, useRoomSubscription } from '../../../contexts/RoomContext'; import { useTabBarOpenUserInfo } from '../../../contexts/ToolboxContext'; -import { useThreadMessage } from '../hooks/useThreadMessage'; type ThreadViewProps = { - tmid: IMessage['_id']; - onGoBack: (e: unknown) => void; - canExpand: boolean; - expanded: boolean; - onToggleExpand: () => void; - title: ReactNode; + mainMessage: IThreadMainMessage; following: boolean; - onToggleFollowing: () => void; - onClose: () => void; }; -const ThreadView = ({ - tmid, - title, - canExpand, - expanded, - following, - onToggleExpand, - onToggleFollowing, - onClose, - onGoBack, -}: ThreadViewProps): ReactElement => { - const t = useTranslation(); - - const expandLabel = expanded ? t('Collapse') : t('Expand'); - const expandIcon = expanded ? 'arrow-collapse' : 'arrow-expand'; - - const followLabel = following ? t('Following') : t('Not_Following'); - const followIcon = following ? 'bell' : 'bell-off'; - - const expandedThreadStyle = - canExpand && expanded - ? css` - max-width: 855px !important; - @media (min-width: 780px) and (max-width: 1135px) { - max-width: calc(100% - var(--sidebar-width)) !important; - } - ` - : undefined; - +const ThreadView = ({ mainMessage, following }: ThreadViewProps): ReactElement => { const ref = useRef(null); const chatContext = useContext(ChatContext); @@ -64,15 +28,13 @@ const ThreadView = ({ messageHighlightContextReactiveVar.set(messageHighlightContext); }, [messageHighlightContext, messageHighlightContextReactiveVar]); - const mainMessage = useThreadMessage(tmid); - const jump = useQueryStringParameter('jump'); const room = useRoom(); const subscription = useRoomSubscription(); const openRoomInfo = useTabBarOpenUserInfo(); - const [viewData, setViewData] = useState(() => ({ + const [viewData, setViewData] = useState(() => ({ mainMessage, jump, following, @@ -115,30 +77,7 @@ const ThreadView = ({ }; }, [viewData]); - return ( - - - {onGoBack && } - {title} - {canExpand && } - - - - - - - - ); + return ; }; export default ThreadView; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useGetMessageByID.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useGetMessageByID.ts new file mode 100644 index 000000000000..e145d2c47e27 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useGetMessageByID.ts @@ -0,0 +1,28 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useCallback } from 'react'; + +import { Messages } from '../../../../../../app/models/client'; +import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi'; + +export const useGetMessageByID = () => { + const getMessage = useEndpoint('GET', '/v1/chat.getMessage'); + + return useCallback( + async (mid: IMessage['_id']) => { + try { + const { message: rawMessage } = await getMessage({ msgId: mid }); + const message = mapMessageFromApi(rawMessage); + Messages.upsert({ _id: message._id }, { $set: message }); + return message; + } catch (error) { + if (typeof error === 'object' && error !== null && 'success' in error) { + throw new Error('Message not found'); + } + + throw error; + } + }, + [getMessage], + ); +}; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMainMessageQuery.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMainMessageQuery.ts new file mode 100644 index 000000000000..fcc19aca652a --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMainMessageQuery.ts @@ -0,0 +1,23 @@ +import { isThreadMainMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; + +import { useRoom } from '../../../contexts/RoomContext'; +import { useGetMessageByID } from './useGetMessageByID'; + +export const useThreadMainMessageQuery = (tmid: IMessage['_id']): UseQueryResult => { + const room = useRoom(); + + const getMessage = useGetMessageByID(); + + return useQuery(['rooms', room._id, 'threads', tmid, 'main-message'] as const, async () => { + const mainMessage = await getMessage(tmid); + + if (!mainMessage && !isThreadMainMessage(mainMessage)) { + throw new Error('Invalid main message'); + } + + return mainMessage; + }); +}; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessage.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessage.ts deleted file mode 100644 index cc7bc2509d32..000000000000 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessage.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { IMessage } from '@rocket.chat/core-typings'; -import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { Tracker } from 'meteor/tracker'; -import { useEffect, useState, useCallback } from 'react'; - -import { ChatMessage } from '../../../../../../app/models/client'; -import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi'; - -export const useThreadMessage = (tmid: string): IMessage | undefined => { - const [message, setMessage] = useState(() => Tracker.nonreactive(() => ChatMessage.findOne({ _id: tmid }))); - const getMessage = useEndpoint('GET', '/v1/chat.getMessage'); - const getMessageParsed = useCallback<(params: { msgId: IMessage['_id'] }) => Promise>( - async (params) => { - const { message } = await getMessage(params); - return mapMessageFromApi(message); - }, - [getMessage], - ); - - useEffect(() => { - const computation = Tracker.autorun(async (computation) => { - const msg = ChatMessage.findOne({ _id: tmid }) || (await getMessageParsed({ msgId: tmid })); - - if (!msg || computation.stopped) { - return; - } - - setMessage((prevMsg) => { - if (!prevMsg || prevMsg._id !== msg._id || prevMsg._updatedAt?.getTime() !== msg._updatedAt?.getTime()) { - return msg; - } - - return prevMsg; - }); - }); - - return (): void => { - computation.stop(); - }; - }, [getMessageParsed, tmid]); - - return message; -}; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useToggleFollowingThreadMutation.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useToggleFollowingThreadMutation.ts new file mode 100644 index 000000000000..a5b8ed8b29da --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useToggleFollowingThreadMutation.ts @@ -0,0 +1,39 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useRoom } from '../../../contexts/RoomContext'; + +type UseToggleFollowingThreadMutationVariables = { + tmid: IMessage['_id']; + follow: boolean; +}; + +export const useToggleFollowingThreadMutation = ( + options?: Omit, 'mutationFn'>, +): UseMutationResult => { + const room = useRoom(); + const followMessage = useEndpoint('POST', '/v1/chat.followMessage'); + const unfollowMessage = useEndpoint('POST', '/v1/chat.unfollowMessage'); + + const queryClient = useQueryClient(); + + return useMutation( + async ({ tmid, follow }) => { + if (follow) { + await followMessage({ mid: tmid }); + return; + } + + await unfollowMessage({ mid: tmid }); + }, + { + ...options, + onSuccess: async (data, variables, context) => { + await queryClient.invalidateQueries(['rooms', room._id, 'threads']); + return options?.onSuccess?.(data, variables, context); + }, + }, + ); +}; diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 3f100168b775..ea149faf06a1 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -79,6 +79,7 @@ "@storybook/addons": "~6.5.14", "@storybook/react": "~6.5.14", "@storybook/testing-library": "0.0.13", + "@tanstack/react-query-devtools": "^4.19.1", "@testing-library/react": "~12.1.5", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "~13.5.0", diff --git a/yarn.lock b/yarn.lock index dda6489f9235..9ddf393fa417 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6986,6 +6986,7 @@ __metadata: "@storybook/react": ~6.5.14 "@storybook/testing-library": 0.0.13 "@tanstack/react-query": ^4.16.1 + "@tanstack/react-query-devtools": ^4.19.1 "@testing-library/react": ~12.1.5 "@testing-library/react-hooks": ^8.0.1 "@testing-library/user-event": ~13.5.0 @@ -9319,6 +9320,15 @@ __metadata: languageName: node linkType: hard +"@tanstack/match-sorter-utils@npm:^8.7.0": + version: 8.7.2 + resolution: "@tanstack/match-sorter-utils@npm:8.7.2" + dependencies: + remove-accents: 0.4.2 + checksum: 0821430d7de76911481b5300f4812f7cfefb804bf2f23fb1bab45e66c273379f0c919785ca1aecd8614b72d9e5e0e0edf9f449cbbb87dd91166d77ca285351f6 + languageName: node + linkType: hard + "@tanstack/query-core@npm:4.15.1": version: 4.15.1 resolution: "@tanstack/query-core@npm:4.15.1" @@ -9326,6 +9336,21 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-query-devtools@npm:^4.19.1": + version: 4.19.1 + resolution: "@tanstack/react-query-devtools@npm:4.19.1" + dependencies: + "@tanstack/match-sorter-utils": ^8.7.0 + superjson: ^1.10.0 + use-sync-external-store: ^1.2.0 + peerDependencies: + "@tanstack/react-query": 4.19.1 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 0c40e7b55d06600ff8a2448f8679e52657f22f05ae397ccee83842ee9ce6dcd681f41bd33606adc6590535bb868561dce3da631233c339701fffa602ec1e381c + languageName: node + linkType: hard + "@tanstack/react-query@npm:^4.16.1": version: 4.16.1 resolution: "@tanstack/react-query@npm:4.16.1" @@ -15136,6 +15161,15 @@ __metadata: languageName: node linkType: hard +"copy-anything@npm:^3.0.2": + version: 3.0.3 + resolution: "copy-anything@npm:3.0.3" + dependencies: + is-what: ^4.1.8 + checksum: d456dc5ec98dee7c7cf87d809eac30dc2ac942acd4cf970fab394e280ceb6dd7a8a7a5a44fcbcc50e0206658de3cc20b92863562f5797930bb2619f164f4c182 + languageName: node + linkType: hard + "copy-concurrently@npm:^1.0.0": version: 1.0.5 resolution: "copy-concurrently@npm:1.0.5" @@ -22444,6 +22478,13 @@ __metadata: languageName: node linkType: hard +"is-what@npm:^4.1.8": + version: 4.1.8 + resolution: "is-what@npm:4.1.8" + checksum: b9bec3acff102d14ad467f4c74c9886af310fa160e07a63292c8c181e6768c7c4c1054644e13d67185b963644e4a513bce8c6b8ce3d3ca6f9488a69fccad5f97 + languageName: node + linkType: hard + "is-whitespace-character@npm:^1.0.0": version: 1.0.4 resolution: "is-whitespace-character@npm:1.0.4" @@ -31379,6 +31420,13 @@ __metadata: languageName: node linkType: hard +"remove-accents@npm:0.4.2": + version: 0.4.2 + resolution: "remove-accents@npm:0.4.2" + checksum: 84a6988555dea24115e2d1954db99509588d43fe55a1590f0b5894802776f7b488b3151c37ceb9e4f4b646f26b80b7325dcea2fae58bc3865df146e1fa606711 + languageName: node + linkType: hard + "remove-markdown@npm:^0.2.2": version: 0.2.2 resolution: "remove-markdown@npm:0.2.2" @@ -33967,6 +34015,15 @@ __metadata: languageName: node linkType: hard +"superjson@npm:^1.10.0": + version: 1.12.0 + resolution: "superjson@npm:1.12.0" + dependencies: + copy-anything: ^3.0.2 + checksum: 57b4a2086ab613a88672f617fa0840af0ba25f253f7b778a8859aa1846b01600f55dba31663fad3439fcead874614ae32a8cf72117c32428e9487eca676964ca + languageName: node + linkType: hard + "supertest@npm:^6.2.3": version: 6.2.4 resolution: "supertest@npm:6.2.4" From a40bcf2b7d41f78d3e7aadbf46415217b28c7ea6 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Tue, 13 Dec 2022 13:02:30 -0300 Subject: [PATCH 04/14] Delegate file upload to components --- .../theme/client/imports/general/base_old.css | 40 ----- .../app/threads/client/flextab/dropzone.ts | 144 ------------------ .../client/flextab/messageBoxFollow.html | 5 - .../client/flextab/messageBoxFollow.ts | 11 -- .../app/threads/client/flextab/thread.html | 3 +- .../app/threads/client/flextab/thread.ts | 31 ---- apps/meteor/app/threads/client/index.ts | 1 - .../views/room/components/body/RoomBody.tsx | 2 +- .../body/useFileUploadDropTarget.ts | 16 +- .../room/contextualBar/Threads/Thread.tsx | 4 +- .../{ThreadView.tsx => ThreadChat.tsx} | 22 ++- 11 files changed, 24 insertions(+), 255 deletions(-) delete mode 100644 apps/meteor/app/threads/client/flextab/dropzone.ts delete mode 100644 apps/meteor/app/threads/client/flextab/messageBoxFollow.html delete mode 100644 apps/meteor/app/threads/client/flextab/messageBoxFollow.ts rename apps/meteor/client/views/room/contextualBar/Threads/components/{ThreadView.tsx => ThreadChat.tsx} (77%) diff --git a/apps/meteor/app/theme/client/imports/general/base_old.css b/apps/meteor/app/theme/client/imports/general/base_old.css index f9425bdf72d0..5704c55eb617 100644 --- a/apps/meteor/app/theme/client/imports/general/base_old.css +++ b/apps/meteor/app/theme/client/imports/general/base_old.css @@ -2220,46 +2220,6 @@ background-color: var(--selection-background); } -.rc-old .dropzone { - & .dropzone-overlay { - position: absolute; - z-index: 1000000; - top: 0; - right: 0; - bottom: 0; - left: 0; - - display: none; - - animation-name: zoomIn; - animation-duration: 0.1s; - - text-align: center; - - background: rgba(255, 255, 255, 0.8); - - font-size: 42px; - align-items: center; - justify-content: center; - - &--enabled { - color: var(--color-blue); - - border: 4px dashed var(--color-blue); - } - - &--disabled { - color: var(--color-red); - - border: 4px dashed var(--color-red); - } - } - - &.over .dropzone-overlay { - display: flex; - } -} - @keyframes zoomIn { 0% { transform: scale3d(0.9, 0.9, 0.9); diff --git a/apps/meteor/app/threads/client/flextab/dropzone.ts b/apps/meteor/app/threads/client/flextab/dropzone.ts deleted file mode 100644 index eb9477c09cab..000000000000 --- a/apps/meteor/app/threads/client/flextab/dropzone.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { IRoom } from '@rocket.chat/core-typings'; -import moment from 'moment'; -import _ from 'underscore'; - -import { Users } from '../../../models/client'; -import { roomCoordinator } from '../../../../client/lib/rooms/roomCoordinator'; -import { settings } from '../../../settings/client'; -import type { ThreadTemplateInstance } from './thread'; - -const userCanDrop = (rid: IRoom['_id']) => - !roomCoordinator.readOnly(rid, Users.findOne({ _id: Meteor.userId() }, { fields: { username: 1 } })); - -async function createFileFromUrl(url: string): Promise { - let response; - try { - response = await fetch(url); - } catch (error) { - throw error; - } - - const data = await response.blob(); - const metadata = { - type: data.type, - }; - const { mime } = await import('../../../utils/lib/mimeTypes'); - const file = new File( - [data], - `File - ${moment().format(settings.get('Message_TimeAndDateFormat'))}.${mime.extension(data.type)}`, - metadata, - ); - return file; -} - -export const dropzoneHelpers = { - dragAndDrop(): string | undefined { - return settings.get('FileUpload_Enabled') ? 'dropzone--disabled' : undefined; - }, - - isDropzoneDisabled(): string { - return settings.get('FileUpload_Enabled') ? 'dropzone-overlay--enabled' : 'dropzone-overlay--disabled'; - }, - - dragAndDropLabel(this: ThreadTemplateInstance['data']): string { - if (!userCanDrop(this.rid)) { - return 'error-not-allowed'; - } - - if (!settings.get('FileUpload_Enabled')) { - return 'FileUpload_Disabled'; - } - - return 'Drop_to_upload_file'; - }, -}; - -export const dropzoneEvents = { - 'dragenter .dropzone'(this: ThreadTemplateInstance['data'], e: JQuery.DragEnterEvent) { - const types = e.originalEvent?.dataTransfer?.types; - - if ( - types && - types.length > 0 && - _.some(types, (type) => type.indexOf('text/') === -1 || type.indexOf('text/uri-list') !== -1 || type.indexOf('text/plain') !== -1) && - userCanDrop(this.rid) - ) { - e.currentTarget.classList.add('over'); - } - e.stopPropagation(); - }, - - 'dragleave .dropzone-overlay'(e: JQuery.DragLeaveEvent) { - e.currentTarget.parentNode.classList.remove('over'); - e.stopPropagation(); - }, - - 'dragover .dropzone-overlay'(event: JQuery.DragOverEvent) { - document.querySelectorAll('.over.dropzone').forEach((dropzone) => { - if (dropzone !== event.currentTarget.parentNode) { - dropzone.classList.remove('over'); - } - }); - - if (event.originalEvent?.dataTransfer) { - if (['move', 'linkMove'].includes(event.originalEvent.dataTransfer.effectAllowed)) { - event.originalEvent.dataTransfer.dropEffect = 'move'; - } else { - event.originalEvent.dataTransfer.dropEffect = 'copy'; - } - } - - event.stopPropagation(); - }, - - async 'dropped .dropzone-overlay'(this: ThreadTemplateInstance['data'], event: JQuery.DropEvent, instance: ThreadTemplateInstance) { - event.currentTarget.parentNode.classList.remove('over'); - - event.stopPropagation(); - event.preventDefault(); - - if (!userCanDrop(this.rid) || !settings.get('FileUpload_Enabled')) { - return false; - } - - const dataTransfer = event.originalEvent?.dataTransfer; - - if (!dataTransfer) { - return; - } - - let files = Array.from(dataTransfer.files ?? []); - - if (files.length < 1) { - const transferData = dataTransfer.getData('text') ?? dataTransfer.getData('url'); - - if (dataTransfer.types.includes('text/uri-list')) { - const url = dataTransfer.getData('text/html').match(''); - const imgURL = url?.[1]; - - if (!imgURL) { - return; - } - - const file = await createFileFromUrl(imgURL); - if (typeof file === 'string') { - instance.onTextDrop?.(file); - return; - } - files = [file]; - } - if (dataTransfer.types.includes('text/plain') && !dataTransfer.types.includes('text/x-moz-url')) { - instance.onTextDrop?.(transferData.trim()); - return; - } - } - const { mime } = await import('../../../utils/lib/mimeTypes'); - - instance.onFileDrop?.( - Array.from(files).map((file) => { - Object.defineProperty(file, 'type', { value: mime.lookup(file.name) }); - return file; - }), - ); - }, -}; diff --git a/apps/meteor/app/threads/client/flextab/messageBoxFollow.html b/apps/meteor/app/threads/client/flextab/messageBoxFollow.html deleted file mode 100644 index c0dd5b9c6a71..000000000000 --- a/apps/meteor/app/threads/client/flextab/messageBoxFollow.html +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/apps/meteor/app/threads/client/flextab/messageBoxFollow.ts b/apps/meteor/app/threads/client/flextab/messageBoxFollow.ts deleted file mode 100644 index 1f48106b1504..000000000000 --- a/apps/meteor/app/threads/client/flextab/messageBoxFollow.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Template } from 'meteor/templating'; - -import { callWithErrorHandling } from '../../../../client/lib/utils/callWithErrorHandling'; -import './messageBoxFollow.html'; - -Template.messageBoxFollow.events({ - 'click .js-follow'(this: { tmid: string }) { - const { tmid } = this; - callWithErrorHandling('followMessage', { mid: tmid }); - }, -}); diff --git a/apps/meteor/app/threads/client/flextab/thread.html b/apps/meteor/app/threads/client/flextab/thread.html index cfb5e31d2ba4..501a92f6ddff 100644 --- a/apps/meteor/app/threads/client/flextab/thread.html +++ b/apps/meteor/app/threads/client/flextab/thread.html @@ -1,6 +1,5 @@ diff --git a/apps/meteor/app/threads/client/flextab/thread.ts b/apps/meteor/app/threads/client/flextab/thread.ts index e1d84bb3183f..e7e543e88149 100644 --- a/apps/meteor/app/threads/client/flextab/thread.ts +++ b/apps/meteor/app/threads/client/flextab/thread.ts @@ -5,7 +5,7 @@ import { Session } from 'meteor/session'; import { ReactiveDict } from 'meteor/reactive-dict'; import { Tracker } from 'meteor/tracker'; import { FlowRouter } from 'meteor/kadira:flow-router'; -import type { IMessage, IEditedMessage, ISubscription, IRoom } from '@rocket.chat/core-typings'; +import type { IMessage, IEditedMessage, ISubscription, IRoom, IThreadMainMessage } from '@rocket.chat/core-typings'; import type { ContextType } from 'react'; import { callWithErrorHandling } from '../../../../client/lib/utils/callWithErrorHandling'; @@ -24,7 +24,7 @@ import { withDebouncing, withThrottling } from '../../../../lib/utils/highOrderF import './thread.html'; export type ThreadTemplateInstance = Blaze.TemplateInstance<{ - mainMessage?: IMessage; + mainMessage: IThreadMainMessage; subscription?: ISubscription; rid: IRoom['_id']; tabBar: { @@ -32,7 +32,9 @@ export type ThreadTemplateInstance = Blaze.TemplateInstance<{ }; chatContext: ContextType; messageContext: ContextType; - messageHighlightContext: () => ContextType; + messageHighlightContext: ContextType; + sendToChannel: boolean; + onSend?: () => void; }> & { firstNode: HTMLElement; wrapper?: HTMLElement; @@ -46,7 +48,6 @@ export type ThreadTemplateInstance = Blaze.TemplateInstance<{ rid: string; tmid?: string; loading?: boolean; - sendToChannel: boolean; jump?: string | null; editingMID?: IMessage['_id']; }>; @@ -107,19 +108,23 @@ Template.thread.helpers({ }, messageBoxData(): MessageBoxTemplateInstance['data'] { const instance = Template.instance() as ThreadTemplateInstance; - const { mainMessage: { rid, _id: tmid } = {}, subscription, chatContext } = Template.currentData() as ThreadTemplateInstance['data']; + const { + mainMessage: { rid, _id: tmid } = {}, + subscription, + chatContext, + sendToChannel, + } = Template.currentData() as ThreadTemplateInstance['data']; if (!chatContext) { throw new Error('chatContext is not defined'); } const showFormattingTips = settings.get('Message_ShowFormattingTips'); - const alsoSendPreferenceState = getUserPreference(Meteor.userId(), 'alsoSendThreadToChannel'); return { chatContext, showFormattingTips, - tshow: instance.state.get('sendToChannel'), + tshow: sendToChannel, subscription, rid: rid ?? '', tmid, @@ -134,9 +139,8 @@ Template.thread.helpers({ }, ) => { instance.sendToBottom(); - if (alsoSendPreferenceState === 'default') { - instance.state.set('sendToChannel', false); - } + + instance.data.onSend?.(); await chatContext.flows.sendMessage({ text, @@ -156,15 +160,6 @@ Template.thread.helpers({ hideUsername() { return getUserPreference(Meteor.userId(), 'hideUsernames') ? 'hide-usernames' : undefined; }, - checkboxData() { - const instance = Template.instance() as ThreadTemplateInstance; - const checked = instance.state.get('sendToChannel'); - return { - id: 'sendAlso', - checked, - onChange: () => instance.state.set('sendToChannel', !checked), - }; - }, // TODO: remove this chatContext() { const { chatContext } = (Template.instance() as ThreadTemplateInstance).data; @@ -178,7 +173,7 @@ Template.thread.helpers({ }); Template.thread.onCreated(async function (this: ThreadTemplateInstance) { - this.Threads = new Mongo.Collection(null) as Mongo.Collection, IMessage> & { + this.Threads = new Mongo.Collection, IMessage>(null) as Mongo.Collection, IMessage> & { direct: Mongo.Collection, IMessage>; queries: unknown[]; }; @@ -361,7 +356,7 @@ Template.thread.onRendered(function (this: ThreadTemplateInstance) { this.autorun(() => { const { messageHighlightContext } = Template.currentData() as ThreadTemplateInstance['data']; - this.state.set('editingMID', messageHighlightContext()?.highlightMessageId); + this.state.set('editingMID', messageHighlightContext.highlightMessageId); }); }); diff --git a/apps/meteor/client/templates.ts b/apps/meteor/client/templates.ts index 278ff547a720..7a2cb5e76025 100644 --- a/apps/meteor/client/templates.ts +++ b/apps/meteor/client/templates.ts @@ -22,17 +22,6 @@ createTemplateForComponent('DiscussionMetric', () => import('./components/messag createTemplateForComponent('BroadCastMetric', () => import('./components/message/Metrics/Broadcast')); -createTemplateForComponent( - 'Checkbox', - async (): Promise<{ default: typeof CheckBox }> => { - const { CheckBox } = await import('@rocket.chat/fuselage'); - return { default: CheckBox }; - }, - { - attachment: 'at-parent', - }, -); - createTemplateForComponent('UnreadMessagesIndicator', () => import('./views/room/components/body/UnreadMessagesIndicator'), { attachment: 'at-parent', }); diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx index d51a90f2afb5..6c094c33e2dd 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx @@ -1,7 +1,9 @@ import type { IThreadMainMessage } from '@rocket.chat/core-typings'; -import { useQueryStringParameter } from '@rocket.chat/ui-contexts'; +import { CheckBox } from '@rocket.chat/fuselage'; +import { useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useUserPreference } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; -import React, { useState, useEffect, useContext, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useContext, useRef } from 'react'; import type { ThreadTemplateInstance } from '../../../../../../app/threads/client/flextab/thread'; import VerticalBar from '../../../../../components/VerticalBar'; @@ -18,70 +20,96 @@ type ThreadChatProps = { }; const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { + const t = useTranslation(); const ref = useRef(null); const chatContext = useContext(ChatContext); const messageContext = useContext(MessageContext); const messageHighlightContext = useContext(MessageHighlightContext); - const { current: messageHighlightContextReactiveVar } = useRef(new ReactiveVar(messageHighlightContext)); - useEffect(() => { - messageHighlightContextReactiveVar.set(messageHighlightContext); - }, [messageHighlightContext, messageHighlightContextReactiveVar]); - - const jump = useQueryStringParameter('jump'); const room = useRoom(); const subscription = useRoomSubscription(); const openRoomInfo = useTabBarOpenUserInfo(); - const [viewData, setViewData] = useState(() => ({ - mainMessage, - jump, - subscription, - rid: room._id, - tabBar: { openRoomInfo }, - chatContext, - messageContext, - messageHighlightContext: () => messageHighlightContextReactiveVar.get(), - })); + const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(); + + const sendToChannelPreference = useUserPreference('alsoSendThreadToChannel'); + + const [sendToChannel, setSendToChannel] = useState(() => { + switch (sendToChannelPreference) { + case 'always': + return true; + case 'never': + return false; + default: + return !mainMessage.tcount; + } + }); + + const handleSend = useCallback((): void => { + if (sendToChannelPreference === 'default') { + setSendToChannel(false); + } + }, [sendToChannelPreference]); + + const reactiveViewDataRef = useRef( + new ReactiveVar({ + mainMessage, + subscription, + rid: room._id, + tabBar: { openRoomInfo }, + chatContext, + messageContext, + messageHighlightContext, + sendToChannel, + onSend: handleSend, + }), + ); useEffect(() => { - setViewData((viewData) => { - if (!mainMessage || viewData.mainMessage?._id === mainMessage._id) { - return viewData; - } - - return { - mainMessage, - jump, - subscription, - rid: room._id, - tabBar: { openRoomInfo }, - chatContext, - messageContext, - messageHighlightContext: () => messageHighlightContextReactiveVar.get(), - }; + reactiveViewDataRef.current.set({ + mainMessage, + subscription, + rid: room._id, + tabBar: { openRoomInfo }, + chatContext, + messageContext, + messageHighlightContext, + sendToChannel, + onSend: handleSend, }); - }, [chatContext, jump, messageContext, messageHighlightContextReactiveVar, subscription, mainMessage, room._id, openRoomInfo]); + }, [chatContext, handleSend, mainMessage, messageContext, messageHighlightContext, openRoomInfo, room._id, sendToChannel, subscription]); + + const viewDataFn = useCallback(() => reactiveViewDataRef.current.get(), []); useEffect(() => { - if (!ref.current || !viewData.mainMessage) { + if (!ref.current) { return; } - const view = Blaze.renderWithData(Template.thread, viewData, ref.current); + const view = Blaze.renderWithData(Template.thread, viewDataFn, ref.current); return (): void => { Blaze.remove(view); }; - }, [viewData]); + }, [viewDataFn]); - const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(); + const sendToChannelID = useUniqueId(); return ( <> +
+
+
+ setSendToChannel((checked) => !checked)} /> +
+ +
+
); }; From d13b21e0a748fe88f76937a47e858b7cc1504326 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Tue, 13 Dec 2022 17:46:13 -0300 Subject: [PATCH 06/14] Replace message box with composer --- .../app/threads/client/flextab/thread.html | 1 - .../app/threads/client/flextab/thread.ts | 53 ------------ apps/meteor/client/lib/chats/ChatAPI.ts | 2 +- .../client/lib/chats/flows/sendMessage.ts | 16 ++-- .../body/composer/ComposerMessage.tsx | 81 ++++++++++--------- .../Threads/components/ThreadChat.tsx | 73 +++++++++++++---- 6 files changed, 107 insertions(+), 119 deletions(-) diff --git a/apps/meteor/app/threads/client/flextab/thread.html b/apps/meteor/app/threads/client/flextab/thread.html index 7b9a71dc67ca..e67da765ae81 100644 --- a/apps/meteor/app/threads/client/flextab/thread.html +++ b/apps/meteor/app/threads/client/flextab/thread.html @@ -18,6 +18,5 @@ {{/with}} - {{> messageBox messageBoxData}} diff --git a/apps/meteor/app/threads/client/flextab/thread.ts b/apps/meteor/app/threads/client/flextab/thread.ts index e7e543e88149..cd7e77cceb09 100644 --- a/apps/meteor/app/threads/client/flextab/thread.ts +++ b/apps/meteor/app/threads/client/flextab/thread.ts @@ -13,10 +13,8 @@ import { messageContext } from '../../../ui-utils/client/lib/messageContext'; import { upsertMessageBulk } from '../../../ui-utils/client/lib/RoomHistoryManager'; import { Messages } from '../../../models/client'; import { getUserPreference } from '../../../utils/client'; -import { settings } from '../../../settings/client'; import { callbacks } from '../../../../lib/callbacks'; import { getCommonRoomEvents } from '../../../ui/client/views/app/lib/getCommonRoomEvents'; -import type { MessageBoxTemplateInstance } from '../../../ui-message/client/messageBox/messageBox'; import type { MessageContext } from '../../../../client/views/room/contexts/MessageContext'; import type { ChatContext } from '../../../../client/views/room/contexts/ChatContext'; import type MessageHighlightContext from '../../../../client/views/room/MessageList/contexts/MessageHighlightContext'; @@ -106,57 +104,6 @@ Template.thread.helpers({ }, }; }, - messageBoxData(): MessageBoxTemplateInstance['data'] { - const instance = Template.instance() as ThreadTemplateInstance; - const { - mainMessage: { rid, _id: tmid } = {}, - subscription, - chatContext, - sendToChannel, - } = Template.currentData() as ThreadTemplateInstance['data']; - - if (!chatContext) { - throw new Error('chatContext is not defined'); - } - - const showFormattingTips = settings.get('Message_ShowFormattingTips'); - - return { - chatContext, - showFormattingTips, - tshow: sendToChannel, - subscription, - rid: rid ?? '', - tmid, - onSend: async ( - _event: Event, - { - value: text, - tshow, - }: { - value: string; - tshow?: boolean; - }, - ) => { - instance.sendToBottom(); - - instance.data.onSend?.(); - - await chatContext.flows.sendMessage({ - text, - tshow, - }); - }, - onEscape: () => { - instance.closeThread(); - }, - onNavigateToPreviousMessage: () => chatContext.messageEditing.toPreviousMessage(), - onNavigateToNextMessage: () => chatContext.messageEditing.toNextMessage(), - onUploadFiles: (files: readonly File[]) => { - return chatContext.flows.uploadFiles(files); - }, - }; - }, hideUsername() { return getUserPreference(Meteor.userId(), 'hideUsernames') ? 'hide-usernames' : undefined; }, diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index 970e2a58d2d2..982092d56ac5 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -87,7 +87,7 @@ export type ChatAPI = { | undefined; readonly flows: { readonly uploadFiles: (files: readonly File[]) => Promise; - readonly sendMessage: ({ text, tshow }: { text: string; tshow?: boolean }) => Promise; + readonly sendMessage: ({ text, tshow }: { text: string; tshow?: boolean }) => Promise; readonly processSlashCommand: (message: IMessage) => Promise; readonly processTooLongMessage: (message: IMessage) => Promise; readonly processMessageEditing: (message: Pick & Partial>) => Promise; diff --git a/apps/meteor/client/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index 9320e7fcf05b..e40394d515d2 100644 --- a/apps/meteor/client/lib/chats/flows/sendMessage.ts +++ b/apps/meteor/client/lib/chats/flows/sendMessage.ts @@ -32,13 +32,13 @@ const process = async (chat: ChatAPI, message: IMessage): Promise => { await call('sendMessage', message); }; -export const sendMessage = async (chat: ChatAPI, { text, tshow }: { text: string; tshow?: boolean }): Promise => { +export const sendMessage = async (chat: ChatAPI, { text, tshow }: { text: string; tshow?: boolean }): Promise => { if (!(await chat.data.isSubscribedToRoom())) { try { await chat.data.joinRoom(); } catch (error) { dispatchToastMessage({ type: 'error', message: error }); - return; + return false; } } @@ -48,7 +48,7 @@ export const sendMessage = async (chat: ChatAPI, { text, tshow }: { text: string if (!text && !chat.currentEditing) { // Nothing to do - return; + return false; } if (text) { @@ -64,7 +64,7 @@ export const sendMessage = async (chat: ChatAPI, { text, tshow }: { text: string } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } - return; + return true; } if (chat.currentEditing) { @@ -72,20 +72,22 @@ export const sendMessage = async (chat: ChatAPI, { text, tshow }: { text: string if (!originalMessage) { dispatchToastMessage({ type: 'warning', message: t('Message_not_found') }); - return; + return false; } try { if (await chat.flows.processMessageEditing({ ...originalMessage, msg: '' })) { chat.currentEditing.stop(); - return; + return false; } await chat.currentEditing?.reset(); await chat.flows.requestMessageDeletion(originalMessage); - return; + return false; } catch (error) { dispatchToastMessage({ type: 'error', message: error }); } } + + return false; }; diff --git a/apps/meteor/client/views/room/components/body/composer/ComposerMessage.tsx b/apps/meteor/client/views/room/components/body/composer/ComposerMessage.tsx index 2e4c0fd48bdd..0c10a217ce5b 100644 --- a/apps/meteor/client/views/room/components/body/composer/ComposerMessage.tsx +++ b/apps/meteor/client/views/room/components/body/composer/ComposerMessage.tsx @@ -19,6 +19,7 @@ export type ComposerMessageProps = { chatMessagesInstance: ContextType; onResize?: () => void; onEscape?: () => void; + onSend?: () => void; onNavigateToNextMessage?: () => void; onNavigateToPreviousMessage?: () => void; onUploadFiles?: (files: readonly File[]) => void; @@ -30,6 +31,7 @@ const ComposerMessage = ({ chatMessagesInstance, onResize, onEscape, + onSend, onNavigateToNextMessage, onNavigateToPreviousMessage, onUploadFiles, @@ -37,6 +39,32 @@ const ComposerMessage = ({ const isLayoutEmbedded = useEmbeddedLayout(); const showFormattingTips = useSetting('Message_ShowFormattingTips') as boolean; + const dispatchToastMessage = useToastMessageDispatch(); + + const handleSend = useCallback( + async ( + _event: Event, + { + value: text, + tshow, + }: { + value: string; + tshow?: boolean; + }, + ): Promise => { + try { + const newMessageSent = await chatMessagesInstance?.flows.sendMessage({ + text, + tshow, + }); + if (newMessageSent) onSend?.(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }, + [chatMessagesInstance?.flows, dispatchToastMessage, onSend], + ); + const messageBoxViewRef = useRef(); const messageBoxViewDataRef = useRef( new ReactiveVar({ @@ -46,6 +74,7 @@ const ComposerMessage = ({ showFormattingTips: showFormattingTips && !isLayoutEmbedded, onResize, onEscape, + onSend: handleSend, onNavigateToNextMessage, onNavigateToPreviousMessage, onUploadFiles, @@ -61,6 +90,7 @@ const ComposerMessage = ({ showFormattingTips: showFormattingTips && !isLayoutEmbedded, onResize, onEscape, + onSend: handleSend, onNavigateToNextMessage, onNavigateToPreviousMessage, onUploadFiles, @@ -77,49 +107,20 @@ const ComposerMessage = ({ onNavigateToNextMessage, onNavigateToPreviousMessage, onUploadFiles, + handleSend, ]); - const dispatchToastMessage = useToastMessageDispatch(); - - const footerRef = useCallback( - (footer: HTMLElement | null) => { - if (footer) { - messageBoxViewRef.current = Blaze.renderWithData( - Template.messageBox, - (): MessageBoxTemplateInstance['data'] => ({ - ...messageBoxViewDataRef.current.get(), - onSend: async ( - _event: Event, - { - value: text, - tshow, - }: { - value: string; - tshow?: boolean; - }, - ): Promise => { - try { - await chatMessagesInstance?.flows.sendMessage({ - text, - tshow, - }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }, - }), - footer, - ); - return; - } + const footerRef = useCallback((footer: HTMLElement | null) => { + if (footer) { + messageBoxViewRef.current = Blaze.renderWithData(Template.messageBox, () => messageBoxViewDataRef.current.get(), footer); + return; + } - if (messageBoxViewRef.current) { - Blaze.remove(messageBoxViewRef.current); - messageBoxViewRef.current = undefined; - } - }, - [chatMessagesInstance, dispatchToastMessage], - ); + if (messageBoxViewRef.current) { + Blaze.remove(messageBoxViewRef.current); + messageBoxViewRef.current = undefined; + } + }, []); const publicationReady = useReactiveValue(useCallback(() => RoomManager.getOpenedRoomByRid(rid)?.streamActive ?? false, [rid])); diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx index 6c094c33e2dd..d29278e46cdb 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx @@ -9,11 +9,12 @@ import type { ThreadTemplateInstance } from '../../../../../../app/threads/clien import VerticalBar from '../../../../../components/VerticalBar'; import MessageHighlightContext from '../../../MessageList/contexts/MessageHighlightContext'; import DropTargetOverlay from '../../../components/body/DropTargetOverlay'; +import ComposerContainer from '../../../components/body/composer/ComposerContainer'; import { useFileUploadDropTarget } from '../../../components/body/useFileUploadDropTarget'; -import { ChatContext } from '../../../contexts/ChatContext'; +import { useChat } from '../../../contexts/ChatContext'; import { MessageContext } from '../../../contexts/MessageContext'; import { useRoom, useRoomSubscription } from '../../../contexts/RoomContext'; -import { useTabBarOpenUserInfo } from '../../../contexts/ToolboxContext'; +import { useTabBarClose, useTabBarOpenUserInfo } from '../../../contexts/ToolboxContext'; type ThreadChatProps = { mainMessage: IThreadMainMessage; @@ -23,9 +24,14 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { const t = useTranslation(); const ref = useRef(null); - const chatContext = useContext(ChatContext); const messageContext = useContext(MessageContext); + const chat = useChat(); + + if (!chat) { + throw new Error('No ChatContext provided'); + } + const messageHighlightContext = useContext(MessageHighlightContext); const room = useRoom(); @@ -59,7 +65,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { subscription, rid: room._id, tabBar: { openRoomInfo }, - chatContext, + chatContext: chat, messageContext, messageHighlightContext, sendToChannel, @@ -73,13 +79,13 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { subscription, rid: room._id, tabBar: { openRoomInfo }, - chatContext, + chatContext: chat, messageContext, messageHighlightContext, sendToChannel, onSend: handleSend, }); - }, [chatContext, handleSend, mainMessage, messageContext, messageHighlightContext, openRoomInfo, room._id, sendToChannel, subscription]); + }, [chat, handleSend, mainMessage, messageContext, messageHighlightContext, openRoomInfo, room._id, sendToChannel, subscription]); const viewDataFn = useCallback(() => reactiveViewDataRef.current.get(), []); @@ -96,20 +102,53 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { const sendToChannelID = useUniqueId(); + const closeTabBar = useTabBarClose(); + const handleComposerEscape = useCallback((): void => { + closeTabBar(); + }, [closeTabBar]); + + const handleNavigateToPreviousMessage = useCallback((): void => { + chat.messageEditing.toPreviousMessage(); + }, [chat.messageEditing]); + + const handleNavigateToNextMessage = useCallback((): void => { + chat.messageEditing.toNextMessage(); + }, [chat.messageEditing]); + + const handleUploadFiles = useCallback( + (files: readonly File[]): void => { + chat.flows.uploadFiles(files); + }, + [chat], + ); + return ( <> - -
-
-
- setSendToChannel((checked) => !checked)} /> -
- -
-
+ + + +
+
+
+ setSendToChannel((checked) => !checked)} /> +
+ +
+
+
); }; From 273458123f428625d66225a50a4207858afc1445 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Thu, 15 Dec 2022 16:02:21 -0300 Subject: [PATCH 07/14] Migrate thread template to component --- .../app/threads/client/flextab/thread.html | 22 - .../app/threads/client/flextab/thread.ts | 316 -------------- .../app/threads/client/flextab/threads.ts | 1 - apps/meteor/app/threads/client/index.ts | 3 +- .../body/LegacyMessageTemplateList.tsx | 12 +- ...ageContext.ts => useRoomMessageContext.ts} | 2 +- .../Threads/components/ThreadChat.tsx | 396 +++++++++++++++--- .../page-objects/fragments/home-flextab.ts | 2 +- packages/ui-contexts/package.json | 3 + .../ui-contexts/src/ServerContext/methods.ts | 1 + 10 files changed, 347 insertions(+), 411 deletions(-) delete mode 100644 apps/meteor/app/threads/client/flextab/thread.html delete mode 100644 apps/meteor/app/threads/client/flextab/thread.ts delete mode 100644 apps/meteor/app/threads/client/flextab/threads.ts rename apps/meteor/client/views/room/components/body/{useMessageContext.ts => useRoomMessageContext.ts} (98%) diff --git a/apps/meteor/app/threads/client/flextab/thread.html b/apps/meteor/app/threads/client/flextab/thread.html deleted file mode 100644 index e67da765ae81..000000000000 --- a/apps/meteor/app/threads/client/flextab/thread.html +++ /dev/null @@ -1,22 +0,0 @@ - diff --git a/apps/meteor/app/threads/client/flextab/thread.ts b/apps/meteor/app/threads/client/flextab/thread.ts deleted file mode 100644 index cd7e77cceb09..000000000000 --- a/apps/meteor/app/threads/client/flextab/thread.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Mongo } from 'meteor/mongo'; -import { Template } from 'meteor/templating'; -import { Session } from 'meteor/session'; -import { ReactiveDict } from 'meteor/reactive-dict'; -import { Tracker } from 'meteor/tracker'; -import { FlowRouter } from 'meteor/kadira:flow-router'; -import type { IMessage, IEditedMessage, ISubscription, IRoom, IThreadMainMessage } from '@rocket.chat/core-typings'; -import type { ContextType } from 'react'; - -import { callWithErrorHandling } from '../../../../client/lib/utils/callWithErrorHandling'; -import { messageContext } from '../../../ui-utils/client/lib/messageContext'; -import { upsertMessageBulk } from '../../../ui-utils/client/lib/RoomHistoryManager'; -import { Messages } from '../../../models/client'; -import { getUserPreference } from '../../../utils/client'; -import { callbacks } from '../../../../lib/callbacks'; -import { getCommonRoomEvents } from '../../../ui/client/views/app/lib/getCommonRoomEvents'; -import type { MessageContext } from '../../../../client/views/room/contexts/MessageContext'; -import type { ChatContext } from '../../../../client/views/room/contexts/ChatContext'; -import type MessageHighlightContext from '../../../../client/views/room/MessageList/contexts/MessageHighlightContext'; -import { withDebouncing, withThrottling } from '../../../../lib/utils/highOrderFunctions'; -import './thread.html'; - -export type ThreadTemplateInstance = Blaze.TemplateInstance<{ - mainMessage: IThreadMainMessage; - subscription?: ISubscription; - rid: IRoom['_id']; - tabBar: { - openRoomInfo: (username: string) => void; - }; - chatContext: ContextType; - messageContext: ContextType; - messageHighlightContext: ContextType; - sendToChannel: boolean; - onSend?: () => void; -}> & { - firstNode: HTMLElement; - wrapper?: HTMLElement; - Threads: Mongo.Collection, IMessage> & { - direct: Mongo.Collection, IMessage>; - queries: unknown[]; - }; - threadsObserve?: Meteor.LiveQueryHandle; - callbackRemove?: () => void; - state: ReactiveDict<{ - rid: string; - tmid?: string; - loading?: boolean; - jump?: string | null; - editingMID?: IMessage['_id']; - }>; - closeThread: () => void; - loadMore: () => Promise; - atBottom?: boolean; - sendToBottom: () => void; - sendToBottomIfNecessary: () => void; - lastJump?: string; -}; - -const sort = { ts: 1 }; - -Template.thread.events({ - ...getCommonRoomEvents(), - 'scroll .js-scroll-thread': withThrottling({ wait: 150 })(({ currentTarget: e }: JQuery.ScrollEvent, i: ThreadTemplateInstance) => { - i.atBottom = e.scrollTop >= e.scrollHeight - e.clientHeight; - }), - 'click .toggle-hidden'(e: JQuery.ClickEvent) { - const id = e.currentTarget.dataset.message; - document.querySelector(`#thread-${id}`)?.classList.toggle('message--ignored'); - }, -}); - -Template.thread.helpers({ - mainMessage() { - const { Threads, state } = Template.instance() as ThreadTemplateInstance; - const tmid = state.get('tmid'); - return Threads.findOne({ _id: tmid }); - }, - isLoading() { - return (Template.instance() as ThreadTemplateInstance).state.get('loading') !== false; - }, - messages() { - const { Threads, state } = Template.instance() as ThreadTemplateInstance; - const tmid = state.get('tmid'); - - return Threads.find({ tmid, _id: { $ne: tmid } }, { sort }); - }, - customClass(msg: IMessage) { - const { state } = Template.instance() as ThreadTemplateInstance; - return msg._id === state.get('editingMID') ? 'editing' : ''; - }, - customClassMain() { - const { state } = Template.instance() as ThreadTemplateInstance; - return ['thread-main', state.get('tmid') === state.get('editingMID') ? 'editing' : ''].filter(Boolean).join(' '); - }, - _messageContext(this: ThreadTemplateInstance['data']) { - const result = messageContext.call(this, { rid: this.mainMessage?.rid }); - return { - ...result, - settings: { - ...result.settings, - showReplyButton: false, - showreply: false, - }, - }; - }, - hideUsername() { - return getUserPreference(Meteor.userId(), 'hideUsernames') ? 'hide-usernames' : undefined; - }, - // TODO: remove this - chatContext() { - const { chatContext } = (Template.instance() as ThreadTemplateInstance).data; - return () => chatContext; - }, - // TODO: remove this - messageContext() { - const { messageContext } = (Template.instance() as ThreadTemplateInstance).data; - return () => messageContext; - }, -}); - -Template.thread.onCreated(async function (this: ThreadTemplateInstance) { - this.Threads = new Mongo.Collection, IMessage>(null) as Mongo.Collection, IMessage> & { - direct: Mongo.Collection, IMessage>; - queries: unknown[]; - }; - - const preferenceState = getUserPreference(Meteor.userId(), 'alsoSendThreadToChannel'); - - let sendToChannel; - switch (preferenceState) { - case 'always': - sendToChannel = true; - break; - case 'never': - sendToChannel = false; - break; - default: - sendToChannel = !this.data.mainMessage?.tcount ?? true; - } - - const { mainMessage } = Template.currentData() as ThreadTemplateInstance['data']; - - this.state = new ReactiveDict(undefined, { - sendToChannel, - tmid: mainMessage?._id, - rid: mainMessage?.rid, - }); - - this.loadMore = async () => { - const { tmid } = Tracker.nonreactive(() => this.state.all()); - if (!tmid) { - return; - } - - this.state.set('loading', true); - - const messages = await callWithErrorHandling('getThreadMessages', { tmid }); - - upsertMessageBulk({ msgs: messages }, this.Threads); - upsertMessageBulk({ msgs: messages }, Messages); - - Tracker.afterFlush(() => { - this.state.set('loading', false); - }); - }; - - this.closeThread = () => { - const { - route, - params: { context, tab, ...params }, - } = FlowRouter.current(); - if (!route || !route.name) { - throw new Error('FlowRouter.current().route.name is undefined'); - } - FlowRouter.go(route.name, params); - }; -}); - -Template.thread.onRendered(function (this: ThreadTemplateInstance) { - const rid = Tracker.nonreactive(() => this.state.get('rid')); - if (!rid) { - throw new Error('No rid found'); - } - - this.atBottom = true; - this.wrapper = this.find('.js-scroll-thread'); - - this.sendToBottom = withThrottling({ wait: 300 })(() => { - this.atBottom = true; - if (this.wrapper) { - this.wrapper.scrollTop = this.wrapper.scrollHeight; - } - }); - - this.sendToBottomIfNecessary = () => { - this.atBottom && this.sendToBottom(); - }; - - const list = this.firstNode.querySelector('.js-scroll-thread ul'); - - if (!list) { - throw new Error('Could not find list element'); - } - - const observer = new ResizeObserver(this.sendToBottomIfNecessary); - observer.observe(list); - - this.autorun(() => { - const rid = this.state.get('rid'); - const tmid = this.state.get('tmid'); - if (!rid) { - return; - } - this.callbackRemove?.(); - - this.callbackRemove = () => callbacks.remove('streamNewMessage', `thread-${rid}`); - - callbacks.add( - 'streamNewMessage', - withDebouncing({ wait: 1000 })((msg: IEditedMessage) => { - if (Session.get('openedRoom') !== msg.rid || rid !== msg.rid || msg.editedAt || msg.tmid !== tmid) { - return; - } - Meteor.call('readThreads', tmid); - }), - callbacks.priority.MEDIUM, - `thread-${rid}`, - ); - }); - - this.autorun(() => { - const tmid = this.state.get('tmid'); - this.threadsObserve?.stop(); - - this.threadsObserve = Messages.find( - { $or: [{ tmid }, { _id: tmid }], _hidden: { $ne: true } }, - { - fields: { - collapsed: 0, - threadMsg: 0, - repliesCount: 0, - }, - }, - ).observe({ - added: ({ _id, ...message }: IMessage) => { - this.Threads.upsert({ _id }, message); - }, - changed: ({ _id, ...message }: IMessage) => { - this.Threads.update({ _id }, message); - }, - removed: ({ _id }: IMessage) => this.Threads.remove(_id), - }); - - this.loadMore(); - }); - - this.autorun(() => { - FlowRouter.watchPathChange(); - const jump = FlowRouter.getQueryParam('jump'); - const { mainMessage } = Template.currentData() as ThreadTemplateInstance['data']; - this.state.set({ - tmid: mainMessage?._id, - rid: mainMessage?.rid, - jump, - }); - }); - - this.autorun(() => { - const jump = this.state.get('jump'); - const loading = this.state.get('loading'); - - if (jump && this.lastJump !== jump && loading === false) { - this.lastJump = jump; - this.find('.js-scroll-thread').style.scrollBehavior = 'smooth'; - this.state.set('jump', null); - Tracker.afterFlush(() => { - const message = this.find(`#thread-${jump}`); - message.classList.add('highlight'); - const removeClass = () => { - message.classList.remove('highlight'); - message.removeEventListener('animationend', removeClass); - }; - message.addEventListener('animationend', removeClass); - setTimeout(() => { - message.scrollIntoView(); - }, 300); - }); - } - }); - - this.autorun(() => { - const { Threads, state } = Template.instance() as ThreadTemplateInstance; - const tmid = state.get('tmid'); - const threads = Threads.findOne({ $or: [{ tmid }, { _id: tmid }] }); - const isLoading = state.get('loading'); - - if (!isLoading && !threads) { - this.closeThread(); - } - }); - - this.autorun(() => { - const { messageHighlightContext } = Template.currentData() as ThreadTemplateInstance['data']; - - this.state.set('editingMID', messageHighlightContext.highlightMessageId); - }); -}); - -Template.thread.onDestroyed(function (this: ThreadTemplateInstance) { - const { Threads, threadsObserve, callbackRemove } = this; - Threads.remove({}); - threadsObserve?.stop(); - - callbackRemove?.(); -}); diff --git a/apps/meteor/app/threads/client/flextab/threads.ts b/apps/meteor/app/threads/client/flextab/threads.ts deleted file mode 100644 index 19a5c0fc47b9..000000000000 --- a/apps/meteor/app/threads/client/flextab/threads.ts +++ /dev/null @@ -1 +0,0 @@ -import '../threads.css'; diff --git a/apps/meteor/app/threads/client/index.ts b/apps/meteor/app/threads/client/index.ts index c984a3eaaba1..4dad7ebd4846 100644 --- a/apps/meteor/app/threads/client/index.ts +++ b/apps/meteor/app/threads/client/index.ts @@ -1,6 +1,5 @@ import './flextab/threadlist'; -import './flextab/thread.ts'; -import './flextab/threads.ts'; import './messageAction/follow'; import './messageAction/unfollow'; import './messageAction/replyInThread'; +import './threads.css'; diff --git a/apps/meteor/client/views/room/components/body/LegacyMessageTemplateList.tsx b/apps/meteor/client/views/room/components/body/LegacyMessageTemplateList.tsx index 6831f2df7901..5756821cc7d3 100644 --- a/apps/meteor/client/views/room/components/body/LegacyMessageTemplateList.tsx +++ b/apps/meteor/client/views/room/components/body/LegacyMessageTemplateList.tsx @@ -5,16 +5,16 @@ import { Template } from 'meteor/templating'; import type { ReactElement } from 'react'; import React, { memo, useCallback, useRef } from 'react'; -import { ChatMessage } from '../../../../../app/models/client'; +import { Messages } from '../../../../../app/models/client'; import { useReactiveValue } from '../../../../hooks/useReactiveValue'; -import { useMessageContext } from './useMessageContext'; +import { useRoomMessageContext } from './useRoomMessageContext'; type LegacyMessageTemplateListProps = { room: IRoom; }; const LegacyMessageTemplateList = ({ room }: LegacyMessageTemplateListProps): ReactElement => { - const messageContext = useMessageContext(room); + const roomMessageContext = useRoomMessageContext(room); const hideSystemMessages = useSetting('Hide_System_Messages') as MessageTypesValues[]; @@ -43,7 +43,7 @@ const LegacyMessageTemplateList = ({ room }: LegacyMessageTemplateListProps): Re }, }; - return ChatMessage.find(query, options).fetch(); + return Messages.find(query, options).fetch(); }, [hideSystemMessages, room._id, room.sysMes]), ); @@ -59,7 +59,7 @@ const LegacyMessageTemplateList = ({ room }: LegacyMessageTemplateListProps): Re index, shouldCollapseReplies: false, msg: message, - ...messageContext, + ...roomMessageContext, }), node.parentElement, node, @@ -76,7 +76,7 @@ const LegacyMessageTemplateList = ({ room }: LegacyMessageTemplateListProps): Re viewsRef.current.delete(message._id); } }, - [messageContext], + [roomMessageContext], ); return ( diff --git a/apps/meteor/client/views/room/components/body/useMessageContext.ts b/apps/meteor/client/views/room/components/body/useRoomMessageContext.ts similarity index 98% rename from apps/meteor/client/views/room/components/body/useMessageContext.ts rename to apps/meteor/client/views/room/components/body/useRoomMessageContext.ts index 63cd5ccf8f63..04867f43b8d6 100644 --- a/apps/meteor/client/views/room/components/body/useMessageContext.ts +++ b/apps/meteor/client/views/room/components/body/useRoomMessageContext.ts @@ -8,7 +8,7 @@ import { useReactiveValue } from '../../../../hooks/useReactiveValue'; import { useRoomSubscription } from '../../contexts/RoomContext'; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export const useMessageContext = (room: IRoom) => { +export const useRoomMessageContext = (room: IRoom) => { const uid = useUserId(); const user = useUser() ?? undefined; const rid = room._id; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx index d29278e46cdb..8bf01405c52d 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx @@ -1,20 +1,29 @@ -import type { IThreadMainMessage } from '@rocket.chat/core-typings'; +import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; +import { isThreadMessage, isEditedMessage } from '@rocket.chat/core-typings'; import { CheckBox } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; -import { useTranslation, useUserPreference } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; -import React, { useState, useEffect, useCallback, useContext, useRef } from 'react'; +import { useCurrentRoute, useMethod, useQueryStringParameter, useRoute, useTranslation, useUserPreference } from '@rocket.chat/ui-contexts'; +import type { ReactElement, UIEvent } from 'react'; +import React, { useState, useEffect, useCallback, useContext, useRef, useMemo } from 'react'; -import type { ThreadTemplateInstance } from '../../../../../../app/threads/client/flextab/thread'; +import { Messages } from '../../../../../../app/models/client'; +import { upsertMessageBulk } from '../../../../../../app/ui-utils/client/lib/RoomHistoryManager'; +import type { CommonRoomTemplateInstance } from '../../../../../../app/ui/client/views/app/lib/CommonRoomTemplateInstance'; +import { getCommonRoomEvents } from '../../../../../../app/ui/client/views/app/lib/getCommonRoomEvents'; +import { callbacks } from '../../../../../../lib/callbacks'; +import { isTruthy } from '../../../../../../lib/isTruthy'; import VerticalBar from '../../../../../components/VerticalBar'; +import { useReactiveValue } from '../../../../../hooks/useReactiveValue'; import MessageHighlightContext from '../../../MessageList/contexts/MessageHighlightContext'; import DropTargetOverlay from '../../../components/body/DropTargetOverlay'; +import LoadingMessagesIndicator from '../../../components/body/LoadingMessagesIndicator'; import ComposerContainer from '../../../components/body/composer/ComposerContainer'; import { useFileUploadDropTarget } from '../../../components/body/useFileUploadDropTarget'; +import { useRoomMessageContext } from '../../../components/body/useRoomMessageContext'; import { useChat } from '../../../contexts/ChatContext'; import { MessageContext } from '../../../contexts/MessageContext'; import { useRoom, useRoomSubscription } from '../../../contexts/RoomContext'; -import { useTabBarClose, useTabBarOpenUserInfo } from '../../../contexts/ToolboxContext'; +import { useTabBarClose, useToolboxContext } from '../../../contexts/ToolboxContext'; type ThreadChatProps = { mainMessage: IThreadMainMessage; @@ -22,7 +31,8 @@ type ThreadChatProps = { const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { const t = useTranslation(); - const ref = useRef(null); + const atBottomRef = useRef(true); + const wrapperRef = useRef(null); const messageContext = useContext(MessageContext); @@ -34,13 +44,11 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { const messageHighlightContext = useContext(MessageHighlightContext); - const room = useRoom(); const subscription = useRoomSubscription(); - const openRoomInfo = useTabBarOpenUserInfo(); const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(); - const sendToChannelPreference = useUserPreference('alsoSendThreadToChannel'); + const sendToChannelPreference = useUserPreference<'always' | 'never' | 'default'>('alsoSendThreadToChannel'); const [sendToChannel, setSendToChannel] = useState(() => { switch (sendToChannelPreference) { @@ -52,6 +60,9 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { return !mainMessage.tcount; } }); + const [loading, setLoading] = useState(false); + + const hideUsernames = useUserPreference('hideUsernames'); const handleSend = useCallback((): void => { if (sendToChannelPreference === 'default') { @@ -59,46 +70,65 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { } }, [sendToChannelPreference]); - const reactiveViewDataRef = useRef( - new ReactiveVar({ - mainMessage, - subscription, - rid: room._id, - tabBar: { openRoomInfo }, - chatContext: chat, - messageContext, - messageHighlightContext, - sendToChannel, - onSend: handleSend, + const sendToBottom = useCallback(() => { + const wrapper = wrapperRef.current; + + wrapper?.scrollTo(30, wrapper.scrollHeight); + }, []); + + const sendToBottomIfNecessary = useCallback(() => { + if (atBottomRef.current === true) { + sendToBottom(); + } + }, [sendToBottom]); + + const room = useRoom(); + const roomMessageContext = useRoomMessageContext(room); + const threadMessageContext = useMemo( + () => ({ + ...roomMessageContext, + settings: { + ...roomMessageContext.settings, + showReplyButton: false, + showreply: false, + }, }), + [roomMessageContext], ); - useEffect(() => { - reactiveViewDataRef.current.set({ - mainMessage, - subscription, - rid: room._id, - tabBar: { openRoomInfo }, - chatContext: chat, - messageContext, - messageHighlightContext, - sendToChannel, - onSend: handleSend, - }); - }, [chat, handleSend, mainMessage, messageContext, messageHighlightContext, openRoomInfo, room._id, sendToChannel, subscription]); - - const viewDataFn = useCallback(() => reactiveViewDataRef.current.get(), []); + const customClassMain = useMemo(() => { + return ['thread-main', mainMessage._id === messageHighlightContext.highlightMessageId ? 'editing' : ''].filter(Boolean).join(' '); + }, [mainMessage._id, messageHighlightContext.highlightMessageId]); - useEffect(() => { - if (!ref.current) { - return; - } - const view = Blaze.renderWithData(Template.thread, viewDataFn, ref.current); + const customClass = useCallback( + (message: IMessage): string => { + return message._id === messageHighlightContext.highlightMessageId ? 'editing' : ''; + }, + [messageHighlightContext.highlightMessageId], + ); - return (): void => { - Blaze.remove(view); - }; - }, [viewDataFn]); + const messages = useReactiveValue( + useCallback(() => { + return Messages.find( + { + $or: [{ tmid: mainMessage._id }, { _id: mainMessage._id }], + _hidden: { $ne: true }, + tmid: mainMessage._id, + _id: { $ne: mainMessage._id }, + }, + { + fields: { + collapsed: 0, + threadMsg: 0, + repliesCount: 0, + }, + sort: { ts: 1 }, + }, + ) + .fetch() + .filter(isThreadMessage); + }, [mainMessage._id]), + ); const sendToChannelID = useUniqueId(); @@ -122,34 +152,276 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { [chat], ); + const handleScroll = useCallback(({ currentTarget: e }: UIEvent) => { + atBottomRef.current = e.scrollTop >= e.scrollHeight - e.clientHeight; + }, []); + + const useLegacyMessageTemplate = useUserPreference('useLegacyMessageTemplate') ?? false; + const toolbox = useToolboxContext(); + + useEffect(() => { + const messageList = wrapperRef.current?.querySelector('ul'); + + if (!messageList) { + return; + } + + const messageEvents: Record void> = { + ...getCommonRoomEvents(useLegacyMessageTemplate), + 'click .toggle-hidden'(event: JQuery.ClickEvent) { + const mid = event.target.dataset.message; + if (mid) document.getElementById(mid)?.classList.toggle('message--ignored'); + }, + 'load .gallery-item'() { + sendToBottomIfNecessary(); + }, + 'rendered .js-block-wrapper'() { + sendToBottomIfNecessary(); + }, + }; + + const eventHandlers = Object.entries(messageEvents).map(([key, handler]) => { + const [, event, selector] = key.match(/^(.+?)\s(.+)$/) ?? [key, key]; + return { + event, + selector, + listener: (e: JQuery.TriggeredEvent) => + handler.call(null, e, { data: { rid: room._id, tabBar: toolbox, chatContext: chat } }), + }; + }); + + for (const { event, selector, listener } of eventHandlers) { + $(messageList).on(event, selector, listener); + } + + return () => { + for (const { event, selector, listener } of eventHandlers) { + $(messageList).off(event, selector, listener); + } + }; + }, [chat, room._id, sendToBottomIfNecessary, toolbox, useLegacyMessageTemplate]); + + useEffect(() => { + const messageList = wrapperRef.current?.querySelector('ul'); + + if (!messageList) { + return; + } + + const observer = new ResizeObserver(() => { + sendToBottomIfNecessary(); + }); + + observer.observe(messageList); + + return () => { + observer?.disconnect(); + }; + }, [sendToBottomIfNecessary]); + + const handleComposerResize = useCallback((): void => { + sendToBottomIfNecessary(); + }, [sendToBottomIfNecessary]); + + const readThreads = useMethod('readThreads'); + + useEffect(() => { + callbacks.add( + 'streamNewMessage', + (msg: IMessage) => { + if (room._id !== msg.rid || (isEditedMessage(msg) && msg.editedAt) || msg.tmid !== mainMessage._id) { + return; + } + + readThreads(mainMessage._id); + }, + callbacks.priority.MEDIUM, + `thread-${room._id}`, + ); + + return () => { + callbacks.remove('streamNewMessage', `thread-${room._id}`); + }; + }, [mainMessage._id, readThreads, room._id, sendToBottom]); + + const jump = useQueryStringParameter('jump'); + const [currentRouteName, currentRouteParams, currentRouteQueryStringParams] = useCurrentRoute(); + if (!currentRouteName) { + throw new Error('No route name'); + } + const currentRoute = useRoute(currentRouteName); + + useEffect(() => { + if (loading || !jump) { + return; + } + + const newQueryStringParams = { ...currentRouteQueryStringParams }; + delete newQueryStringParams.jump; + currentRoute.replace(currentRouteParams, newQueryStringParams); + + const messageElement = document.querySelector(`#thread-${jump}`); + if (!messageElement) { + return; + } + + messageElement.classList.add('highlight'); + const removeClass = () => { + messageElement.classList.remove('highlight'); + messageElement.removeEventListener('animationend', removeClass); + }; + messageElement.addEventListener('animationend', removeClass); + setTimeout(() => { + messageElement.scrollIntoView(); + }, 300); + }, [currentRoute, currentRouteParams, currentRouteQueryStringParams, jump, loading]); + + const getThreadMessages = useMethod('getThreadMessages'); + + useEffect(() => { + setLoading(true); + getThreadMessages({ tmid: mainMessage._id }).then((messages) => { + upsertMessageBulk({ msgs: messages }, Messages); + setLoading(false); + }); + }, [getThreadMessages, mainMessage._id]); + + const viewsRef = useRef>(new Map()); + + const mainMessageRef = useCallback( + (mainMessage: IMessage, index: number) => (node: HTMLLIElement | null) => { + if (node?.parentElement) { + const view = Blaze.renderWithData( + Template.message, + () => ({ + index, + groupable: false, + hideRoles: true, + msg: mainMessage, + room: threadMessageContext.room, + subscription: threadMessageContext.subscription, + settings: threadMessageContext.settings, + templatePrefix: 'thread-', + customClass: customClassMain, + u: threadMessageContext.u, + ignored: false, + shouldCollapseReplies: true, + chatContext: chat, + messageContext, + }), + node.parentElement, + node, + ); + + viewsRef.current.set(mainMessage._id, view); + } + + if (!node && viewsRef.current.has(mainMessage._id)) { + const view = viewsRef.current.get(mainMessage._id); + if (view) { + Blaze.remove(view); + } + viewsRef.current.delete(mainMessage._id); + } + }, + [ + chat, + customClassMain, + messageContext, + threadMessageContext.room, + threadMessageContext.settings, + threadMessageContext.subscription, + threadMessageContext.u, + ], + ); + + const messageRef = useCallback( + (message: IMessage, index: number) => (node: HTMLLIElement | null) => { + if (node?.parentElement) { + const view = Blaze.renderWithData( + Template.message, + () => ({ + index, + hideRoles: true, + msg: message, + room: threadMessageContext.room, + shouldCollapseReplies: true, + subscription: threadMessageContext.subscription, + settings: threadMessageContext.settings, + templatePrefix: 'thread-', + customClass: customClass(message), + u: threadMessageContext.u, + context: 'threads', + chatContext: chat, + messageContext, + }), + node.parentElement, + node, + ); + + viewsRef.current.set(message._id, view); + } + + if (!node && viewsRef.current.has(message._id)) { + const view = viewsRef.current.get(message._id); + if (view) { + Blaze.remove(view); + } + viewsRef.current.delete(message._id); + } + }, + [ + chat, + customClass, + messageContext, + threadMessageContext.room, + threadMessageContext.settings, + threadMessageContext.subscription, + threadMessageContext.u, + ], + ); + return ( - <> + - - +
+
+
    + {loading ? ( +
  • + +
  • + ) : ( + <> +
  • + {messages.map((message, index) => ( +
  • + ))} + + )} +
+
-
-
-
- setSendToChannel((checked) => !checked)} /> -
- -
-
- - +
+
+ setSendToChannel((checked) => !checked)} /> +
+ +
+
+
); }; diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts index 253b16f23527..173fac968215 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts @@ -46,7 +46,7 @@ export class HomeFlextab { get flexTabViewThreadMessage(): Locator { return this.page.locator( - 'div.thread-list.js-scroll-thread ul.thread [data-qa-type="message"]:last-child div.message-body-wrapper [data-qa-type="message-body"]', + 'div.thread-list ul.thread [data-qa-type="message"]:last-child div.message-body-wrapper [data-qa-type="message-body"]', ); } diff --git a/packages/ui-contexts/package.json b/packages/ui-contexts/package.json index c3fba2b35782..d94f6cd0ed80 100644 --- a/packages/ui-contexts/package.json +++ b/packages/ui-contexts/package.json @@ -27,6 +27,9 @@ "react": "~17.0.2", "use-sync-external-store": "^1.2.0" }, + "volta": { + "extends": "../../package.json" + }, "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", diff --git a/packages/ui-contexts/src/ServerContext/methods.ts b/packages/ui-contexts/src/ServerContext/methods.ts index ebb8a12b17c5..17998d518107 100644 --- a/packages/ui-contexts/src/ServerContext/methods.ts +++ b/packages/ui-contexts/src/ServerContext/methods.ts @@ -183,6 +183,7 @@ export interface ServerMethods { 'personalAccessTokens:removeToken': (...args: any[]) => any; 'e2e.requestSubscriptionKeys': (...args: any[]) => any; 'readMessages': (...args: any[]) => any; + 'readThreads': (tmid: IMessage['_id']) => void; 'refreshClients': (...args: any[]) => any; 'refreshOAuthService': (...args: any[]) => any; 'registerUser': (...args: any[]) => any; From 8bce69276c9a93a9e3a945c022d122b1df92879b Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Mon, 19 Dec 2022 11:53:54 -0300 Subject: [PATCH 08/14] Stabilize message rendering --- .../Threads/components/ThreadChat.tsx | 186 ++++++++---------- 1 file changed, 85 insertions(+), 101 deletions(-) diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx index 8bf01405c52d..aefe2f4b2f2a 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx @@ -1,9 +1,10 @@ import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; -import { isThreadMessage, isEditedMessage } from '@rocket.chat/core-typings'; +import { isThreadMainMessage, isThreadMessage, isEditedMessage } from '@rocket.chat/core-typings'; import { CheckBox } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useCurrentRoute, useMethod, useQueryStringParameter, useRoute, useTranslation, useUserPreference } from '@rocket.chat/ui-contexts'; -import type { ReactElement, UIEvent } from 'react'; +import { ReactiveDict } from 'meteor/reactive-dict'; +import type { ReactElement, RefCallback, UIEvent } from 'react'; import React, { useState, useEffect, useCallback, useContext, useRef, useMemo } from 'react'; import { Messages } from '../../../../../../app/models/client'; @@ -96,17 +97,6 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { [roomMessageContext], ); - const customClassMain = useMemo(() => { - return ['thread-main', mainMessage._id === messageHighlightContext.highlightMessageId ? 'editing' : ''].filter(Boolean).join(' '); - }, [mainMessage._id, messageHighlightContext.highlightMessageId]); - - const customClass = useCallback( - (message: IMessage): string => { - return message._id === messageHighlightContext.highlightMessageId ? 'editing' : ''; - }, - [messageHighlightContext.highlightMessageId], - ); - const messages = useReactiveValue( useCallback(() => { return Messages.find( @@ -286,99 +276,93 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { }); }, [getThreadMessages, mainMessage._id]); - const viewsRef = useRef>(new Map()); - - const mainMessageRef = useCallback( - (mainMessage: IMessage, index: number) => (node: HTMLLIElement | null) => { - if (node?.parentElement) { - const view = Blaze.renderWithData( - Template.message, - () => ({ - index, - groupable: false, - hideRoles: true, - msg: mainMessage, - room: threadMessageContext.room, - subscription: threadMessageContext.subscription, - settings: threadMessageContext.settings, - templatePrefix: 'thread-', - customClass: customClassMain, - u: threadMessageContext.u, - ignored: false, - shouldCollapseReplies: true, - chatContext: chat, - messageContext, - }), - node.parentElement, - node, - ); - - viewsRef.current.set(mainMessage._id, view); - } - - if (!node && viewsRef.current.has(mainMessage._id)) { - const view = viewsRef.current.get(mainMessage._id); - if (view) { - Blaze.remove(view); - } - viewsRef.current.delete(mainMessage._id); - } - }, - [ - chat, - customClassMain, - messageContext, - threadMessageContext.room, - threadMessageContext.settings, - threadMessageContext.subscription, - threadMessageContext.u, - ], + const [reactiveThreadMessageContext] = useState( + () => + new ReactiveDict(undefined, { + ...threadMessageContext, + 'messageHighlightContext.highlightMessageId': messageHighlightContext.highlightMessageId, + messageContext, + 'chatContext': chat, + }), ); + useEffect(() => { + reactiveThreadMessageContext.set({ + ...threadMessageContext, + 'messageHighlightContext.highlightMessageId': messageHighlightContext.highlightMessageId, + messageContext, + 'chatContext': chat, + }); + }, [chat, messageContext, messageHighlightContext.highlightMessageId, reactiveThreadMessageContext, threadMessageContext]); + + const refs = useRef; reactiveMessage: ReactiveVar }>>(new Map()); - const messageRef = useCallback( - (message: IMessage, index: number) => (node: HTMLLIElement | null) => { - if (node?.parentElement) { - const view = Blaze.renderWithData( - Template.message, - () => ({ - index, - hideRoles: true, - msg: message, - room: threadMessageContext.room, - shouldCollapseReplies: true, - subscription: threadMessageContext.subscription, - settings: threadMessageContext.settings, - templatePrefix: 'thread-', - customClass: customClass(message), - u: threadMessageContext.u, - context: 'threads', - chatContext: chat, - messageContext, - }), - node.parentElement, - node, - ); - - viewsRef.current.set(message._id, view); + const getRef = useCallback( + (message: IMessage, index: number) => { + const pair = refs.current.get(message._id); + + if (pair) { + pair.reactiveMessage.set(message); + return pair.callback; } - if (!node && viewsRef.current.has(message._id)) { - const view = viewsRef.current.get(message._id); - if (view) { + let view: Blaze.View; + + const reactiveMessage = new ReactiveVar(message); + + const callback = (node: HTMLLIElement | null) => { + if (node?.parentElement) { + view = Blaze.renderWithData( + Template.message, + () => { + const message = reactiveMessage.get(); + + return { + index, + msg: message, + room: reactiveThreadMessageContext.get('room'), + subscription: reactiveThreadMessageContext.get('subscription'), + settings: reactiveThreadMessageContext.get('settings'), + u: reactiveThreadMessageContext.get('u'), + chatContext: reactiveThreadMessageContext.get('chatContext'), + messageContext: reactiveThreadMessageContext.get('messageContext'), + hideRoles: true, + shouldCollapseReplies: true, + templatePrefix: 'thread-', + + context: 'threads', + ...(isThreadMainMessage(message) + ? { + customClass: [ + 'thread-main', + message._id === reactiveThreadMessageContext.get('messageHighlightContext.highlightMessageId') ? 'editing' : '', + ] + .filter(Boolean) + .join(' '), + ignored: false, + groupable: false, + } + : { + customClass: + message._id === reactiveThreadMessageContext.get('messageHighlightContext.highlightMessageId') ? 'editing' : '', + context: 'threads', + }), + }; + }, + node.parentElement, + node, + ); + } + + if (!node) { Blaze.remove(view); } - viewsRef.current.delete(message._id); - } + }; + + refs.current.set(message._id, { callback, reactiveMessage }); + + return callback; }, - [ - chat, - customClass, - messageContext, - threadMessageContext.room, - threadMessageContext.settings, - threadMessageContext.subscription, - threadMessageContext.u, - ], + [reactiveThreadMessageContext], ); return ( @@ -393,9 +377,9 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { ) : ( <> -
  • +
  • {messages.map((message, index) => ( -
  • +
  • ))} )} From 2b630b4d89090f621a29f3f6d7f01e660fd0c6d9 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Mon, 19 Dec 2022 16:40:32 -0300 Subject: [PATCH 09/14] Move away all behavior related to legacy message --- .../app/ui-message/{index.js => index.ts} | 0 .../components/LegacyThreadMessageList.tsx | 58 +++ .../Threads/components/ThreadChat.tsx | 329 ++---------------- .../hooks/useLegacyThreadMessageJump.ts | 40 +++ .../useLegacyThreadMessageListScrolling.ts | 88 +++++ .../hooks/useLegacyThreadMessageRef.ts | 112 ++++++ .../Threads/hooks/useLegacyThreadMessages.ts | 47 +++ 7 files changed, 374 insertions(+), 300 deletions(-) rename apps/meteor/app/ui-message/{index.js => index.ts} (100%) create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/components/LegacyThreadMessageList.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageJump.ts create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageListScrolling.ts create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageRef.ts create mode 100644 apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessages.ts diff --git a/apps/meteor/app/ui-message/index.js b/apps/meteor/app/ui-message/index.ts similarity index 100% rename from apps/meteor/app/ui-message/index.js rename to apps/meteor/app/ui-message/index.ts diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/LegacyThreadMessageList.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/LegacyThreadMessageList.tsx new file mode 100644 index 000000000000..c4e0b0295f3e --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/LegacyThreadMessageList.tsx @@ -0,0 +1,58 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { useMergedRefs } from '@rocket.chat/fuselage-hooks'; +import { useUserPreference } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { isTruthy } from '../../../../../../lib/isTruthy'; +import LoadingMessagesIndicator from '../../../components/body/LoadingMessagesIndicator'; +import { useLegacyThreadMessageJump } from '../hooks/useLegacyThreadMessageJump'; +import { useLegacyThreadMessageListScrolling } from '../hooks/useLegacyThreadMessageListScrolling'; +import { useLegacyThreadMessageRef } from '../hooks/useLegacyThreadMessageRef'; +import { useLegacyThreadMessages } from '../hooks/useLegacyThreadMessages'; + +type LegacyThreadMessageListProps = { + mainMessage: IMessage; + jumpTo?: string; + onJumpTo?: (mid: IMessage['_id']) => void; +}; + +const LegacyThreadMessageList = function LegacyThreadChatList({ + mainMessage, + jumpTo, + onJumpTo, +}: LegacyThreadMessageListProps): ReactElement { + const { messages, loading } = useLegacyThreadMessages(mainMessage._id); + const messageRef = useLegacyThreadMessageRef(); + const { listWrapperRef: listWrapperScrollRef, listRef: listScrollRef, onScroll: handleScroll } = useLegacyThreadMessageListScrolling(); + const { parentRef: listJumpRef } = useLegacyThreadMessageJump(jumpTo, { enabled: !loading, onJumpTo }); + + const listRef = useMergedRefs(listScrollRef, listJumpRef); + const hideUsernames = useUserPreference('hideUsernames'); + + return ( +
    +
      + {loading ? ( +
    • + +
    • + ) : ( + <> +
    • + {messages.map((message, index) => ( +
    • + ))} + + )} +
    +
    + ); +}; + +export default LegacyThreadMessageList; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx index aefe2f4b2f2a..a7e698862734 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx @@ -1,52 +1,26 @@ import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; -import { isThreadMainMessage, isThreadMessage, isEditedMessage } from '@rocket.chat/core-typings'; +import { isEditedMessage } from '@rocket.chat/core-typings'; import { CheckBox } from '@rocket.chat/fuselage'; import { useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useCurrentRoute, useMethod, useQueryStringParameter, useRoute, useTranslation, useUserPreference } from '@rocket.chat/ui-contexts'; -import { ReactiveDict } from 'meteor/reactive-dict'; -import type { ReactElement, RefCallback, UIEvent } from 'react'; -import React, { useState, useEffect, useCallback, useContext, useRef, useMemo } from 'react'; +import type { ReactElement } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; -import { Messages } from '../../../../../../app/models/client'; -import { upsertMessageBulk } from '../../../../../../app/ui-utils/client/lib/RoomHistoryManager'; -import type { CommonRoomTemplateInstance } from '../../../../../../app/ui/client/views/app/lib/CommonRoomTemplateInstance'; -import { getCommonRoomEvents } from '../../../../../../app/ui/client/views/app/lib/getCommonRoomEvents'; import { callbacks } from '../../../../../../lib/callbacks'; -import { isTruthy } from '../../../../../../lib/isTruthy'; import VerticalBar from '../../../../../components/VerticalBar'; -import { useReactiveValue } from '../../../../../hooks/useReactiveValue'; -import MessageHighlightContext from '../../../MessageList/contexts/MessageHighlightContext'; import DropTargetOverlay from '../../../components/body/DropTargetOverlay'; -import LoadingMessagesIndicator from '../../../components/body/LoadingMessagesIndicator'; import ComposerContainer from '../../../components/body/composer/ComposerContainer'; import { useFileUploadDropTarget } from '../../../components/body/useFileUploadDropTarget'; -import { useRoomMessageContext } from '../../../components/body/useRoomMessageContext'; import { useChat } from '../../../contexts/ChatContext'; -import { MessageContext } from '../../../contexts/MessageContext'; import { useRoom, useRoomSubscription } from '../../../contexts/RoomContext'; -import { useTabBarClose, useToolboxContext } from '../../../contexts/ToolboxContext'; +import { useTabBarClose } from '../../../contexts/ToolboxContext'; +import LegacyThreadMessageList from './LegacyThreadMessageList'; type ThreadChatProps = { mainMessage: IThreadMainMessage; }; const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { - const t = useTranslation(); - const atBottomRef = useRef(true); - const wrapperRef = useRef(null); - - const messageContext = useContext(MessageContext); - - const chat = useChat(); - - if (!chat) { - throw new Error('No ChatContext provided'); - } - - const messageHighlightContext = useContext(MessageHighlightContext); - - const subscription = useRoomSubscription(); - const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(); const sendToChannelPreference = useUserPreference<'always' | 'never' | 'default'>('alsoSendThreadToChannel'); @@ -61,9 +35,6 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { return !mainMessage.tcount; } }); - const [loading, setLoading] = useState(false); - - const hideUsernames = useUserPreference('hideUsernames'); const handleSend = useCallback((): void => { if (sendToChannelPreference === 'default') { @@ -71,150 +42,30 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { } }, [sendToChannelPreference]); - const sendToBottom = useCallback(() => { - const wrapper = wrapperRef.current; - - wrapper?.scrollTo(30, wrapper.scrollHeight); - }, []); - - const sendToBottomIfNecessary = useCallback(() => { - if (atBottomRef.current === true) { - sendToBottom(); - } - }, [sendToBottom]); - - const room = useRoom(); - const roomMessageContext = useRoomMessageContext(room); - const threadMessageContext = useMemo( - () => ({ - ...roomMessageContext, - settings: { - ...roomMessageContext.settings, - showReplyButton: false, - showreply: false, - }, - }), - [roomMessageContext], - ); - - const messages = useReactiveValue( - useCallback(() => { - return Messages.find( - { - $or: [{ tmid: mainMessage._id }, { _id: mainMessage._id }], - _hidden: { $ne: true }, - tmid: mainMessage._id, - _id: { $ne: mainMessage._id }, - }, - { - fields: { - collapsed: 0, - threadMsg: 0, - repliesCount: 0, - }, - sort: { ts: 1 }, - }, - ) - .fetch() - .filter(isThreadMessage); - }, [mainMessage._id]), - ); - - const sendToChannelID = useUniqueId(); - const closeTabBar = useTabBarClose(); const handleComposerEscape = useCallback((): void => { closeTabBar(); }, [closeTabBar]); + const chat = useChat(); + const handleNavigateToPreviousMessage = useCallback((): void => { - chat.messageEditing.toPreviousMessage(); - }, [chat.messageEditing]); + chat?.messageEditing.toPreviousMessage(); + }, [chat?.messageEditing]); const handleNavigateToNextMessage = useCallback((): void => { - chat.messageEditing.toNextMessage(); - }, [chat.messageEditing]); + chat?.messageEditing.toNextMessage(); + }, [chat?.messageEditing]); const handleUploadFiles = useCallback( (files: readonly File[]): void => { - chat.flows.uploadFiles(files); + chat?.flows.uploadFiles(files); }, - [chat], + [chat?.flows], ); - const handleScroll = useCallback(({ currentTarget: e }: UIEvent) => { - atBottomRef.current = e.scrollTop >= e.scrollHeight - e.clientHeight; - }, []); - - const useLegacyMessageTemplate = useUserPreference('useLegacyMessageTemplate') ?? false; - const toolbox = useToolboxContext(); - - useEffect(() => { - const messageList = wrapperRef.current?.querySelector('ul'); - - if (!messageList) { - return; - } - - const messageEvents: Record void> = { - ...getCommonRoomEvents(useLegacyMessageTemplate), - 'click .toggle-hidden'(event: JQuery.ClickEvent) { - const mid = event.target.dataset.message; - if (mid) document.getElementById(mid)?.classList.toggle('message--ignored'); - }, - 'load .gallery-item'() { - sendToBottomIfNecessary(); - }, - 'rendered .js-block-wrapper'() { - sendToBottomIfNecessary(); - }, - }; - - const eventHandlers = Object.entries(messageEvents).map(([key, handler]) => { - const [, event, selector] = key.match(/^(.+?)\s(.+)$/) ?? [key, key]; - return { - event, - selector, - listener: (e: JQuery.TriggeredEvent) => - handler.call(null, e, { data: { rid: room._id, tabBar: toolbox, chatContext: chat } }), - }; - }); - - for (const { event, selector, listener } of eventHandlers) { - $(messageList).on(event, selector, listener); - } - - return () => { - for (const { event, selector, listener } of eventHandlers) { - $(messageList).off(event, selector, listener); - } - }; - }, [chat, room._id, sendToBottomIfNecessary, toolbox, useLegacyMessageTemplate]); - - useEffect(() => { - const messageList = wrapperRef.current?.querySelector('ul'); - - if (!messageList) { - return; - } - - const observer = new ResizeObserver(() => { - sendToBottomIfNecessary(); - }); - - observer.observe(messageList); - - return () => { - observer?.disconnect(); - }; - }, [sendToBottomIfNecessary]); - - const handleComposerResize = useCallback((): void => { - sendToBottomIfNecessary(); - }, [sendToBottomIfNecessary]); - + const room = useRoom(); const readThreads = useMethod('readThreads'); - useEffect(() => { callbacks.add( 'streamNewMessage', @@ -232,166 +83,44 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { return () => { callbacks.remove('streamNewMessage', `thread-${room._id}`); }; - }, [mainMessage._id, readThreads, room._id, sendToBottom]); + }, [mainMessage._id, readThreads, room._id]); const jump = useQueryStringParameter('jump'); + const [currentRouteName, currentRouteParams, currentRouteQueryStringParams] = useCurrentRoute(); if (!currentRouteName) { throw new Error('No route name'); } const currentRoute = useRoute(currentRouteName); - useEffect(() => { - if (loading || !jump) { - return; - } - + const handleJumpTo = useCallback(() => { const newQueryStringParams = { ...currentRouteQueryStringParams }; delete newQueryStringParams.jump; currentRoute.replace(currentRouteParams, newQueryStringParams); + }, [currentRoute, currentRouteParams, currentRouteQueryStringParams]); - const messageElement = document.querySelector(`#thread-${jump}`); - if (!messageElement) { - return; - } - - messageElement.classList.add('highlight'); - const removeClass = () => { - messageElement.classList.remove('highlight'); - messageElement.removeEventListener('animationend', removeClass); - }; - messageElement.addEventListener('animationend', removeClass); - setTimeout(() => { - messageElement.scrollIntoView(); - }, 300); - }, [currentRoute, currentRouteParams, currentRouteQueryStringParams, jump, loading]); - - const getThreadMessages = useMethod('getThreadMessages'); - - useEffect(() => { - setLoading(true); - getThreadMessages({ tmid: mainMessage._id }).then((messages) => { - upsertMessageBulk({ msgs: messages }, Messages); - setLoading(false); - }); - }, [getThreadMessages, mainMessage._id]); - - const [reactiveThreadMessageContext] = useState( - () => - new ReactiveDict(undefined, { - ...threadMessageContext, - 'messageHighlightContext.highlightMessageId': messageHighlightContext.highlightMessageId, - messageContext, - 'chatContext': chat, - }), - ); - useEffect(() => { - reactiveThreadMessageContext.set({ - ...threadMessageContext, - 'messageHighlightContext.highlightMessageId': messageHighlightContext.highlightMessageId, - messageContext, - 'chatContext': chat, - }); - }, [chat, messageContext, messageHighlightContext.highlightMessageId, reactiveThreadMessageContext, threadMessageContext]); - - const refs = useRef; reactiveMessage: ReactiveVar }>>(new Map()); - - const getRef = useCallback( - (message: IMessage, index: number) => { - const pair = refs.current.get(message._id); - - if (pair) { - pair.reactiveMessage.set(message); - return pair.callback; - } - - let view: Blaze.View; - - const reactiveMessage = new ReactiveVar(message); - - const callback = (node: HTMLLIElement | null) => { - if (node?.parentElement) { - view = Blaze.renderWithData( - Template.message, - () => { - const message = reactiveMessage.get(); - - return { - index, - msg: message, - room: reactiveThreadMessageContext.get('room'), - subscription: reactiveThreadMessageContext.get('subscription'), - settings: reactiveThreadMessageContext.get('settings'), - u: reactiveThreadMessageContext.get('u'), - chatContext: reactiveThreadMessageContext.get('chatContext'), - messageContext: reactiveThreadMessageContext.get('messageContext'), - hideRoles: true, - shouldCollapseReplies: true, - templatePrefix: 'thread-', - - context: 'threads', - ...(isThreadMainMessage(message) - ? { - customClass: [ - 'thread-main', - message._id === reactiveThreadMessageContext.get('messageHighlightContext.highlightMessageId') ? 'editing' : '', - ] - .filter(Boolean) - .join(' '), - ignored: false, - groupable: false, - } - : { - customClass: - message._id === reactiveThreadMessageContext.get('messageHighlightContext.highlightMessageId') ? 'editing' : '', - context: 'threads', - }), - }; - }, - node.parentElement, - node, - ); - } - - if (!node) { - Blaze.remove(view); - } - }; - - refs.current.set(message._id, { callback, reactiveMessage }); + const subscription = useRoomSubscription(); + const sendToChannelID = useUniqueId(); + const t = useTranslation(); - return callback; - }, - [reactiveThreadMessageContext], - ); + const useLegacyMessageTemplate = useUserPreference('useLegacyMessageTemplate') ?? false; return ( -
    -
    -
      - {loading ? ( -
    • - -
    • - ) : ( - <> -
    • - {messages.map((message, index) => ( -
    • - ))} - - )} -
    -
    +
    + {useLegacyMessageTemplate ? ( + + ) : ( + // TODO: create new thread message list + + )} void }, +) => { + const parentRef = useRef(null); + const onJumpToRef = useRef(onJumpTo); + onJumpToRef.current = onJumpTo; + + useEffect(() => { + const parent = parentRef.current; + + if (!enabled || !mid || !parent) { + return; + } + + const messageElement = parent.querySelector(`[data-id="${mid}"]`); + if (!messageElement) { + return; + } + + messageElement.classList.add('highlight'); + + const removeClass = () => { + messageElement.classList.remove('highlight'); + messageElement.removeEventListener('animationend', removeClass); + }; + messageElement.addEventListener('animationend', removeClass); + + setTimeout(() => { + messageElement.scrollIntoView(); + const onJumpTo = onJumpToRef.current; + onJumpTo?.(mid); + }, 300); + }, [enabled, mid, onJumpTo]); + + return { parentRef }; +}; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageListScrolling.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageListScrolling.ts new file mode 100644 index 000000000000..b19f12c046d0 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageListScrolling.ts @@ -0,0 +1,88 @@ +import type { UIEvent } from 'react'; +import { useContext, useCallback, useEffect, useRef } from 'react'; + +import type { CommonRoomTemplateInstance } from '../../../../../../app/ui/client/views/app/lib/CommonRoomTemplateInstance'; +import { getCommonRoomEvents } from '../../../../../../app/ui/client/views/app/lib/getCommonRoomEvents'; +import { ChatContext } from '../../../contexts/ChatContext'; +import { useRoom } from '../../../contexts/RoomContext'; +import { useToolboxContext } from '../../../contexts/ToolboxContext'; + +export const useLegacyThreadMessageListScrolling = () => { + const listWrapperRef = useRef(null); + const listRef = useRef(null); + + const atBottomRef = useRef(true); + + const onScroll = useCallback(({ currentTarget: e }: UIEvent) => { + atBottomRef.current = e.scrollTop >= e.scrollHeight - e.clientHeight; + }, []); + + const sendToBottomIfNecessary = useCallback(() => { + if (atBottomRef.current === true) { + const listWrapper = listWrapperRef.current; + + listWrapper?.scrollTo(30, listWrapper.scrollHeight); + } + }, []); + + const toolbox = useToolboxContext(); + + const room = useRoom(); + const chatContext = useContext(ChatContext); + useEffect(() => { + const messageList = listRef.current; + + if (!messageList) { + return; + } + + const messageEvents: Record void> = { + ...getCommonRoomEvents(), + 'click .toggle-hidden'(event: JQuery.ClickEvent) { + const mid = event.target.dataset.message; + if (mid) document.getElementById(mid)?.classList.toggle('message--ignored'); + }, + 'load .gallery-item'() { + sendToBottomIfNecessary(); + }, + 'rendered .js-block-wrapper'() { + sendToBottomIfNecessary(); + }, + }; + + const eventHandlers = Object.entries(messageEvents).map(([key, handler]) => { + const [, event, selector] = key.match(/^(.+?)\s(.+)$/) ?? [key, key]; + return { + event, + selector, + listener: (e: JQuery.TriggeredEvent) => + handler.call(null, e, { data: { rid: room._id, tabBar: toolbox, chatContext } }), + }; + }); + + for (const { event, selector, listener } of eventHandlers) { + $(messageList).on(event, selector, listener); + } + + return () => { + for (const { event, selector, listener } of eventHandlers) { + $(messageList).off(event, selector, listener); + } + }; + }, [chatContext, room._id, sendToBottomIfNecessary, toolbox]); + + useEffect(() => { + const observer = new ResizeObserver(() => { + sendToBottomIfNecessary(); + }); + + if (listWrapperRef.current) observer.observe(listWrapperRef.current); + if (listRef.current) observer.observe(listRef.current); + + return () => { + observer.disconnect(); + }; + }, [sendToBottomIfNecessary]); + + return { listWrapperRef, listRef, onScroll }; +}; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageRef.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageRef.ts new file mode 100644 index 000000000000..0a177116b843 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageRef.ts @@ -0,0 +1,112 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { isThreadMainMessage } from '@rocket.chat/core-typings'; +import { ReactiveVar } from 'meteor/reactive-var'; +import type { RefCallback } from 'react'; +import { useEffect, useMemo, useState, useContext, useCallback, useRef } from 'react'; + +import MessageHighlightContext from '../../../MessageList/contexts/MessageHighlightContext'; +import { useRoomMessageContext } from '../../../components/body/useRoomMessageContext'; +import { ChatContext } from '../../../contexts/ChatContext'; +import { MessageContext } from '../../../contexts/MessageContext'; +import { useRoom } from '../../../contexts/RoomContext'; + +export const useLegacyThreadMessageRef = () => { + const messageContext = useContext(MessageContext); + const chatContext = useContext(ChatContext); + const messageHighlightContext = useContext(MessageHighlightContext); + const room = useRoom(); + const roomMessageContext = useRoomMessageContext(room); + const threadMessageContext = useMemo( + () => ({ + ...roomMessageContext, + settings: { + ...roomMessageContext.settings, + showReplyButton: false, + showreply: false, + }, + }), + [roomMessageContext], + ); + + const [reactiveThreadMessageContext] = useState( + () => + new ReactiveVar({ + ...threadMessageContext, + 'messageHighlightContext.highlightMessageId': messageHighlightContext.highlightMessageId, + messageContext, + chatContext, + }), + ); + useEffect(() => { + reactiveThreadMessageContext.set({ + ...threadMessageContext, + 'messageHighlightContext.highlightMessageId': messageHighlightContext.highlightMessageId, + messageContext, + chatContext, + }); + }, [chatContext, messageContext, messageHighlightContext.highlightMessageId, reactiveThreadMessageContext, threadMessageContext]); + + const cache = useRef; reactiveMessage: ReactiveVar }>>(new Map()); + + return useCallback( + (message: IMessage, index: number) => { + const pair = cache.current.get(message._id); + + if (pair) { + pair.reactiveMessage.set(message); + return pair.callback; + } + + let view: Blaze.View; + + const reactiveMessage = new ReactiveVar(message); + + const callback = (node: HTMLLIElement | null) => { + if (node?.parentElement) { + view = Blaze.renderWithData( + Template.message, + () => { + const message = reactiveMessage.get(); + const editing = message._id === reactiveThreadMessageContext.get()['messageHighlightContext.highlightMessageId']; + + return { + index, + msg: message, + room: reactiveThreadMessageContext.get().room, + subscription: reactiveThreadMessageContext.get().subscription, + settings: reactiveThreadMessageContext.get().settings, + u: reactiveThreadMessageContext.get().u, + chatContext: reactiveThreadMessageContext.get().chatContext, + messageContext: reactiveThreadMessageContext.get().messageContext, + hideRoles: true, + shouldCollapseReplies: true, + templatePrefix: 'thread-', + ...(isThreadMainMessage(message) + ? { + customClass: ['thread-main', editing ? 'editing' : ''].filter(Boolean).join(' '), + ignored: false, + groupable: false, + } + : { + customClass: editing ? 'editing' : '', + context: 'threads', + }), + }; + }, + node.parentElement, + node, + ); + } + + if (!node) { + Blaze.remove(view); + } + }; + + cache.current.set(message._id, { callback, reactiveMessage }); + + return callback; + }, + [reactiveThreadMessageContext], + ); +}; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessages.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessages.ts new file mode 100644 index 000000000000..f96c721dbed9 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessages.ts @@ -0,0 +1,47 @@ +import type { IThreadMainMessage } from '@rocket.chat/core-typings'; +import { isThreadMessage } from '@rocket.chat/core-typings'; +import { useMethod } from '@rocket.chat/ui-contexts'; +import { useEffect, useState, useCallback } from 'react'; + +import { Messages } from '../../../../../../app/models/client'; +import { upsertMessageBulk } from '../../../../../../app/ui-utils/client/lib/RoomHistoryManager'; +import { useReactiveValue } from '../../../../../hooks/useReactiveValue'; + +export const useLegacyThreadMessages = (tmid: IThreadMainMessage['_id']) => { + const messages = useReactiveValue( + useCallback(() => { + return Messages.find( + { + $or: [{ tmid }, { _id: tmid }], + _hidden: { $ne: true }, + tmid, + _id: { $ne: tmid }, + }, + { + fields: { + collapsed: 0, + threadMsg: 0, + repliesCount: 0, + }, + sort: { ts: 1 }, + }, + ) + .fetch() + .filter(isThreadMessage); + }, [tmid]), + ); + + const [loading, setLoading] = useState(false); + + const getThreadMessages = useMethod('getThreadMessages'); + + useEffect(() => { + setLoading(true); + getThreadMessages({ tmid }).then((messages) => { + upsertMessageBulk({ msgs: messages }, Messages); + setLoading(false); + }); + }, [getThreadMessages, tmid]); + + return { messages, loading }; +}; From a56769760980ed2cd39b2d85565630438394b77c Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Mon, 19 Dec 2022 18:59:56 -0300 Subject: [PATCH 10/14] Workaround E2E test selector --- .../tests/e2e/page-objects/fragments/home-flextab.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts index 173fac968215..0dd13b775aac 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts @@ -45,9 +45,10 @@ export class HomeFlextab { } get flexTabViewThreadMessage(): Locator { - return this.page.locator( - 'div.thread-list ul.thread [data-qa-type="message"]:last-child div.message-body-wrapper [data-qa-type="message-body"]', - ); + return this.page + .locator('div.thread-list ul.thread [data-qa-type="message"]') + .last() + .locator('div.message-body-wrapper [data-qa-type="message-body"]'); } get userInfoUsername(): Locator { From f57ef6f1c93d2c58e3c16373b16b7743ee877a53 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Tue, 20 Dec 2022 01:05:19 -0300 Subject: [PATCH 11/14] Watch thread main message mutations --- .../room/contextualBar/Threads/Thread.tsx | 11 ++- .../hooks/useThreadMainMessageQuery.ts | 99 ++++++++++++++++++- 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx b/apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx index 8dd1b9c0f96f..0c127558bc68 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx @@ -23,7 +23,14 @@ type ThreadProps = { }; const Thread = ({ tmid }: ThreadProps): ReactElement => { - const mainMessageQueryResult = useThreadMainMessageQuery(tmid); + const goToThreadList = useGoToThreadList(); + const closeTabBar = useTabBarClose(); + + const mainMessageQueryResult = useThreadMainMessageQuery(tmid, { + onDelete: () => { + closeTabBar(); + }, + }); const room = useRoom(); const subscription = useRoomSubscription(); @@ -31,8 +38,6 @@ const Thread = ({ tmid }: ThreadProps): ReactElement => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); - const closeTabBar = useTabBarClose(); - const goToThreadList = useGoToThreadList(); const canExpand = useLayoutContextualBarExpanded(); const [expanded, setExpanded] = useLocalStorage('expand-threads', false); diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMainMessageQuery.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMainMessageQuery.ts index fcc19aca652a..6e831ac12ebb 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMainMessageQuery.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMainMessageQuery.ts @@ -1,23 +1,116 @@ import { isThreadMainMessage } from '@rocket.chat/core-typings'; import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; +import { useStream } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; -import { useQuery } from '@tanstack/react-query'; +import { useQueryClient, useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useRef } from 'react'; +import type { FieldExpression, Query } from '../../../../../lib/minimongo'; +import { createFilterFromQuery } from '../../../../../lib/minimongo'; import { useRoom } from '../../../contexts/RoomContext'; import { useGetMessageByID } from './useGetMessageByID'; -export const useThreadMainMessageQuery = (tmid: IMessage['_id']): UseQueryResult => { +type RoomMessagesRidEvent = IMessage; + +type NotifyRoomRidDeleteMessageEvent = { _id: IMessage['_id'] }; + +type NotifyRoomRidDeleteMessageBulkEvent = { + rid: IMessage['rid']; + excludePinned: boolean; + ignoreDiscussion: boolean; + ts: FieldExpression; + users: string[]; +}; + +const createDeleteCriteria = (params: NotifyRoomRidDeleteMessageBulkEvent): ((message: IMessage) => boolean) => { + const query: Query = { ts: params.ts }; + + if (params.excludePinned) { + query.pinned = { $ne: true }; + } + + if (params.ignoreDiscussion) { + query.drid = { $exists: false }; + } + if (params.users?.length) { + query['u.username'] = { $in: params.users }; + } + + return createFilterFromQuery(query); +}; + +const useSubscribeToMessage = () => { + const subscribeToRoomMessages = useStream('room-messages'); + const subscribeToNotifyRoom = useStream('notify-room'); + + return useCallback( + (message: IMessage, { onMutate, onDelete }: { onMutate?: (message: IMessage) => void; onDelete?: () => void }) => { + const unsubscribeFromRoomMessages = subscribeToRoomMessages(message.rid, (event: RoomMessagesRidEvent) => { + if (message._id === event._id) onMutate?.(event); + }); + + const unsubscribeFromDeleteMessage = subscribeToNotifyRoom( + `${message.rid}/deleteMessage`, + (event: NotifyRoomRidDeleteMessageEvent) => { + if (message._id === event._id) onDelete?.(); + }, + ); + + const unsubscribeFromDeleteMessageBulk = subscribeToNotifyRoom( + `${message.rid}/deleteMessageBulk`, + (params: NotifyRoomRidDeleteMessageBulkEvent) => { + const matchDeleteCriteria = createDeleteCriteria(params); + if (matchDeleteCriteria(message)) onDelete?.(); + }, + ); + + return () => { + unsubscribeFromRoomMessages(); + unsubscribeFromDeleteMessage(); + unsubscribeFromDeleteMessageBulk(); + }; + }, + [subscribeToNotifyRoom, subscribeToRoomMessages], + ); +}; + +export const useThreadMainMessageQuery = ( + tmid: IMessage['_id'], + { onDelete }: { onDelete?: () => void } = {}, +): UseQueryResult => { const room = useRoom(); const getMessage = useGetMessageByID(); + const subscribeToMessage = useSubscribeToMessage(); - return useQuery(['rooms', room._id, 'threads', tmid, 'main-message'] as const, async () => { + const queryClient = useQueryClient(); + const unsubscribeRef = useRef<(() => void) | undefined>(); + + useEffect(() => { + return () => { + unsubscribeRef.current?.(); + }; + }, []); + + return useQuery(['rooms', room._id, 'threads', tmid, 'main-message'] as const, async ({ queryKey }) => { const mainMessage = await getMessage(tmid); if (!mainMessage && !isThreadMainMessage(mainMessage)) { throw new Error('Invalid main message'); } + unsubscribeRef.current?.(); + + unsubscribeRef.current = subscribeToMessage(mainMessage, { + onMutate: () => { + queryClient.invalidateQueries(queryKey, { exact: true }); + }, + onDelete: () => { + onDelete?.(); + queryClient.invalidateQueries(queryKey, { exact: true }); + }, + }); + return mainMessage; }); }; From c320f5cf3a9c5d0ea6daec788c6c11273491e882 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Tue, 20 Dec 2022 08:52:11 -0300 Subject: [PATCH 12/14] Workaround E2E test selector --- apps/meteor/tests/e2e/message-actions.spec.ts | 2 +- .../meteor/tests/e2e/page-objects/fragments/home-content.ts | 6 +++--- apps/meteor/tests/e2e/thread-actions.spec.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/meteor/tests/e2e/message-actions.spec.ts b/apps/meteor/tests/e2e/message-actions.spec.ts index 53c683d63715..5ca96bb23761 100644 --- a/apps/meteor/tests/e2e/message-actions.spec.ts +++ b/apps/meteor/tests/e2e/message-actions.spec.ts @@ -63,7 +63,7 @@ test.describe.serial('message-actions', () => { await page.locator('[name="msg"]').fill('this is a quote message'); await page.keyboard.press('Enter'); - await expect(poHomeChannel.content.waitForLastMessageTextAttachmentEqualsText).toHaveText(message); + await expect(poHomeChannel.content.lastMessageTextAttachmentEqualsText).toHaveText(message); }); test('expect star the message', async ({ page }) => { diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts index 7bac8d76ee7b..ea908e812d42 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-content.ts @@ -66,12 +66,12 @@ export class HomeContent { return this.page.locator('[data-qa-type="message"]:last-child [data-qa-type="attachment-title-link"]'); } - get waitForLastMessageTextAttachmentEqualsText(): Locator { + get lastMessageTextAttachmentEqualsText(): Locator { return this.page.locator('[data-qa-type="message"]:last-child .rcx-attachment__details .rcx-message-body'); } - get waitForLastThreadMessageTextAttachmentEqualsText(): Locator { - return this.page.locator('//main//aside >> [data-qa-type="message"]:last-child .rcx-attachment__details'); + get lastThreadMessageTextAttachmentEqualsText(): Locator { + return this.page.locator('div.thread-list ul.thread [data-qa-type="message"]').last().locator('.rcx-attachment__details'); } get btnOptionEditMessage(): Locator { diff --git a/apps/meteor/tests/e2e/thread-actions.spec.ts b/apps/meteor/tests/e2e/thread-actions.spec.ts index 4abdd4625aa7..ef6af3b21fc8 100644 --- a/apps/meteor/tests/e2e/thread-actions.spec.ts +++ b/apps/meteor/tests/e2e/thread-actions.spec.ts @@ -58,7 +58,7 @@ test.describe.serial('message-actions', () => { await page.locator('[name="msg"]').last().fill('this is a quote message'); await page.keyboard.press('Enter'); - await expect(poHomeChannel.content.waitForLastThreadMessageTextAttachmentEqualsText).toContainText('this is a message for reply'); + await expect(poHomeChannel.content.lastThreadMessageTextAttachmentEqualsText).toContainText('this is a message for reply'); }); test('expect star the thread message', async ({ page }) => { From 2670467ad3e12e29c7864dc34be2581724b75208 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Tue, 20 Dec 2022 12:49:19 -0300 Subject: [PATCH 13/14] Force focus on E2E test --- apps/meteor/tests/e2e/thread-actions.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/meteor/tests/e2e/thread-actions.spec.ts b/apps/meteor/tests/e2e/thread-actions.spec.ts index ef6af3b21fc8..59a53205977d 100644 --- a/apps/meteor/tests/e2e/thread-actions.spec.ts +++ b/apps/meteor/tests/e2e/thread-actions.spec.ts @@ -80,6 +80,7 @@ test.describe.serial('message-actions', () => { await expect(page).toHaveURL(/.*thread/); await expect(page.locator('//main//aside >> [data-qa-type="message"]')).toBeVisible(); + await page.locator('[name="msg"]').last().focus(); await page.keyboard.press('Escape'); await expect(page).not.toHaveURL(/.*thread/); From 4ad302bafa8637a483cd24c9bcfa50ebc0d93fb8 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Tue, 20 Dec 2022 12:53:39 -0300 Subject: [PATCH 14/14] Handle focus of mesage composer on E2E test --- apps/meteor/tests/e2e/thread-actions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/tests/e2e/thread-actions.spec.ts b/apps/meteor/tests/e2e/thread-actions.spec.ts index 59a53205977d..59a77d9fb88b 100644 --- a/apps/meteor/tests/e2e/thread-actions.spec.ts +++ b/apps/meteor/tests/e2e/thread-actions.spec.ts @@ -80,7 +80,7 @@ test.describe.serial('message-actions', () => { await expect(page).toHaveURL(/.*thread/); await expect(page.locator('//main//aside >> [data-qa-type="message"]')).toBeVisible(); - await page.locator('[name="msg"]').last().focus(); + await expect(page.locator('[name="msg"]').last()).toBeFocused(); await page.keyboard.press('Escape'); await expect(page).not.toHaveURL(/.*thread/);