diff --git a/apps/meteor/app/models/client/index.ts b/apps/meteor/app/models/client/index.ts index fbcbee481f5c4..641f0a00ba822 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/theme/client/imports/general/base_old.css b/apps/meteor/app/theme/client/imports/general/base_old.css index f9425bdf72d05..5704c55eb6179 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 eb9477c09cab4..0000000000000 --- 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 c0dd5b9c6a71d..0000000000000 --- 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 1f48106b15043..0000000000000 --- 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 deleted file mode 100644 index cfb5e31d2ba46..0000000000000 --- a/apps/meteor/app/threads/client/flextab/thread.html +++ /dev/null @@ -1,32 +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 c420fe494501b..0000000000000 --- a/apps/meteor/app/threads/client/flextab/thread.ts +++ /dev/null @@ -1,416 +0,0 @@ -import _ from 'underscore'; -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 } 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 { dropzoneEvents, dropzoneHelpers } from './dropzone'; -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'; - -export type ThreadTemplateInstance = Blaze.TemplateInstance<{ - mainMessage: IMessage; - subscription: ISubscription; - jump: unknown; - following: boolean; - rid: IRoom['_id']; - tabBar: { - openRoomInfo: (username: string) => void; - }; - chatContext: ContextType; - messageContext: ContextType; - messageHighlightContext: () => ContextType; -}> & { - 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; - sendToChannel: boolean; - jump?: string | null; - editingMID?: IMessage['_id']; - }>; - closeThread: () => void; - loadMore: () => Promise; - atBottom?: boolean; - sendToBottom: () => void; - sendToBottomIfNecessary: () => void; - onFileDrop: (files: File[]) => void; - onTextDrop: (text: string) => void; - lastJump?: string; -}; - -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) => { - 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'); - }, -}); - -Template.thread.helpers({ - ...dropzoneHelpers, - 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: { mainMessage: IMessage }) { - const result = messageContext.call(this, { rid: this.mainMessage.rid }); - return { - ...result, - settings: { - ...result.settings, - showReplyButton: false, - showreply: false, - }, - }; - }, - messageBoxData(): MessageBoxTemplateInstance['data'] { - const instance = Template.instance() as ThreadTemplateInstance; - const { - mainMessage: { rid, _id: tmid }, - subscription, - chatContext, - } = 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'), - subscription, - rid, - tmid, - onSend: async ( - _event: Event, - { - value: text, - tshow, - }: { - value: string; - tshow?: boolean; - }, - ) => { - instance.sendToBottom(); - if (alsoSendPreferenceState === 'default') { - instance.state.set('sendToChannel', false); - } - - 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; - }, - 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; - 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(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; - } - - const { mainMessage } = Template.currentData(); - - 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 = _.throttle(() => { - this.atBottom = true; - if (this.wrapper) { - this.wrapper.scrollTop = this.wrapper.scrollHeight; - } - }, 300); - - 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.onTextDrop = (droppedText: string) => { - const composer = this.data.chatContext?.composer; - - if (!composer) { - return; - } - - const { text, selection } = composer; - - const initText = text.slice(0, selection.start ?? undefined); - const finalText = text.slice(selection.end ?? undefined, text.length); - - composer.setText(initText + droppedText + finalText); - }; - - this.onFileDrop = (files) => { - const rid = this.state.get('rid'); - - if (!rid) { - throw new Error('No rid found'); - } - - this.data.chatContext?.flows.uploadFiles(files); - }; - - 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', - _.debounce((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}`, - ); - }); - - 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(); - 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 19a5c0fc47b9e..0000000000000 --- 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 9ed8c69007277..4dad7ebd4846c 100644 --- a/apps/meteor/app/threads/client/index.ts +++ b/apps/meteor/app/threads/client/index.ts @@ -1,7 +1,5 @@ import './flextab/threadlist'; -import './flextab/thread.ts'; -import './flextab/messageBoxFollow.ts'; -import './flextab/threads.ts'; import './messageAction/follow'; import './messageAction/unfollow'; import './messageAction/replyInThread'; +import './threads.css'; 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/components/VerticalBar/VerticalBarAction.tsx b/apps/meteor/client/components/VerticalBar/VerticalBarAction.tsx index 5a186cd8e8307..afe5b5b15b40d 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/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index 970e2a58d2d2b..982092d56ac58 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/data.ts b/apps/meteor/client/lib/chats/data.ts index f2cfa17528d0e..b0e323cd6ffbd 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/lib/chats/flows/sendMessage.ts b/apps/meteor/client/lib/chats/flows/sendMessage.ts index 9320e7fcf05be..e40394d515d25 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/templates.ts b/apps/meteor/client/templates.ts index 278ff547a7200..7a2cb5e76025c 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/components/body/LegacyMessageTemplateList.tsx b/apps/meteor/client/views/room/components/body/LegacyMessageTemplateList.tsx index 6831f2df79012..5756821cc7d36 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/RoomBody.tsx b/apps/meteor/client/views/room/components/body/RoomBody.tsx index c30a35db1681d..fb585209ba981 100644 --- a/apps/meteor/client/views/room/components/body/RoomBody.tsx +++ b/apps/meteor/client/views/room/components/body/RoomBody.tsx @@ -80,7 +80,7 @@ const RoomBody = (): ReactElement => { throw new Error('No ChatContext provided'); } - const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(room); + const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(); const _isAtBottom = useCallback((scrollThreshold = 0) => { const wrapper = wrapperRef.current; 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 2e4c0fd48bdde..0c10a217ce5b9 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/components/body/useFileUploadDropTarget.ts b/apps/meteor/client/views/room/components/body/useFileUploadDropTarget.ts index 21e043928a9cd..bd89a3d72a60a 100644 --- a/apps/meteor/client/views/room/components/body/useFileUploadDropTarget.ts +++ b/apps/meteor/client/views/room/components/body/useFileUploadDropTarget.ts @@ -1,19 +1,16 @@ -import type { IRoom } from '@rocket.chat/core-typings'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useSetting, useTranslation } from '@rocket.chat/ui-contexts'; +import { useSetting, useTranslation, useUser } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; import type React from 'react'; import { useCallback, useMemo } from 'react'; -import { Users } from '../../../../../app/models/client'; import { useReactiveValue } from '../../../../hooks/useReactiveValue'; import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; import { useChat } from '../../contexts/ChatContext'; +import { useRoom } from '../../contexts/RoomContext'; import { useDropTarget } from './useDropTarget'; -export const useFileUploadDropTarget = ( - room: IRoom, -): readonly [ +export const useFileUploadDropTarget = (): readonly [ fileUploadTriggerProps: { onDragEnter: (event: React.DragEvent) => void; }, @@ -24,16 +21,15 @@ export const useFileUploadDropTarget = ( reason?: ReactNode; }, ] => { + const room = useRoom(); const { triggerProps, overlayProps } = useDropTarget(); const t = useTranslation(); const fileUploadEnabled = useSetting('FileUpload_Enabled') as boolean; + const user = useUser(); const fileUploadAllowedForUser = useReactiveValue( - useCallback( - () => !roomCoordinator.readOnly(room._id, Users.findOne({ _id: Meteor.userId() }, { fields: { username: 1 } })), - [room._id], - ), + useCallback(() => !roomCoordinator.readOnly(room._id, { username: user?.username }), [room._id, user?.username]), ); const chat = useChat(); 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 63cd5ccf8f63a..04867f43b8d66 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/Thread.tsx b/apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx new file mode 100644 index 0000000000000..0c127558bc688 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/Thread.tsx @@ -0,0 +1,134 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import { Box, Modal, Skeleton } from '@rocket.chat/fuselage'; +import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import { useLayoutContextualBarExpanded, useToastMessageDispatch, useTranslation, useUserId } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import React from 'react'; + +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 ThreadChat from './components/ThreadChat'; +import ThreadSkeleton from './components/ThreadSkeleton'; +import ThreadTitle from './components/ThreadTitle'; +import { useGoToThreadList } from './hooks/useGoToThreadList'; +import { useThreadMainMessageQuery } from './hooks/useThreadMainMessageQuery'; +import { useToggleFollowingThreadMutation } from './hooks/useToggleFollowingThreadMutation'; + +type ThreadProps = { + tmid: IMessage['_id']; +}; + +const Thread = ({ tmid }: ThreadProps): ReactElement => { + const goToThreadList = useGoToThreadList(); + const closeTabBar = useTabBarClose(); + + const mainMessageQueryResult = useThreadMainMessageQuery(tmid, { + onDelete: () => { + closeTabBar(); + }, + }); + + const room = useRoom(); + const subscription = useRoomSubscription(); + + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const canExpand = useLayoutContextualBarExpanded(); + const [expanded, setExpanded] = useLocalStorage('expand-threads', false); + + const uid = useUserId(); + const following = uid ? mainMessageQueryResult.data?.replies?.includes(uid) ?? false : false; + const toggleFollowingMutation = useToggleFollowingThreadMutation({ + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + }); + + const handleBackdropClick = () => { + closeTabBar(); + }; + + const handleGoBack = () => { + goToThreadList(); + }; + + const handleToggleExpand = () => { + setExpanded((expanded) => !expanded); + }; + + const handleToggleFollowing = () => { + toggleFollowingMutation.mutate({ tmid, follow: !following }); + }; + + const handleClose = () => { + closeTabBar(); + }; + + return ( + + {canExpand && expanded && } + + + + + {(mainMessageQueryResult.isLoading && ) || + (mainMessageQueryResult.isSuccess && ) || + null} + + {canExpand && ( + + )} + + + + + + {(mainMessageQueryResult.isLoading && ) || + (mainMessageQueryResult.isSuccess && ( + + + + + + )) || + null} + + + + ); +}; + +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 ef7251173472c..6534e6accbd57 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 e7447dd0857cc..0000000000000 --- 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 0000000000000..fe33cfa89cccd --- /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/LegacyThreadMessageList.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/LegacyThreadMessageList.tsx new file mode 100644 index 0000000000000..c4e0b0295f3e6 --- /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 new file mode 100644 index 0000000000000..a7e698862734e --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx @@ -0,0 +1,141 @@ +import type { IMessage, IThreadMainMessage } 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 type { ReactElement } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; + +import { callbacks } from '../../../../../../lib/callbacks'; +import VerticalBar from '../../../../../components/VerticalBar'; +import DropTargetOverlay from '../../../components/body/DropTargetOverlay'; +import ComposerContainer from '../../../components/body/composer/ComposerContainer'; +import { useFileUploadDropTarget } from '../../../components/body/useFileUploadDropTarget'; +import { useChat } from '../../../contexts/ChatContext'; +import { useRoom, useRoomSubscription } from '../../../contexts/RoomContext'; +import { useTabBarClose } from '../../../contexts/ToolboxContext'; +import LegacyThreadMessageList from './LegacyThreadMessageList'; + +type ThreadChatProps = { + mainMessage: IThreadMainMessage; +}; + +const ThreadChat = ({ mainMessage }: ThreadChatProps): ReactElement => { + const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(); + + const sendToChannelPreference = useUserPreference<'always' | 'never' | 'default'>('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 closeTabBar = useTabBarClose(); + const handleComposerEscape = useCallback((): void => { + closeTabBar(); + }, [closeTabBar]); + + const chat = useChat(); + + 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?.flows], + ); + + const room = useRoom(); + 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]); + + const jump = useQueryStringParameter('jump'); + + const [currentRouteName, currentRouteParams, currentRouteQueryStringParams] = useCurrentRoute(); + if (!currentRouteName) { + throw new Error('No route name'); + } + const currentRoute = useRoute(currentRouteName); + + const handleJumpTo = useCallback(() => { + const newQueryStringParams = { ...currentRouteQueryStringParams }; + delete newQueryStringParams.jump; + currentRoute.replace(currentRouteParams, newQueryStringParams); + }, [currentRoute, currentRouteParams, currentRouteQueryStringParams]); + + const subscription = useRoomSubscription(); + const sendToChannelID = useUniqueId(); + const t = useTranslation(); + + const useLegacyMessageTemplate = useUserPreference('useLegacyMessageTemplate') ?? false; + + return ( + + +
+ {useLegacyMessageTemplate ? ( + + ) : ( + // TODO: create new thread message list + + )} + +
+
+ setSendToChannel((checked) => !checked)} /> +
+ +
+
+
+ ); +}; + +export default ThreadChat; 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 0000000000000..44432de2e84fc --- /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 a0ca193f0e275..99128229424f9 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 0000000000000..8517a46c84307 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadSkeleton.tsx @@ -0,0 +1,18 @@ +import { Box, Skeleton } from '@rocket.chat/fuselage'; +import type { ReactElement } from 'react'; +import React from 'react'; + +const ThreadSkeleton = (): ReactElement => { + return ( + + + {Array(5) + .fill(5) + .map((_, index) => ( + + ))} + + ); +}; + +export default ThreadSkeleton; 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 0000000000000..c2044ba2afb6e --- /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/hooks/useGetMessageByID.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useGetMessageByID.ts new file mode 100644 index 0000000000000..e145d2c47e273 --- /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/useGoToThread.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useGoToThread.ts new file mode 100644 index 0000000000000..7901490efe7ae --- /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 0000000000000..a83f76bad3e6f --- /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/useLegacyThreadMessageJump.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageJump.ts new file mode 100644 index 0000000000000..6d144f58793de --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useLegacyThreadMessageJump.ts @@ -0,0 +1,40 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { useRef, useEffect } from 'react'; + +export const useLegacyThreadMessageJump = ( + mid: IMessage['_id'] | undefined, + { enabled = true, onJumpTo }: { enabled?: boolean; onJumpTo?: (mid: IMessage['_id']) => 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 0000000000000..b19f12c046d00 --- /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 0000000000000..0a177116b843d --- /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 0000000000000..f96c721dbed9e --- /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 }; +}; 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 0000000000000..6e831ac12ebbb --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMainMessageQuery.ts @@ -0,0 +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 { 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'; + +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(); + + 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; + }); +}; 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 81211a6ec6ef0..c84580d545ca8 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/hooks/useToggleFollowingThreadMutation.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useToggleFollowingThreadMutation.ts new file mode 100644 index 0000000000000..a5b8ed8b29da7 --- /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/client/views/room/contextualBar/Threads/index.ts b/apps/meteor/client/views/room/contextualBar/Threads/index.ts index 501741426be23..1cce8747f4d44 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 0f3782ab331e3..0000000000000 --- 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 aa380b41c82b8..0000000000000 --- 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 d7ee4afe5f5f5..0000000000000 --- 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 35aa2274eb339..0000000000000 --- 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 de446e1d6c64d..0000000000000 --- 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; diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 3f100168b7756..ea149faf06a15 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/apps/meteor/tests/e2e/message-actions.spec.ts b/apps/meteor/tests/e2e/message-actions.spec.ts index 53c683d637151..5ca96bb237617 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 7bac8d76ee7be..ea908e812d42d 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/page-objects/fragments/home-flextab.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab.ts index 253b16f235274..0dd13b775aacf 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.js-scroll-thread 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 { diff --git a/apps/meteor/tests/e2e/thread-actions.spec.ts b/apps/meteor/tests/e2e/thread-actions.spec.ts index 4abdd4625aa7d..59a77d9fb88b2 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 }) => { @@ -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 expect(page.locator('[name="msg"]').last()).toBeFocused(); await page.keyboard.press('Escape'); await expect(page).not.toHaveURL(/.*thread/); diff --git a/packages/ui-contexts/package.json b/packages/ui-contexts/package.json index c3fba2b357824..d94f6cd0ed803 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 ebb8a12b17c54..17998d5181077 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; diff --git a/yarn.lock b/yarn.lock index dda6489f9235e..9ddf393fa417c 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"