From 4bfa63cf53f7e4a48e255341eca538d33d433b3a Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Fri, 15 Jan 2021 15:39:34 -0300 Subject: [PATCH] [IMPROVE] Message Collection Hooks (#20121) --- .eslintrc | 13 +- app/ui-utils/client/config.js | 1 + client/contexts/ServerContext.ts | 17 +- client/hooks/lists/useRecordList.ts | 65 +++ .../hooks/lists/useScrollableMessageList.ts | 30 ++ client/hooks/lists/useScrollableRecordList.ts | 28 ++ .../lists/useStreamUpdatesForMessageList.ts | 80 ++++ client/hooks/useAsyncState.ts | 149 ++----- client/lib/asyncState/AsyncState.ts | 10 + client/lib/asyncState/AsyncStatePhase.ts | 6 + client/lib/asyncState/functions.ts | 106 +++++ client/lib/asyncState/index.ts | 3 + client/lib/lists/DiscussionsList.ts | 55 +++ client/lib/lists/MessageList.ts | 12 + client/lib/lists/RecordList.ts | 169 ++++++++ client/lib/lists/ThreadsList.ts | 90 ++++ client/lib/minimongo/bson.spec.ts | 37 ++ client/lib/minimongo/bson.ts | 184 ++++++++ client/lib/minimongo/comparisons.ts | 84 ++++ client/lib/minimongo/index.ts | 6 + client/lib/minimongo/lookups.spec.ts | 13 + client/lib/minimongo/lookups.ts | 42 ++ client/lib/minimongo/query.ts | 275 ++++++++++++ client/lib/minimongo/sort.ts | 75 ++++ client/lib/minimongo/types.ts | 73 ++++ client/providers/ServerProvider.js | 12 +- client/views/room/Header/icons/Translate.js | 2 +- .../room/contextualBar/Discussions/index.js | 90 +--- .../Discussions/useDiscussionsList.ts | 63 +++ .../views/room/contextualBar/Threads/index.js | 397 ++++++++++-------- .../contextualBar/Threads/useThreadsList.ts | 64 +++ definition/IMessage.ts | 7 + definition/ObjectFromApi.ts | 3 + 33 files changed, 1889 insertions(+), 372 deletions(-) create mode 100644 client/hooks/lists/useRecordList.ts create mode 100644 client/hooks/lists/useScrollableMessageList.ts create mode 100644 client/hooks/lists/useScrollableRecordList.ts create mode 100644 client/hooks/lists/useStreamUpdatesForMessageList.ts create mode 100644 client/lib/asyncState/AsyncState.ts create mode 100644 client/lib/asyncState/AsyncStatePhase.ts create mode 100644 client/lib/asyncState/functions.ts create mode 100644 client/lib/asyncState/index.ts create mode 100644 client/lib/lists/DiscussionsList.ts create mode 100644 client/lib/lists/MessageList.ts create mode 100644 client/lib/lists/RecordList.ts create mode 100644 client/lib/lists/ThreadsList.ts create mode 100644 client/lib/minimongo/bson.spec.ts create mode 100644 client/lib/minimongo/bson.ts create mode 100644 client/lib/minimongo/comparisons.ts create mode 100644 client/lib/minimongo/index.ts create mode 100644 client/lib/minimongo/lookups.spec.ts create mode 100644 client/lib/minimongo/lookups.ts create mode 100644 client/lib/minimongo/query.ts create mode 100644 client/lib/minimongo/sort.ts create mode 100644 client/lib/minimongo/types.ts create mode 100644 client/views/room/contextualBar/Discussions/useDiscussionsList.ts create mode 100644 client/views/room/contextualBar/Threads/useThreadsList.ts create mode 100644 definition/ObjectFromApi.ts diff --git a/.eslintrc b/.eslintrc index d1d22fdb7fa..e486bf36405 100644 --- a/.eslintrc +++ b/.eslintrc @@ -83,7 +83,9 @@ "indent": "off", "no-extra-parens": "off", "no-spaced-func": "off", + "no-unused-vars": "off", "no-useless-constructor": "off", + "no-use-before-define": "off", "react/jsx-uses-react": "error", "react/jsx-uses-vars": "error", "react/jsx-no-undef": "error", @@ -99,6 +101,10 @@ "SwitchCase": 1 } ], + "@typescript-eslint/interface-name-prefix": [ + "error", + "always" + ], "@typescript-eslint/no-extra-parens": [ "error", "all", @@ -111,10 +117,9 @@ } ], "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/interface-name-prefix": [ - "error", - "always" - ] + "@typescript-eslint/no-unused-vars": ["error", { + "argsIgnorePattern": "^_" + }] }, "env": { "browser": true, diff --git a/app/ui-utils/client/config.js b/app/ui-utils/client/config.js index 996c37ebeab..ce484f86a7d 100644 --- a/app/ui-utils/client/config.js +++ b/app/ui-utils/client/config.js @@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor'; const url = new URL(window.location); const keys = new Set(); + export const getConfig = (key) => { keys.add(key); return url.searchParams.get(key) || Meteor._localStorage.getItem(`rc-config-${ key }`); diff --git a/client/contexts/ServerContext.ts b/client/contexts/ServerContext.ts index 3ef3ceb69bf..bb252b2ba37 100644 --- a/client/contexts/ServerContext.ts +++ b/client/contexts/ServerContext.ts @@ -1,17 +1,12 @@ import { createContext, useCallback, useContext, useMemo } from 'react'; -interface IServerStream { - on(eventName: string, callback: (data: any) => void): void; - off(eventName: string, callback: (data: any) => void): void; -} - type ServerContextValue = { info: {}; absoluteUrl: (path: string) => string; callMethod: (methodName: string, ...args: any[]) => Promise; callEndpoint: (httpMethod: 'GET' | 'POST' | 'DELETE', endpoint: string, ...args: any[]) => Promise; uploadToEndpoint: (endpoint: string, params: any, formData: any) => Promise; - getStream: (streamName: string, options?: {}) => IServerStream; + getStream: (streamName: string, options?: {}) => (eventName: string, callback: (data: T) => void) => () => void; }; export const ServerContext = createContext({ @@ -20,10 +15,7 @@ export const ServerContext = createContext({ callMethod: async () => undefined, callEndpoint: async () => undefined, uploadToEndpoint: async () => undefined, - getStream: () => ({ - on: (): void => undefined, - off: (): void => undefined, - }), + getStream: () => () => (): void => undefined, }); export const useServerInformation = (): {} => useContext(ServerContext).info; @@ -45,7 +37,10 @@ export const useUpload = (endpoint: string): (params: any, formData: any) => Pro return useCallback((params, formData: any) => uploadToEndpoint(endpoint, params, formData), [endpoint, uploadToEndpoint]); }; -export const useStream = (streamName: string, options?: {}): IServerStream => { +export const useStream = ( + streamName: string, + options?: {}, +): (eventName: string, callback: (data: T) => void) => (() => void) => { const { getStream } = useContext(ServerContext); return useMemo(() => getStream(streamName, options), [getStream, streamName, options]); }; diff --git a/client/hooks/lists/useRecordList.ts b/client/hooks/lists/useRecordList.ts new file mode 100644 index 00000000000..44be8269d88 --- /dev/null +++ b/client/hooks/lists/useRecordList.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; + +import { AsyncStatePhase } from '../../lib/asyncState'; +import { RecordList } from '../../lib/lists/RecordList'; +import { IRocketChatRecord } from '../../../definition/IRocketChatRecord'; + +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/client/hooks/lists/useScrollableMessageList.ts b/client/hooks/lists/useScrollableMessageList.ts new file mode 100644 index 00000000000..b7723814493 --- /dev/null +++ b/client/hooks/lists/useScrollableMessageList.ts @@ -0,0 +1,30 @@ +import { useCallback } from 'react'; + +import { IMessage } from '../../../definition/IMessage'; +import { MessageList } from '../../lib/lists/MessageList'; +import { ObjectFromApi } from '../../../definition/ObjectFromApi'; +import { useScrollableRecordList } from './useScrollableRecordList'; +import { RecordListBatchChanges } from '../../lib/lists/RecordList'; + +const convertMessageFromApi = (apiMessage: ObjectFromApi): IMessage => ({ + ...apiMessage, + _updatedAt: new Date(apiMessage._updatedAt), + ts: new Date(apiMessage.ts), + ...apiMessage.tlm && { tlm: new Date(apiMessage.tlm) }, +}); + +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(convertMessageFromApi) }, + ...batchChanges.itemCount && { itemCount: batchChanges.itemCount }, + }; + }, [fetchMessages]); + + return useScrollableRecordList(messageList, fetchItems, initialItemCount); +}; diff --git a/client/hooks/lists/useScrollableRecordList.ts b/client/hooks/lists/useScrollableRecordList.ts new file mode 100644 index 00000000000..56a12f60341 --- /dev/null +++ b/client/hooks/lists/useScrollableRecordList.ts @@ -0,0 +1,28 @@ +import { useCallback, useEffect } from 'react'; + +import { RecordList, RecordListBatchChanges } from '../../lib/lists/RecordList'; +import { IRocketChatRecord } from '../../../definition/IRocketChatRecord'; + +const INITIAL_ITEM_COUNT = 25; + +export const useScrollableRecordList = ( + recordList: RecordList, + fetchBatchChanges: (start: number, end: number) => Promise>, + initialItemCount: number = INITIAL_ITEM_COUNT, +): { + loadMoreItems: (start: number, end: number) => void; + initialItemCount: number; +} => { + const loadMoreItems = useCallback( + (start: number, end: number) => { + recordList.batchHandle(() => fetchBatchChanges(start, end)); + }, + [recordList, fetchBatchChanges], + ); + + useEffect(() => { + loadMoreItems(0, initialItemCount ?? INITIAL_ITEM_COUNT); + }, [loadMoreItems, initialItemCount]); + + return { loadMoreItems, initialItemCount }; +}; diff --git a/client/hooks/lists/useStreamUpdatesForMessageList.ts b/client/hooks/lists/useStreamUpdatesForMessageList.ts new file mode 100644 index 00000000000..f7709784e55 --- /dev/null +++ b/client/hooks/lists/useStreamUpdatesForMessageList.ts @@ -0,0 +1,80 @@ +import { useEffect } from 'react'; + +import { useStream } from '../../contexts/ServerContext'; +import { IMessage } from '../../../definition/IMessage'; +import { + createFilterFromQuery, + FieldExpression, + Query, +} from '../../lib/minimongo'; +import { MessageList } from '../../lib/lists/MessageList'; +import { IRoom } from '../../../definition/IRoom'; +import { IUser } from '../../../definition/IUser'; + +type RoomMessagesRidEvent = IMessage; + +type NotifyRoomRidDeleteMessageEvent = { _id: IMessage['_id'] }; + +type NotifyRoomRidDeleteMessageBulkEvent = { + rid: IMessage['rid']; + excludePinned: boolean; + ignoreDiscussion: boolean; + ts: FieldExpression; + users: string[]; +}; + +const createDeleteCriteria = ( + params: NotifyRoomRidDeleteMessageBulkEvent, +): ((message: IMessage) => boolean) => { + const query: Query = { ts: params.ts }; + + if (params.excludePinned) { + query.pinned = { $ne: true }; + } + + if (params.ignoreDiscussion) { + query.drid = { $exists: false }; + } + if (params.users && params.users.length) { + query['u.username'] = { $in: params.users }; + } + + return createFilterFromQuery(query); +}; + +export const useStreamUpdatesForMessageList = (messageList: MessageList, uid: IUser['_id'] | null, 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); + messageList.prune(matchDeleteCriteria); + }, + ); + + return (): void => { + unsubscribeFromRoomMessages(); + unsubscribeFromDeleteMessage(); + unsubscribeFromDeleteMessageBulk(); + }; + }, [subscribeToRoomMessages, subscribeToNotifyRoom, uid, rid, messageList]); +}; diff --git a/client/hooks/useAsyncState.ts b/client/hooks/useAsyncState.ts index 9f1333ab537..83e8a9dbda0 100644 --- a/client/hooks/useAsyncState.ts +++ b/client/hooks/useAsyncState.ts @@ -1,20 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSafely } from '@rocket.chat/fuselage-hooks'; +import { useCallback, useMemo, useState } from 'react'; -export const enum AsyncStatePhase { - LOADING = 'loading', - RESOLVED = 'resolved', - REJECTED = 'rejected', - UPDATING = 'updating' -} - -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 } -); +import { AsyncStatePhase, AsyncState, asyncState } from '../lib/asyncState'; type AsyncStateObject = AsyncState & { resolve: (value: T | ((prev: T | undefined) => T)) => void; @@ -24,123 +11,42 @@ type AsyncStateObject = AsyncState & { }; export const useAsyncState = (initialValue?: T | (() => T)): AsyncStateObject => { - const [state, setState] = useState>(() => { - if (typeof initialValue === 'undefined') { - return { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: undefined, - }; - } - - return { - phase: AsyncStatePhase.RESOLVED, - value: typeof initialValue === 'function' - ? (initialValue as () => T)() - : initialValue, - }; - }); - - const isMountedRef = useRef(true); - - useEffect(() => { - isMountedRef.current = true; + const [state, setState] = useSafely( + useState>(() => { + if (typeof initialValue === 'undefined') { + return asyncState.loading(); + } - return (): void => { - isMountedRef.current = false; - }; - }, []); + return asyncState.resolved( + typeof initialValue === 'function' + ? (initialValue as () => T)() + : initialValue, + ); + }), + ); const resolve = useCallback((value: T | ((prev: T | undefined) => T)) => { - if (!isMountedRef.current) { - return; - } - setState((state) => { - if (![AsyncStatePhase.LOADING, AsyncStatePhase.UPDATING].includes(state.phase)) { - return state; + if (typeof value === 'function') { + return asyncState.resolve(state, (value as (prev: T | undefined) => T)(state.value)); } - return { - phase: AsyncStatePhase.RESOLVED, - value: typeof value === 'function' ? (value as (prev: T | undefined) => T)(state.value) : value, - error: undefined, - }; + return asyncState.resolve(state, value); }); - }, []); + }, [setState]); const reject = useCallback((error: Error) => { - if (!isMountedRef.current) { - return; - } - - setState((state) => { - if (![AsyncStatePhase.LOADING, AsyncStatePhase.UPDATING].includes(state.phase)) { - return state; - } - - return { - phase: AsyncStatePhase.REJECTED, - value: undefined, - error, - }; - }); - }, []); + setState((state) => asyncState.reject(state, error)); + }, [setState]); const update = useCallback(() => { - if (!isMountedRef.current) { - return; - } - - setState((state) => { - switch (state.phase) { - case AsyncStatePhase.LOADING: - case AsyncStatePhase.UPDATING: - return state; - case AsyncStatePhase.RESOLVED: - return { - phase: AsyncStatePhase.UPDATING, - value: state.value, - error: state.error, - }; - - case AsyncStatePhase.REJECTED: - return { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: state.error, - }; - } - }); - }, []); + setState((state) => asyncState.update(state)); + }, [setState]); const reset = useCallback(() => { - if (!isMountedRef.current) { - return; - } - - setState((state) => { - switch (state.phase) { - case AsyncStatePhase.LOADING: - return state; - case AsyncStatePhase.UPDATING: - case AsyncStatePhase.RESOLVED: - return { - phase: AsyncStatePhase.LOADING, - value: state.value, - error: state.error, - }; - - case AsyncStatePhase.REJECTED: - return { - phase: AsyncStatePhase.LOADING, - value: undefined, - error: state.error, - }; - } - }); - }, []); + setState((state) => asyncState.reload(state)); + }, [setState]); return useMemo(() => ({ ...state, @@ -150,3 +56,8 @@ export const useAsyncState = (initialValue?: T | (() => T)): AsyncStateObject update, }), [state, resolve, reject, reset, update]); }; + +export { + AsyncStatePhase, + AsyncState, +}; diff --git a/client/lib/asyncState/AsyncState.ts b/client/lib/asyncState/AsyncState.ts new file mode 100644 index 00000000000..f02a99138c3 --- /dev/null +++ b/client/lib/asyncState/AsyncState.ts @@ -0,0 +1,10 @@ +import { 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/client/lib/asyncState/AsyncStatePhase.ts b/client/lib/asyncState/AsyncStatePhase.ts new file mode 100644 index 00000000000..6d599f8d675 --- /dev/null +++ b/client/lib/asyncState/AsyncStatePhase.ts @@ -0,0 +1,6 @@ +export const enum AsyncStatePhase { + LOADING = 'loading', + RESOLVED = 'resolved', + REJECTED = 'rejected', + UPDATING = 'updating' +} diff --git a/client/lib/asyncState/functions.ts b/client/lib/asyncState/functions.ts new file mode 100644 index 00000000000..f34d0fc5472 --- /dev/null +++ b/client/lib/asyncState/functions.ts @@ -0,0 +1,106 @@ +import { 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/client/lib/asyncState/index.ts b/client/lib/asyncState/index.ts new file mode 100644 index 00000000000..aaf5e6c37c2 --- /dev/null +++ b/client/lib/asyncState/index.ts @@ -0,0 +1,3 @@ +export { AsyncState } from './AsyncState'; +export { AsyncStatePhase } from './AsyncStatePhase'; +export * as asyncState from './functions'; diff --git a/client/lib/lists/DiscussionsList.ts b/client/lib/lists/DiscussionsList.ts new file mode 100644 index 00000000000..86d5184cd72 --- /dev/null +++ b/client/lib/lists/DiscussionsList.ts @@ -0,0 +1,55 @@ +import { MessageList } from './MessageList'; +import type { IMessage } from '../../../definition/IMessage'; +import { escapeRegExp } from '../../../lib/escapeRegExp'; + +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 filter(message: IMessage): boolean { + const { rid } = this._options; + + if (!isDiscussionMessageInRoom(message, rid)) { + return false; + } + + if (this._options.text) { + const regex = new RegExp( + this._options.text.split(/\s/g) + .map((text) => escapeRegExp(text)).join('|'), + ); + if (!isDiscussionTextMatching(message, regex)) { + return false; + } + } + + return true; + } + + protected compare(a: IMessage, b: IMessage): number { + return (b.tlm ?? b.ts).getTime() - (a.tlm ?? a.ts).getTime(); + } +} diff --git a/client/lib/lists/MessageList.ts b/client/lib/lists/MessageList.ts new file mode 100644 index 00000000000..b18aae4783c --- /dev/null +++ b/client/lib/lists/MessageList.ts @@ -0,0 +1,12 @@ +import type { IMessage } from '../../../definition/IMessage'; +import { RecordList } from './RecordList'; + +export class MessageList extends RecordList { + protected filter(message: IMessage): boolean { + return message._hidden !== true; + } + + protected compare(a: IMessage, b: IMessage): number { + return a.ts.getTime() - b.ts.getTime(); + } +} diff --git a/client/lib/lists/RecordList.ts b/client/lib/lists/RecordList.ts new file mode 100644 index 00000000000..42f297bedaf --- /dev/null +++ b/client/lib/lists/RecordList.ts @@ -0,0 +1,169 @@ +import { Emitter } from '@rocket.chat/emitter'; + +import type { IRocketChatRecord } from '../../../definition/IRocketChatRecord'; +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 { + return a._updatedAt.getTime() - b._updatedAt.getTime(); + } + + 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 (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/client/lib/lists/ThreadsList.ts b/client/lib/lists/ThreadsList.ts new file mode 100644 index 00000000000..efa0b23f448 --- /dev/null +++ b/client/lib/lists/ThreadsList.ts @@ -0,0 +1,90 @@ +import { MessageList } from './MessageList'; +import type { IMessage } from '../../../definition/IMessage'; +import { IUser } from '../../../definition/IUser'; +import { ISubscription } from '../../../definition/ISubscription'; +import { escapeRegExp } from '../../../lib/escapeRegExp'; + +type ThreadMessage = Omit & Required>; + +export type ThreadsListOptions = { + rid: IMessage['rid']; + text?: string; +} +& ( + { + type: 'unread'; + tunread: ISubscription['tunread']; + } + | { + type: 'following'; + uid: IUser['_id']; + } + | { + type: 'all'; + } +); + +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 => + 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 filter(message: IMessage): 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( + this._options.text.split(/\s/g) + .map((text) => escapeRegExp(text)).join('|'), + ); + if (!isThreadTextMatching(message, regex)) { + return false; + } + } + + return true; + } + + protected compare(a: IMessage, b: IMessage): number { + return (b.tlm ?? b.ts).getTime() - (a.tlm ?? a.ts).getTime(); + } +} diff --git a/client/lib/minimongo/bson.spec.ts b/client/lib/minimongo/bson.spec.ts new file mode 100644 index 00000000000..4d800bf9ad8 --- /dev/null +++ b/client/lib/minimongo/bson.spec.ts @@ -0,0 +1,37 @@ +import chai from 'chai'; +import { describe, it } from 'mocha'; + +import { BSONType } from './types'; +import { getBSONType, compareBSONValues } from './bson'; + +describe('getBSONType', () => { + it('should work', () => { + chai.expect(getBSONType(1)).to.be.equals(BSONType.Double); + chai.expect(getBSONType('xyz')).to.be.equals(BSONType.String); + chai.expect(getBSONType({})).to.be.equals(BSONType.Object); + chai.expect(getBSONType([])).to.be.equals(BSONType.Array); + chai.expect(getBSONType(new Uint8Array())).to.be.equals(BSONType.BinData); + chai.expect(getBSONType(undefined)).to.be.equals(BSONType.Object); + chai.expect(getBSONType(null)).to.be.equals(BSONType.Null); + chai.expect(getBSONType(false)).to.be.equals(BSONType.Boolean); + chai.expect(getBSONType(/.*/)).to.be.equals(BSONType.Regex); + chai.expect(getBSONType(() => true)).to.be.equals(BSONType.JavaScript); + chai.expect(getBSONType(new Date(0))).to.be.equals(BSONType.Date); + }); +}); + +describe('compareBSONValues', () => { + it('should work for the same types', () => { + chai.expect(compareBSONValues(2, 3)).to.be.equals(-1); + chai.expect(compareBSONValues('xyz', 'abc')).to.be.equals(1); + chai.expect(compareBSONValues({}, {})).to.be.equals(0); + chai.expect(compareBSONValues(true, false)).to.be.equals(1); + chai.expect(compareBSONValues(new Date(0), new Date(1))).to.be.equals(-1); + }); + + it('should work for different types', () => { + chai.expect(compareBSONValues(2, null)).to.be.equals(1); + chai.expect(compareBSONValues('xyz', {})).to.be.equals(-1); + chai.expect(compareBSONValues(false, 3)).to.be.equals(1); + }); +}); diff --git a/client/lib/minimongo/bson.ts b/client/lib/minimongo/bson.ts new file mode 100644 index 00000000000..40b0a882ab9 --- /dev/null +++ b/client/lib/minimongo/bson.ts @@ -0,0 +1,184 @@ +import { BSONType } from './types'; + +export const getBSONType = (v: T): BSONType => { + if (typeof v === 'number') { + return BSONType.Double; + } + + if (typeof v === 'string') { + return BSONType.String; + } + + if (typeof v === 'boolean') { + return BSONType.Boolean; + } + + if (Array.isArray(v)) { + return BSONType.Array; + } + + if (v === null) { + return BSONType.Null; + } + + if (v instanceof RegExp) { + return BSONType.Regex; + } + + if (typeof v === 'function') { + return BSONType.JavaScript; + } + + if (v instanceof Date) { + return BSONType.Date; + } + + if (v instanceof Uint8Array) { + return BSONType.BinData; + } + + return BSONType.Object; +}; + +const getBSONTypeOrder = (type: BSONType): number => { + switch (type) { + case BSONType.Null: + return 0; + + case BSONType.Double: + case BSONType.Int: + case BSONType.Long: + return 1; + + case BSONType.String: + case BSONType.Symbol: + return 2; + + case BSONType.Object: + return 3; + + case BSONType.Array: + return 4; + + case BSONType.BinData: + return 5; + + case BSONType.ObjectId: + return 6; + + case BSONType.Boolean: + return 7; + + case BSONType.Date: + case BSONType.Timestamp: + return 8; + + case BSONType.Regex: + return 9; + + case BSONType.JavaScript: + case BSONType.JavaScriptWithScope: + return 100; + + default: + return -1; + } +}; + +type ObjectID = { + toHexString(): string; + equals(otherID: ObjectID): boolean; +}; + +export const compareBSONValues = (a: unknown, b: unknown): number => { + if (a === undefined) { + return b === undefined ? 0 : -1; + } + + if (b === undefined) { + return 1; + } + + const ta = getBSONType(a); + const oa = getBSONTypeOrder(ta); + + const tb = getBSONType(b); + const ob = getBSONTypeOrder(tb); + + if (oa !== ob) { + return oa < ob ? -1 : 1; + } + + if (ta !== tb) { + throw Error('Missing type coercion logic in compareBSONValues'); + } + + switch (ta) { + case BSONType.Double: + return (a as number) - (b as number); + + case BSONType.String: + return (a as string).localeCompare(b as string); + + case BSONType.Object: + return compareBSONValues( + Array.prototype.concat.call([], ...Object.entries(a as Record)), + Array.prototype.concat.call([], ...Object.entries(b as Record)), + ); + + case BSONType.Array: { + for (let i = 0; ; i++) { + if (i === (a as unknown[]).length) { + return i === (b as unknown[]).length ? 0 : -1; + } + + if (i === (b as unknown[]).length) { + return 1; + } + + const s = compareBSONValues((a as unknown[])[i], (b as unknown[])[i]); + if (s !== 0) { + return s; + } + } + } + + case BSONType.BinData: { + if ((a as Uint8Array).length !== (b as Uint8Array).length) { + return (a as Uint8Array).length - (b as Uint8Array).length; + } + + for (let i = 0; i < (a as Uint8Array).length; i++) { + if ((a as Uint8Array)[i] === (b as Uint8Array)[i]) { + continue; + } + + return (a as Uint8Array)[i] < (b as Uint8Array)[i] ? -1 : 1; + } + + return 0; + } + + case BSONType.Null: + case BSONType.Undefined: + return 0; + + case BSONType.ObjectId: + return (a as ObjectID).toHexString().localeCompare((b as ObjectID).toHexString()); + + case BSONType.Boolean: + return Number(a) - Number(b); + + case BSONType.Date: + return (a as Date).getTime() - (b as Date).getTime(); + + case BSONType.Regex: + throw Error('Sorting not supported on regular expression'); + + case BSONType.JavaScript: + case BSONType.JavaScriptWithScope: + throw Error('Sorting not supported on Javascript code'); + } + + throw Error('Unknown type to sort'); +}; diff --git a/client/lib/minimongo/comparisons.ts b/client/lib/minimongo/comparisons.ts new file mode 100644 index 00000000000..7dcb9b57e7a --- /dev/null +++ b/client/lib/minimongo/comparisons.ts @@ -0,0 +1,84 @@ +export const equals = (a: T, b: T): boolean => { + if (a === b) { + return true; + } + + if (!a || !b) { + return false; + } + + if (typeof a !== 'object' || typeof b !== 'object') { + return false; + } + + if (a instanceof Date && b instanceof Date) { + return a.valueOf() === b.valueOf(); + } + + if (a instanceof Uint8Array && b instanceof Uint8Array) { + if (a.length !== b.length) { return false; } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; + } + + if (Array.isArray(a)) { + if (!Array.isArray(b)) { + return false; + } + + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (!equals(a[i], b[i])) { + return false; + } + } + return true; + } + + if (Object.keys(b).length !== Object.keys(a).length) { + return false; + } + + for (const key of Object.keys(a)) { + if (!(key in b)) { + return false; + } + + if (!equals((a as Record)[key], (b as Record)[key])) { + return false; + } + } + + return true; +}; + +export const isObject = (value: unknown): value is object => { + const type = typeof value; + return !!value && (type === 'object' || type === 'function'); +}; + +export const flatSome = (x: T[] | T, f: (x: T) => boolean): boolean => { + if (Array.isArray(x)) { + return x.some(f); + } + + return f(x); +}; + +export const some = (x: T | T[], f: (x: T | T[]) => boolean): boolean => { + if (f(x)) { + return true; + } + + return Array.isArray(x) && x.some(f); +}; + +export const isEmptyArray = (value: unknown): value is T[] & { length: 0 } => + Array.isArray(value) && value.length === 0; diff --git a/client/lib/minimongo/index.ts b/client/lib/minimongo/index.ts new file mode 100644 index 00000000000..426acef0b4f --- /dev/null +++ b/client/lib/minimongo/index.ts @@ -0,0 +1,6 @@ +import { compileDocumentSelector } from './query'; +import { compileSort } from './sort'; + +export const createFilterFromQuery = compileDocumentSelector; +export const createComparatorFromSort = compileSort; +export { FieldExpression, Query, Sort } from './types'; diff --git a/client/lib/minimongo/lookups.spec.ts b/client/lib/minimongo/lookups.spec.ts new file mode 100644 index 00000000000..736e0d3cd70 --- /dev/null +++ b/client/lib/minimongo/lookups.spec.ts @@ -0,0 +1,13 @@ +import chai from 'chai'; +import { describe, it } from 'mocha'; + +import { createLookupFunction } from './lookups'; + +describe('createLookupFunction', () => { + it('should work', () => { + chai.expect(createLookupFunction('a.x')({ a: { x: 1 } })).to.be.deep.equals([1]); + chai.expect(createLookupFunction('a.x')({ a: { x: [1] } })).to.be.deep.equals([[1]]); + chai.expect(createLookupFunction('a.x')({ a: 5 })).to.be.deep.equals([undefined]); + chai.expect(createLookupFunction('a.x')({ a: [{ x: 1 }, { x: [2] }, { y: 3 }] })).to.be.deep.equals([1, [2], undefined]); + }); +}); diff --git a/client/lib/minimongo/lookups.ts b/client/lib/minimongo/lookups.ts new file mode 100644 index 00000000000..f5fc99837c8 --- /dev/null +++ b/client/lib/minimongo/lookups.ts @@ -0,0 +1,42 @@ +import { isEmptyArray } from './comparisons'; + +const isNullDocument = (doc: unknown): doc is undefined | null => + doc === undefined || doc === null; + +const isRecordDocument = (doc: unknown): doc is Record => + doc !== undefined && doc !== null && (typeof doc === 'object' || typeof doc === 'function'); + +const isIndexedByNumber = (value: unknown, isIndexedByNumber: boolean): value is T[] => + Array.isArray(value) || isIndexedByNumber; + +export const createLookupFunction = (key: string): ((doc: T) => unknown[]) => { + const [first, rest] = key.split(/\.(.+)/); + + if (!rest) { + return (doc: T): unknown[] => { + if (isNullDocument(doc) || !isRecordDocument(doc)) { + return [undefined]; + } + + return [doc[first]]; + }; + } + + const lookupRest = createLookupFunction(rest); + const nextIsNumeric = /^\d+(\.|$)/.test(rest); + + return (doc: T): unknown[] => { + if (isNullDocument(doc) || !isRecordDocument(doc)) { + return [undefined]; + } + + const firstLevel = doc[first]; + + if (isEmptyArray(firstLevel)) { + return [undefined]; + } + + const docs = isIndexedByNumber(firstLevel, nextIsNumeric) ? firstLevel : [firstLevel as T]; + return Array.prototype.concat.apply([], docs.map(lookupRest)); + }; +}; diff --git a/client/lib/minimongo/query.ts b/client/lib/minimongo/query.ts new file mode 100644 index 00000000000..046a0fa77a3 --- /dev/null +++ b/client/lib/minimongo/query.ts @@ -0,0 +1,275 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { equals, flatSome, isObject, some } from './comparisons'; +import { createLookupFunction } from './lookups'; +import { BSONType, FieldExpression, Query } from './types'; +import { compareBSONValues, getBSONType } from './bson'; + +const isArrayOfFields = (values: unknown[]): values is T[] => + values.every((value) => ['number', 'string', 'symbol'].includes(typeof value)); + +const $in = (operand: T[], _options: undefined): ((value: T) => boolean) => { + let index: Record | null = null; + if (isArrayOfFields(operand)) { + index = {} as Record; + for (const operandElement of operand) { + index[operandElement] = operandElement; + } + } + + return (value: T): boolean => some(value, (x) => { + if (typeof x === 'string' && index !== null) { + return !!index[x]; + } + + return operand.some((operandElement) => equals(operandElement, x)); + }); +}; + +const $nin = (operand: T[], _options: undefined): ((value: T) => boolean) => { + const isIn = $in(operand, undefined); + + return (value: T): boolean => { + if (value === undefined) { + return true; + } + + return !isIn(value); + }; +}; + +const $all = (operand: T[], _options: undefined): ((value: T) => boolean) => + (value: T): boolean => { + if (!Array.isArray(value)) { + return false; + } + + return operand.every((operandElement) => value.some((valueElement) => equals(operandElement, valueElement))); + }; + +const $lt = (operand: T, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => flatSome(value, (x) => compareBSONValues(x, operand) < 0); + +const $lte = (operand: T, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => flatSome(value, (x) => compareBSONValues(x, operand) <= 0); + +const $gt = (operand: T, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => flatSome(value, (x) => compareBSONValues(x, operand) > 0); + +const $gte = (operand: T, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => flatSome(value, (x) => compareBSONValues(x, operand) >= 0); + +const $ne = (operand: T, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => !some(value, (x) => equals(x, operand)); + +const $exists = (operand: boolean, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => operand === (value !== undefined); + +const $mod = ([divisor, remainder]: [number, number], _options: undefined): ((value: T) => boolean) => + (value: T): boolean => flatSome(value, (x) => Number(x) % divisor === remainder); + +const $size = (operand: number, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => Array.isArray(value) && operand === value.length; + +const $type = (operand: BSONType, _options: undefined): ((value: T) => boolean) => + (value: T): boolean => { + if (value === undefined) { + return false; + } + + return flatSome(value, (x) => getBSONType(x) === operand); + }; + +const $regex = (operand: string | RegExp, options: string): ((value: T) => boolean) => { + let regex: RegExp; + + if (options !== undefined) { + const regexSource = operand instanceof RegExp ? operand.source : operand; + regex = new RegExp(regexSource, options); + } else if (!(operand instanceof RegExp)) { + regex = new RegExp(operand); + } + + return (value: T): boolean => { + if (value === undefined) { + return false; + } + + return flatSome(value, (x) => regex.test(String(x))); + }; +}; + +const $elemMatch = (operand: Query, _options: undefined): ((value: T) => boolean) => { + const matcher = compileDocumentSelector(operand); + + return (value: T): boolean => { + if (!Array.isArray(value)) { + return false; + } + + return value.some((x) => matcher(x)); + }; +}; + +const $not = (operand: FieldExpression, _options: undefined): ((value: T) => boolean) => { + const matcher = compileValueSelector(operand); + return (value: T): boolean => !matcher(value); +}; + +const dummyOperator = (_operand: unknown, _options: undefined): ((value: T) => boolean) => + (_value: T): boolean => true; + +const $options = dummyOperator; +const $near = dummyOperator; +const $geoIntersects = dummyOperator; + +const valueOperators = { + $in, + $nin, + $all, + $lt, + $lte, + $gt, + $gte, + $ne, + $exists, + $mod, + $size, + $type, + $regex, + $elemMatch, + $not, + $options, + $near, + $geoIntersects, +} as const; + +const $and = (subSelector: Query[]): ((doc: T) => boolean) => { + const subSelectorFunctions = subSelector.map(compileDocumentSelector); + return (doc: T): boolean => subSelectorFunctions.every((f) => f(doc)); +}; + +const $or = (subSelector: Query[]): ((doc: T) => boolean) => { + const subSelectorFunctions = subSelector.map(compileDocumentSelector); + return (doc: T): boolean => subSelectorFunctions.some((f) => f(doc)); +}; + +const $nor = (subSelector: Query[]): ((doc: T) => boolean) => { + const subSelectorFunctions = subSelector.map(compileDocumentSelector); + return (doc: T): boolean => subSelectorFunctions.every((f) => !f(doc)); +}; + +const $where = (selectorValue: string | Function): ((doc: T) => boolean) => { + const fn = selectorValue instanceof Function ? selectorValue : Function(`return ${ selectorValue }`); + return (doc: T): boolean => !!fn.call(doc); +}; + +const logicalOperators = { + $and, + $or, + $nor, + $where, +} as const; + +const isValueOperator = (operator: string): operator is keyof typeof valueOperators => + operator in valueOperators; + +const isLogicalOperator = (operator: string): operator is keyof typeof logicalOperators => + operator in logicalOperators; + +const hasValueOperators = (valueSelector: FieldExpression): boolean => + Object.keys(valueSelector).every((key) => key.slice(0, 1) === '$'); + +const compileUndefinedOrNullSelector = (): ((value: T) => boolean) => + (value: T): boolean => flatSome(value, (x) => x === undefined || x === null); + +const compilePrimitiveSelector = (primitive: T) => + (value: T): boolean => flatSome(value, (x) => x === primitive); + +const compileRegexSelector = (regex: RegExp) => + (value: T): boolean => { + if (value === undefined) { + return false; + } + + return flatSome(value, (x) => regex.test(String(x))); + }; + +const compileArraySelector = (expected: T) => + (value: T): boolean => { + if (!Array.isArray(value)) { + return false; + } + + return some(value, (x) => equals(expected, x)); + }; + +const compileValueOperatorsSelector = (expression: FieldExpression): ((value: T) => boolean) => { + const operatorFunctions: ((value: T) => boolean)[] = []; + for (const operator of Object.keys(expression) as (keyof FieldExpression)[]) { + if (!isValueOperator(operator)) { + continue; + } + + const operand = expression[operator]; + const operation = valueOperators[operator] as unknown as ((operand: unknown, options: unknown) => (value: T) => boolean); + operatorFunctions.push(operation(operand, expression.$options)); + } + return (value: T): boolean => operatorFunctions.every((f) => f(value)); +}; + +const compileValueSelector = (valueSelector: FieldExpression[keyof FieldExpression]): ((value: T) => boolean) => { + if (valueSelector === undefined || valueSelector === null) { + return compileUndefinedOrNullSelector(); + } + + if (!isObject(valueSelector)) { + return compilePrimitiveSelector(valueSelector as T); + } + + if (valueSelector instanceof RegExp) { + return compileRegexSelector(valueSelector); + } + + if (Array.isArray(valueSelector)) { + return compileArraySelector(valueSelector as unknown as T); + } + + if (hasValueOperators(valueSelector)) { + return compileValueOperatorsSelector(valueSelector); + } + + return (value: T): boolean => flatSome(value, (x) => equals(valueSelector, x as unknown as object)); +}; + +export const compileDocumentSelector = (docSelector: Query | FieldExpression['$where'][]): ((doc: T) => boolean) => { + const perKeySelectors = Object.entries(docSelector).map(([key, subSelector]) => { + if (subSelector === undefined) { + return (): boolean => true; + } + + if (isLogicalOperator(key)) { + switch (key) { + case '$and': + return $and(subSelector); + + case '$or': + return $or(subSelector); + + case '$nor': + return $nor(subSelector); + + case '$where': + return $where(subSelector); + } + } + + const lookUpByIndex = createLookupFunction(key); + const valueSelectorFunc = compileValueSelector(subSelector); + return (doc: T): boolean => { + const branchValues = lookUpByIndex(doc); + return branchValues.some(valueSelectorFunc); + }; + }); + + return (doc: T): boolean => perKeySelectors.every((f) => f(doc)); +}; diff --git a/client/lib/minimongo/sort.ts b/client/lib/minimongo/sort.ts new file mode 100644 index 00000000000..4e4a4f6f5aa --- /dev/null +++ b/client/lib/minimongo/sort.ts @@ -0,0 +1,75 @@ +import { compareBSONValues } from './bson'; +import { isEmptyArray } from './comparisons'; +import { createLookupFunction } from './lookups'; +import { Sort } from './types'; + +const createSortSpecParts = (spec: Sort): { + lookup: (doc: T) => unknown[]; + ascending: boolean; +}[] => { + if (Array.isArray(spec)) { + return spec.map((value) => { + if (typeof value === 'string') { + return { + lookup: createLookupFunction(value), + ascending: true, + }; + } + + return { + lookup: createLookupFunction(value[0]), + ascending: value[1] !== 'desc', + }; + }); + } + + return Object.entries(spec).map(([key, value]) => ({ + lookup: createLookupFunction(key), + ascending: value >= 0, + })); +}; + +const reduceValue = (branchValues: unknown[], ascending: boolean): unknown => + ([] as unknown[]).concat( + ...branchValues.map((branchValue) => { + if (!Array.isArray(branchValue)) { + return [branchValue]; + } + + if (isEmptyArray(branchValue)) { + return [undefined]; + } + + return branchValue; + }), + ).reduce((reduced, value) => { + const cmp = compareBSONValues(reduced, value); + if ((ascending && cmp > 0) || (!ascending && cmp < 0)) { + return value; + } + + return reduced; + }); + +export const compileSort = (spec: Sort): ((a: unknown, b: unknown) => number) => { + const sortSpecParts = createSortSpecParts(spec); + + if (sortSpecParts.length === 0) { + return (): number => 0; + } + + return (a: unknown, b: unknown): number => { + for (let i = 0; i < sortSpecParts.length; ++i) { + const specPart = sortSpecParts[i]; + const aValue = reduceValue(specPart.lookup(a), specPart.ascending); + const bValue = reduceValue(specPart.lookup(b), specPart.ascending); + const compare = compareBSONValues(aValue, bValue); + + if (compare !== 0) { + return specPart.ascending ? compare : -compare; + } + } + + return 0; + }; +}; diff --git a/client/lib/minimongo/types.ts b/client/lib/minimongo/types.ts new file mode 100644 index 00000000000..02b318edb58 --- /dev/null +++ b/client/lib/minimongo/types.ts @@ -0,0 +1,73 @@ +export const enum BSONType { + Double = 1, + String, + Object, + Array, + BinData, + /** @deprecated */ + Undefined, + ObjectId, + Boolean, + Date, + Null, + Regex, + /** @deprecated */ + DBPointer, + JavaScript, + /** @deprecated */ + Symbol, + JavaScriptWithScope, + Int, + Timestamp, + Long, + Decimal, + MinKey = -1, + MaxKey = 127, +} + +export type FieldExpression = { + $eq?: T; + $gt?: T; + $gte?: T; + $lt?: T; + $lte?: T; + $in?: T[]; + $nin?: T[]; + $ne?: T; + $exists?: boolean; + $type?: BSONType[] | BSONType; + $not?: FieldExpression; + $expr?: FieldExpression; + $jsonSchema?: unknown; + $mod?: number[]; + $regex?: RegExp | string; + $options?: string; + $text?: { $search: string; $language?: string; $caseSensitive?: boolean; $diacriticSensitive?: boolean }; + $where?: string | Function; + $geoIntersects?: unknown; + $geoWithin?: unknown; + $near?: unknown; + $nearSphere?: unknown; + $all?: T[]; + $elemMatch?: T extends {} ? Query : FieldExpression; + $size?: number; + $bitsAllClear?: unknown; + $bitsAllSet?: unknown; + $bitsAnyClear?: unknown; + $bitsAnySet?: unknown; + $comment?: string; +}; + +export type Flatten = T extends unknown[] ? T[0] : T; + +export type Query = { + [P in keyof T]?: Flatten | RegExp | FieldExpression> +} & { + $or?: Query[]; + $and?: Query[]; + $nor?: Query[]; +} & Record>; + +export type Sort = (string | [string, 'asc' | 'desc'])[] | { + [key: string]: -1 | 1; +}; diff --git a/client/providers/ServerProvider.js b/client/providers/ServerProvider.js index c378261972e..64e2dcfc06d 100644 --- a/client/providers/ServerProvider.js +++ b/client/providers/ServerProvider.js @@ -42,7 +42,17 @@ const uploadToEndpoint = (endpoint, params, formData) => { return APIClient.v1.upload(endpoint, params, formData).promise; }; -const getStream = (streamName, options = {}) => new Meteor.Streamer(streamName, options); +const getStream = (streamName, options = {}) => { + const streamer = Meteor.StreamerCentral.instances[streamName] + ? Meteor.StreamerCentral.instances[streamName] + : new Meteor.Streamer(streamName, options); + return (eventName, callback) => { + streamer.on(eventName, callback); + return () => { + streamer.removeListener(eventName, callback); + }; + }; +}; const contextValue = { info, diff --git a/client/views/room/Header/icons/Translate.js b/client/views/room/Header/icons/Translate.js index afc6c2d1f91..f5b24f43fff 100644 --- a/client/views/room/Header/icons/Translate.js +++ b/client/views/room/Header/icons/Translate.js @@ -10,7 +10,7 @@ const Translate = ({ room: { autoTranslateLanguage, autoTranslate } }) => { const t = useTranslation(); const autoTranslateEnabled = useSetting('AutoTranslate_Enabled'); const encryptedLabel = t('Translated'); - return autoTranslateEnabled && autoTranslate && autoTranslateLanguage ? : null; + return autoTranslateEnabled && autoTranslate && autoTranslateLanguage ? : null; }; export default memo(Translate); diff --git a/client/views/room/contextualBar/Discussions/index.js b/client/views/room/contextualBar/Discussions/index.js index a2cfaa20c52..50526ab2df9 100644 --- a/client/views/room/contextualBar/Discussions/index.js +++ b/client/views/room/contextualBar/Discussions/index.js @@ -1,14 +1,10 @@ -import { Mongo } from 'meteor/mongo'; -import { Tracker } from 'meteor/tracker'; import { FlowRouter } from 'meteor/kadira:flow-router'; -import React, { useCallback, useMemo, useState, useEffect, useRef, memo } from 'react'; +import React, { useCallback, useMemo, useState, useRef, memo } from 'react'; import { Box, Icon, TextInput, Callout } from '@rocket.chat/fuselage'; import { FixedSizeList as List } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; -import { useDebouncedValue, useDebouncedState, useResizeObserver } from '@rocket.chat/fuselage-hooks'; +import { useDebouncedValue, useResizeObserver } from '@rocket.chat/fuselage-hooks'; -import { getConfig } from '../../../../../app/ui-utils/client/config'; -import { Messages } from '../../../../../app/models/client'; import VerticalBar from '../../../../components/VerticalBar'; import { useTranslation } from '../../../../contexts/TranslationContext'; import { useUserId, useUserSubscription } from '../../../../contexts/UserContext'; @@ -19,11 +15,12 @@ import { useSetting } from '../../../../contexts/SettingsContext'; import DiscussionListMessage from './components/Message'; import { clickableItem } from '../../../../lib/clickableItem'; import { escapeHTML } from '../../../../../lib/escapeHTML'; -import { useEndpointData } from '../../../../hooks/useEndpointData'; import { AsyncStatePhase } from '../../../../hooks/useAsyncState'; import ScrollableContentWrapper from '../../../../components/ScrollableContentWrapper'; import { useTabBarClose } from '../../providers/ToolboxProvider'; import { renderMessageBody } from '../../../../lib/renderMessageBody'; +import { useDiscussionsList } from './useDiscussionsList'; +import { useRecordList } from '../../../../hooks/lists/useRecordList'; function mapProps(WrappedComponent) { return ({ msg, username, tcount, ts, ...props }) => ; @@ -33,10 +30,6 @@ const Discussion = React.memo(mapProps(clickableItem(DiscussionListMessage))); const Skeleton = React.memo(clickableItem(MessageSkeleton)); -const LIST_SIZE = parseInt(getConfig('discussionListSize')) || 25; - -const filterProps = ({ msg, drid, u, dcount, mentions, tcount, ts, _id, dlm, attachments, name }) => ({ ..._id && { _id }, drid, attachments, name, mentions, msg, u, dcount, tcount, ts: new Date(ts), dlm: new Date(dlm) }); - const subscriptionFields = { tunread: 1, tunreadUser: 1, tunreadGroup: 1 }; const roomFields = { t: 1, name: 1 }; @@ -48,65 +41,21 @@ export function withData(WrappedComponent) { const onClose = useTabBarClose(); const [text, setText] = useState(''); - const [total, setTotal] = useState(LIST_SIZE); - const [discussions, setDiscussions] = useDebouncedState([], 100); - const Discussions = useRef(new Mongo.Collection(null)); - const ref = useRef(); - const [pagination, setPagination] = useState({ skip: 0, count: LIST_SIZE }); - - const params = useMemo(() => ({ roomId: room._id, count: pagination.count, offset: pagination.skip, text }), [room._id, pagination.skip, pagination.count, text]); + const debouncedText = useDebouncedValue(text, 400); - const { value: data, phase: state, error } = useEndpointData('chat.getDiscussions', useDebouncedValue(params, 400)); - - const loadMoreItems = useCallback((skip, count) => { - setPagination({ skip, count: count - skip }); - - return new Promise((resolve) => { ref.current = resolve; }); - }, []); + const options = useMemo(() => ({ + rid, + text: debouncedText, + }), [rid, debouncedText]); - useEffect(() => () => Discussions.current.remove({}, () => {}), [text]); - - useEffect(() => { - if (state !== AsyncStatePhase.RESOLVED || !data || !data.messages) { - return; - } - - data.messages.forEach(({ _id, ...message }) => { - Discussions.current.upsert({ _id }, filterProps(message)); - }); - - setTotal(data.total); - ref.current && ref.current(); - }, [data, state]); - - useEffect(() => { - const cursor = Messages.find({ rid: room._id, drid: { $exists: true } }).observe({ - added: ({ _id, ...message }) => { - Discussions.current.upsert({ _id }, message); - }, // Update message to re-render DOM - changed: ({ _id, ...message }) => { - Discussions.current.update({ _id }, message); - }, // Update message to re-render DOM - removed: ({ _id }) => { - Discussions.current.remove(_id); - }, - }); - return () => cursor.stop(); - }, [room._id]); - - - useEffect(() => { - const cursor = Tracker.autorun(() => { - const query = { - }; - setDiscussions(Discussions.current.find(query, { sort: { tlm: -1 } }).fetch().map(filterProps)); - }); - - return () => cursor.stop(); - }, [room._id, setDiscussions, userId]); + const { + discussionsList, + initialItemCount, + loadMoreItems, + } = useDiscussionsList(options, userId); + const { phase, error, items: discussions, itemCount: totalItemCount } = useRecordList(discussionsList); const handleTextChange = useCallback((e) => { - setPagination({ skip: 0, count: LIST_SIZE }); setText(e.currentTarget.value); }, []); @@ -119,8 +68,9 @@ export function withData(WrappedComponent) { userId={userId} error={error} discussions={discussions} - total={total} - loading={state === AsyncStatePhase.LOADING} + total={totalItemCount} + initial={initialItemCount} + loading={phase === AsyncStatePhase.LOADING} loadMoreItems={loadMoreItems} room={room} text={text} @@ -180,7 +130,7 @@ const Row = memo(function Row({ />; }); -export function DiscussionList({ total = 10, discussions = [], loadMoreItems, loading, onClose, error, userId, text, setText }) { +export function DiscussionList({ total = 10, initial = 10, discussions = [], loadMoreItems, loading, onClose, error, userId, text, setText }) { const showRealNames = useSetting('UI_Use_Real_Name'); const discussionsRef = useRef(); @@ -231,7 +181,7 @@ export function DiscussionList({ total = 10, discussions = [], loadMoreItems, lo itemData={discussions} itemSize={124} ref={ref} - minimumBatchSize={LIST_SIZE} + minimumBatchSize={initial} onItemsRendered={onItemsRendered} >{rowRenderer} )} diff --git a/client/views/room/contextualBar/Discussions/useDiscussionsList.ts b/client/views/room/contextualBar/Discussions/useDiscussionsList.ts new file mode 100644 index 00000000000..93993c79deb --- /dev/null +++ b/client/views/room/contextualBar/Discussions/useDiscussionsList.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + DiscussionsList, + DiscussionsListOptions, +} from '../../../../lib/lists/DiscussionsList'; +import { useEndpoint } from '../../../../contexts/ServerContext'; +import { useScrollableMessageList } from '../../../../hooks/lists/useScrollableMessageList'; +import { useStreamUpdatesForMessageList } from '../../../../hooks/lists/useStreamUpdatesForMessageList'; +import { IUser } from '../../../../../definition/IUser'; +import { getConfig } from '../../../../../app/ui-utils/client/config'; + +export const useDiscussionsList = ( + options: DiscussionsListOptions, + uid: IUser['_id'], +): { + discussionsList: DiscussionsList; + initialItemCount: number; + loadMoreItems: (start: number, end: number) => void; + } => { + const [discussionsList] = useState(() => new DiscussionsList(options)); + + useEffect(() => { + if (discussionsList.options !== options) { + discussionsList.updateFilters(options); + } + }, [discussionsList, options]); + + const getDiscussions = useEndpoint('GET', 'chat.getDiscussions'); + + const fetchMessages = useCallback( + async (start, end) => { + const { messages, total } = await getDiscussions({ + roomId: options.rid, + text: options.text, + offset: start, + count: end - start, + }); + + return { + items: messages, + itemCount: total, + }; + }, + [getDiscussions, options.rid, options.text], + ); + + const { loadMoreItems, initialItemCount } = useScrollableMessageList( + discussionsList, + fetchMessages, + useMemo(() => { + const discussionListSize = getConfig('discussionListSize'); + return discussionListSize ? parseInt(discussionListSize, 10) : undefined; + }, []), + ); + useStreamUpdatesForMessageList(discussionsList, uid, options.rid); + + return { + discussionsList, + loadMoreItems, + initialItemCount, + }; +}; diff --git a/client/views/room/contextualBar/Threads/index.js b/client/views/room/contextualBar/Threads/index.js index 9d316af787a..537558c5b71 100644 --- a/client/views/room/contextualBar/Threads/index.js +++ b/client/views/room/contextualBar/Threads/index.js @@ -1,14 +1,33 @@ -import React, { useCallback, useMemo, useState, useEffect, useRef, memo } from 'react'; -import { Box, Icon, TextInput, Select, Margins, Callout } from '@rocket.chat/fuselage'; +import React, { useCallback, useMemo, useState, useRef, memo } from 'react'; +import { + Box, + Icon, + TextInput, + Select, + Margins, + Callout, +} from '@rocket.chat/fuselage'; import { FixedSizeList as List } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; -import { useDebouncedValue, useResizeObserver, useLocalStorage, useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { + useDebouncedValue, + useResizeObserver, + useLocalStorage, + useMutableCallback, +} from '@rocket.chat/fuselage-hooks'; import VerticalBar from '../../../../components/VerticalBar'; import { useTranslation } from '../../../../contexts/TranslationContext'; -import { useRoute, useCurrentRoute, useQueryStringParameter } from '../../../../contexts/RouterContext'; +import { + useRoute, + useCurrentRoute, + useQueryStringParameter, +} from '../../../../contexts/RouterContext'; import { call } from '../../../../../app/ui-utils/client'; -import { useUserId, useUserSubscription } from '../../../../contexts/UserContext'; +import { + useUserId, + useUserSubscription, +} from '../../../../contexts/UserContext'; import { useUserRoom } from '../../hooks/useUserRoom'; import { useSetting } from '../../../../contexts/SettingsContext'; import { useTimeAgo } from '../../../../hooks/useTimeAgo'; @@ -16,134 +35,101 @@ import { clickableItem } from '../../../../lib/clickableItem'; import { MessageSkeleton } from '../../components/Message'; import ThreadListMessage from './components/Message'; import { escapeHTML } from '../../../../../lib/escapeHTML'; -import { getConfig } from '../../../../../app/ui-utils/client/config'; -import { useEndpoint } from '../../../../contexts/ServerContext'; import { AsyncStatePhase } from '../../../../hooks/useAsyncState'; import ScrollableContentWrapper from '../../../../components/ScrollableContentWrapper'; import { useTabBarClose, useTabContext } from '../../providers/ToolboxProvider'; import ThreadComponent from '../../../../../app/threads/client/components/ThreadComponent'; import { renderMessageBody } from '../../../../lib/renderMessageBody'; +import { useThreadsList } from './useThreadsList'; +import { useRecordList } from '../../../../hooks/lists/useRecordList'; function mapProps(WrappedComponent) { - return ({ msg, username, replies, tcount, ts, ...props }) => ; + return ({ msg, username, replies = [], tcount, ts, ...props }) => ( + + ); } const Thread = React.memo(mapProps(clickableItem(ThreadListMessage))); const Skeleton = React.memo(clickableItem(MessageSkeleton)); -const LIST_SIZE = parseInt(getConfig('threadsListSize')) || 25; - -const filterProps = ({ msg, u, replies, mentions, tcount, ts, _id, tlm, attachments }) => ({ ..._id && { _id }, attachments, mentions, msg, u, replies, tcount, ts: new Date(ts), tlm: new Date(tlm) }); - const subscriptionFields = { tunread: 1, tunreadUser: 1, tunreadGroup: 1 }; const roomFields = { t: 1, name: 1 }; -const mergeThreads = (threads, newThreads) => - Array.from( - new Map([ - ...threads.map((msg) => [msg._id, msg]), - ...newThreads.map((msg) => [msg._id, msg]), - ]).values(), - ) - .sort((a, b) => b.tlm.getTime() - a.tlm.getTime()); - export function withData(WrappedComponent) { return ({ rid, ...props }) => { + const userId = useUserId(); const onClose = useTabBarClose(); const room = useUserRoom(rid, roomFields); const subscription = useUserSubscription(rid, subscriptionFields); - const userId = useUserId(); - const [{ - state, - error, - threads, - count, - }, setState] = useState(() => ({ - state: AsyncStatePhase.LOADING, - error: null, - threads: [], - count: 0, - })); const [type, setType] = useLocalStorage('thread-list-type', 'all'); - const [text, setText] = useState(''); - - const getThreadsList = useEndpoint('GET', 'chat.getThreadsList'); - const fetchThreads = useMutableCallback(async ({ rid, offset, limit, type, text }) => { - try { - const data = await getThreadsList({ - rid, - offset, - count: limit, - type, - text, - }); - - setState(({ threads }) => ({ - state: AsyncStatePhase.RESOLVED, - error: null, - threads: mergeThreads(offset === 0 ? [] : threads, data.threads.map(filterProps)), - count: data.total, - })); - } catch (error) { - setState(({ threads, count }) => ({ - state: AsyncStatePhase.REJECTED, - error, - threads, - count, - })); - } - }); + const [text, setText] = useState(''); const debouncedText = useDebouncedValue(text, 400); - useEffect(() => { - fetchThreads({ - rid: room._id, - offset: 0, - limit: LIST_SIZE, - type, - text: debouncedText, - }); - }, [debouncedText, fetchThreads, room._id, type]); - const loadMoreItems = useCallback((start, end) => fetchThreads({ - rid: room._id, - offset: start, - limit: end - start, - type, - text, - }), [fetchThreads, room._id, type, text]); + const options = useMemo( + () => ({ + rid, + text: debouncedText, + type, + tunread: subscription?.tunread, + uid: userId, + }), + [rid, debouncedText, type, subscription, userId], + ); + + const { + threadsList, + initialItemCount, + loadMoreItems, + } = useThreadsList(options, userId); + const { phase, error, items: threads, itemCount: totalItemCount } = useRecordList(threadsList); const handleTextChange = useCallback((event) => { setText(event.currentTarget.value); }, []); - return ; + return ( + + ); }; } const handleFollowButton = (e) => { e.preventDefault(); e.stopPropagation(); - call(![true, 'true'].includes(e.currentTarget.dataset.following) ? 'followMessage' : 'unfollowMessage', { mid: e.currentTarget.dataset.id }); + call( + ![true, 'true'].includes(e.currentTarget.dataset.following) + ? 'followMessage' + : 'unfollowMessage', + { mid: e.currentTarget.dataset.id }, + ); }; export const normalizeThreadMessage = ({ ...message }) => { @@ -152,7 +138,9 @@ export const normalizeThreadMessage = ({ ...message }) => { } if (message.attachments) { - const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); + const attachment = message.attachments.find( + (attachment) => attachment.title || attachment.description, + ); if (attachment && attachment.description) { return escapeHTML(attachment.description); @@ -179,31 +167,51 @@ const Row = memo(function Row({ const formatDate = useTimeAgo(); if (!data[index]) { - return ; + return ; } const thread = data[index]; const msg = normalizeThreadMessage(thread); const { name = thread.u.username } = thread.u; - return ; + return ( + + ); }); -export function ThreadList({ total = 10, threads = [], room, unread = [], unreadUser = [], unreadGroup = [], type, setType, loadMoreItems, loading, onClose, error, userId, text, setText }) { +export function ThreadList({ + total = 10, + initial = 10, + threads = [], + room, + unread = [], + unreadUser = [], + unreadGroup = [], + type, + setType, + loadMoreItems, + loading, + onClose, + error, + userId, + text, + setText, +}) { const showRealNames = useSetting('UI_Use_Real_Name'); const threadsRef = useRef(); @@ -221,68 +229,125 @@ export function ThreadList({ total = 10, threads = [], room, unread = [], unread }); }); - const options = useMemo(() => [['all', t('All')], ['following', t('Following')], ['unread', t('Unread')]], [t]); + const options = useMemo( + () => [ + ['all', t('All')], + ['following', t('Following')], + ['unread', t('Unread')], + ], + [t], + ); threadsRef.current = threads; - const rowRenderer = useCallback(({ data, index, style }) => , [showRealNames, unread, unreadUser, unreadGroup, userId, onClick]); - - const isItemLoaded = useMutableCallback((index) => index < threadsRef.current.length); - const { ref, contentBoxSize: { inlineSize = 378, blockSize = 1 } = {} } = useResizeObserver({ debounceDelay: 200 }); + const rowRenderer = useCallback( + ({ data, index, style }) => ( + + ), + [showRealNames, unread, unreadUser, unreadGroup, userId, onClick], + ); + + const isItemLoaded = useMutableCallback( + (index) => index < threadsRef.current.length, + ); + const { + ref, + contentBoxSize: { inlineSize = 378, blockSize = 1 } = {}, + } = useResizeObserver({ debounceDelay: 200 }); const mid = useTabContext(); const jump = useQueryStringParameter('jump'); - return <> - - - {t('Threads')} - - - - - - - }/> - + + - - - {error && {error.toString()}} - {total === 0 && {t('No_Threads')}} - {!error && total > 0 && {} : loadMoreItems} + - {({ onItemsRendered, ref }) => ({rowRenderer} + {error && ( + + {error.toString()} + )} - } - - - { mid && } - ; + {total === 0 && {t('No_Threads')}} + {!error && total > 0 && ( + {} : loadMoreItems} + > + {({ onItemsRendered, ref }) => ( + + {rowRenderer} + + )} + + )} + + + {mid && ( + + + + )} + + ); } export default withData(ThreadList); diff --git a/client/views/room/contextualBar/Threads/useThreadsList.ts b/client/views/room/contextualBar/Threads/useThreadsList.ts new file mode 100644 index 00000000000..91ae14577eb --- /dev/null +++ b/client/views/room/contextualBar/Threads/useThreadsList.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + ThreadsList, + ThreadsListOptions, +} from '../../../../lib/lists/ThreadsList'; +import { useEndpoint } from '../../../../contexts/ServerContext'; +import { useScrollableMessageList } from '../../../../hooks/lists/useScrollableMessageList'; +import { useStreamUpdatesForMessageList } from '../../../../hooks/lists/useStreamUpdatesForMessageList'; +import { IUser } from '../../../../../definition/IUser'; +import { getConfig } from '../../../../../app/ui-utils/client/config'; + +export const useThreadsList = ( + options: ThreadsListOptions, + uid: IUser['_id'], +): { + threadsList: ThreadsList; + initialItemCount: number; + loadMoreItems: (start: number, end: number) => void; + } => { + const [threadsList] = useState(() => new ThreadsList(options)); + + useEffect(() => { + if (threadsList.options !== options) { + threadsList.updateFilters(options); + } + }, [threadsList, options]); + + const getThreadsList = useEndpoint('GET', 'chat.getThreadsList'); + + const fetchMessages = useCallback( + async (start, end) => { + const { threads, total } = await getThreadsList({ + rid: options.rid, + type: options.type, + text: options.text, + offset: start, + count: end - start, + }); + + return { + items: threads, + itemCount: total, + }; + }, + [getThreadsList, options.rid, options.text, options.type], + ); + + const { loadMoreItems, initialItemCount } = useScrollableMessageList( + threadsList, + fetchMessages, + useMemo(() => { + const threadsListSize = getConfig('threadsListSize'); + return threadsListSize ? parseInt(threadsListSize, 10) : undefined; + }, []), + ); + useStreamUpdatesForMessageList(threadsList, uid, options.rid); + + return { + threadsList, + loadMoreItems, + initialItemCount, + }; +}; diff --git a/definition/IMessage.ts b/definition/IMessage.ts index 7a974a59313..f67dfe12d3c 100644 --- a/definition/IMessage.ts +++ b/definition/IMessage.ts @@ -20,4 +20,11 @@ export interface IMessage extends IRocketChatRecord { type: 'Point'; coordinates: [string, string]; }; + starred?: {_id: string}[]; + pinned?: boolean; + drid?: RoomID; + tlm?: Date; + + dcount?: number; + tcount?: number; } diff --git a/definition/ObjectFromApi.ts b/definition/ObjectFromApi.ts new file mode 100644 index 00000000000..fc3ea3d2aa9 --- /dev/null +++ b/definition/ObjectFromApi.ts @@ -0,0 +1,3 @@ +export type ObjectFromApi = { + [K in keyof T]: T[K] extends Date ? string : T[K]; +};