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 });
}