diff --git a/.changeset/chatty-camels-explain.md b/.changeset/chatty-camels-explain.md new file mode 100644 index 0000000000000..818dbaf10b29f --- /dev/null +++ b/.changeset/chatty-camels-explain.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fixes `/sendEmailAttachment` to support sending multiple file attachments in a single email diff --git a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx index d74de13207791..f57508cb070a1 100644 --- a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx +++ b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/OmnichannelAppSourceRoomIcon.tsx @@ -3,7 +3,6 @@ import { Icon, Box } from '@rocket.chat/fuselage'; import type { ComponentProps } from 'react'; import { useOmnichannelRoomIcon } from './context/OmnichannelRoomIconContext'; -import { AsyncStatePhase } from '../../../lib/asyncState/AsyncStatePhase'; type OmnichannelAppSourceRoomIconProps = { source: IOmnichannelSourceFromApp; @@ -14,9 +13,9 @@ type OmnichannelAppSourceRoomIconProps = { export const OmnichannelAppSourceRoomIcon = ({ source, color, size, placement }: OmnichannelAppSourceRoomIconProps) => { const icon = (placement === 'sidebar' && source.sidebarIcon) || source.defaultIcon; - const { phase, value } = useOmnichannelRoomIcon(source.id, icon || ''); + const value = useOmnichannelRoomIcon(source.id, icon || ''); - if ([AsyncStatePhase.REJECTED, AsyncStatePhase.LOADING].includes(phase)) { + if (!value) { return ; } diff --git a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx index dd1935740b2eb..62e2abaf6143f 100644 --- a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx +++ b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx @@ -1,24 +1,14 @@ import { createContext, useMemo, useContext, useSyncExternalStore } from 'react'; -import type { AsyncState } from '../../../../lib/asyncState/AsyncState'; -import { AsyncStatePhase } from '../../../../lib/asyncState/AsyncStatePhase'; - type IOmnichannelRoomIconContext = { - queryIcon(app: string, icon: string): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => AsyncState]; + queryIcon(app: string, icon: string): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => string | undefined]; }; export const OmnichannelRoomIconContext = createContext({ - queryIcon: () => [ - (): (() => void) => (): void => undefined, - (): AsyncState => ({ - phase: AsyncStatePhase.LOADING, - value: undefined, - error: undefined, - }), - ], + queryIcon: () => [(): (() => void) => (): void => undefined, () => undefined], }); -export const useOmnichannelRoomIcon = (app: string, icon: string): AsyncState => { +export const useOmnichannelRoomIcon = (app: string, icon: string): string | undefined => { const { queryIcon } = useContext(OmnichannelRoomIconContext); const [subscribe, getSnapshot] = useMemo(() => queryIcon(app, icon), [app, queryIcon, icon]); return useSyncExternalStore(subscribe, getSnapshot); diff --git a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx index d7a55f889e38e..5aacd297a8f76 100644 --- a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx +++ b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx @@ -3,8 +3,6 @@ import type { ReactNode } from 'react'; import { useCallback, useMemo, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; -import type { AsyncState } from '../../../../lib/asyncState/AsyncState'; -import { AsyncStatePhase } from '../../../../lib/asyncState/AsyncStatePhase'; import { OmnichannelRoomIconContext } from '../context/OmnichannelRoomIconContext'; import OmnichannelRoomIconManager from '../lib/OmnichannelRoomIconManager'; @@ -30,32 +28,16 @@ export const OmnichannelRoomIconProvider = ({ children }: OmnichannelRoomIconPro return ( { - const extractSnapshot = (app: string, iconName: string): AsyncState => { - const icon = OmnichannelRoomIconManager.get(app, iconName); - - if (icon) { - return { - phase: AsyncStatePhase.RESOLVED, - value: icon, - error: undefined, - }; - } - - return { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: undefined, - }; - }; + const extractSnapshot = (app: string, iconName: string) => OmnichannelRoomIconManager.get(app, iconName); // We cache all the icons here, so that we can use them in the OmnichannelRoomIcon component - const snapshots = new Map>(); + const snapshots = new Map(); return { queryIcon: ( app: string, iconName: string, - ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => AsyncState] => [ + ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => string | undefined] => [ (callback): (() => void) => OmnichannelRoomIconManager.on(`${app}-${iconName}`, () => { snapshots.set(`${app}-${iconName}`, extractSnapshot(app, iconName)); @@ -65,7 +47,7 @@ export const OmnichannelRoomIconProvider = ({ children }: OmnichannelRoomIconPro }), // No problem here, because it's return value is a cached in the snapshots map on subsequent calls - (): AsyncState => { + () => { let snapshot = snapshots.get(`${app}-${iconName}`); if (!snapshot) { diff --git a/apps/meteor/client/components/message/content/location/MapView.tsx b/apps/meteor/client/components/message/content/location/MapView.tsx index 0337b36195f28..009adfb02d355 100644 --- a/apps/meteor/client/components/message/content/location/MapView.tsx +++ b/apps/meteor/client/components/message/content/location/MapView.tsx @@ -13,17 +13,13 @@ type MapViewProps = { const MapView = ({ latitude, longitude }: MapViewProps) => { const googleMapsApiKey = useSetting('MapView_GMapsAPIKey', ''); - const linkUrl = `https://maps.google.com/maps?daddr=${latitude},${longitude}`; - - const imageUrl = useAsyncImage( + const { data: imageUrl } = useAsyncImage( googleMapsApiKey ? `https://maps.googleapis.com/maps/api/staticmap?zoom=14&size=250x250&markers=color:gray%7Clabel:%7C${latitude},${longitude}&key=${googleMapsApiKey}` : undefined, ); - if (!linkUrl) { - return null; - } + const linkUrl = `https://maps.google.com/maps?daddr=${latitude},${longitude}`; if (!imageUrl) { return ; diff --git a/apps/meteor/client/components/message/content/location/hooks/useAsyncImage.ts b/apps/meteor/client/components/message/content/location/hooks/useAsyncImage.ts index 4a3740d668159..004177b416a3c 100644 --- a/apps/meteor/client/components/message/content/location/hooks/useAsyncImage.ts +++ b/apps/meteor/client/components/message/content/location/hooks/useAsyncImage.ts @@ -1,26 +1,25 @@ -import { useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; -import { useAsyncState } from '../../../../../hooks/useAsyncState'; +export const useAsyncImage = (src: string | undefined) => { + return useQuery({ + queryKey: ['async-image', src], + queryFn: () => + new Promise((resolve, reject) => { + if (!src) { + reject(new Error('No src provided')); + return; + } -export const useAsyncImage = (src: string | undefined): string | undefined => { - const { value, resolve, reject, reset } = useAsyncState(); - - useEffect(() => { - reset(); - - if (!src) { - return; - } - - const image = new Image(); - image.addEventListener('load', () => { - resolve(image.src); - }); - image.addEventListener('error', (e) => { - reject(e.error); - }); - image.src = src; - }, [src, resolve, reject, reset]); - - return value; + const image = new Image(); + image.addEventListener('load', () => { + resolve(image.src); + }); + image.addEventListener('error', () => { + reject(new Error(`Failed to load image: ${src}`)); + }); + image.src = src; + }), + enabled: Boolean(src), + retry: false, + }); }; diff --git a/apps/meteor/client/contexts/AppsContext.tsx b/apps/meteor/client/contexts/AppsContext.tsx index 7a31ddbf4f2fa..39929c20aed08 100644 --- a/apps/meteor/client/contexts/AppsContext.tsx +++ b/apps/meteor/client/contexts/AppsContext.tsx @@ -5,8 +5,6 @@ import type { Serialized } from '@rocket.chat/core-typings'; import { createContext } from 'react'; import type { IAppExternalURL, ICategory } from '../apps/@types/IOrchestrator'; -import type { AsyncState } from '../lib/asyncState'; -import { AsyncStatePhase } from '../lib/asyncState'; import type { App } from '../views/marketplace/types'; export interface IAppsOrchestrator { @@ -26,32 +24,6 @@ export interface IAppsOrchestrator { getCategories(): Promise>; } -export type AppsContextValue = { - installedApps: AsyncState<{ apps: App[] }>; - marketplaceApps: AsyncState<{ apps: App[] }>; - privateApps: AsyncState<{ apps: App[] }>; - reload: () => Promise; - orchestrator?: IAppsOrchestrator; - privateAppsEnabled: boolean; -}; +type AppsContextValue = IAppsOrchestrator | undefined; -export const AppsContext = createContext({ - installedApps: { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: undefined, - }, - marketplaceApps: { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: undefined, - }, - privateApps: { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: undefined, - }, - reload: () => Promise.resolve(), - orchestrator: undefined, - privateAppsEnabled: false, -}); +export const AppsContext = createContext(undefined); diff --git a/apps/meteor/client/contexts/hooks/useAppsReload.ts b/apps/meteor/client/contexts/hooks/useAppsReload.ts deleted file mode 100644 index 276c30fe5a158..0000000000000 --- a/apps/meteor/client/contexts/hooks/useAppsReload.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useContext } from 'react'; - -import { AppsContext } from '../AppsContext'; - -export const useAppsReload = (): (() => void) => { - const { reload } = useContext(AppsContext); - return reload; -}; diff --git a/apps/meteor/client/contexts/hooks/useAppsResult.ts b/apps/meteor/client/contexts/hooks/useAppsResult.ts deleted file mode 100644 index fb8deed2a9cbe..0000000000000 --- a/apps/meteor/client/contexts/hooks/useAppsResult.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useContext } from 'react'; - -import type { AppsContextValue } from '../AppsContext'; -import { AppsContext } from '../AppsContext'; - -export const useAppsResult = (): AppsContextValue => useContext(AppsContext); diff --git a/apps/meteor/client/hooks/lists/useRecordList.ts b/apps/meteor/client/hooks/lists/useRecordList.ts deleted file mode 100644 index 5a6053a6e321f..0000000000000 --- a/apps/meteor/client/hooks/lists/useRecordList.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useEffect, useState } from 'react'; - -import type { AsyncStatePhase } from '../../lib/asyncState'; -import type { RecordList } from '../../lib/lists/RecordList'; - -type RecordListValue = { - phase: AsyncStatePhase; - items: T[]; - itemCount: number; - error: Error | undefined; -}; - -export const useRecordList = (recordList: RecordList): RecordListValue => { - const [state, setState] = useState>(() => ({ - phase: recordList.phase, - items: recordList.items, - itemCount: recordList.itemCount, - error: undefined, - })); - - useEffect(() => { - const disconnectMutatingEvent = recordList.on('mutating', () => { - setState(() => ({ - phase: recordList.phase, - items: recordList.items, - itemCount: recordList.itemCount, - error: undefined, - })); - }); - - const disconnectMutatedEvent = recordList.on('mutated', () => { - setState((prevState) => ({ - phase: recordList.phase, - items: recordList.items, - itemCount: recordList.itemCount, - error: prevState.error, - })); - }); - - const disconnectClearedEvent = recordList.on('cleared', () => { - setState(() => ({ - phase: recordList.phase, - items: recordList.items, - itemCount: recordList.itemCount, - error: undefined, - })); - }); - - const disconnectErroredEvent = recordList.on('errored', (error) => { - setState((state) => ({ ...state, error })); - }); - - return (): void => { - disconnectMutatingEvent(); - disconnectMutatedEvent(); - disconnectClearedEvent(); - disconnectErroredEvent(); - }; - }, [recordList]); - - return state; -}; diff --git a/apps/meteor/client/hooks/lists/useScrollableMessageList.ts b/apps/meteor/client/hooks/lists/useScrollableMessageList.ts deleted file mode 100644 index 679ba23b5cd24..0000000000000 --- a/apps/meteor/client/hooks/lists/useScrollableMessageList.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { IMessage, Serialized } from '@rocket.chat/core-typings'; -import { useCallback } from 'react'; - -import { useScrollableRecordList } from './useScrollableRecordList'; -import type { MessageList } from '../../lib/lists/MessageList'; -import type { RecordListBatchChanges } from '../../lib/lists/RecordList'; -import { mapMessageFromApi } from '../../lib/utils/mapMessageFromApi'; - -export const useScrollableMessageList = ( - messageList: MessageList, - fetchMessages: (start: number, end: number) => Promise>>, - initialItemCount?: number, -): ReturnType => { - const fetchItems = useCallback( - async (start: number, end: number): Promise> => { - const batchChanges = await fetchMessages(start, end); - return { - ...(batchChanges.items && { items: batchChanges.items.map(mapMessageFromApi) }), - ...(batchChanges.itemCount && { itemCount: batchChanges.itemCount }), - }; - }, - [fetchMessages], - ); - - return useScrollableRecordList(messageList, fetchItems, initialItemCount); -}; diff --git a/apps/meteor/client/hooks/lists/useScrollableRecordList.ts b/apps/meteor/client/hooks/lists/useScrollableRecordList.ts deleted file mode 100644 index 8beabd3e14e5c..0000000000000 --- a/apps/meteor/client/hooks/lists/useScrollableRecordList.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useCallback, useEffect } from 'react'; - -import { AsyncStatePhase } from '../../lib/asyncState'; -import type { RecordList, RecordListBatchChanges } from '../../lib/lists/RecordList'; - -const INITIAL_ITEM_COUNT = 25; - -export const useScrollableRecordList = ( - recordList: RecordList, - fetchBatchChanges: (start: number, end: number) => Promise>, - initialItemCount: number = INITIAL_ITEM_COUNT, -): { - loadMoreItems: (start: number) => void; - initialItemCount: number; -} => { - const loadMoreItems = useCallback( - (start: number) => { - if (recordList.phase === AsyncStatePhase.LOADING || start + 1 < recordList.itemCount) { - recordList.batchHandle(() => fetchBatchChanges(start, initialItemCount)); - } - }, - [recordList, fetchBatchChanges, initialItemCount], - ); - - useEffect(() => { - loadMoreItems(0); - }, [loadMoreItems]); - - return { loadMoreItems, initialItemCount }; -}; diff --git a/apps/meteor/client/hooks/lists/useStreamUpdatesForMessageList.ts b/apps/meteor/client/hooks/lists/useStreamUpdatesForMessageList.ts deleted file mode 100644 index 322b6558888dd..0000000000000 --- a/apps/meteor/client/hooks/lists/useStreamUpdatesForMessageList.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { IMessage, IRoom, IUser, MessageAttachment } from '@rocket.chat/core-typings'; -import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter'; -import { useStream } from '@rocket.chat/ui-contexts'; -import type { Condition, Filter } from 'mongodb'; -import { useEffect } from 'react'; - -import type { MessageList } from '../../lib/lists/MessageList'; -import { modifyMessageOnFilesDelete } from '../../lib/utils/modifyMessageOnFilesDelete'; - -type NotifyRoomRidDeleteBulkEvent = { - rid: IMessage['rid']; - excludePinned: boolean; - ignoreDiscussion: boolean; - ts: Condition; - users: string[]; - ids?: string[]; // message ids have priority over ts -} & ( - | { - filesOnly: true; - replaceFileAttachmentsWith?: MessageAttachment; - } - | { - filesOnly?: false; - } -); - -const createDeleteCriteria = (params: NotifyRoomRidDeleteBulkEvent): ((message: IMessage) => boolean) => { - const query: Filter = {}; - - if (params.ids) { - query._id = { $in: params.ids }; - } else { - 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 createPredicateFromFilter(query); -}; - -export const useStreamUpdatesForMessageList = (messageList: MessageList, uid: IUser['_id'] | undefined, rid: IRoom['_id'] | null): void => { - const subscribeToRoomMessages = useStream('room-messages'); - const subscribeToNotifyRoom = useStream('notify-room'); - - useEffect(() => { - if (!uid || !rid) { - messageList.clear(); - return; - } - - const unsubscribeFromRoomMessages = subscribeToRoomMessages(rid, (message) => { - messageList.handle(message); - }); - - const unsubscribeFromDeleteMessage = subscribeToNotifyRoom(`${rid}/deleteMessage`, ({ _id: mid }) => { - messageList.remove(mid); - }); - - const unsubscribeFromDeleteMessageBulk = subscribeToNotifyRoom(`${rid}/deleteMessageBulk`, (params) => { - const matchDeleteCriteria = createDeleteCriteria(params); - if (params.filesOnly) { - return messageList.batchHandle(async () => { - const items = messageList.items.filter(matchDeleteCriteria).map((message) => { - return modifyMessageOnFilesDelete(message, params.replaceFileAttachmentsWith); - }); - return { items }; - }); - } - messageList.prune(matchDeleteCriteria); - }); - - return (): void => { - unsubscribeFromRoomMessages(); - unsubscribeFromDeleteMessage(); - unsubscribeFromDeleteMessageBulk(); - }; - }, [subscribeToRoomMessages, subscribeToNotifyRoom, uid, rid, messageList]); -}; diff --git a/apps/meteor/client/hooks/useAsyncState.ts b/apps/meteor/client/hooks/useAsyncState.ts deleted file mode 100644 index 80fce671c1f3c..0000000000000 --- a/apps/meteor/client/hooks/useAsyncState.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useSafely } from '@rocket.chat/fuselage-hooks'; -import { useCallback, useMemo, useState } from 'react'; - -import { AsyncStatePhase, AsyncState, asyncState } from '../lib/asyncState'; - -type AsyncStateObject = AsyncState & { - resolve: (value: T | ((prev: T | undefined) => T)) => void; - reject: (error: Error) => void; - reset: () => void; - update: () => void; -}; - -export const useAsyncState = (initialValue?: T | (() => T)): AsyncStateObject => { - const [state, setState] = useSafely( - useState>(() => { - if (typeof initialValue === 'undefined') { - return asyncState.loading(); - } - - return asyncState.resolved(typeof initialValue === 'function' ? (initialValue as () => T)() : initialValue); - }), - ); - - const resolve = useCallback( - (value: T | ((prev: T | undefined) => T)) => { - setState((state) => { - if (typeof value === 'function') { - return asyncState.resolve(state, (value as (prev: T | undefined) => T)(state.value)); - } - - return asyncState.resolve(state, value); - }); - }, - [setState], - ); - - const reject = useCallback( - (error: Error) => { - setState((state) => asyncState.reject(state, error)); - }, - [setState], - ); - - const update = useCallback(() => { - setState((state) => asyncState.update(state)); - }, [setState]); - - const reset = useCallback(() => { - setState((state) => asyncState.reload(state)); - }, [setState]); - - return useMemo( - () => ({ - ...state, - resolve, - reject, - reset, - update, - }), - [state, resolve, reject, reset, update], - ); -}; - -export { AsyncStatePhase, AsyncState }; diff --git a/apps/meteor/client/hooks/useComponentDidUpdate.ts b/apps/meteor/client/hooks/useComponentDidUpdate.ts deleted file mode 100644 index 0dd57be7fde92..0000000000000 --- a/apps/meteor/client/hooks/useComponentDidUpdate.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useEffect, useRef } from 'react'; - -export const useComponentDidUpdate = (effect: () => void, dependencies: unknown[] = []): void => { - const hasMounted = useRef(false); - useEffect(() => { - if (!hasMounted.current) { - hasMounted.current = true; - return; - } - effect(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, dependencies); -}; diff --git a/apps/meteor/client/hooks/useInfiniteMessageQueryUpdates.ts b/apps/meteor/client/hooks/useInfiniteMessageQueryUpdates.ts new file mode 100644 index 0000000000000..3e778a7334915 --- /dev/null +++ b/apps/meteor/client/hooks/useInfiniteMessageQueryUpdates.ts @@ -0,0 +1,108 @@ +import type { IMessage, IRoom } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useStream, useUserId } from '@rocket.chat/ui-contexts'; +import type { InfiniteData } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +export const useInfiniteMessageQueryUpdates = ({ + queryKey, + roomId, + filter, + compare = (a, b) => b.ts.getTime() - a.ts.getTime(), +}: { + queryKey: TQueryKey; + roomId: IRoom['_id']; + filter: (message: IMessage) => message is T; + compare?: (a: T, b: T) => number; +}) => { + const queryClient = useQueryClient(); + + const subscribeToRoomMessages = useStream('room-messages'); + const subscribeToNotifyRoom = useStream('notify-room'); + + const getQueryKey = useEffectEvent(() => queryKey); + const doFilter = useEffectEvent(filter); + const doCompare = useEffectEvent(compare); + + const mutateQueryData = useEffectEvent((mutation: (items: T[]) => void) => { + const queryData = queryClient.getQueryData< + InfiniteData< + { + items: T[]; + itemCount: number; + }, + number + > + >(queryKey); + + const items = queryData?.pages.flatMap((page) => page.items) ?? []; + const lastPage = queryData?.pages.at(-1) ?? { items: [], itemCount: 0 }; + + const beforeMutationItemsLength = items.length; + mutation(items); + const afterMutationItemsLength = items.length; + + const pageSize = lastPage.items.length || items.length; + const newPageCount = items.length > 0 ? Math.ceil(items.length / pageSize) : 0; + + const newQueryData: typeof queryData = { + pages: Array.from({ length: newPageCount }, (_, pageIndex) => ({ + items: items.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize), + itemCount: lastPage.itemCount - (beforeMutationItemsLength - afterMutationItemsLength), + })), + pageParams: Array.from({ length: newPageCount }, (_, pageIndex) => pageIndex * pageSize), + }; + + queryClient.setQueryData< + InfiniteData< + { + items: T[]; + itemCount: number; + }, + number + > + >(queryKey, newQueryData); + }); + + const userId = useUserId(); + + useEffect(() => { + if (!userId || !roomId) { + return; + } + + const unsubscribeFromRoomMessages = subscribeToRoomMessages(roomId, (message) => { + if (!doFilter(message)) return; + + mutateQueryData((items) => { + const index = items.findIndex((i) => i._id === message._id); + if (index !== -1) { + items[index] = message as T; + } else { + items.push(message as T); + items.sort(doCompare); + } + }); + }); + + const unsubscribeFromDeleteMessage = subscribeToNotifyRoom(`${roomId}/deleteMessage`, ({ _id }) => { + mutateQueryData((items) => { + const index = items.findIndex((i) => i._id === _id); + if (index !== -1) { + items.splice(index, 1); + } + }); + }); + + const unsubscribeFromDeleteMessageBulk = subscribeToNotifyRoom(`${roomId}/deleteMessageBulk`, () => { + queryClient.invalidateQueries({ queryKey: getQueryKey() }); + }); + + return () => { + unsubscribeFromRoomMessages(); + unsubscribeFromDeleteMessage(); + unsubscribeFromDeleteMessageBulk(); + }; + }, [subscribeToRoomMessages, subscribeToNotifyRoom, userId, roomId, queryClient, getQueryKey, doFilter, doCompare, mutateQueryData]); +}; diff --git a/apps/meteor/client/hooks/useRoomsList.ts b/apps/meteor/client/hooks/useRoomsList.ts index 6dfed72f6eee5..1df1a6048f45e 100644 --- a/apps/meteor/client/hooks/useRoomsList.ts +++ b/apps/meteor/client/hooks/useRoomsList.ts @@ -1,47 +1,24 @@ -import type { IRoom } from '@rocket.chat/core-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback, useState } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; -import { useScrollableRecordList } from './lists/useScrollableRecordList'; -import { useComponentDidUpdate } from './useComponentDidUpdate'; -import { RecordList } from '../lib/lists/RecordList'; - -type RoomListOptions = { - text: string; -}; - -type IRoomClient = Pick & { - label: string; - value: string; -}; - -export const useRoomsList = ( - options: RoomListOptions, -): { - itemsList: RecordList; - initialItemCount: number; - reload: () => void; - loadMoreItems: (start: number, end: number) => void; -} => { - const [itemsList, setItemsList] = useState(() => new RecordList()); - const reload = useCallback(() => setItemsList(new RecordList()), []); +import { roomsQueryKeys } from '../lib/queryKeys'; +export const useRoomsList = ({ text }: { text: string }) => { const getRooms = useEndpoint('GET', '/v1/rooms.autocomplete.channelAndPrivate.withPagination'); - useComponentDidUpdate(() => { - options && reload(); - }, [options, reload]); + const count = 25; - const fetchData = useCallback( - async (start: number, end: number) => { + return useInfiniteQuery({ + queryKey: roomsQueryKeys.autocomplete(text), + queryFn: async ({ pageParam: offset }) => { const { items: rooms, total } = await getRooms({ - selector: JSON.stringify({ name: options.text || '' }), - offset: start, - count: start + end, + selector: JSON.stringify({ name: text }), + offset, + count, sort: JSON.stringify({ name: 1 }), }); - const items = rooms.map((room: any) => ({ + const items = rooms.map((room) => ({ _id: room._id, _updatedAt: new Date(room._updatedAt), label: room.name ?? '', @@ -53,15 +30,12 @@ export const useRoomsList = ( itemCount: total, }; }, - [getRooms, options.text], - ); - - const { loadMoreItems, initialItemCount } = useScrollableRecordList(itemsList, fetchData, 25); - - return { - reload, - itemsList, - loadMoreItems, - initialItemCount, - }; + initialPageParam: 0, + getNextPageParam: (lastPage, _, lastOffset) => { + const nextOffset = lastOffset + count; + if (nextOffset >= lastPage.itemCount) return undefined; + return nextOffset; + }, + select: ({ pages }) => pages.flatMap((page) => page.items), + }); }; diff --git a/apps/meteor/client/lib/asyncState/AsyncState.ts b/apps/meteor/client/lib/asyncState/AsyncState.ts deleted file mode 100644 index 4b3292eabdad5..0000000000000 --- a/apps/meteor/client/lib/asyncState/AsyncState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AsyncStatePhase } from './AsyncStatePhase'; - -export type AsyncState = - | { phase: AsyncStatePhase.LOADING; value: undefined; error: undefined } - | { phase: AsyncStatePhase.LOADING; value: T; error: undefined } - | { phase: AsyncStatePhase.LOADING; value: undefined; error: Error } - | { phase: AsyncStatePhase.RESOLVED; value: T; error?: undefined } - | { phase: AsyncStatePhase.UPDATING; value: T; error: undefined } - | { phase: AsyncStatePhase.REJECTED; value: undefined; error: Error }; diff --git a/apps/meteor/client/lib/asyncState/AsyncStatePhase.ts b/apps/meteor/client/lib/asyncState/AsyncStatePhase.ts deleted file mode 100644 index 937213c86fa76..0000000000000 --- a/apps/meteor/client/lib/asyncState/AsyncStatePhase.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum AsyncStatePhase { - LOADING = 'loading', - RESOLVED = 'resolved', - REJECTED = 'rejected', - UPDATING = 'updating', -} diff --git a/apps/meteor/client/lib/asyncState/functions.ts b/apps/meteor/client/lib/asyncState/functions.ts deleted file mode 100644 index 3c2ab0feeac55..0000000000000 --- a/apps/meteor/client/lib/asyncState/functions.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { AsyncState } from './AsyncState'; -import { AsyncStatePhase } from './AsyncStatePhase'; - -export const loading = (): AsyncState => ({ - phase: AsyncStatePhase.LOADING, - value: undefined, - error: undefined, -}); - -export const updating = (value: T): AsyncState => ({ - phase: AsyncStatePhase.UPDATING, - value, - error: undefined, -}); - -export const resolved = (value: T): AsyncState => ({ - phase: AsyncStatePhase.RESOLVED, - value, - error: undefined, -}); - -export const rejected = (error: Error): AsyncState => ({ - phase: AsyncStatePhase.REJECTED, - value: undefined, - error, -}); - -export const reload = (prevState: AsyncState): AsyncState => { - switch (prevState.phase) { - case AsyncStatePhase.LOADING: - return prevState; - - case AsyncStatePhase.UPDATING: - case AsyncStatePhase.RESOLVED: - return { - phase: AsyncStatePhase.LOADING, - value: prevState.value, - error: undefined, - }; - - case AsyncStatePhase.REJECTED: - return { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: prevState.error, - }; - } -}; - -export const update = (prevState: AsyncState): AsyncState => { - switch (prevState.phase) { - case AsyncStatePhase.LOADING: - case AsyncStatePhase.UPDATING: - return prevState; - - case AsyncStatePhase.RESOLVED: - return { - phase: AsyncStatePhase.UPDATING, - value: prevState.value, - error: undefined, - }; - - case AsyncStatePhase.REJECTED: - return { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: prevState.error, - }; - } -}; - -export const resolve = (prevState: AsyncState, value: T): AsyncState => { - switch (prevState.phase) { - case AsyncStatePhase.LOADING: - case AsyncStatePhase.UPDATING: - return { - phase: AsyncStatePhase.RESOLVED, - value, - error: undefined, - }; - - case AsyncStatePhase.RESOLVED: - case AsyncStatePhase.REJECTED: - return prevState; - } -}; - -export const reject = (prevState: AsyncState, error: Error): AsyncState => { - switch (prevState.phase) { - case AsyncStatePhase.LOADING: - case AsyncStatePhase.UPDATING: - return { - phase: AsyncStatePhase.REJECTED, - value: undefined, - error, - }; - - case AsyncStatePhase.RESOLVED: - case AsyncStatePhase.REJECTED: - return prevState; - } -}; - -export const value = (state: AsyncState): T | undefined => state.value; - -export const error = (state: AsyncState): Error | undefined => state.error; diff --git a/apps/meteor/client/lib/asyncState/index.ts b/apps/meteor/client/lib/asyncState/index.ts deleted file mode 100644 index aaf5e6c37c203..0000000000000 --- a/apps/meteor/client/lib/asyncState/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { AsyncState } from './AsyncState'; -export { AsyncStatePhase } from './AsyncStatePhase'; -export * as asyncState from './functions'; diff --git a/apps/meteor/client/lib/lists/CannedResponseList.ts b/apps/meteor/client/lib/lists/CannedResponseList.ts deleted file mode 100644 index e2706da32ad3b..0000000000000 --- a/apps/meteor/client/lib/lists/CannedResponseList.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings'; - -import { RecordList } from './RecordList'; - -type CannedResponseOptions = { - filter: string; - type: string; -}; - -export class CannedResponseList extends RecordList { - public constructor(private _options: CannedResponseOptions) { - super(); - } - - public get options(): CannedResponseOptions { - return this._options; - } - - public updateFilters(options: CannedResponseOptions): void { - this._options = options; - this.clear(); - } -} diff --git a/apps/meteor/client/lib/lists/DiscussionsList.ts b/apps/meteor/client/lib/lists/DiscussionsList.ts deleted file mode 100644 index 0bd00fa2717b6..0000000000000 --- a/apps/meteor/client/lib/lists/DiscussionsList.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { IDiscussionMessage, IMessage } from '@rocket.chat/core-typings'; -import { escapeRegExp } from '@rocket.chat/string-helpers'; - -import { MessageList } from './MessageList'; - -type DiscussionMessage = Omit & Required>; - -export type DiscussionsListOptions = { - rid: IMessage['rid']; - text?: string; -}; - -const isDiscussionMessageInRoom = (message: IMessage, rid: IMessage['rid']): message is DiscussionMessage => - message.rid === rid && 'drid' in message; - -const isDiscussionTextMatching = (discussionMessage: DiscussionMessage, regex: RegExp): boolean => regex.test(discussionMessage.msg); - -export class DiscussionsList extends MessageList { - public constructor(private _options: DiscussionsListOptions) { - super(); - } - - public get options(): DiscussionsListOptions { - return this._options; - } - - public updateFilters(options: DiscussionsListOptions): void { - this._options = options; - this.clear(); - } - - protected override filter(message: IMessage): boolean { - const { rid } = this._options; - - if (!isDiscussionMessageInRoom(message, rid)) { - return false; - } - - if (this._options.text) { - const regex = new RegExp(escapeRegExp(this._options.text), 'i'); - if (!isDiscussionTextMatching(message, regex)) { - return false; - } - } - - return true; - } - - protected override compare(a: IMessage, b: IMessage): number { - return (b.tlm ?? b.ts).getTime() - (a.tlm ?? a.ts).getTime(); - } -} diff --git a/apps/meteor/client/lib/lists/FilesList.ts b/apps/meteor/client/lib/lists/FilesList.ts deleted file mode 100644 index f1ec0315afeea..0000000000000 --- a/apps/meteor/client/lib/lists/FilesList.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { IUpload } from '@rocket.chat/core-typings'; - -import { RecordList } from './RecordList'; - -type FilesMessage = Omit & Required>; - -export type FilesListOptions = { - rid: Required['rid']; - type: string; - text: string; -}; - -const isFileMessageInRoom = (upload: IUpload, rid: IUpload['rid']): upload is FilesMessage => upload.rid === rid && 'rid' in upload; - -export class FilesList extends RecordList { - public constructor(private _options: FilesListOptions) { - super(); - } - - public get options(): FilesListOptions { - return this._options; - } - - public updateFilters(options: FilesListOptions): void { - this._options = options; - this.clear(); - } - - protected override filter(message: IUpload): boolean { - const { rid } = this._options; - - if (!isFileMessageInRoom(message, rid)) { - return false; - } - - return true; - } -} diff --git a/apps/meteor/client/lib/lists/ImagesList.ts b/apps/meteor/client/lib/lists/ImagesList.ts deleted file mode 100644 index d5f22c4eb6c45..0000000000000 --- a/apps/meteor/client/lib/lists/ImagesList.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { IUpload } from '@rocket.chat/core-typings'; - -import { RecordList } from './RecordList'; - -type FilesMessage = Omit & Required>; - -export type ImagesListOptions = { - roomId: Required['rid']; - startingFromId?: string; - count?: number; - offset?: number; -}; - -const isFileMessageInRoom = (upload: IUpload, rid: IUpload['rid']): upload is FilesMessage => upload.rid === rid && 'rid' in upload; - -export class ImagesList extends RecordList { - public constructor(private _options: ImagesListOptions) { - super(); - } - - public get options(): ImagesListOptions { - return this._options; - } - - public updateFilters(options: ImagesListOptions): void { - this._options = options; - this.clear(); - } - - protected override filter(message: IUpload): boolean { - const { roomId } = this._options; - - if (!isFileMessageInRoom(message, roomId)) { - return false; - } - - return true; - } -} diff --git a/apps/meteor/client/lib/lists/MessageList.ts b/apps/meteor/client/lib/lists/MessageList.ts deleted file mode 100644 index 917bf47f69372..0000000000000 --- a/apps/meteor/client/lib/lists/MessageList.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { IMessage } from '@rocket.chat/core-typings'; - -import { RecordList } from './RecordList'; - -export class MessageList extends RecordList { - protected override filter(message: T): boolean { - return message._hidden !== true; - } - - protected override compare(a: T, b: T): number { - return a.ts.getTime() - b.ts.getTime(); - } -} diff --git a/apps/meteor/client/lib/lists/RecordList.spec.ts b/apps/meteor/client/lib/lists/RecordList.spec.ts deleted file mode 100644 index ba6ae17ec2cfb..0000000000000 --- a/apps/meteor/client/lib/lists/RecordList.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { AsyncStatePhase } from '../asyncState'; -import { RecordList } from './RecordList'; // Adjust the import path if necessary - -type TestItem = { - _id: string; - _updatedAt?: Date; -}; - -describe('RecordList', () => { - let recordList: RecordList; - - beforeEach(() => { - recordList = new RecordList(); - recordList.emit = jest.fn(); - }); - - test('should initialize with loading phase', () => { - expect(recordList.phase).toBe(AsyncStatePhase.LOADING); - expect(recordList.items).toEqual([]); - }); - - it('should insert a new item and emit an "inserted" event', async () => { - const item = { _id: '1', _updatedAt: new Date() }; - await recordList.handle(item); - - expect(recordList.items).toContainEqual(item); - expect(recordList.emit).toHaveBeenCalledWith('1/inserted', item); - }); - - it('should update an existing item and emit an "updated" event', async () => { - const item = { _id: '1', _updatedAt: new Date() }; - await recordList.handle(item); - - const updatedItem = { _id: '1', _updatedAt: new Date() }; - await recordList.handle(updatedItem); - - expect(recordList.items).toContainEqual(updatedItem); - expect(recordList.items.length).toBe(1); - expect(recordList.emit).toHaveBeenCalledWith('1/updated', updatedItem); - }); - - it('should delete an item and emit a "deleted" event', async () => { - const item = { _id: '1', _updatedAt: new Date() }; - await recordList.handle(item); - await recordList.remove('1'); - - expect(recordList.items).not.toContainEqual(item); - expect(recordList.emit).toHaveBeenCalledWith('1/deleted'); - }); - - it('should emit "errored" event if an error occurs during mutation', async () => { - const error = new Error('Mutation error'); - const getInfo = jest.fn().mockRejectedValue(error); - - await recordList.batchHandle(getInfo); - - expect(recordList.emit).toHaveBeenCalledWith('errored', error); - }); - - test('should batch handle multiple items the items should be sorted by _updatedAt descending', async () => { - const changes = { - items: [ - { _id: '1', _updatedAt: new Date('2025-06-11T23:14:00.002Z') }, - { _id: '3', _updatedAt: new Date('2025-06-11T23:14:00.004Z') }, - { _id: '4', _updatedAt: new Date('2025-06-11T23:14:00.005Z') }, - { _id: '2', _updatedAt: new Date('2025-06-11T23:14:00.003Z') }, - ], - itemCount: 2, - }; - - const getInfo = jest.fn().mockResolvedValue(changes); - - await recordList.batchHandle(getInfo); - - expect(recordList.items).toEqual([changes.items[2], changes.items[1], changes.items[3], changes.items[0]]); - expect(recordList.itemCount).toBe(changes.itemCount); - expect(recordList.emit).toHaveBeenCalledWith('1/inserted', changes.items[0]); - expect(recordList.emit).toHaveBeenCalledWith('3/inserted', changes.items[1]); - expect(recordList.emit).toHaveBeenCalledWith('4/inserted', changes.items[2]); - expect(recordList.emit).toHaveBeenCalledWith('2/inserted', changes.items[3]); - expect(recordList.emit).toHaveBeenCalledWith('mutated', true); - }); - - test('should fallback to index count if itemCount is not present', async () => { - const batchData = async () => ({ - items: [ - { _id: '1', _updatedAt: new Date() }, - { _id: '2', _updatedAt: new Date() }, - ], - }); - await recordList.batchHandle(batchData); - expect(recordList.itemCount).toBe(2); - }); - - test('should consider itemCount even if value is 0', async () => { - const batchData = async () => ({ - items: [ - { _id: '1', _updatedAt: new Date() }, - { _id: '2', _updatedAt: new Date() }, - ], - itemCount: 0, - }); - await recordList.batchHandle(batchData); - expect(recordList.itemCount).toBe(0); - }); - - test('should clear all items and emit cleared event', async () => { - const item = { _id: '1', _updatedAt: new Date() }; - - await await recordList.handle(item); - await recordList.clear(); - - expect(recordList.items).toEqual([]); - expect(recordList.itemCount).toBe(0); - expect(recordList.items.length).toBe(0); - expect(recordList.emit).toHaveBeenCalledWith('cleared'); - }); - - it('should prune items based on match criteria and emit delete events', async () => { - const item1 = { _id: '1', _updatedAt: new Date() }; - const item2 = { _id: '2', _updatedAt: new Date() }; - await await recordList.handle(item1); - await await recordList.handle(item2); - - const matchCriteria = (item: TestItem) => item._id === '1'; - await recordList.prune(matchCriteria); - - expect(recordList.items).not.toContainEqual(item1); - expect(recordList.emit).toHaveBeenCalledWith('1/deleted'); - expect(recordList.items).toContainEqual(item2); - }); - - test('should sort items based on _updatedAt', async () => { - const oldItem = { _id: '2', _updatedAt: new Date(Date.now() - 1000) }; - await await recordList.handle(oldItem); - - const newItem = { _id: '1', _updatedAt: new Date() }; - await await recordList.handle(newItem); - - expect(recordList.items[0]).toBe(newItem); - }); -}); diff --git a/apps/meteor/client/lib/lists/RecordList.ts b/apps/meteor/client/lib/lists/RecordList.ts deleted file mode 100644 index fed03c272e85f..0000000000000 --- a/apps/meteor/client/lib/lists/RecordList.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Emitter } from '@rocket.chat/emitter'; - -import { AsyncStatePhase } from '../asyncState'; - -export type RecordListBatchChanges = { - items?: T[]; - itemCount?: number; -}; - -export class RecordList extends Emitter { - #hasChanges = false; - - #index = new Map(); - - #phase: AsyncStatePhase.LOADING | AsyncStatePhase.UPDATING | AsyncStatePhase.RESOLVED = AsyncStatePhase.LOADING; - - #items: T[] | undefined = undefined; - - #itemCount: number | undefined = undefined; - - protected filter(_item: T): boolean { - return true; - } - - protected compare(a: T, b: T): number { - const aUpdatedAt = typeof a._updatedAt === 'string' ? new Date(a._updatedAt) : a._updatedAt; - const bUpdatedAt = typeof b._updatedAt === 'string' ? new Date(b._updatedAt) : b._updatedAt; - return (bUpdatedAt?.getTime() ?? -1) - (aUpdatedAt?.getTime() ?? -1); - } - - public get phase(): AsyncStatePhase { - return this.#phase; - } - - public get items(): T[] { - if (!this.#items) { - this.#items = Array.from(this.#index.values()).sort(this.compare); - } - - return this.#items; - } - - public get itemCount(): number { - return this.#itemCount ?? this.#index.size; - } - - private insert(item: T): void { - this.#index.set(item._id, item); - this.emit(`${item._id}/inserted`, item); - if (typeof this.#itemCount === 'number') { - this.#itemCount++; - } - this.#hasChanges = true; - } - - private update(item: T): void { - this.#index.set(item._id, item); - this.emit(`${item._id}/updated`, item); - this.#hasChanges = true; - } - - private delete(_id: T['_id']): void { - this.#index.delete(_id); - this.emit(`${_id}/deleted`); - if (typeof this.#itemCount === 'number') { - this.#itemCount--; - } - this.#hasChanges = true; - } - - private push(item: T): void { - const exists = this.#index.has(item._id); - const valid = this.filter(item); - - if (exists && !valid) { - this.delete(item._id); - return; - } - - if (exists && valid) { - this.update(item); - return; - } - - if (!exists && valid) { - this.insert(item); - } - } - - #pedingMutation: Promise = Promise.resolve(); - - protected async mutate(mutation: () => void | Promise): Promise { - try { - if (this.#phase === AsyncStatePhase.RESOLVED) { - this.#phase = AsyncStatePhase.UPDATING; - this.emit('mutating'); - } - - this.#pedingMutation = this.#pedingMutation.then(mutation); - await this.#pedingMutation; - } catch (error) { - this.emit('errored', error); - } finally { - const hasChanged = this.#hasChanges; - this.#phase = AsyncStatePhase.RESOLVED; - if (hasChanged) { - this.#items = undefined; - this.#hasChanges = false; - } - this.emit('mutated', hasChanged); - } - } - - public batchHandle(getInfo: () => Promise>): Promise { - return this.mutate(async () => { - const info = await getInfo(); - - if (info.items) { - for (const item of info.items) { - this.push(item); - } - } - - if (Number.isInteger(info.itemCount)) { - this.#itemCount = info.itemCount; - this.#hasChanges = true; - } - }); - } - - public prune(matchCriteria: (item: T) => boolean): Promise { - return this.mutate(() => { - for (const item of this.#index.values()) { - if (matchCriteria(item)) { - this.delete(item._id); - } - } - }); - } - - public handle(item: T): Promise { - return this.mutate(() => { - this.push(item); - }); - } - - public remove(_id: T['_id']): Promise { - return this.mutate(() => { - if (!this.#index.has(_id)) { - return; - } - - this.delete(_id); - }); - } - - public clear(): Promise { - return this.mutate(() => { - if (this.#index.size === 0) { - return; - } - - this.#index.clear(); - this.#items = undefined; - this.#itemCount = undefined; - this.#hasChanges = true; - this.emit('cleared'); - }); - } -} diff --git a/apps/meteor/client/lib/lists/ThreadsList.ts b/apps/meteor/client/lib/lists/ThreadsList.ts deleted file mode 100644 index 1ca17a2ee2a8e..0000000000000 --- a/apps/meteor/client/lib/lists/ThreadsList.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { IMessage, ISubscription, IUser, IThreadMainMessage } from '@rocket.chat/core-typings'; -import { escapeRegExp } from '@rocket.chat/string-helpers'; - -import { MessageList } from './MessageList'; - -type ThreadMessage = Omit & Required>; - -export type ThreadsListOptions = { - rid: IMessage['rid']; - text?: string; -} & ( - | { - type: 'unread'; - tunread: ISubscription['tunread']; - } - | { - type: 'following'; - uid: IUser['_id']; - } - | { - type: undefined; - } -); - -const isThreadMessageInRoom = (message: IMessage, rid: IMessage['rid']): message is ThreadMessage => - message.rid === rid && typeof (message as ThreadMessage).tcount === 'number'; - -const isThreadFollowedByUser = (threadMessage: ThreadMessage, uid: IUser['_id']): boolean => threadMessage.replies?.includes(uid) ?? false; - -const isThreadUnread = (threadMessage: ThreadMessage, tunread: ISubscription['tunread']): boolean => - Boolean(tunread?.includes(threadMessage._id)); - -const isThreadTextMatching = (threadMessage: ThreadMessage, regex: RegExp): boolean => regex.test(threadMessage.msg); - -export class ThreadsList extends MessageList { - public constructor(private _options: ThreadsListOptions) { - super(); - } - - public get options(): ThreadsListOptions { - return this._options; - } - - public updateFilters(options: ThreadsListOptions): void { - this._options = options; - this.clear(); - } - - protected override filter(message: IThreadMainMessage): boolean { - const { rid } = this._options; - - if (!isThreadMessageInRoom(message, rid)) { - return false; - } - - if (this._options.type === 'following') { - const { uid } = this._options; - if (!isThreadFollowedByUser(message, uid)) { - return false; - } - } - - if (this._options.type === 'unread') { - const { tunread } = this._options; - if (!isThreadUnread(message, tunread)) { - return false; - } - } - - if (this._options.text) { - const regex = new RegExp(escapeRegExp(this._options.text), 'i'); - if (!isThreadTextMatching(message, regex)) { - return false; - } - } - - return true; - } - - protected override compare(a: IThreadMainMessage, b: IThreadMainMessage): number { - return (b.tlm ?? b.ts).getTime() - (a.tlm ?? a.ts).getTime(); - } -} diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index 9d1a86dea8f3a..44529ad6d2543 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -23,13 +23,18 @@ export const roomsQueryKeys = { pinnedMessages: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'pinned-messages'] as const, messages: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'messages'] as const, message: (rid: IRoom['_id'], mid: IMessage['_id']) => [...roomsQueryKeys.messages(rid), mid] as const, - threads: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'threads'] as const, + threads: (rid: IRoom['_id'], ...args: [filters?: { type?: 'unread' | 'following'; text?: string }]) => + [...roomsQueryKeys.room(rid), 'threads', ...args] as const, roles: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'roles'] as const, info: (rid: IRoom['_id']) => [...roomsQueryKeys.room(rid), 'info'] as const, members: (rid: IRoom['_id'], roomType: RoomType, type?: 'all' | 'online', filter?: string) => !type && !filter ? ([...roomsQueryKeys.room(rid), 'members', roomType] as const) : ([...roomsQueryKeys.room(rid), 'members', roomType, type, filter] as const), + files: (rid: IRoom['_id'], options?: { type: string; text: string }) => [...roomsQueryKeys.room(rid), 'files', options] as const, + images: (rid: IRoom['_id'], options?: { startingFromId?: string }) => [...roomsQueryKeys.room(rid), 'images', options] as const, + autocomplete: (text: string) => [...roomsQueryKeys.all, 'autocomplete', text] as const, + discussions: (rid: IRoom['_id'], ...args: [filter: { text?: string }]) => [...roomsQueryKeys.room(rid), 'discussions', ...args] as const, }; export const subscriptionsQueryKeys = { @@ -39,7 +44,8 @@ export const subscriptionsQueryKeys = { export const cannedResponsesQueryKeys = { all: ['canned-responses'] as const, -}; + list: (params: { filter: string; type: string }) => [...cannedResponsesQueryKeys.all, params] as const, +} as const; export const rolesQueryKeys = { all: ['roles'] as const, @@ -84,7 +90,9 @@ export const omnichannelQueryKeys = { }, contacts: (query?: { filter: string; limit?: number }) => !query ? [...omnichannelQueryKeys.all, 'contacts'] : ([...omnichannelQueryKeys.all, 'contacts', query] as const), - contact: (contactId?: string) => [...omnichannelQueryKeys.contacts(), contactId] as const, + contact: (contactId?: IRoom['_id']) => [...omnichannelQueryKeys.contacts(), contactId] as const, + contactMessages: (contactId: IRoom['_id'], filter: { searchTerm: string }) => + [...omnichannelQueryKeys.contact(contactId), 'messages', filter] as const, outboundProviders: (filter?: { type: IOutboundProvider['providerType'] }) => !filter ? ([...omnichannelQueryKeys.all, 'outbound-messaging', 'providers'] as const) @@ -121,6 +129,8 @@ export const teamsQueryKeys = { roomsOfUser: (teamId: ITeam['_id'], userId: IUser['_id'], options?: { canUserDelete: boolean }) => [...teamsQueryKeys.team(teamId), 'rooms-of-user', userId, options] as const, listUserTeams: (userId: IUser['_id']) => [...teamsQueryKeys.all, 'listUserTeams', userId] as const, + listChannels: (teamId: ITeam['_id'], options?: { type: 'all' | 'autoJoin'; text: string }) => + [...teamsQueryKeys.team(teamId), 'channels', options] as const, }; export const appsQueryKeys = { @@ -151,3 +161,29 @@ export const callHistoryQueryKeys = { all: ['call-history'] as const, info: (callId?: string) => [...callHistoryQueryKeys.all, 'info', callId] as const, }; + +export const marketplaceQueryKeys = { + all: ['marketplace'] as const, + appsMarketplace: (...args: [canManageApps?: boolean]) => [...marketplaceQueryKeys.all, 'apps-marketplace', ...args] as const, + appsInstance: (...args: [canManageApps?: boolean]) => [...marketplaceQueryKeys.all, 'apps-instance', ...args] as const, + appsStored: (...args: unknown[]) => [...marketplaceQueryKeys.all, 'apps-stored', ...args] as const, + app: (appId: string) => [...marketplaceQueryKeys.all, 'apps', appId] as const, + appStatus: (appId: string) => [...marketplaceQueryKeys.app(appId), 'status'] as const, + appLogs: ( + appId: string, + query: { + instanceId?: string | undefined; + endDate?: string | undefined; + startDate?: string | undefined; + method?: string | undefined; + logLevel?: '0' | '1' | '2' | undefined; + count: number; + offset: number; + }, + ) => [...marketplaceQueryKeys.app(appId), 'logs', query] as const, +} as const; + +export const videoConferenceQueryKeys = { + all: ['video-conference'] as const, + fromRoom: (roomId: IRoom['_id']) => [...videoConferenceQueryKeys.all, 'rooms', roomId] as const, +} as const; diff --git a/apps/meteor/client/providers/AppsProvider.tsx b/apps/meteor/client/providers/AppsProvider.tsx new file mode 100644 index 0000000000000..df741ef976412 --- /dev/null +++ b/apps/meteor/client/providers/AppsProvider.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; + +import { AppClientOrchestratorInstance } from '../apps/orchestrator'; +import { AppsContext } from '../contexts/AppsContext'; + +type AppsProviderProps = { + children: ReactNode; +}; + +const AppsProvider = ({ children }: AppsProviderProps) => { + return ; +}; + +export default AppsProvider; diff --git a/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx b/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx deleted file mode 100644 index c20245595a316..0000000000000 --- a/apps/meteor/client/providers/AppsProvider/AppsProvider.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; -import { useInvalidateLicense, useLicense } from '@rocket.chat/ui-client'; -import { usePermission, useStream } from '@rocket.chat/ui-contexts'; -import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; -import type { ReactNode } from 'react'; -import { useEffect } from 'react'; - -import { storeQueryFunction } from './storeQueryFunction'; -import { AppClientOrchestratorInstance } from '../../apps/orchestrator'; -import { AppsContext } from '../../contexts/AppsContext'; -import type { AsyncState } from '../../lib/asyncState'; -import { AsyncStatePhase } from '../../lib/asyncState'; -import { useInvalidateAppsCountQueryCallback } from '../../views/marketplace/hooks/useAppsCountQuery'; -import type { App } from '../../views/marketplace/types'; - -const getAppState = ( - loading: boolean, - apps: App[] | undefined, - error?: Error, -): AsyncState<{ - apps: App[]; -}> => { - if (error) { - return { - phase: AsyncStatePhase.REJECTED, - value: undefined, - error, - }; - } - - return { - phase: loading ? AsyncStatePhase.LOADING : AsyncStatePhase.RESOLVED, - value: { apps: apps || [] }, - error, - }; -}; - -type AppsProviderProps = { - children: ReactNode; -}; - -const AppsProvider = ({ children }: AppsProviderProps) => { - const isAdminUser = usePermission('manage-apps'); - - const queryClient = useQueryClient(); - - const { isPending: isLicenseInformationLoading, data: { license, limits } = {} } = useLicense({ loadValues: true }); - const isEnterprise = isLicenseInformationLoading ? undefined : !!license; - - const invalidateAppsCountQuery = useInvalidateAppsCountQueryCallback(); - const invalidateLicenseQuery = useInvalidateLicense(); - - const stream = useStream('apps'); - - const invalidate = useDebouncedCallback( - () => { - queryClient.invalidateQueries({ - queryKey: ['marketplace', 'apps-instance'], - }); - invalidateAppsCountQuery(); - }, - 100, - [], - ); - - useEffect(() => { - return stream('apps', ([key]) => { - if (['app/added', 'app/removed', 'app/updated', 'app/statusUpdate', 'app/settingUpdated'].includes(key)) { - invalidate(); - } - if (['app/added', 'app/removed'].includes(key) && !isEnterprise) { - invalidateLicenseQuery(); - } - }); - }, [invalidate, invalidateLicenseQuery, isEnterprise, stream]); - - const marketplace = useQuery({ - queryKey: ['marketplace', 'apps-marketplace', isAdminUser], - - queryFn: async () => { - const result = await AppClientOrchestratorInstance.getAppsFromMarketplace(isAdminUser); - if (result.error && typeof result.error === 'string') { - throw new Error(result.error); - } - return result.apps; - }, - - staleTime: Infinity, - placeholderData: keepPreviousData, - }); - - const instance = useQuery({ - queryKey: ['marketplace', 'apps-instance', isAdminUser], - - queryFn: async () => { - const result = await AppClientOrchestratorInstance.getInstalledApps().then((result: App[]) => - result.map((current: App) => ({ - ...current, - installed: true, - })), - ); - return result; - }, - - staleTime: Infinity, - refetchOnMount: 'always', - }); - - const { isPending: isMarketplaceDataLoading, data: marketplaceData } = useQuery({ - queryKey: ['marketplace', 'apps-stored', instance.data, marketplace.data], - queryFn: () => storeQueryFunction(marketplace, instance), - enabled: marketplace.isFetched && instance.isFetched, - placeholderData: keepPreviousData, - }); - - const [marketplaceAppsData, installedAppsData, privateAppsData] = marketplaceData || []; - - useEffect(() => { - if (instance.data && marketplace.data) { - queryClient.invalidateQueries({ - queryKey: ['marketplace', 'apps-stored'], - }); - } - }, [marketplace.data, instance.data, queryClient]); - - return ( - { - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ['marketplace'], - }), - ]); - }, - orchestrator: AppClientOrchestratorInstance, - privateAppsEnabled: (limits?.privateApps?.max ?? 0) !== 0, - }} - /> - ); -}; - -export default AppsProvider; diff --git a/apps/meteor/client/providers/AppsProvider/index.ts b/apps/meteor/client/providers/AppsProvider/index.ts deleted file mode 100644 index 94ae81e87e4cb..0000000000000 --- a/apps/meteor/client/providers/AppsProvider/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import AppsProvider from './AppsProvider'; - -export default AppsProvider; diff --git a/apps/meteor/client/providers/AppsProvider/storeQueryFunction.ts b/apps/meteor/client/providers/AppsProvider/storeQueryFunction.ts deleted file mode 100644 index b571906664a5e..0000000000000 --- a/apps/meteor/client/providers/AppsProvider/storeQueryFunction.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { type UseQueryResult } from '@tanstack/react-query'; - -import type { App } from '../../views/marketplace/types'; - -const sortByName = (apps: App[]): App[] => apps.sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)); - -/** - * Aggregates result data from marketplace request and instance installed into their appropriate lists - * - * Exporting for better testing - */ -export function storeQueryFunction( - marketplace: UseQueryResult, - instance: UseQueryResult, -): [App[], App[], App[]] { - if (!marketplace.isFetched && !instance.isFetched) { - throw new Error('Apps not loaded'); - } - - const marketplaceApps: App[] = []; - const installedApps: App[] = []; - const privateApps: App[] = []; - const clonedData = [...(instance.data || [])]; - - sortByName(marketplace.data || []).forEach((app) => { - const appIndex = clonedData.findIndex(({ id }) => id === app.id); - const [installedApp] = appIndex > -1 ? clonedData.splice(appIndex, 1) : []; - - const record = { - ...app, - ...(installedApp && { - private: installedApp.private, - clusterStatus: installedApp.clusterStatus, - installed: true, - status: installedApp.status, - version: installedApp.version, - licenseValidation: installedApp.licenseValidation, - migrated: installedApp.migrated, - installedAddon: installedApp.addon, - }), - bundledIn: app.bundledIn, - marketplaceVersion: app.version, - }; - - if (installedApp) { - if (installedApp.private) { - privateApps.push(record); - } else { - installedApps.push(record); - } - } - - marketplaceApps.push(record); - }); - - sortByName(clonedData).forEach((app) => { - if (app.private) { - privateApps.push(app); - return; - } - - installedApps.push(app); - }); - - return [marketplaceApps, installedApps, privateApps]; -} diff --git a/apps/meteor/client/views/mailer/MailerUnsubscriptionPage.tsx b/apps/meteor/client/views/mailer/MailerUnsubscriptionPage.tsx index 0fabe42c3bea5..198e9e6abfa23 100644 --- a/apps/meteor/client/views/mailer/MailerUnsubscriptionPage.tsx +++ b/apps/meteor/client/views/mailer/MailerUnsubscriptionPage.tsx @@ -1,52 +1,46 @@ import { Box, Callout, Throbber } from '@rocket.chat/fuselage'; import { HeroLayout } from '@rocket.chat/layout'; import { useToastMessageDispatch, useRouteParameter, useEndpoint } from '@rocket.chat/ui-contexts'; +import { useMutation } from '@tanstack/react-query'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import type { AsyncState } from '../../hooks/useAsyncState'; -import { AsyncStatePhase, useAsyncState } from '../../hooks/useAsyncState'; - -const useMailerUnsubscriptionState = (): AsyncState => { - const { resolve, reject, ...unsubscribedState } = useAsyncState(); - +const useMailerUnsubscriptionMutation = () => { const unsubscribe = useEndpoint('POST', '/v1/mailer.unsubscribe'); const _id = useRouteParameter('_id'); const createdAt = useRouteParameter('createdAt'); const dispatchToastMessage = useToastMessageDispatch(); - useEffect(() => { - const doUnsubscribe = async (_id: string, createdAt: string): Promise => { - try { - await unsubscribe({ _id, createdAt }); - resolve(true); - } catch (error: unknown) { - dispatchToastMessage({ type: 'error', message: error }); - reject(error instanceof Error ? error : new Error(String(error))); - } - }; + const mutation = useMutation({ + mutationFn: async ({ _id, createdAt }: { _id: string; createdAt: string }) => { + await unsubscribe({ _id, createdAt }); + }, + onError: (error) => { + dispatchToastMessage({ type: 'error', message: error }); + }, + }); + useEffect(() => { if (!_id || !createdAt) { return; } - doUnsubscribe(_id, createdAt); - }, [resolve, reject, unsubscribe, _id, createdAt, dispatchToastMessage]); + mutation.mutate.call(null, { _id, createdAt }); + }, [_id, createdAt, mutation.mutate]); - return unsubscribedState; + return mutation; }; const MailerUnsubscriptionPage = () => { - const { phase, error } = useMailerUnsubscriptionState(); - const { t } = useTranslation(); + const { isIdle, isPending, isError, error, isSuccess } = useMailerUnsubscriptionMutation(); return ( - {(phase === AsyncStatePhase.LOADING && ) || - (phase === AsyncStatePhase.REJECTED && ) || - (phase === AsyncStatePhase.RESOLVED && )} + {((isIdle || isPending) && ) || + (isError && ) || + (isSuccess && )} ); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppRequests/AppRequests.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppRequests/AppRequests.tsx index 8b9357deebf3a..4a1335a7392b6 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppRequests/AppRequests.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppRequests/AppRequests.tsx @@ -8,8 +8,8 @@ import { useTranslation } from 'react-i18next'; import AppRequestItem from './AppRequestItem'; import AppRequestsLoading from './AppRequestsLoading'; -import { useAppsReload } from '../../../../../contexts/hooks/useAppsReload'; import { useAppRequests } from '../../../hooks/useAppRequests'; +import { useAppsReload } from '../../../hooks/useAppsReload'; type itemsPerPage = 25 | 50 | 100; diff --git a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx index b7e8bbdc76c7c..49c35601ed6c9 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContent.tsx @@ -1,7 +1,6 @@ import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { usePagination } from '@rocket.chat/ui-client'; import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; -import type { ReactElement } from 'react'; import { useEffect, useMemo, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -15,27 +14,25 @@ import NoInstalledAppsEmptyState from './NoInstalledAppsEmptyState'; import NoMarketplaceOrInstalledAppMatchesEmptyState from './NoMarketplaceOrInstalledAppMatchesEmptyState'; import PrivateEmptyState from './PrivateEmptyState'; import UnsupportedEmptyState from './UnsupportedEmptyState'; -import { useAppsResult } from '../../../contexts/hooks/useAppsResult'; -import { AsyncStatePhase } from '../../../lib/asyncState'; import MarketplaceHeader from '../components/MarketplaceHeader'; import type { RadioDropDownGroup } from '../definitions/RadioDropDownDefinitions'; +import { useAppsReload } from '../hooks/useAppsReload'; import { useCategories } from '../hooks/useCategories'; import { useFilteredApps } from '../hooks/useFilteredApps'; -import type { appsDataType } from '../hooks/useFilteredApps'; import { useRadioToggle } from '../hooks/useRadioToggle'; type AppsContext = 'explore' | 'installed' | 'premium' | 'private' | 'requested'; -const AppsPageContent = (): ReactElement => { +const AppsPageContent = () => { const { t } = useTranslation(); - const { marketplaceApps, installedApps, privateApps, reload } = useAppsResult(); + const reload = useAppsReload(); const [text, setText] = useState(''); const debouncedText = useDebouncedValue(text, 500); const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination(); const router = useRouter(); - const context = useRouteParameter('context') as AppsContext; + const context = (useRouteParameter('context') as AppsContext | undefined) ?? 'explore'; const isMarketplace = context === 'explore'; const isPremium = context === 'premium'; @@ -94,19 +91,6 @@ const AppsPageContent = (): ReactElement => { const sortFilterOnSelected = useRadioToggle(setSortFilterStructure); - const getAppsData = useCallback((): appsDataType => { - switch (context) { - case 'premium': - case 'explore': - case 'requested': - return marketplaceApps; - case 'private': - return privateApps; - default: - return installedApps; - } - }, [context, marketplaceApps, installedApps, privateApps]); - const findSort = () => { const possibleSort = sortFilterStructure.items.find(({ checked }) => checked); @@ -126,8 +110,7 @@ const AppsPageContent = (): ReactElement => { }; const [categories, selectedCategories, categoryTagList, onSelected] = useCategories(); - const appsResult = useFilteredApps({ - appsData: getAppsData(), + const { isPending, isError, error, data } = useFilteredApps({ text: debouncedText, current, itemsPerPage, @@ -138,26 +121,6 @@ const AppsPageContent = (): ReactElement => { context, }); - const noInstalledApps = appsResult.phase === AsyncStatePhase.RESOLVED && !isMarketplace && appsResult.value?.totalAppsLength === 0; - - // TODO: Introduce error codes, so we can avoid using error message strings for this kind of logic - // whenever we change the error message, this code ends up breaking. - const unsupportedVersion = - appsResult.phase === AsyncStatePhase.REJECTED && appsResult.error.message === 'Marketplace_Unsupported_Version'; - - const noMarketplaceOrInstalledAppMatches = - appsResult.phase === AsyncStatePhase.RESOLVED && (isMarketplace || isPremium) && appsResult.value?.count === 0; - - const noInstalledAppMatches = - appsResult.phase === AsyncStatePhase.RESOLVED && - context === 'installed' && - appsResult.value?.totalAppsLength !== 0 && - appsResult.value?.count === 0; - - const noAppRequests = context === 'requested' && appsResult?.value?.count === 0; - - const noErrorsOcurred = !noMarketplaceOrInstalledAppMatches && !noInstalledAppMatches && !noInstalledApps && !noAppRequests; - const isFiltered = Boolean(text.length) || freePaidFilterStructure.items.find((item) => item.checked)?.id !== 'all' || @@ -199,37 +162,61 @@ const AppsPageContent = (): ReactElement => { toggleInitialSortOption(isRequested); }, [isMarketplace, isRequested, sortFilterOnSelected, t, toggleInitialSortOption]); - const getEmptyState = () => { - if (unsupportedVersion) { - return ; - } - - if (noAppRequests) { - return ; - } - - if (noMarketplaceOrInstalledAppMatches) { - return ; - } - - if (noInstalledAppMatches) { - return ( - + + - ); - } - - if (noInstalledApps) { - return context === 'private' ? : ; - } - }; + + + ); + } + + if (isError) { + // TODO: Introduce error codes, so we can avoid using error message strings for this kind of logic + // whenever we change the error message, this code ends up breaking. + const unsupportedVersion = error.message === 'Marketplace_Unsupported_Version'; + + return ( + <> + + + {unsupportedVersion ? : } + + ); + } return ( <> - + { categoryTagList={categoryTagList} statusFilterStructure={statusFilterStructure} statusFilterOnSelected={statusFilterOnSelected} - context={context || 'explore'} + context={context} /> - {appsResult.phase === AsyncStatePhase.LOADING && } - {appsResult.phase === AsyncStatePhase.RESOLVED && noErrorsOcurred && !unsupportedVersion && ( - - )} - {getEmptyState()} - {appsResult.phase === AsyncStatePhase.REJECTED && !unsupportedVersion && } + {(context === 'requested' && data.count === 0 && ) || + ((isMarketplace || isPremium) && data.count === 0 && ( + + )) || + (context === 'installed' && data.totalAppsLength !== 0 && data.count === 0 && ( + + )) || + (context === 'private' && !isMarketplace && data.totalAppsLength === 0 && ) || + (context !== 'private' && !isMarketplace && data.totalAppsLength === 0 && ( + + )) || ( + + )} ); }; diff --git a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContentBody.tsx b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContentBody.tsx index 0f05d0a3ec6e6..e97f5564761a0 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/AppsPageContentBody.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/AppsPageContentBody.tsx @@ -25,7 +25,6 @@ type AppsPageContentBodyProps = { itemsPerPageLabel: () => string; showingResultsLabel: (context: { count: number; current: number; itemsPerPage: 25 | 50 | 100 }) => string; }; - noErrorsOcurred: boolean; }; const AppsPageContentBody = ({ @@ -37,7 +36,6 @@ const AppsPageContentBody = ({ onSetItemsPerPage, onSetCurrent, paginationProps, - noErrorsOcurred, }: AppsPageContentBodyProps) => { const { t } = useTranslation(); const scrollableRef = useRef(null); @@ -46,12 +44,10 @@ const AppsPageContentBody = ({ return ( <> - {noErrorsOcurred && ( - - {isMarketplace && !isFiltered && } - - - )} + + {isMarketplace && !isFiltered && } + + {Boolean(appsResult?.count) && ( ({ + ...jest.requireActual('@rocket.chat/ui-client'), + useLicense: jest.fn(), +})); describe('with private apps enabled', () => { - const appRoot = mockAppRoot() - .withTranslations('en', 'core', { - Private_apps_upgrade_empty_state_title: 'Upgrade to unlock private apps', - No_private_apps_installed: 'No private apps installed', - }) - .wrap((children) => ( - Promise.resolve(), - orchestrator: undefined, - privateAppsEnabled: true, - }} - > - {children} - - )); + beforeEach(async () => { + const { useLicense } = await import('@rocket.chat/ui-client'); + (useLicense as jest.Mock).mockReturnValue({ + data: { + limits: { + privateApps: { + max: 5, + }, + }, + }, + isPending: false, + }); + }); + + const appRoot = mockAppRoot().withTranslations('en', 'core', { + Private_apps_upgrade_empty_state_title: 'Upgrade to unlock private apps', + No_private_apps_installed: 'No private apps installed', + }); it('should offer to upgrade to unlock private apps', () => { render(, { wrapper: appRoot.build() }); @@ -34,25 +36,24 @@ describe('with private apps enabled', () => { }); describe('without private apps enabled', () => { - const appRoot = mockAppRoot() - .withTranslations('en', 'core', { - Private_apps_upgrade_empty_state_title: 'Upgrade to unlock private apps', - No_private_apps_installed: 'No private apps installed', - }) - .wrap((children) => ( - Promise.resolve(), - orchestrator: undefined, - privateAppsEnabled: false, - }} - > - {children} - - )); + beforeEach(async () => { + const { useLicense } = await import('@rocket.chat/ui-client'); + (useLicense as jest.Mock).mockReturnValue({ + data: { + limits: { + privateApps: { + max: 0, + }, + }, + }, + isPending: false, + }); + }); + + const appRoot = mockAppRoot().withTranslations('en', 'core', { + Private_apps_upgrade_empty_state_title: 'Upgrade to unlock private apps', + No_private_apps_installed: 'No private apps installed', + }); it('should offer to upgrade to unlock private apps', () => { render(, { wrapper: appRoot.build() }); diff --git a/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.spec.tsx b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.spec.tsx index 896a42ea9185d..feb3d0d87939f 100644 --- a/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.spec.tsx +++ b/apps/meteor/client/views/marketplace/AppsPage/UnsupportedEmptyState.spec.tsx @@ -2,28 +2,11 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import { render, screen } from '@testing-library/react'; import UnsupportedEmptyState from './UnsupportedEmptyState'; -import { AppsContext } from '../../../contexts/AppsContext'; -import { asyncState } from '../../../lib/asyncState'; describe('with private apps enabled', () => { - const appRoot = mockAppRoot() - .withTranslations('en', 'core', { - Marketplace_unavailable: 'Marketplace unavailable', - }) - .wrap((children) => ( - Promise.resolve(), - orchestrator: undefined, - }} - > - {children} - - )); + const appRoot = mockAppRoot().withTranslations('en', 'core', { + Marketplace_unavailable: 'Marketplace unavailable', + }); it('should inform that the marketplace is unavailable due unsupported version', () => { render(, { wrapper: appRoot.build() }); diff --git a/apps/meteor/client/views/marketplace/hooks/useAppInfo.ts b/apps/meteor/client/views/marketplace/hooks/useAppInfo.ts index 130a32c1421fb..c562017ef1899 100644 --- a/apps/meteor/client/views/marketplace/hooks/useAppInfo.ts +++ b/apps/meteor/client/views/marketplace/hooks/useAppInfo.ts @@ -1,10 +1,10 @@ import type { App } from '@rocket.chat/core-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useState, useEffect, useContext } from 'react'; +import { useState, useEffect } from 'react'; +import { useApps } from './useApps'; import type { ISettings } from '../../../apps/@types/IOrchestrator'; import { AppClientOrchestratorInstance } from '../../../apps/orchestrator'; -import { AppsContext } from '../../../contexts/AppsContext'; import type { AppInfo } from '../definitions/AppInfo'; const getBundledInApp = async (app: App): Promise => { @@ -20,7 +20,7 @@ const getBundledInApp = async (app: App): Promise => { }; export const useAppInfo = (appId: string, context: string): AppInfo | undefined => { - const { installedApps, marketplaceApps, privateApps } = useContext(AppsContext); + const { data: { installedApps, marketplaceApps, privateApps } = {} } = useApps(); const [appData, setAppData] = useState(); @@ -31,18 +31,18 @@ export const useAppInfo = (appId: string, context: string): AppInfo | undefined useEffect(() => { const fetchAppInfo = async (): Promise => { - if ((!marketplaceApps.value?.apps?.length && !installedApps.value?.apps.length && !privateApps.value?.apps.length) || !appId) { + if ((!marketplaceApps?.length && !installedApps?.length && !privateApps?.length) || !appId) { return; } let appResult: App | undefined; const marketplaceAppsContexts = ['explore', 'premium', 'requested']; - if (marketplaceAppsContexts.includes(context)) appResult = marketplaceApps.value?.apps.find((app) => app.id === appId); + if (marketplaceAppsContexts.includes(context)) appResult = marketplaceApps?.find((app) => app.id === appId); - if (context === 'private') appResult = privateApps.value?.apps.find((app) => app.id === appId); + if (context === 'private') appResult = privateApps?.find((app) => app.id === appId); - if (context === 'installed') appResult = installedApps.value?.apps.find((app) => app.id === appId); + if (context === 'installed') appResult = installedApps?.find((app) => app.id === appId); if (!appResult) return; @@ -83,7 +83,7 @@ export const useAppInfo = (appId: string, context: string): AppInfo | undefined }; fetchAppInfo(); - }, [appId, context, getApis, getBundledIn, getScreenshots, getSettings, installedApps, marketplaceApps, privateApps.value?.apps]); + }, [appId, context, getApis, getBundledIn, getScreenshots, getSettings, installedApps, marketplaceApps, privateApps]); return appData; }; diff --git a/apps/meteor/client/views/marketplace/hooks/useAppInstances.ts b/apps/meteor/client/views/marketplace/hooks/useAppInstances.ts index 3e537aa69e8d7..df968f1ea5cf8 100644 --- a/apps/meteor/client/views/marketplace/hooks/useAppInstances.ts +++ b/apps/meteor/client/views/marketplace/hooks/useAppInstances.ts @@ -3,11 +3,13 @@ import { useEndpoint } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; +import { marketplaceQueryKeys } from '../../../lib/queryKeys'; + export const useAppInstances = ({ appId }: { appId: string }): UseQueryResult> => { const status = useEndpoint('GET', '/apps/:id/status', { id: appId }); return useQuery({ - queryKey: ['marketplace', 'apps', appId], + queryKey: marketplaceQueryKeys.appStatus(appId), queryFn: () => status(), }); }; diff --git a/apps/meteor/client/providers/AppsProvider/AppsProvider.spec.ts b/apps/meteor/client/views/marketplace/hooks/useApps.spec.ts similarity index 91% rename from apps/meteor/client/providers/AppsProvider/AppsProvider.spec.ts rename to apps/meteor/client/views/marketplace/hooks/useApps.spec.ts index c05ed7f1d0861..cbc5f07c568f0 100644 --- a/apps/meteor/client/providers/AppsProvider/AppsProvider.spec.ts +++ b/apps/meteor/client/views/marketplace/hooks/useApps.spec.ts @@ -1,9 +1,9 @@ import type { App } from '@rocket.chat/core-typings'; import type { UseQueryResult } from '@tanstack/react-query'; -import { storeQueryFunction } from './storeQueryFunction'; -import { createFakeApp } from '../../../tests/mocks/data'; -import { createFakeAppInstalledMarketplace, createFakeAppPrivate } from '../../../tests/mocks/data/marketplace'; +import { storeQueryFunction } from './useApps'; +import { createFakeApp } from '../../../../tests/mocks/data'; +import { createFakeAppInstalledMarketplace, createFakeAppPrivate } from '../../../../tests/mocks/data/marketplace'; describe(`when an app installed from the Marketplace, but has since been unpublished`, () => { it(`should still be present in the installed app data provided`, () => { diff --git a/apps/meteor/client/views/marketplace/hooks/useApps.ts b/apps/meteor/client/views/marketplace/hooks/useApps.ts new file mode 100644 index 0000000000000..1dfa4f8bbaa99 --- /dev/null +++ b/apps/meteor/client/views/marketplace/hooks/useApps.ts @@ -0,0 +1,180 @@ +import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; +import { useInvalidateLicense } from '@rocket.chat/ui-client'; +import { usePermission, useStream } from '@rocket.chat/ui-contexts'; +import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useContext, useEffect } from 'react'; + +import { useInvalidateAppsCountQueryCallback } from './useAppsCountQuery'; +import { AppsContext } from '../../../contexts/AppsContext'; +import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; +import { marketplaceQueryKeys } from '../../../lib/queryKeys'; +import type { App } from '../types'; + +const sortByName = (apps: App[]): App[] => apps.toSorted((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)); + +/** + * Aggregates result data from marketplace request and instance installed into their appropriate lists + * + * Exporting for better testing + */ +export const storeQueryFunction = ( + marketplace: UseQueryResult, + instance: UseQueryResult, +): [App[], App[], App[]] => { + if (!marketplace.isFetched && !instance.isFetched) { + throw new Error('Apps not loaded'); + } + + const marketplaceApps: App[] = []; + const installedApps: App[] = []; + const privateApps: App[] = []; + const clonedData = [...(instance.data || [])]; + + sortByName(marketplace.data || []).forEach((app) => { + const appIndex = clonedData.findIndex(({ id }) => id === app.id); + const [installedApp] = appIndex > -1 ? clonedData.splice(appIndex, 1) : []; + + const record = { + ...app, + ...(installedApp && { + private: installedApp.private, + clusterStatus: installedApp.clusterStatus, + installed: true, + status: installedApp.status, + version: installedApp.version, + licenseValidation: installedApp.licenseValidation, + migrated: installedApp.migrated, + installedAddon: installedApp.addon, + }), + bundledIn: app.bundledIn, + marketplaceVersion: app.version, + }; + + if (installedApp) { + if (installedApp.private) { + privateApps.push(record); + } else { + installedApps.push(record); + } + } + + marketplaceApps.push(record); + }); + + sortByName(clonedData).forEach((app) => { + if (app.private) { + privateApps.push(app); + return; + } + + installedApps.push(app); + }); + + return [marketplaceApps, installedApps, privateApps]; +}; + +export const useApps = < + TData = { + installedApps: App[]; + marketplaceApps: App[]; + privateApps: App[]; + }, +>( + options?: Omit< + UseQueryOptions< + { + installedApps: App[]; + marketplaceApps: App[]; + privateApps: App[]; + }, + Error, + TData, + ReturnType + >, + 'queryKey' | 'queryFn' | 'enabled' | 'placeholderData' + >, +) => { + const orchestrator = useContext(AppsContext); + + if (!orchestrator) throw new Error('Apps Orchestrator is not available'); + + const canManageApps = usePermission('manage-apps'); + + const queryClient = useQueryClient(); + + const { data: isEnterprise } = useIsEnterprise(); + + const invalidateAppsCountQuery = useInvalidateAppsCountQueryCallback(); + const invalidateLicenseQuery = useInvalidateLicense(); + + const stream = useStream('apps'); + + const invalidate = useDebouncedCallback( + () => { + queryClient.invalidateQueries({ queryKey: marketplaceQueryKeys.appsInstance() }); + invalidateAppsCountQuery(); + }, + 100, + [queryClient, invalidateAppsCountQuery], + ); + + useEffect(() => { + return stream('apps', ([key]) => { + if (['app/added', 'app/removed', 'app/updated', 'app/statusUpdate', 'app/settingUpdated'].includes(key)) { + invalidate(); + } + if (['app/added', 'app/removed'].includes(key) && !isEnterprise) { + invalidateLicenseQuery(); + } + }); + }, [invalidate, invalidateLicenseQuery, isEnterprise, stream]); + + const marketplace = useQuery({ + queryKey: marketplaceQueryKeys.appsMarketplace(canManageApps), + queryFn: async () => { + const result = await orchestrator.getAppsFromMarketplace(canManageApps); + if (result.error && typeof result.error === 'string') { + throw new Error(result.error); + } + return result.apps; + }, + staleTime: Infinity, + placeholderData: keepPreviousData, + }); + + const instance = useQuery({ + queryKey: marketplaceQueryKeys.appsInstance(canManageApps), + queryFn: async () => { + const result = await orchestrator.getInstalledApps().then((result: App[]) => + result.map((current: App) => ({ + ...current, + installed: true, + })), + ); + return result; + }, + staleTime: Infinity, + refetchOnMount: 'always', + }); + + return useQuery({ + queryKey: marketplaceQueryKeys.appsStored(instance.data, marketplace.data), + queryFn: async () => { + if (marketplace.isError) { + throw marketplace.error; + } + + const [marketplaceAppsData, installedAppsData, privateAppsData] = storeQueryFunction(marketplace, instance); + + return { + installedApps: installedAppsData, + marketplaceApps: marketplaceAppsData, + privateApps: privateAppsData, + }; + }, + enabled: marketplace.isFetched && instance.isFetched, + placeholderData: keepPreviousData, + ...options, + }); +}; diff --git a/apps/meteor/client/views/marketplace/hooks/useAppsOrchestration.ts b/apps/meteor/client/views/marketplace/hooks/useAppsOrchestration.ts index 18cd8d3d070af..1fb4d9950a208 100644 --- a/apps/meteor/client/views/marketplace/hooks/useAppsOrchestration.ts +++ b/apps/meteor/client/views/marketplace/hooks/useAppsOrchestration.ts @@ -2,8 +2,4 @@ import { useContext } from 'react'; import { AppsContext } from '../../../contexts/AppsContext'; -export const useAppsOrchestration = () => { - const { orchestrator } = useContext(AppsContext); - - return orchestrator; -}; +export const useAppsOrchestration = () => useContext(AppsContext); diff --git a/apps/meteor/client/views/marketplace/hooks/useAppsReload.ts b/apps/meteor/client/views/marketplace/hooks/useAppsReload.ts new file mode 100644 index 0000000000000..337c96a10a347 --- /dev/null +++ b/apps/meteor/client/views/marketplace/hooks/useAppsReload.ts @@ -0,0 +1,11 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; + +import { marketplaceQueryKeys } from '../../../lib/queryKeys'; + +export const useAppsReload = () => { + const queryClient = useQueryClient(); + return useCallback(() => { + queryClient.invalidateQueries({ queryKey: marketplaceQueryKeys.all }); + }, [queryClient]); +}; diff --git a/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts b/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts index 1a669b868080f..5f0d15f141335 100644 --- a/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts +++ b/apps/meteor/client/views/marketplace/hooks/useFilteredApps.ts @@ -1,10 +1,6 @@ import type { PaginatedResult } from '@rocket.chat/rest-typings'; -import type { ContextType } from 'react'; -import { useMemo } from 'react'; -import type { AppsContext } from '../../../contexts/AppsContext'; -import type { AsyncState } from '../../../lib/asyncState'; -import { AsyncStatePhase } from '../../../lib/asyncState'; +import { useApps } from './useApps'; import { filterAppsByCategories } from '../helpers/filterAppsByCategories'; import { filterAppsByDisabled } from '../helpers/filterAppsByDisabled'; import { filterAppsByEnabled } from '../helpers/filterAppsByEnabled'; @@ -16,10 +12,7 @@ import { sortAppsByAlphabeticalOrInverseOrder } from '../helpers/sortAppsByAlpha import { sortAppsByClosestOrFarthestModificationDate } from '../helpers/sortAppsByClosestOrFarthestModificationDate'; import type { App } from '../types'; -export type appsDataType = ContextType['installedApps'] | ContextType['marketplaceApps']; - export const useFilteredApps = ({ - appsData, text, current, itemsPerPage, @@ -29,7 +22,6 @@ export const useFilteredApps = ({ status, context, }: { - appsData: appsDataType; text: string; current: number; itemsPerPage: number; @@ -39,104 +31,100 @@ export const useFilteredApps = ({ sortingMethod: string; status: string; context?: string; -}): AsyncState< - PaginatedResult<{ - items: App[]; - shouldShowSearchText: boolean; - allApps: App[]; - totalAppsLength: number; - }> -> => { - const value = useMemo(() => { - if (appsData.value === undefined) { - return undefined; - } - - const { apps } = appsData.value; - const fallback = (apps: App[]) => apps; - - const sortingMethods: Record App[]> = { - urf: (apps: App[]) => - apps.sort((firstApp, secondApp) => (secondApp?.appRequestStats?.totalUnseen || 0) - (firstApp?.appRequestStats?.totalUnseen || 0)), - url: (apps: App[]) => - apps.sort((firstApp, secondApp) => (firstApp?.appRequestStats?.totalUnseen || 0) - (secondApp?.appRequestStats?.totalUnseen || 0)), - az: (apps: App[]) => apps.sort((firstApp, secondApp) => sortAppsByAlphabeticalOrInverseOrder(firstApp.name, secondApp.name)), - za: (apps: App[]) => apps.sort((firstApp, secondApp) => sortAppsByAlphabeticalOrInverseOrder(secondApp.name, firstApp.name)), - mru: (apps: App[]) => - apps.sort((firstApp, secondApp) => sortAppsByClosestOrFarthestModificationDate(firstApp.modifiedAt, secondApp.modifiedAt)), - lru: (apps: App[]) => - apps.sort((firstApp, secondApp) => sortAppsByClosestOrFarthestModificationDate(secondApp.modifiedAt, firstApp.modifiedAt)), - }; - - const filterByPurchaseType: Record App[]> = { - all: fallback, - paid: (apps: App[]) => apps.filter(filterAppsByPaid), - premium: (apps: App[]) => apps.filter(filterAppsByEnterprise), - free: (apps: App[]) => apps.filter(filterAppsByFree), - }; +}) => + useApps({ + select: ({ + marketplaceApps, + installedApps, + privateApps, + }): PaginatedResult<{ + items: App[]; + shouldShowSearchText: boolean; + allApps: App[]; + totalAppsLength: number; + }> => { + const apps = (() => { + switch (context) { + case 'premium': + case 'explore': + case 'requested': + return marketplaceApps; + case 'private': + return privateApps; + default: + return installedApps; + } + })(); - const filterByStatus: Record App[]> = { - all: fallback, - enabled: (apps: App[]) => apps.filter(filterAppsByEnabled), - disabled: (apps: App[]) => apps.filter(filterAppsByDisabled), - }; + const fallback = (apps: App[]) => apps; - const filterByContext: Record App[]> = { - explore: fallback, - installed: fallback, - private: fallback, - premium: (apps: App[]) => apps.filter(({ categories }) => categories.includes('Premium')), - requested: (apps: App[]) => apps.filter(({ appRequestStats, installed }) => Boolean(appRequestStats) && !installed), - }; + const sortingMethods: Record App[]> = { + urf: (apps: App[]) => + apps.toSorted( + (firstApp, secondApp) => (secondApp?.appRequestStats?.totalUnseen || 0) - (firstApp?.appRequestStats?.totalUnseen || 0), + ), + url: (apps: App[]) => + apps.toSorted( + (firstApp, secondApp) => (firstApp?.appRequestStats?.totalUnseen || 0) - (secondApp?.appRequestStats?.totalUnseen || 0), + ), + az: (apps: App[]) => apps.toSorted((firstApp, secondApp) => sortAppsByAlphabeticalOrInverseOrder(firstApp.name, secondApp.name)), + za: (apps: App[]) => apps.toSorted((firstApp, secondApp) => sortAppsByAlphabeticalOrInverseOrder(secondApp.name, firstApp.name)), + mru: (apps: App[]) => + apps.toSorted((firstApp, secondApp) => sortAppsByClosestOrFarthestModificationDate(firstApp.modifiedAt, secondApp.modifiedAt)), + lru: (apps: App[]) => + apps.toSorted((firstApp, secondApp) => sortAppsByClosestOrFarthestModificationDate(secondApp.modifiedAt, firstApp.modifiedAt)), + }; - type appsFilterFunction = (apps: App[]) => App[]; - const pipeAppsFilter = - (...functions: appsFilterFunction[]) => - (initialValue: App[]) => - functions.reduce((currentAppsList, currentFilterFunction) => currentFilterFunction(currentAppsList), initialValue); + const filterByPurchaseType: Record App[]> = { + all: fallback, + paid: (apps: App[]) => apps.filter(filterAppsByPaid), + premium: (apps: App[]) => apps.filter(filterAppsByEnterprise), + free: (apps: App[]) => apps.filter(filterAppsByFree), + }; - const filtered = pipeAppsFilter( - context ? filterByContext[context] : fallback, - filterByPurchaseType[purchaseType], - filterByStatus[status], - categories.length ? (apps: App[]) => apps.filter((app) => filterAppsByCategories(app, categories)) : fallback, - text ? (apps: App[]) => apps.filter(({ name }) => filterAppsByText(name, text)) : fallback, - sortingMethods[sortingMethod], - )(apps); + const filterByStatus: Record App[]> = { + all: fallback, + enabled: (apps: App[]) => apps.filter(filterAppsByEnabled), + disabled: (apps: App[]) => apps.filter(filterAppsByDisabled), + }; - const shouldShowSearchText = !!text; - const total = filtered.length; - const offset = current > total ? 0 : current; - const end = current + itemsPerPage; - const slice = filtered.slice(offset, end); + const filterByContext: Record App[]> = { + explore: fallback, + installed: fallback, + private: fallback, + premium: (apps: App[]) => apps.filter(({ categories }) => categories.includes('Premium')), + requested: (apps: App[]) => apps.filter(({ appRequestStats, installed }) => Boolean(appRequestStats) && !installed), + }; - return { - items: slice, - offset, - total: filtered.length, - totalAppsLength: apps.length, - count: slice.length, - shouldShowSearchText, - allApps: filtered, - }; - }, [appsData.value, sortingMethod, purchaseType, status, categories, text, context, current, itemsPerPage]); + type appsFilterFunction = (apps: App[]) => App[]; + const pipeAppsFilter = + (...functions: appsFilterFunction[]) => + (initialValue: App[]) => + functions.reduce((currentAppsList, currentFilterFunction) => currentFilterFunction(currentAppsList), initialValue); - if (appsData.phase === AsyncStatePhase.RESOLVED) { - if (!value) { - throw new Error('useFilteredApps - Unexpected state'); - } - return { - ...appsData, - value, - }; - } + const filtered = pipeAppsFilter( + context ? filterByContext[context] : fallback, + filterByPurchaseType[purchaseType], + filterByStatus[status], + categories.length ? (apps: App[]) => apps.filter((app) => filterAppsByCategories(app, categories)) : fallback, + text ? (apps: App[]) => apps.filter(({ name }) => filterAppsByText(name, text)) : fallback, + sortingMethods[sortingMethod], + )(apps); - if (appsData.phase === AsyncStatePhase.UPDATING) { - throw new Error('useFilteredApps - Unexpected state'); - } + const shouldShowSearchText = !!text; + const total = filtered.length; + const offset = current > total ? 0 : current; + const end = current + itemsPerPage; + const slice = filtered.slice(offset, end); - return { - ...appsData, - value: undefined, - }; -}; + return { + items: slice, + offset, + total: filtered.length, + totalAppsLength: apps.length, + count: slice.length, + shouldShowSearchText, + allApps: filtered, + }; + }, + }); diff --git a/apps/meteor/client/views/marketplace/hooks/useInstallApp.tsx b/apps/meteor/client/views/marketplace/hooks/useInstallApp.tsx index be0ca31dc4269..29aefa198c46a 100644 --- a/apps/meteor/client/views/marketplace/hooks/useInstallApp.tsx +++ b/apps/meteor/client/views/marketplace/hooks/useInstallApp.tsx @@ -3,8 +3,8 @@ import { useRouter, useSetModal, useUpload } from '@rocket.chat/ui-contexts'; import { useMutation } from '@tanstack/react-query'; import { useCallback, useState } from 'react'; +import { useAppsReload } from './useAppsReload'; import { AppClientOrchestratorInstance } from '../../../apps/orchestrator'; -import { useAppsReload } from '../../../contexts/hooks/useAppsReload'; import { useIsEnterprise } from '../../../hooks/useIsEnterprise'; import AppExemptModal from '../AppExemptModal'; import AppPermissionsReviewModal from '../AppPermissionsReviewModal'; diff --git a/apps/meteor/client/views/marketplace/hooks/useLogs.ts b/apps/meteor/client/views/marketplace/hooks/useLogs.ts index 6fd6b388af9e4..18ebddd7de34b 100644 --- a/apps/meteor/client/views/marketplace/hooks/useLogs.ts +++ b/apps/meteor/client/views/marketplace/hooks/useLogs.ts @@ -4,6 +4,8 @@ import type { UseQueryResult } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; +import { marketplaceQueryKeys } from '../../../lib/queryKeys'; + export const useLogs = ({ appId, current, @@ -38,7 +40,7 @@ export const useLogs = ({ const logs = useEndpoint('GET', '/apps/:id/logs', { id: appId }); return useQuery({ - queryKey: ['marketplace', 'apps', appId, 'logs', query], + queryKey: marketplaceQueryKeys.appLogs(appId, query), queryFn: () => logs(query), }); }; diff --git a/apps/meteor/client/views/marketplace/hooks/usePrivateAppsEnabled.ts b/apps/meteor/client/views/marketplace/hooks/usePrivateAppsEnabled.ts index 49050960b4df9..c432b766b23b1 100644 --- a/apps/meteor/client/views/marketplace/hooks/usePrivateAppsEnabled.ts +++ b/apps/meteor/client/views/marketplace/hooks/usePrivateAppsEnabled.ts @@ -1,9 +1,3 @@ -import { useContext } from 'react'; +import { useLicense } from '@rocket.chat/ui-client'; -import { AppsContext } from '../../../contexts/AppsContext'; - -export const usePrivateAppsEnabled = () => { - const { privateAppsEnabled } = useContext(AppsContext); - - return privateAppsEnabled; -}; +export const usePrivateAppsEnabled = () => (useLicense({ loadValues: true }).data?.limits?.privateApps?.max ?? 0) !== 0; diff --git a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponse.tsx b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponse.tsx index f6f7885db6d98..ff5685da9655e 100644 --- a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponse.tsx +++ b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponse.tsx @@ -19,7 +19,7 @@ type CannedResponseProps = { allowEdit: boolean; allowUse: boolean; data: { - departmentName: ILivechatDepartment['name']; + departmentName?: ILivechatDepartment['name']; shortcut: IOmnichannelCannedResponse['shortcut']; text: IOmnichannelCannedResponse['text']; scope: IOmnichannelCannedResponse['scope']; diff --git a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponseList.tsx b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponseList.tsx index 53fc20de6c021..9d2df044c3201 100644 --- a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponseList.tsx +++ b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/CannedResponseList.tsx @@ -21,11 +21,10 @@ import WrapCannedResponse from './WrapCannedResponse'; import { useCanCreateCannedResponse } from '../../hooks/useCanCreateCannedResponse'; type CannedResponseListProps = { - loadMoreItems: (start: number, end: number) => void; - cannedItems: (IOmnichannelCannedResponse & { departmentName: ILivechatDepartment['name'] })[]; + loadMoreItems: () => void; + cannedItems: (IOmnichannelCannedResponse & { departmentName?: ILivechatDepartment['name'] })[]; itemCount: number; - onClose: any; - loading: boolean; + onClose: () => void; options: [string, string][]; text: string; setText: FormEventHandler; @@ -43,7 +42,6 @@ const CannedResponseList = ({ cannedItems, itemCount, onClose, - loading, options, text, setText, @@ -110,7 +108,7 @@ const CannedResponseList = ({ loadMoreItems(start, Math.min(25, itemCount - start))} + endReached={loadMoreItems} overscan={25} data={cannedItems} itemContent={(_index, data): ReactElement => ( diff --git a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/Item.tsx b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/Item.tsx index f50be366c5db7..4f61352bfd357 100644 --- a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/Item.tsx +++ b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/Item.tsx @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'; import { useScopeDict } from '../../../hooks/useScopeDict'; type ItemProps = { - data: IOmnichannelCannedResponse & { departmentName: ILivechatDepartment['name'] }; + data: IOmnichannelCannedResponse & { departmentName?: ILivechatDepartment['name'] }; allowUse?: boolean; onClickItem: (e: MouseEvent) => void; onClickUse: (e: MouseEvent, text: string) => void; diff --git a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/WrapCannedResponse.tsx b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/WrapCannedResponse.tsx index 860547f077383..a81b48b260c37 100644 --- a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/WrapCannedResponse.tsx +++ b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/WrapCannedResponse.tsx @@ -9,7 +9,7 @@ import CreateCannedResponse from '../../modals/CreateCannedResponse'; type WrapCannedResponseProps = { canUseCannedResponses: boolean; - cannedItem: IOmnichannelCannedResponse & { departmentName: ILivechatDepartment['name'] }; + cannedItem: IOmnichannelCannedResponse & { departmentName?: ILivechatDepartment['name'] }; onClickBack: MouseEventHandler; onClickUse: (e: MouseEvent, text: string) => void; onClose: () => void; diff --git a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/WrapCannedResponseList.tsx b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/WrapCannedResponseList.tsx index 395fa692442cf..9109ddcdd1f5c 100644 --- a/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/WrapCannedResponseList.tsx +++ b/apps/meteor/client/views/omnichannel/cannedResponses/contextualBar/CannedResponse/WrapCannedResponseList.tsx @@ -2,11 +2,9 @@ import type { IOmnichannelCannedResponse, ILivechatDepartment } from '@rocket.ch import { useDebouncedValue, useLocalStorage, useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useSetModal, useRouter, useRoomToolbox } from '@rocket.chat/ui-contexts'; import type { ChangeEvent, MouseEvent } from 'react'; -import { memo, useCallback, useMemo, useState } from 'react'; +import { memo, useCallback, useState } from 'react'; import CannedResponseList from './CannedResponseList'; -import { useRecordList } from '../../../../../hooks/lists/useRecordList'; -import { AsyncStatePhase } from '../../../../../lib/asyncState'; import { useChat } from '../../../../room/contexts/ChatContext'; import { useRoom } from '../../../../room/contexts/RoomContext'; import { useCannedResponseFilterOptions } from '../../../hooks/useCannedResponseFilterOptions'; @@ -33,10 +31,8 @@ export const WrapCannedResponseList = () => { const debouncedText = useDebouncedValue(text, 400); - const { cannedList, loadMoreItems, reload } = useCannedResponseList( - useMemo(() => ({ filter: debouncedText, type }), [debouncedText, type]), - ); - const { phase, items, itemCount } = useRecordList(cannedList); + // TODO: handle pending and error states + const { data, fetchNextPage, refetch } = useCannedResponseList({ filter: debouncedText, type }); const onClickItem = useEffectEvent( ( @@ -68,16 +64,15 @@ export const WrapCannedResponseList = () => { }; const onClickCreate = (): void => { - setModal( setModal(null)} reloadCannedList={reload} />); + setModal( setModal(null)} reloadCannedList={refetch} />); }; return ( { onClickUse={onClickUse} onClickItem={onClickItem} onClickCreate={onClickCreate} - reload={reload} + reload={refetch} /> ); }; diff --git a/apps/meteor/client/views/omnichannel/cannedResponses/modals/CreateCannedResponse/CreateCannedResponseModal.tsx b/apps/meteor/client/views/omnichannel/cannedResponses/modals/CreateCannedResponse/CreateCannedResponseModal.tsx index 10b83ef5e1483..33033eac03a27 100644 --- a/apps/meteor/client/views/omnichannel/cannedResponses/modals/CreateCannedResponse/CreateCannedResponseModal.tsx +++ b/apps/meteor/client/views/omnichannel/cannedResponses/modals/CreateCannedResponse/CreateCannedResponseModal.tsx @@ -1,4 +1,4 @@ -import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings'; +import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; import { GenericModal } from '@rocket.chat/ui-client'; import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; @@ -11,9 +11,7 @@ import GenericError from '../../../../../components/GenericError'; import CannedResponseForm from '../../components/CannedResponseForm'; import type { CannedResponseEditFormData } from '../CannedResponseEdit'; -const getInitialData = ( - cannedResponseData: (IOmnichannelCannedResponse & { departmentName: ILivechatDepartment['name'] }) | undefined, -) => ({ +const getInitialData = (cannedResponseData: IOmnichannelCannedResponse | undefined) => ({ _id: cannedResponseData?._id || '', shortcut: cannedResponseData?.shortcut || '', text: cannedResponseData?.text || '', @@ -23,7 +21,7 @@ const getInitialData = ( }); type CreateCannedResponseModalProps = { - cannedResponseData?: IOmnichannelCannedResponse & { departmentName: ILivechatDepartment['name'] }; + cannedResponseData?: IOmnichannelCannedResponse; onClose: () => void; reloadCannedList: () => void; }; diff --git a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessagesList.tsx b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessagesList.tsx index 4d16d9da8d99d..1fb25a10d2719 100644 --- a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessagesList.tsx +++ b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessagesList.tsx @@ -23,16 +23,14 @@ import { ContextualbarDialog, ContextualbarFooter, } from '@rocket.chat/ui-client'; -import { useSetting, useUserPreference, useUserId } from '@rocket.chat/ui-contexts'; -import type { ChangeEvent, ReactElement } from 'react'; +import { useSetting, useUserPreference } from '@rocket.chat/ui-contexts'; +import type { ChangeEvent } from 'react'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Virtuoso } from 'react-virtuoso'; import ContactHistoryMessage from './ContactHistoryMessage'; import { useHistoryMessageList } from './useHistoryMessageList'; -import { useRecordList } from '../../../../hooks/lists/useRecordList'; -import { AsyncStatePhase } from '../../../../lib/asyncState'; import { isMessageNewDay } from '../../../room/MessageList/lib/isMessageNewDay'; import { isMessageSequential } from '../../../room/MessageList/lib/isMessageSequential'; @@ -46,7 +44,6 @@ const ContactHistoryMessagesList = ({ chatId, onClose, onOpenRoom }: ContactHist const { t } = useTranslation(); const [text, setText] = useState(''); const showUserAvatar = !!useUserPreference('displayAvatars'); - const userId = useUserId(); const { ref, contentBoxSize: { inlineSize = 378, blockSize = 1 } = {} } = useResizeObserver({ debounceDelay: 200, @@ -63,13 +60,15 @@ const ContactHistoryMessagesList = ({ chatId, onClose, onOpenRoom }: ContactHist 500, ); - const { itemsList: messageList, loadMoreItems } = useHistoryMessageList(query, userId); + const { isPending, error, isSuccess, data, fetchNextPage } = useHistoryMessageList(query); + + const messages = data?.items || []; + const totalItemCount = data?.itemCount ?? 0; const handleSearchChange = (event: ChangeEvent): void => { setText(event.currentTarget.value); }; - const { phase, error, items: messages, itemCount: totalItemCount } = useRecordList(messageList); const messageGroupingPeriod = useSetting('Message_GroupingPeriod', 300); return ( @@ -101,7 +100,7 @@ const ContactHistoryMessagesList = ({ chatId, onClose, onOpenRoom }: ContactHist - {phase === AsyncStatePhase.LOADING && ( + {isPending && ( @@ -113,9 +112,9 @@ const ContactHistoryMessagesList = ({ chatId, onClose, onOpenRoom }: ContactHist {error.toString()} )} - {phase !== AsyncStatePhase.LOADING && totalItemCount === 0 && } + {isSuccess && totalItemCount === 0 && } - {!error && totalItemCount > 0 && history.length > 0 && ( + {!error && totalItemCount > 0 && messages.length > 0 && ( undefined - : (start): void => { - loadMoreItems(start, Math.min(50, totalItemCount - start)); - } - } + endReached={() => fetchNextPage()} overscan={25} data={messages} - itemContent={(index, data): ReactElement => { + itemContent={(index, data) => { const lastMessage = messages[index - 1]; const isSequential = isMessageSequential(data, lastMessage, messageGroupingPeriod); const isNewDay = isMessageNewDay(data, lastMessage); diff --git a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/useHistoryMessageList.ts b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/useHistoryMessageList.ts index 7d098a58e63eb..f484fd879cbbc 100644 --- a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/useHistoryMessageList.ts +++ b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/useHistoryMessageList.ts @@ -1,61 +1,59 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IMessage } from '@rocket.chat/core-typings'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback, useMemo, useState } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; -import { useScrollableMessageList } from '../../../../hooks/lists/useScrollableMessageList'; -import { useStreamUpdatesForMessageList } from '../../../../hooks/lists/useStreamUpdatesForMessageList'; -import { useComponentDidUpdate } from '../../../../hooks/useComponentDidUpdate'; -import { MessageList } from '../../../../lib/lists/MessageList'; +import { useInfiniteMessageQueryUpdates } from '../../../../hooks/useInfiniteMessageQueryUpdates'; +import { omnichannelQueryKeys } from '../../../../lib/queryKeys'; import { getConfig } from '../../../../lib/utils/getConfig'; +import { mapMessageFromApi } from '../../../../lib/utils/mapMessageFromApi'; type HistoryMessageListOptions = { filter: string; roomId: string; }; -export const useHistoryMessageList = ( - options: HistoryMessageListOptions, - uid: IUser['_id'] | undefined, -): { - itemsList: MessageList; - initialItemCount: number; - loadMoreItems: (start: number, end: number) => void; -} => { - const [itemsList, setItemsList] = useState(() => new MessageList()); - const reload = useCallback(() => setItemsList(new MessageList()), []); - - const getMessages = useEndpoint('GET', '/v1/livechat/:rid/messages', { rid: options.roomId }); - - useComponentDidUpdate(() => { - options && reload(); - }, [options, reload]); - - const fetchMessages = useCallback( - async (start: number, end: number) => { +export const useHistoryMessageList = ({ roomId, filter: searchTerm }: HistoryMessageListOptions) => { + const getMessages = useEndpoint('GET', '/v1/livechat/:rid/messages', { rid: roomId }); + + const count = parseInt(`${getConfig('historyMessageListSize', 10)}`, 10); + + useInfiniteMessageQueryUpdates({ + queryKey: omnichannelQueryKeys.contactMessages(roomId, { searchTerm }), + roomId, + // Replicates the filtering done server-side + filter: (message): message is IMessage => + (!('t' in message) || message.t === 'livechat-close') && + (!searchTerm || new RegExp(escapeRegExp(searchTerm), 'ig').test(message.msg)), + // Replicates the sorting forced on server-side + compare: (a, b) => a.ts.getTime() - b.ts.getTime(), + }); + + return useInfiniteQuery({ + queryKey: omnichannelQueryKeys.contactMessages(roomId, { searchTerm }), + queryFn: async ({ pageParam: offset }) => { const { messages, total } = await getMessages({ - ...(options.filter && { searchTerm: options.filter }), - offset: start, - count: end, - sort: `{ "ts": 1 }`, + ...(searchTerm && { searchTerm }), + offset, + count, + sort: JSON.stringify({ ts: 1 }), }); + return { - items: messages, + items: messages.map(mapMessageFromApi), itemCount: total, }; }, - [getMessages, options.filter], - ); - - const { loadMoreItems, initialItemCount } = useScrollableMessageList( - itemsList, - fetchMessages, - useMemo(() => parseInt(`${getConfig('historyMessageListSize', 10)}`), []), - ); - useStreamUpdatesForMessageList(itemsList, uid, options.roomId); - - return { - itemsList, - loadMoreItems, - initialItemCount, - }; + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + // FIXME: This is an estimation, as messages can be created or removed while paginating + // Ideally, the server should return the next offset to use or the pagination should be done using "createdAt" or "updatedAt" + const loadedItemsCount = allPages.reduce((acc, page) => acc + page.items.length, 0); + return loadedItemsCount < lastPage.itemCount ? loadedItemsCount : undefined; + }, + select: ({ pages }) => ({ + items: pages.flatMap((page) => page.items), + itemCount: pages.at(-1)?.itemCount ?? 0, + }), + }); }; diff --git a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoHistory/ContactInfoHistoryMessages.tsx b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoHistory/ContactInfoHistoryMessages.tsx index 364560dd480fe..81814d326c385 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoHistory/ContactInfoHistoryMessages.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/tabs/ContactInfoHistory/ContactInfoHistoryMessages.tsx @@ -14,14 +14,12 @@ import { } from '@rocket.chat/fuselage'; import { useDebouncedValue, useResizeObserver } from '@rocket.chat/fuselage-hooks'; import { VirtualizedScrollbars, ContextualbarContent, ContextualbarEmptyContent, ContextualbarFooter } from '@rocket.chat/ui-client'; -import { useSetting, useUserPreference, useUserId } from '@rocket.chat/ui-contexts'; -import type { ChangeEvent, ReactElement } from 'react'; +import { useSetting, useUserPreference } from '@rocket.chat/ui-contexts'; +import type { ChangeEvent } from 'react'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Virtuoso } from 'react-virtuoso'; -import { useRecordList } from '../../../../../hooks/lists/useRecordList'; -import { AsyncStatePhase } from '../../../../../lib/asyncState'; import { isMessageNewDay } from '../../../../room/MessageList/lib/isMessageNewDay'; import { isMessageSequential } from '../../../../room/MessageList/lib/isMessageSequential'; import ContactHistoryMessage from '../../../contactHistory/MessageList/ContactHistoryMessage'; @@ -37,7 +35,6 @@ const ContactInfoHistoryMessages = ({ chatId, onBack, onOpenRoom }: ContactHisto const { t } = useTranslation(); const [text, setText] = useState(''); const showUserAvatar = !!useUserPreference('displayAvatars'); - const userId = useUserId(); const { ref, contentBoxSize: { inlineSize = 378, blockSize = 1 } = {} } = useResizeObserver({ debounceDelay: 200, @@ -48,13 +45,15 @@ const ContactInfoHistoryMessages = ({ chatId, onBack, onOpenRoom }: ContactHisto 500, ); - const { itemsList: messageList, loadMoreItems } = useHistoryMessageList(query, userId); + const { isPending, error, isSuccess, data, fetchNextPage } = useHistoryMessageList(query); + + const messages = data?.items || []; + const totalItemCount = data?.itemCount ?? 0; const handleSearchChange = (event: ChangeEvent): void => { setText(event.currentTarget.value); }; - const { phase, error, items: messages, itemCount: totalItemCount } = useRecordList(messageList); const messageGroupingPeriod = Number(useSetting('Message_GroupingPeriod')); return ( @@ -81,7 +80,7 @@ const ContactInfoHistoryMessages = ({ chatId, onBack, onOpenRoom }: ContactHisto - {phase === AsyncStatePhase.LOADING && ( + {isPending && ( @@ -93,9 +92,9 @@ const ContactInfoHistoryMessages = ({ chatId, onBack, onOpenRoom }: ContactHisto {error.toString()} )} - {phase !== AsyncStatePhase.LOADING && totalItemCount === 0 && } + {isSuccess && totalItemCount === 0 && } - {!error && totalItemCount > 0 && history.length > 0 && ( + {!error && totalItemCount > 0 && messages.length > 0 && ( undefined - : (start): void => { - loadMoreItems(start, Math.min(50, totalItemCount - start)); - } - } + endReached={() => fetchNextPage()} overscan={25} data={messages} - itemContent={(index, data): ReactElement => { + itemContent={(index, data) => { const lastMessage = messages[index - 1]; const isSequential = isMessageSequential(data, lastMessage, messageGroupingPeriod); const isNewDay = isMessageNewDay(data, lastMessage); diff --git a/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx b/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx index c856f3299a7ef..895706a3962cc 100644 --- a/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx +++ b/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx @@ -20,10 +20,11 @@ import { import { useDebouncedValue, useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { validateEmail } from '@rocket.chat/tools'; import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client'; -import { useToastMessageDispatch, useEndpoint, useTranslation, useRouter, usePermission } from '@rocket.chat/ui-contexts'; +import { useToastMessageDispatch, useEndpoint, useRouter, usePermission } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; -import { useId, useMemo, useState } from 'react'; +import { useId, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import DepartmentsAgentsTable from './DepartmentAgentsTable/DepartmentAgentsTable'; import DepartmentTags from './DepartmentTags'; @@ -31,10 +32,8 @@ import type { EditDepartmentFormData } from './definitions'; import { formatAgentListPayload } from './utils/formatAgentListPayload'; import { formatEditDepartmentPayload } from './utils/formatEditDepartmentPayload'; import { getFormInitialValues } from './utils/getFormInititalValues'; -import { useRecordList } from '../../../hooks/lists/useRecordList'; import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; import { useRoomsList } from '../../../hooks/useRoomsList'; -import { AsyncStatePhase } from '../../../lib/asyncState'; import { EeTextInput, EeTextAreaInput, EeNumberInput, DepartmentBusinessHours } from '../additionalForms'; import AutoCompleteUnit from '../additionalForms/AutoCompleteUnit'; import AutoCompleteDepartment from '../components/AutoCompleteDepartment'; @@ -54,7 +53,7 @@ export type EditDepartmentProps = { function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmentProps) { const dispatchToastMessage = useToastMessageDispatch(); - const t = useTranslation(); + const { t } = useTranslation(); const router = useRouter(); const queryClient = useQueryClient(); @@ -77,11 +76,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen const debouncedFallbackFilter = useDebouncedValue(fallbackFilter, 500); - const { itemsList: RoomsList, loadMoreItems: loadMoreRooms } = useRoomsList( - useMemo(() => ({ text: debouncedFallbackFilter }), [debouncedFallbackFilter]), - ); - - const { phase: roomsPhase, items: roomsItems, itemCount: roomsTotal } = useRecordList(RoomsList); + const { data: roomItems = [], fetchNextPage } = useRoomsList({ text: debouncedFallbackFilter }); const createDepartment = useEndpoint('POST', '/v1/livechat/department'); const updateDepartmentInfo = useEndpoint('PUT', '/v1/livechat/department/:_id', { _id: id || '' }); @@ -254,11 +249,9 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen flexShrink={0} filter={fallbackFilter} setFilter={setFallbackFilter as (value?: string | number) => void} - options={roomsItems} + options={roomItems} placeholder={t('Channel_name')} - endReached={ - roomsPhase === AsyncStatePhase.LOADING ? () => undefined : (start) => loadMoreRooms(start, Math.min(50, roomsTotal)) - } + endReached={() => fetchNextPage()} aria-busy={fallbackFilter !== debouncedFallbackFilter} /> )} diff --git a/apps/meteor/client/views/omnichannel/hooks/useCannedResponseList.ts b/apps/meteor/client/views/omnichannel/hooks/useCannedResponseList.ts index edf1194f601d9..88c903a0bc7f9 100644 --- a/apps/meteor/client/views/omnichannel/hooks/useCannedResponseList.ts +++ b/apps/meteor/client/views/omnichannel/hooks/useCannedResponseList.ts @@ -1,75 +1,59 @@ +import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback, useEffect, useState } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; -import { useScrollableRecordList } from '../../../hooks/lists/useScrollableRecordList'; -import { useComponentDidUpdate } from '../../../hooks/useComponentDidUpdate'; -import { CannedResponseList } from '../../../lib/lists/CannedResponseList'; - -export const useCannedResponseList = ( - options: any, -): { - reload: () => void; - cannedList: CannedResponseList; - initialItemCount: number; - loadMoreItems: (start: number, end: number) => void; -} => { - const [cannedList, setCannedList] = useState(() => new CannedResponseList(options)); - const reload = useCallback(() => setCannedList(new CannedResponseList(options)), [options]); - - useComponentDidUpdate(() => { - options && reload(); - }, [options, reload]); - - useEffect(() => { - if (cannedList.options !== options) { - cannedList.updateFilters(options); - } - }, [cannedList, options]); +import { cannedResponsesQueryKeys } from '../../../lib/queryKeys'; +export const useCannedResponseList = ({ filter, type }: { filter: string; type: string }) => { const getCannedResponses = useEndpoint('GET', '/v1/canned-responses'); const getDepartments = useEndpoint('GET', '/v1/livechat/department'); - const fetchData = useCallback( - async (start: number, end: number) => { + const count = 25; + + return useInfiniteQuery({ + queryKey: cannedResponsesQueryKeys.list({ filter, type }), + queryFn: async ({ pageParam: offset }) => { const { cannedResponses, total } = await getCannedResponses({ - ...(options.filter && { text: options.filter }), - ...(options.type && ['global', 'user'].find((option) => option === options.type) && { scope: options.type }), - ...(options.type && - !['global', 'user', 'all'].find((option) => option === options.type) && { + ...(filter && { text: filter }), + ...(type && ['global', 'user'].find((option) => option === type) && { scope: type }), + ...(type && + !['global', 'user', 'all'].find((option) => option === type) && { scope: 'department', - departmentId: options.type, + departmentId: type, }), - offset: start, - count: end + start, + offset, + count, }); + // TODO: Optimize by creating an endpoint that returns canned responses with department names + // TODO: Use another query for departments to avoid refetching const { departments } = await getDepartments({ text: '' }); return { - items: cannedResponses.map((cannedResponse: any) => { - if (cannedResponse.departmentId) { - departments.forEach((department: any) => { - if (cannedResponse.departmentId === department._id) { - cannedResponse.departmentName = department.name; - } - }); - } - cannedResponse._updatedAt = new Date(cannedResponse._updatedAt); - cannedResponse._createdAt = new Date(cannedResponse._createdAt); - return cannedResponse; + items: cannedResponses.map((cannedResponse): IOmnichannelCannedResponse & { departmentName?: ILivechatDepartment['name'] } => { + const departmentName = cannedResponse.departmentId + ? departments.find((department) => department._id === cannedResponse.departmentId)?.name + : undefined; + + return { + ...cannedResponse, + _updatedAt: new Date(cannedResponse._updatedAt), + _createdAt: new Date(cannedResponse._createdAt), + departmentName, + }; }), itemCount: total, }; }, - [getCannedResponses, getDepartments, options.filter, options.type], - ); - - const { loadMoreItems, initialItemCount } = useScrollableRecordList(cannedList, fetchData); - - return { - reload, - cannedList, - loadMoreItems, - initialItemCount, - }; + initialPageParam: 0, + getNextPageParam: (lastPage, _, lastOffset) => { + const nextOffset = lastOffset + count; + if (nextOffset >= lastPage.itemCount) return undefined; + return nextOffset; + }, + select: ({ pages }) => ({ + cannedItems: pages.flatMap((page) => page.items), + total: pages.at(-1)?.itemCount, + }), + }); }; diff --git a/apps/meteor/client/views/omnichannel/hooks/useScopeDict.ts b/apps/meteor/client/views/omnichannel/hooks/useScopeDict.ts index ffab5d55aaf0d..71e32cee23408 100644 --- a/apps/meteor/client/views/omnichannel/hooks/useScopeDict.ts +++ b/apps/meteor/client/views/omnichannel/hooks/useScopeDict.ts @@ -1,7 +1,7 @@ import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings'; import { useTranslation } from 'react-i18next'; -export const useScopeDict = (scope: IOmnichannelCannedResponse['scope'], departmentName: ILivechatDepartment['name']): string => { +export const useScopeDict = (scope: IOmnichannelCannedResponse['scope'], departmentName: ILivechatDepartment['name'] | undefined) => { const { t } = useTranslation(); const dict: Record = { diff --git a/apps/meteor/client/views/room/ImageGallery/ImageGalleryData.tsx b/apps/meteor/client/views/room/ImageGallery/ImageGalleryData.tsx index 91ac214922e58..8e831e4570468 100644 --- a/apps/meteor/client/views/room/ImageGallery/ImageGalleryData.tsx +++ b/apps/meteor/client/views/room/ImageGallery/ImageGalleryData.tsx @@ -1,28 +1,26 @@ -import { useContext, useMemo } from 'react'; +import { useContext } from 'react'; +import { useImagesList } from './hooks/useImagesList'; import { ImageGallery, ImageGalleryError, ImageGalleryLoading } from '../../../components/ImageGallery'; import { ImageGalleryContext } from '../../../contexts/ImageGalleryContext'; -import { useRecordList } from '../../../hooks/lists/useRecordList'; import { useRoom } from '../contexts/RoomContext'; -import { useImagesList } from './hooks/useImagesList'; const ImageGalleryData = () => { const { _id: rid } = useRoom(); const { imageId, onClose } = useContext(ImageGalleryContext); - const { filesList, loadMoreItems } = useImagesList(useMemo(() => ({ roomId: rid, startingFromId: imageId }), [imageId, rid])); - const { phase, items: images, error } = useRecordList(filesList); + const { isPending, isError, data: images, fetchNextPage } = useImagesList({ roomId: rid, startingFromId: imageId }); - if (error) { - return ; + if (isPending) { + return ; } - if (phase === 'loading') { - return ; + if (isError) { + return ; } - return loadMoreItems(images.length - 1)} onClose={onClose} />; + return ; }; export default ImageGalleryData; diff --git a/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts b/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts index b753a5a7cc33a..09a76032097c1 100644 --- a/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts +++ b/apps/meteor/client/views/room/ImageGallery/hooks/useImagesList.ts @@ -1,45 +1,24 @@ import { Base64 } from '@rocket.chat/base64'; -import type { RoomsImagesProps } from '@rocket.chat/rest-typings'; +import type { IRoom } from '@rocket.chat/core-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback, useEffect, useState } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; -import { useScrollableRecordList } from '../../../../hooks/lists/useScrollableRecordList'; -import { useComponentDidUpdate } from '../../../../hooks/useComponentDidUpdate'; import { e2e } from '../../../../lib/e2ee/rocketchat.e2e'; -import { ImagesList } from '../../../../lib/lists/ImagesList'; +import { roomsQueryKeys } from '../../../../lib/queryKeys'; -export const useImagesList = ( - options: RoomsImagesProps, -): { - filesList: ImagesList; - initialItemCount: number; - reload: () => void; - loadMoreItems: (start: number) => void; -} => { - const [filesList, setFilesList] = useState(() => new ImagesList(options)); - const reload = useCallback(() => setFilesList(new ImagesList(options)), [options]); +export const useImagesList = ({ roomId, startingFromId }: { roomId: IRoom['_id']; startingFromId?: string }) => { + const getFiles = useEndpoint('GET', '/v1/rooms.images'); - useComponentDidUpdate(() => { - options && reload(); - }, [options, reload]); + const count = 5; - useEffect(() => { - if (filesList.options !== options) { - filesList.updateFilters(options); - } - }, [filesList, options]); - - const apiEndPoint = '/v1/rooms.images'; - - const getFiles = useEndpoint('GET', apiEndPoint); - - const fetchMessages = useCallback( - async (start: number, end: number) => { + return useInfiniteQuery({ + queryKey: roomsQueryKeys.images(roomId, { startingFromId }), + queryFn: async ({ pageParam: offset }) => { const { files, total } = await getFiles({ - roomId: options.roomId, - startingFromId: options.startingFromId, - offset: start, - count: end, + roomId, + startingFromId, + offset, + count, }); const items = files.map((file) => ({ @@ -71,15 +50,13 @@ export const useImagesList = ( itemCount: total, }; }, - [getFiles, options.roomId, options.startingFromId], - ); - - const { loadMoreItems, initialItemCount } = useScrollableRecordList(filesList, fetchMessages, 5); - - return { - reload, - filesList, - loadMoreItems, - initialItemCount, - }; + initialPageParam: 0, + getNextPageParam: (lastPage, _, lastOffset) => { + const nextOffset = lastOffset + count; + if (nextOffset >= lastPage.itemCount) return undefined; + return nextOffset; + }, + // Remove duplicates while preserving order + select: ({ pages }) => Array.from(new Map(pages.flatMap((page) => page.items.map((item) => [item._id, item]))).values()), + }); }; diff --git a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsListContextBar.tsx b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsListContextBar.tsx index c8af9f719ada1..76682bbe6b131 100644 --- a/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsListContextBar.tsx +++ b/apps/meteor/client/views/room/contextualBar/Discussions/DiscussionsListContextBar.tsx @@ -1,16 +1,13 @@ -import type { IDiscussionMessage } from '@rocket.chat/core-typings'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import { useUserId, useRoomToolbox } from '@rocket.chat/ui-contexts'; -import type { ChangeEvent, ReactElement } from 'react'; +import type { ChangeEvent } from 'react'; import { useCallback, useMemo, useState } from 'react'; import DiscussionsList from './DiscussionsList'; import { useDiscussionsList } from './useDiscussionsList'; -import { useRecordList } from '../../../../hooks/lists/useRecordList'; -import { AsyncStatePhase } from '../../../../hooks/useAsyncState'; import { useRoom } from '../../contexts/RoomContext'; -const DiscussionListContextBar = (): ReactElement | null => { +const DiscussionListContextBar = () => { const userId = useUserId(); const room = useRoom(); const { closeTab } = useRoomToolbox(); @@ -26,8 +23,10 @@ const DiscussionListContextBar = (): ReactElement | null => { [room._id, debouncedText], ); - const { discussionsList, loadMoreItems } = useDiscussionsList(options, userId); - const { phase, error, items: discussions, itemCount: totalItemCount } = useRecordList(discussionsList); + const { isPending, error, data, fetchNextPage } = useDiscussionsList(options); + + const discussions = data?.items || []; + const totalItemCount = data?.itemCount ?? 0; const handleTextChange = useCallback((e: ChangeEvent) => { setText(e.currentTarget.value); @@ -43,8 +42,8 @@ const DiscussionListContextBar = (): ReactElement | null => { error={error} discussions={discussions} total={totalItemCount} - loading={phase === AsyncStatePhase.LOADING} - loadMoreItems={loadMoreItems} + loading={isPending} + loadMoreItems={() => fetchNextPage()} text={text} onChangeFilter={handleTextChange} /> diff --git a/apps/meteor/client/views/room/contextualBar/Discussions/useDiscussionsList.ts b/apps/meteor/client/views/room/contextualBar/Discussions/useDiscussionsList.ts index ef86598558ca0..894f5a51d9361 100644 --- a/apps/meteor/client/views/room/contextualBar/Discussions/useDiscussionsList.ts +++ b/apps/meteor/client/views/room/contextualBar/Discussions/useDiscussionsList.ts @@ -1,56 +1,63 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IDiscussionMessage, IMessage } from '@rocket.chat/core-typings'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback, useMemo } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; -import { useScrollableMessageList } from '../../../../hooks/lists/useScrollableMessageList'; -import { useStreamUpdatesForMessageList } from '../../../../hooks/lists/useStreamUpdatesForMessageList'; -import type { DiscussionsListOptions } from '../../../../lib/lists/DiscussionsList'; -import { DiscussionsList } from '../../../../lib/lists/DiscussionsList'; +import { useInfiniteMessageQueryUpdates } from '../../../../hooks/useInfiniteMessageQueryUpdates'; +import { roomsQueryKeys } from '../../../../lib/queryKeys'; import { getConfig } from '../../../../lib/utils/getConfig'; +import { mapMessageFromApi } from '../../../../lib/utils/mapMessageFromApi'; -export const useDiscussionsList = ( - options: DiscussionsListOptions, - uid: IUser['_id'] | undefined, -): { - discussionsList: DiscussionsList; - initialItemCount: number; - loadMoreItems: (start: number, end: number) => void; -} => { - if (!uid) { - throw new Error('User ID is undefined. Cannot load discussions list'); - } - - const discussionsList = useMemo(() => new DiscussionsList(options), [options]); - +export const useDiscussionsList = ({ rid, text }: { rid: IMessage['rid']; text?: string }) => { const getDiscussions = useEndpoint('GET', '/v1/chat.getDiscussions'); - const fetchMessages = useCallback( - async (start: number, end: number) => { + const count = parseInt(`${getConfig('discussionListSize', 10)}`, 10); + + useInfiniteMessageQueryUpdates({ + queryKey: roomsQueryKeys.discussions(rid, { text }), + roomId: rid, + // Replicates the filtering done server-side + filter: (message): message is IDiscussionMessage => { + if (!('drid' in message)) { + return false; + } + + if (text) { + const regex = new RegExp(escapeRegExp(text), 'i'); + if (!regex.test(message.msg)) { + return false; + } + } + + return true; + }, + }); + + return useInfiniteQuery({ + queryKey: roomsQueryKeys.discussions(rid, { text }), + queryFn: async ({ pageParam: offset }) => { const { messages, total } = await getDiscussions({ - roomId: options.rid, - text: options.text, - offset: start, - count: end, + roomId: rid, + text, + offset, + count, }); return { - items: messages, + items: messages.map(mapMessageFromApi) as IDiscussionMessage[], itemCount: total, }; }, - [getDiscussions, options.rid, options.text], - ); - - const { loadMoreItems, initialItemCount } = useScrollableMessageList( - discussionsList, - fetchMessages, - useMemo(() => parseInt(`${getConfig('discussionListSize', 10)}`), []), - ); - useStreamUpdatesForMessageList(discussionsList, uid, options.rid); - - return { - discussionsList, - loadMoreItems, - initialItemCount, - }; + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + // FIXME: This is an estimation, as discussions can be created or removed while paginating + // Ideally, the server should return the next offset to use or the pagination should be done using "createdAt" or "updatedAt" + const loadedItemsCount = allPages.reduce((acc, page) => acc + page.items.length, 0); + return loadedItemsCount < lastPage.itemCount ? loadedItemsCount : undefined; + }, + select: ({ pages }) => ({ + items: pages.flatMap((page) => page.items), + itemCount: pages.at(-1)?.itemCount ?? 0, + }), + }); }; diff --git a/apps/meteor/client/views/room/contextualBar/RoomFiles/RoomFiles.tsx b/apps/meteor/client/views/room/contextualBar/RoomFiles/RoomFiles.tsx index 851ced24d8664..77d60c0b0a2e4 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomFiles/RoomFiles.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomFiles/RoomFiles.tsx @@ -25,7 +25,7 @@ type RoomFilesProps = { type: string; text: string; filesItems: IUploadWithUser[]; - loadMoreItems: (start: number, end: number) => void; + loadMoreItems: () => void; setType: (value: any) => void; setText: (e: ChangeEvent) => void; total: number; @@ -94,7 +94,7 @@ const RoomFiles = ({ width: '100%', }} totalCount={total} - endReached={(start) => loadMoreItems(start, Math.min(50, total - start))} + endReached={loadMoreItems} overscan={100} data={filesItems} itemContent={(_, data) => } diff --git a/apps/meteor/client/views/room/contextualBar/RoomFiles/RoomFilesWithData.tsx b/apps/meteor/client/views/room/contextualBar/RoomFiles/RoomFilesWithData.tsx index 80c864b431ffd..e93023ca6ca0d 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomFiles/RoomFilesWithData.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomFiles/RoomFilesWithData.tsx @@ -1,13 +1,11 @@ import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; import { useRoomToolbox } from '@rocket.chat/ui-contexts'; import type { ChangeEvent } from 'react'; -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback } from 'react'; import RoomFiles from './RoomFiles'; import { useDeleteFile } from './hooks/useDeleteFile'; import { useFilesList } from './hooks/useFilesList'; -import { useRecordList } from '../../../../hooks/lists/useRecordList'; -import { AsyncStatePhase } from '../../../../hooks/useAsyncState'; import { useRoom } from '../../contexts/RoomContext'; const RoomFilesWithData = () => { @@ -20,30 +18,24 @@ const RoomFilesWithData = () => { setText(event.currentTarget.value); }, []); - const query = useMemo( - () => ({ - rid: room._id, - type, - text, - }), - [room._id, type, text], - ); - - const { filesList, loadMoreItems, reload } = useFilesList(query); - const { phase, items: filesItems, itemCount: totalItemCount } = useRecordList(filesList); + const { isPending, data, fetchNextPage, refetch } = useFilesList({ + rid: room._id, + type, + text, + }); - const handleDeleteFile = useDeleteFile(reload); + const handleDeleteFile = useDeleteFile(refetch); return ( diff --git a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts index cd6e763064b5a..aa403f02fd36b 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts +++ b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useFilesList.ts @@ -1,35 +1,14 @@ import { Base64 } from '@rocket.chat/base64'; +import type { IUpload } from '@rocket.chat/core-typings'; import { useUserRoom, useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; -import { useScrollableRecordList } from '../../../../../hooks/lists/useScrollableRecordList'; -import { useComponentDidUpdate } from '../../../../../hooks/useComponentDidUpdate'; import { e2e } from '../../../../../lib/e2ee/rocketchat.e2e'; -import type { FilesListOptions } from '../../../../../lib/lists/FilesList'; -import { FilesList } from '../../../../../lib/lists/FilesList'; +import { roomsQueryKeys } from '../../../../../lib/queryKeys'; import { getConfig } from '../../../../../lib/utils/getConfig'; -export const useFilesList = ( - options: FilesListOptions, -): { - filesList: FilesList; - initialItemCount: number; - reload: () => void; - loadMoreItems: (start: number, end: number) => void; -} => { - const [filesList, setFilesList] = useState(() => new FilesList(options)); - const reload = useCallback(() => setFilesList(new FilesList(options)), [options]); - const room = useUserRoom(options.rid); - - useComponentDidUpdate(() => { - options && reload(); - }, [options, reload]); - - useEffect(() => { - if (filesList.options !== options) { - filesList.updateFilters(options); - } - }, [filesList, options]); +export const useFilesList = ({ rid, type, text }: { rid: Required['rid']; type: string; text: string }) => { + const room = useUserRoom(rid); const roomTypes = { c: '/v1/channels.files', @@ -39,20 +18,21 @@ export const useFilesList = ( p: '/v1/groups.files', } as const; - const apiEndPoint = room ? roomTypes[room.t] : '/v1/channels.files'; + const getFiles = useEndpoint('GET', roomTypes[room?.t ?? 'c']); - const getFiles = useEndpoint('GET', apiEndPoint); + const count = parseInt(`${getConfig('discussionListSize', 10)}`, 10); - const fetchMessages = useCallback( - async (start: number, end: number) => { + return useInfiniteQuery({ + queryKey: roomsQueryKeys.files(rid, { type, text }), + queryFn: async ({ pageParam: offset }) => { const { files, total } = await getFiles({ - roomId: options.rid, - offset: start, - count: end, + roomId: rid, + offset, + count, sort: JSON.stringify({ uploadedAt: -1 }), - ...(options.text ? { name: options.text } : {}), - ...(options.type !== 'all' && { - typeGroup: options.type, + ...(text ? { name: text } : {}), + ...(type !== 'all' && { + typeGroup: type, }), onlyConfirmed: true, }); @@ -86,19 +66,15 @@ export const useFilesList = ( itemCount: total, }; }, - [getFiles, options.rid, options.type, options.text], - ); - - const { loadMoreItems, initialItemCount } = useScrollableRecordList( - filesList, - fetchMessages, - useMemo(() => parseInt(`${getConfig('discussionListSize', 10)}`), []), - ); - - return { - reload, - filesList, - loadMoreItems, - initialItemCount, - }; + initialPageParam: 0, + getNextPageParam: (lastPage, _, lastOffset) => { + const nextOffset = lastOffset + count; + if (nextOffset >= lastPage.itemCount) return undefined; + return nextOffset; + }, + select: ({ pages }) => ({ + filesItems: pages.flatMap((page) => page.items), + total: pages.at(-1)?.itemCount, + }), + }); }; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx b/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx index 4f1b41d1ac48e..f0978a439ef6f 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/ThreadList.tsx @@ -13,16 +13,13 @@ import { ContextualbarDialog, } from '@rocket.chat/ui-client'; import { useTranslation, useUserId, useRoomToolbox } from '@rocket.chat/ui-contexts'; -import type { FormEvent, ReactElement } from 'react'; +import type { FormEvent } from 'react'; import { useMemo, useState, useCallback } from 'react'; import { Virtuoso } from 'react-virtuoso'; import ThreadListItem from './components/ThreadListItem'; import { useThreadsList } from './hooks/useThreadsList'; -import { useRecordList } from '../../../../hooks/lists/useRecordList'; -import { AsyncStatePhase } from '../../../../lib/asyncState'; import { getErrorMessage } from '../../../../lib/errorHandling'; -import type { ThreadsListOptions } from '../../../../lib/lists/ThreadsList'; import { useRoom, useRoomSubscription } from '../../contexts/RoomContext'; import { useGoToThread } from '../../hooks/useGoToThread'; @@ -78,7 +75,7 @@ const ThreadList = () => { const uid = useUserId(); const tunread = subscription?.tunread?.sort().join(','); const text = useDebouncedValue(searchText, 400); - const options: ThreadsListOptions = useDebouncedValue( + const options = useDebouncedValue( useMemo(() => { if (type === 'all' || !subscribed || !uid) { return { @@ -106,8 +103,10 @@ const ThreadList = () => { 300, ); - const { threadsList, loadMoreItems } = useThreadsList(options, uid); - const { phase, error, items, itemCount } = useRecordList(threadsList); + const { isPending, error, isSuccess, data, fetchNextPage } = useThreadsList(options); + + const items = data?.items || []; + const itemCount = data?.itemCount ?? 0; const goToThread = useGoToThread({ replace: true }); const handleThreadClick = useCallback( @@ -137,7 +136,7 @@ const ThreadList = () => { - {phase === AsyncStatePhase.LOADING && ( + {isPending && ( @@ -149,7 +148,7 @@ const ThreadList = () => { )} - {phase !== AsyncStatePhase.LOADING && itemCount === 0 && } + {isSuccess && itemCount === 0 && } {!error && itemCount > 0 && items.length > 0 && ( @@ -160,16 +159,10 @@ const ThreadList = () => { width: inlineSize, }} totalCount={itemCount} - endReached={ - phase === AsyncStatePhase.LOADING - ? (): void => undefined - : (start): void => { - loadMoreItems(start, Math.min(50, itemCount - start)); - } - } + endReached={() => fetchNextPage()} overscan={25} data={items} - itemContent={(_index, data: IThreadMainMessage): ReactElement => ( + itemContent={(_index, data: IThreadMainMessage) => ( void; -} => { - const threadsList = useMemo(() => new ThreadsList(options), [options]); +type ThreadsListOptions = + | { + rid: IMessage['rid']; + text?: string; + type: 'unread'; + tunread: ISubscription['tunread']; + } + | { + rid: IMessage['rid']; + text?: string; + type: 'following'; + tunread?: never; + } + | { + rid: IMessage['rid']; + text?: string; + type?: undefined; + tunread?: never; + }; +export const useThreadsList = ({ rid, text, type, tunread }: ThreadsListOptions) => { const getThreadsList = useEndpoint('GET', '/v1/chat.getThreadsList'); - const fetchMessages = useCallback( - async (start: number, end: number) => { + const count = parseInt(`${getConfig('threadsListSize', 10)}`, 10); + + const userId = useUserId(); + + useInfiniteMessageQueryUpdates({ + queryKey: roomsQueryKeys.threads(rid, { type, text }), + roomId: rid, + // Replicates the filtering done server-side + filter: (message): message is IThreadMainMessage => { + if (typeof message.tcount !== 'number') { + return false; + } + + if (type === 'following') { + if (!userId || !message.replies?.includes(userId)) { + return false; + } + } + + if (type === 'unread') { + if (!tunread?.includes(message._id)) { + return false; + } + } + + if (text) { + const regex = new RegExp(escapeRegExp(text), 'i'); + if (!regex.test(message.msg)) { + return false; + } + } + + return true; + }, + // Replicates the sorting done server-side + compare: (a, b) => (b.tlm ?? b.ts).getTime() - (a.tlm ?? a.ts).getTime(), + }); + + return useInfiniteQuery({ + queryKey: roomsQueryKeys.threads(rid, { type, text }), + queryFn: async ({ pageParam: offset }) => { const { threads, total } = await getThreadsList({ - rid: options.rid, - type: options.type, - text: options.text, - offset: start, - count: end, + rid, + type, + text, + offset, + count, }); return { - items: threads, + items: threads.map(mapMessageFromApi) as IThreadMainMessage[], itemCount: total, }; }, - [getThreadsList, options.rid, options.text, options.type], - ); - - const { loadMoreItems, initialItemCount } = useScrollableMessageList( - threadsList, - fetchMessages, - useMemo(() => parseInt(`${getConfig('threadsListSize', 10)}`), []), - ); - useStreamUpdatesForMessageList(threadsList, uid, options.rid); - - return { - threadsList, - loadMoreItems, - initialItemCount, - }; + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + // FIXME: This is an estimation, as threads can be created or removed while paginating + // Ideally, the server should return the next offset to use or the pagination should be done using "createdAt" or "updatedAt" + const loadedItemsCount = allPages.reduce((acc, page) => acc + page.items.length, 0); + return loadedItemsCount < lastPage.itemCount ? loadedItemsCount : undefined; + }, + select: ({ pages }) => ({ + items: pages.flatMap((page) => page.items), + itemCount: pages.at(-1)?.itemCount ?? 0, + }), + }); }; diff --git a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfList.tsx b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfList.tsx index 24bb0cfc7395c..cd1ff50875068 100644 --- a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfList.tsx +++ b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfList.tsx @@ -25,7 +25,7 @@ type VideoConfListProps = { loading: boolean; error?: Error; reload: () => void; - loadMoreItems: (min: number, max: number) => void; + loadMoreItems: () => void; }; const VideoConfList = ({ onClose, total, videoConfs, loading, error, reload, loadMoreItems }: VideoConfListProps): ReactElement => { @@ -76,13 +76,7 @@ const VideoConfList = ({ onClose, total, videoConfs, loading, error, reload, loa width: inlineSize, }} totalCount={total} - endReached={ - loading - ? (): void => undefined - : (start) => { - loadMoreItems(start, Math.min(50, total - start)); - } - } + endReached={loadMoreItems} overscan={25} data={videoConfs} itemContent={(_index, data): ReactElement => } diff --git a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListWithData.tsx b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListWithData.tsx index a5be773321593..c4ca506e13ba1 100644 --- a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListWithData.tsx +++ b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListWithData.tsx @@ -1,28 +1,23 @@ import { useRoomToolbox } from '@rocket.chat/ui-contexts'; -import { useMemo } from 'react'; import VideoConfList from './VideoConfList'; import { useVideoConfList } from './useVideoConfList'; -import { useRecordList } from '../../../../../hooks/lists/useRecordList'; -import { AsyncStatePhase } from '../../../../../hooks/useAsyncState'; import { useRoom } from '../../../contexts/RoomContext'; const VideoConfListWithData = () => { const room = useRoom(); const { closeTab } = useRoomToolbox(); - const options = useMemo(() => ({ roomId: room._id }), [room._id]); - const { reload, videoConfList, loadMoreItems } = useVideoConfList(options); - const { phase, error, items: videoConfs, itemCount: totalItemCount } = useRecordList(videoConfList); + const { isPending, data, error, refetch, fetchNextPage } = useVideoConfList({ roomId: room._id }); return ( ); }; diff --git a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfRecordList.ts b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfRecordList.ts deleted file mode 100644 index ae298216a5ffa..0000000000000 --- a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfRecordList.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { VideoConference } from '@rocket.chat/core-typings'; - -import { RecordList } from '../../../../../lib/lists/RecordList'; - -export class VideoConfRecordList extends RecordList { - protected override compare(a: VideoConference, b: VideoConference): number { - return b.createdAt.getTime() - a.createdAt.getTime(); - } -} diff --git a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts index 3ed6914ff97d0..0f499432d2c2b 100644 --- a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts +++ b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts @@ -1,52 +1,48 @@ -import type { IRoom } from '@rocket.chat/core-typings'; +import type { IRoom, VideoConference } from '@rocket.chat/core-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback, useState } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; -import { VideoConfRecordList } from './VideoConfRecordList'; -import { useScrollableRecordList } from '../../../../../hooks/lists/useScrollableRecordList'; -import { useComponentDidUpdate } from '../../../../../hooks/useComponentDidUpdate'; +import { videoConferenceQueryKeys } from '../../../../../lib/queryKeys'; -export const useVideoConfList = (options: { - roomId: IRoom['_id']; -}): { - videoConfList: VideoConfRecordList; - initialItemCount: number; - reload: () => void; - loadMoreItems: (start: number, end: number) => void; -} => { +export const useVideoConfList = ({ roomId }: { roomId: IRoom['_id'] }) => { const getVideoConfs = useEndpoint('GET', '/v1/video-conference.list'); - const [videoConfList, setVideoConfList] = useState(() => new VideoConfRecordList()); - const reload = useCallback(() => setVideoConfList(new VideoConfRecordList()), []); - useComponentDidUpdate(() => { - options && reload(); - }, [options, reload]); + const count = 25; - const fetchData = useCallback( - async (_start: number, _end: number) => { + return useInfiniteQuery({ + queryKey: videoConferenceQueryKeys.fromRoom(roomId), + queryFn: async ({ pageParam: offset }) => { const { data, total } = await getVideoConfs({ - roomId: options.roomId, + roomId, + offset, + count, }); return { - items: data.map((videoConf: any) => ({ - ...videoConf, - _updatedAt: new Date(videoConf._updatedAt), - createdAt: new Date(videoConf.createdAt), - endedAt: videoConf.endedAt ? new Date(videoConf.endedAt) : undefined, - })), + items: data.map( + (videoConf): VideoConference => ({ + ...videoConf, + _updatedAt: new Date(videoConf._updatedAt), + createdAt: new Date(videoConf.createdAt), + endedAt: videoConf.endedAt ? new Date(videoConf.endedAt) : undefined, + users: videoConf.users.map((user) => ({ + ...user, + ts: new Date(user.ts), + })), + }), + ), itemCount: total, }; }, - [getVideoConfs, options], - ); - - const { loadMoreItems, initialItemCount } = useScrollableRecordList(videoConfList, fetchData); - - return { - reload, - videoConfList, - loadMoreItems, - initialItemCount, - }; + initialPageParam: 0, + getNextPageParam: (lastPage, _, lastOffset) => { + const nextOffset = lastOffset + count; + if (nextOffset >= lastPage.itemCount) return undefined; + return nextOffset; + }, + select: ({ pages }) => ({ + videoConfs: pages.flatMap((page) => page.items), + total: pages.at(-1)?.itemCount, + }), + }); }; diff --git a/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx b/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx index b8130950dd3d4..7b153154cb951 100644 --- a/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx +++ b/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx @@ -28,13 +28,13 @@ type TeamsChannelsProps = { mainRoom: IRoom; text: string; type: 'all' | 'autoJoin'; - setType: Dispatch>; setText: (e: ChangeEvent) => void; + setType: Dispatch>; onClickClose: () => void; onClickAddExisting: false | ((e: SyntheticEvent) => void); onClickCreateNew: false | ((e: SyntheticEvent) => void); total: number; - loadMoreItems: (start: number, end: number) => void; + loadMoreItems: () => void; onClickView: (room: IRoom) => void; reload: () => void; }; @@ -66,7 +66,7 @@ const TeamsChannels = ({ [t], ); - const lm = useEffectEvent((start: number) => !loading && loadMoreItems(start, Math.min(50, total - start))); + const lm = useEffectEvent(() => !loading && loadMoreItems()); const loadMoreChannels = useDebouncedCallback( () => { @@ -74,7 +74,7 @@ const TeamsChannels = ({ return; } - lm(channels.length); + lm(); }, 300, [lm, channels], diff --git a/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannelsWithData.tsx b/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannelsWithData.tsx index 985912fa69a7b..65c9a32a34e5e 100644 --- a/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannelsWithData.tsx +++ b/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannelsWithData.tsx @@ -2,13 +2,11 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { useLocalStorage, useDebouncedValue, useEffectEvent } from '@rocket.chat/fuselage-hooks'; import { useSetModal, usePermission, useAtLeastOnePermission, useRoomToolbox } from '@rocket.chat/ui-contexts'; import type { ChangeEvent } from 'react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; import AddExistingModal from './AddExistingModal'; import TeamsChannels from './TeamsChannels'; import { useTeamsChannelList } from './hooks/useTeamsChannelList'; -import { useRecordList } from '../../../../hooks/lists/useRecordList'; -import { AsyncStatePhase } from '../../../../lib/asyncState'; import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; import CreateChannelModal from '../../../../navbar/NavBarPagesGroup/actions/CreateChannelModal'; import { useRoom } from '../../../room/contexts/RoomContext'; @@ -30,22 +28,18 @@ const TeamsChannelsWithData = () => { const [text, setText] = useState(''); const debouncedText = useDebouncedValue(text, 800); - const { teamsChannelList, loadMoreItems, reload } = useTeamsChannelList( - useMemo(() => ({ teamId, text: debouncedText, type }), [teamId, debouncedText, type]), - ); - - const { phase, items, itemCount: total } = useRecordList(teamsChannelList); + const { isPending, data, fetchNextPage, refetch } = useTeamsChannelList({ teamId, text: debouncedText, type }); const handleTextChange = useCallback((event: ChangeEvent) => { setText(event.currentTarget.value); }, []); const handleAddExisting = useEffectEvent(() => { - setModal( setModal(null)} reload={reload} />); + setModal( setModal(null)} reload={refetch} />); }); const handleCreateNew = useEffectEvent(() => { - setModal( setModal(null)} reload={reload} />); + setModal( setModal(null)} reload={refetch} />); }); const goToRoom = useEffectEvent((room: IRoom) => { @@ -54,20 +48,20 @@ const TeamsChannelsWithData = () => { return ( ); }; diff --git a/apps/meteor/client/views/teams/contextualBar/channels/hooks/useTeamsChannelList.ts b/apps/meteor/client/views/teams/contextualBar/channels/hooks/useTeamsChannelList.ts index e18c455dfa096..27fbf8e1047bb 100644 --- a/apps/meteor/client/views/teams/contextualBar/channels/hooks/useTeamsChannelList.ts +++ b/apps/meteor/client/views/teams/contextualBar/channels/hooks/useTeamsChannelList.ts @@ -1,10 +1,8 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useCallback, useMemo, useState } from 'react'; +import { useInfiniteQuery } from '@tanstack/react-query'; -import { useScrollableRecordList } from '../../../../../hooks/lists/useScrollableRecordList'; -import { useComponentDidUpdate } from '../../../../../hooks/useComponentDidUpdate'; -import { RecordList } from '../../../../../lib/lists/RecordList'; +import { teamsQueryKeys } from '../../../../../lib/queryKeys'; import { getConfig } from '../../../../../lib/utils/getConfig'; import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi'; @@ -14,58 +12,48 @@ type TeamsChannelListOptions = { text: string; }; -export const useTeamsChannelList = ( - options: TeamsChannelListOptions, -): { - teamsChannelList: RecordList; - initialItemCount: number; - reload: () => void; - loadMoreItems: (start: number, end: number) => void; -} => { - const apiEndPoint = useEndpoint('GET', '/v1/teams.listRooms'); - const [teamsChannelList, setTeamsChannelList] = useState(() => new RecordList()); - const reload = useCallback(() => setTeamsChannelList(new RecordList()), []); +export const useTeamsChannelList = ({ teamId, type, text }: TeamsChannelListOptions) => { + const listTeamRooms = useEndpoint('GET', '/v1/teams.listRooms'); - useComponentDidUpdate(() => { - options && reload(); - }, [options, reload]); + const count = parseInt(`${getConfig('teamsChannelListSize', 10)}`, 10); - const fetchData = useCallback( - async (start: number, end: number) => { - const { rooms, total } = await apiEndPoint({ - teamId: options.teamId, - offset: start, - count: end, - filter: options.text, - type: options.type, + return useInfiniteQuery({ + queryKey: teamsQueryKeys.listChannels(teamId, { type, text }), + queryFn: async ({ pageParam: offset }) => { + const { rooms, total } = await listTeamRooms({ + teamId, + offset, + count, + filter: text, + type, }); return { - items: rooms.map(({ _updatedAt, lastMessage, lm, ts, webRtcCallStartTime, usersWaitingForE2EKeys, ...room }) => ({ - ...(lm && { lm: new Date(lm) }), - ...(ts && { ts: new Date(ts) }), - _updatedAt: new Date(_updatedAt), - ...(lastMessage && { lastMessage: mapMessageFromApi(lastMessage) }), - ...(webRtcCallStartTime && { webRtcCallStartTime: new Date(webRtcCallStartTime) }), - ...usersWaitingForE2EKeys?.map(({ userId, ts }) => ({ userId, ts: new Date(ts) })), - ...room, - })), + items: rooms.map( + ({ _updatedAt, lastMessage, lm, ts, webRtcCallStartTime, usersWaitingForE2EKeys, ...room }): IRoom => ({ + ...(lm && { lm: new Date(lm) }), + ...(ts && { ts: new Date(ts) }), + _updatedAt: new Date(_updatedAt), + ...(lastMessage && { lastMessage: mapMessageFromApi(lastMessage) }), + ...(webRtcCallStartTime && { webRtcCallStartTime: new Date(webRtcCallStartTime) }), + ...(usersWaitingForE2EKeys && { + usersWaitingForE2EKeys: usersWaitingForE2EKeys?.map(({ userId, ts }) => ({ userId, ts: new Date(ts) })), + }), + ...room, + }), + ), itemCount: total, }; }, - [apiEndPoint, options], - ); - - const { loadMoreItems, initialItemCount } = useScrollableRecordList( - teamsChannelList, - fetchData, - useMemo(() => parseInt(`${getConfig('teamsChannelListSize', 10)}`), []), - ); - - return { - reload, - teamsChannelList, - loadMoreItems, - initialItemCount, - }; + initialPageParam: 0, + getNextPageParam: (lastPage, _, lastOffset) => { + const nextOffset = lastOffset + count; + if (nextOffset >= lastPage.itemCount) return undefined; + return nextOffset; + }, + select: ({ pages }) => ({ + channels: pages.flatMap((page) => page.items), + total: pages.at(-1)?.itemCount, + }), + }); }; diff --git a/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts b/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts index 50dddeb35862f..cd9c16671f857 100644 --- a/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts +++ b/apps/meteor/ee/server/apps/marketplace/fetchMarketplaceApps.ts @@ -2,9 +2,9 @@ import type { App } from '@rocket.chat/core-typings'; import { z } from 'zod'; import { getMarketplaceHeaders } from './getMarketplaceHeaders'; +import { MarketplaceAppsError, MarketplaceConnectionError, MarketplaceUnsupportedVersionError } from './marketplaceErrors'; import { getWorkspaceAccessToken } from '../../../../app/cloud/server'; import { Apps } from '../orchestrator'; -import { MarketplaceAppsError, MarketplaceConnectionError, MarketplaceUnsupportedVersionError } from './marketplaceErrors'; type FetchMarketplaceAppsParams = { endUserID?: string; diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts index 8ff7e4a0c5821..8d4a669762280 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts @@ -1,6 +1,7 @@ import { isIMessageInbox } from '@rocket.chat/core-typings'; -import type { IEmailInbox, IUser, IOmnichannelRoom, SlashCommandCallbackParams } from '@rocket.chat/core-typings'; +import type { IEmailInbox, IUser, IOmnichannelRoom, SlashCommandCallbackParams, IUpload } from '@rocket.chat/core-typings'; import { Messages, Uploads, LivechatRooms, Rooms, Users } from '@rocket.chat/models'; +import { isTruthy } from '@rocket.chat/tools'; import { Match } from 'meteor/check'; import type Mail from 'nodemailer/lib/mailer'; @@ -22,6 +23,18 @@ const getRocketCatUser = async (): Promise => Users.findOneById('r const language = settings.get('Language') || 'en'; const t = i18n.getFixedT(language); +async function buildMailAttachment(file: IUpload): Promise { + const buffer = await FileUpload.getBuffer(file); + if (!buffer) { + return; + } + return { + content: buffer, + contentType: file.type, + filename: file.name, + }; +} + // TODO: change these messages with room notifications const sendErrorReplyMessage = async (error: string, options: any) => { if (!options?.rid || !options?.msgId) { @@ -98,12 +111,16 @@ slashCommands.add({ } const message = await Messages.findOneById(params.trim()); - if (!message?.file) { + if (!message) { return; } - const room = await Rooms.findOneById(message.rid); + const fileRefs = (message.files || [message.file]).filter(isTruthy); + if (!fileRefs.length) { + return; + } + const room = await Rooms.findOneById(message.rid); if (!room?.email) { return; } @@ -118,37 +135,35 @@ slashCommands.add({ }); } - const file = await Uploads.findOneById(message.file._id); - - if (!file) { + const files = await Uploads.find({ _id: { $in: fileRefs.map((f) => f._id) } }).toArray(); + const emailAttachments = await Promise.all(files.map(buildMailAttachment)); + const validAttachments = emailAttachments.filter((a): a is Mail.Attachment => Boolean(a)); + if (validAttachments.length === 0) { return; } - const buffer = await FileUpload.getBuffer(file); - if (buffer) { - void sendEmail( - inbox, - { - to: room.email?.replyTo, - subject: room.email?.subject, - text: message?.attachments?.[0].description || '', - attachments: [ - { - content: buffer, - contentType: file.type, - filename: file.name, - }, - ], - inReplyTo: Array.isArray(room.email?.thread) ? room.email?.thread[0] : room.email?.thread, - references: ([] as string[]).concat(room.email?.thread || []), - }, - { - msgId: message._id, - sender: message.u.username, - rid: message.rid, - }, - ).then((info) => LivechatRooms.updateEmailThreadByRoomId(room._id, info.messageId)); - } + const emailText = + message?.attachments + ?.map((a) => a.description) + .filter(Boolean) + .join('\n\n') || ''; + + void sendEmail( + inbox, + { + to: room.email?.replyTo, + subject: room.email?.subject, + text: emailText, + attachments: validAttachments, + inReplyTo: Array.isArray(room.email?.thread) ? room.email?.thread[0] : room.email?.thread, + references: ([] as string[]).concat(room.email?.thread || []), + }, + { + msgId: message._id, + sender: message.u.username, + rid: message.rid, + }, + ).then((info) => LivechatRooms.updateEmailThreadByRoomId(room._id, info.messageId)); await Messages.updateOne( { _id: message._id }, diff --git a/apps/meteor/tests/mocks/client/marketplace.tsx b/apps/meteor/tests/mocks/client/marketplace.tsx index fd66f68aef883..52f1acae2c584 100644 --- a/apps/meteor/tests/mocks/client/marketplace.tsx +++ b/apps/meteor/tests/mocks/client/marketplace.tsx @@ -1,12 +1,10 @@ -import { faker } from '@faker-js/faker'; import { AppClientManager } from '@rocket.chat/apps-engine/client/AppClientManager'; import { AppsEngineUIHost } from '@rocket.chat/apps-engine/client/AppsEngineUIHost'; import type { IExternalComponentRoomInfo } from '@rocket.chat/apps-engine/client/definition'; import type { ReactNode } from 'react'; import { AppsContext, type IAppsOrchestrator } from '../../../client/contexts/AppsContext'; -import { AsyncStatePhase } from '../../../client/lib/asyncState'; -import { createFakeApp, createFakeExternalComponentRoomInfo, createFakeExternalComponentUserInfo } from '../data'; +import { createFakeExternalComponentRoomInfo, createFakeExternalComponentUserInfo } from '../data'; class MockedAppsEngineUIHost extends AppsEngineUIHost { public async getClientRoomInfo(): Promise { @@ -45,25 +43,5 @@ export const mockAppsOrchestrator = () => { }; export const mockedAppsContext = (children: ReactNode) => ( - Promise.resolve(), - orchestrator: mockAppsOrchestrator(), - privateAppsEnabled: false, - }} - > - {children} - + {children} ); diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 35e07ad893b3e..9ae2d019be0ea 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -4322,8 +4322,8 @@ "Regexp_validation": "Validation by regular expression", "Register": "Register", "RegisterWorkspace_Button": "Register workspace", - "RegisterWorkspace_Connection_Error": "An error occured connecting", - "RegisterWorkspace_Disconnect_Error": "An error occured disconnecting", + "RegisterWorkspace_Connection_Error": "An error occurred connecting", + "RegisterWorkspace_Disconnect_Error": "An error occurred disconnecting", "RegisterWorkspace_Disconnect_Subtitle": "Disconnecting your workspace will result in the loss of the following", "RegisterWorkspace_Features_Marketplace_Description": "Install Rocket.Chat Marketplace apps on this workspace.", "RegisterWorkspace_Features_Marketplace_Disconnect": "It will no longer be possible to install apps.", @@ -4354,7 +4354,7 @@ "RegisterWorkspace_Setup_Steps": "Step {{step}} of {{numberOfSteps}}", "RegisterWorkspace_Setup_Subtitle": "To register this workspace it needs to be associated it with a Rocket.Chat Cloud account.", "RegisterWorkspace_Syncing_Complete": "Sync Complete", - "RegisterWorkspace_Syncing_Error": "An error occured syncing your workspace", + "RegisterWorkspace_Syncing_Error": "An error occurred syncing your workspace", "RegisterWorkspace_Token_Step_Two": "Copy the token and paste it below.", "RegisterWorkspace_Token_Title": "Register workspace with token", "RegisterWorkspace_with_email": "Register workspace with email", diff --git a/packages/model-typings/src/models/IBaseUploadsModel.ts b/packages/model-typings/src/models/IBaseUploadsModel.ts index 332801dd50546..1161ab0fe86d9 100644 --- a/packages/model-typings/src/models/IBaseUploadsModel.ts +++ b/packages/model-typings/src/models/IBaseUploadsModel.ts @@ -10,6 +10,8 @@ export interface IBaseUploadsModel extends IBaseModel { confirmTemporaryFile(fileId: string, userId: string): Promise | undefined; + findByIds(_ids: string[], options?: FindOptions): FindCursor; + findOneByName(name: string, options?: { session?: ClientSession }): Promise; findOneByRoomId(rid: string): Promise; diff --git a/packages/models/src/models/BaseUploadModel.ts b/packages/models/src/models/BaseUploadModel.ts index 77614d162d36e..ba4165534c00d 100644 --- a/packages/models/src/models/BaseUploadModel.ts +++ b/packages/models/src/models/BaseUploadModel.ts @@ -92,6 +92,16 @@ export abstract class BaseUploadModelRaw extends BaseRaw implements IBaseUplo return this.updateOne(filter, update); } + findByIds(_ids: string[], options?: FindOptions): FindCursor { + const query = { + _id: { + $in: _ids, + }, + }; + + return this.find(query, options); + } + async findOneByName(name: string, options?: { session?: ClientSession }): Promise { return this.findOne({ name }, { session: options?.session }); }