[IMPROVE] Message Collection Hooks (#20121)

pull/20220/head
Tasso Evangelista 5 years ago committed by GitHub
parent 28a25778b4
commit 4bfa63cf53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 13
      .eslintrc
  2. 1
      app/ui-utils/client/config.js
  3. 17
      client/contexts/ServerContext.ts
  4. 65
      client/hooks/lists/useRecordList.ts
  5. 30
      client/hooks/lists/useScrollableMessageList.ts
  6. 28
      client/hooks/lists/useScrollableRecordList.ts
  7. 80
      client/hooks/lists/useStreamUpdatesForMessageList.ts
  8. 149
      client/hooks/useAsyncState.ts
  9. 10
      client/lib/asyncState/AsyncState.ts
  10. 6
      client/lib/asyncState/AsyncStatePhase.ts
  11. 106
      client/lib/asyncState/functions.ts
  12. 3
      client/lib/asyncState/index.ts
  13. 55
      client/lib/lists/DiscussionsList.ts
  14. 12
      client/lib/lists/MessageList.ts
  15. 169
      client/lib/lists/RecordList.ts
  16. 90
      client/lib/lists/ThreadsList.ts
  17. 37
      client/lib/minimongo/bson.spec.ts
  18. 184
      client/lib/minimongo/bson.ts
  19. 84
      client/lib/minimongo/comparisons.ts
  20. 6
      client/lib/minimongo/index.ts
  21. 13
      client/lib/minimongo/lookups.spec.ts
  22. 42
      client/lib/minimongo/lookups.ts
  23. 275
      client/lib/minimongo/query.ts
  24. 75
      client/lib/minimongo/sort.ts
  25. 73
      client/lib/minimongo/types.ts
  26. 12
      client/providers/ServerProvider.js
  27. 2
      client/views/room/Header/icons/Translate.js
  28. 90
      client/views/room/contextualBar/Discussions/index.js
  29. 63
      client/views/room/contextualBar/Discussions/useDiscussionsList.ts
  30. 397
      client/views/room/contextualBar/Threads/index.js
  31. 64
      client/views/room/contextualBar/Threads/useThreadsList.ts
  32. 7
      definition/IMessage.ts
  33. 3
      definition/ObjectFromApi.ts

@ -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,

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

@ -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<any>;
callEndpoint: (httpMethod: 'GET' | 'POST' | 'DELETE', endpoint: string, ...args: any[]) => Promise<any>;
uploadToEndpoint: (endpoint: string, params: any, formData: any) => Promise<void>;
getStream: (streamName: string, options?: {}) => IServerStream;
getStream: (streamName: string, options?: {}) => <T>(eventName: string, callback: (data: T) => void) => () => void;
};
export const ServerContext = createContext<ServerContextValue>({
@ -20,10 +15,7 @@ export const ServerContext = createContext<ServerContextValue>({
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?: {},
): <T>(eventName: string, callback: (data: T) => void) => (() => void) => {
const { getStream } = useContext(ServerContext);
return useMemo(() => getStream(streamName, options), [getStream, streamName, options]);
};

@ -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<T> = {
phase: AsyncStatePhase;
items: T[];
itemCount: number;
error: Error | undefined;
}
export const useRecordList = <T extends IRocketChatRecord>(
recordList: RecordList<T>,
): RecordListValue<T> => {
const [state, setState] = useState<RecordListValue<T>>(() => ({
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;
};

@ -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>): 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<RecordListBatchChanges<ObjectFromApi<IMessage>>>,
initialItemCount?: number,
): ReturnType<typeof useScrollableRecordList> => {
const fetchItems = useCallback(async (start: number, end: number): Promise<RecordListBatchChanges<IMessage>> => {
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);
};

@ -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 = <T extends IRocketChatRecord>(
recordList: RecordList<T>,
fetchBatchChanges: (start: number, end: number) => Promise<RecordListBatchChanges<T>>,
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 };
};

@ -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<Date>;
users: string[];
};
const createDeleteCriteria = (
params: NotifyRoomRidDeleteMessageBulkEvent,
): ((message: IMessage) => boolean) => {
const query: Query<IMessage> = { 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<IMessage>(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<RoomMessagesRidEvent>(rid, (message) => {
messageList.handle(message);
});
const unsubscribeFromDeleteMessage = subscribeToNotifyRoom<NotifyRoomRidDeleteMessageEvent>(
`${ rid }/deleteMessage`,
({ _id: mid }) => {
messageList.remove(mid);
},
);
const unsubscribeFromDeleteMessageBulk = subscribeToNotifyRoom<NotifyRoomRidDeleteMessageBulkEvent>(
`${ rid }/deleteMessageBulk`,
(params) => {
const matchDeleteCriteria = createDeleteCriteria(params);
messageList.prune(matchDeleteCriteria);
},
);
return (): void => {
unsubscribeFromRoomMessages();
unsubscribeFromDeleteMessage();
unsubscribeFromDeleteMessageBulk();
};
}, [subscribeToRoomMessages, subscribeToNotifyRoom, uid, rid, messageList]);
};

@ -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<T> = (
{ 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<T> = AsyncState<T> & {
resolve: (value: T | ((prev: T | undefined) => T)) => void;
@ -24,123 +11,42 @@ type AsyncStateObject<T> = AsyncState<T> & {
};
export const useAsyncState = <T>(initialValue?: T | (() => T)): AsyncStateObject<T> => {
const [state, setState] = useState<AsyncState<T>>(() => {
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<AsyncState<T>>(() => {
if (typeof initialValue === 'undefined') {
return asyncState.loading<T>();
}
return (): void => {
isMountedRef.current = false;
};
}, []);
return asyncState.resolved<T>(
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 = <T>(initialValue?: T | (() => T)): AsyncStateObject
update,
}), [state, resolve, reject, reset, update]);
};
export {
AsyncStatePhase,
AsyncState,
};

@ -0,0 +1,10 @@
import { AsyncStatePhase } from './AsyncStatePhase';
export type AsyncState<T> = (
{ 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 }
);

@ -0,0 +1,6 @@
export const enum AsyncStatePhase {
LOADING = 'loading',
RESOLVED = 'resolved',
REJECTED = 'rejected',
UPDATING = 'updating'
}

@ -0,0 +1,106 @@
import { AsyncState } from './AsyncState';
import { AsyncStatePhase } from './AsyncStatePhase';
export const loading = <T>(): AsyncState<T> => ({
phase: AsyncStatePhase.LOADING,
value: undefined,
error: undefined,
});
export const updating = <T>(value: T): AsyncState<T> => ({
phase: AsyncStatePhase.UPDATING,
value,
error: undefined,
});
export const resolved = <T>(value: T): AsyncState<T> => ({
phase: AsyncStatePhase.RESOLVED,
value,
error: undefined,
});
export const rejected = <T>(error: Error): AsyncState<T> => ({
phase: AsyncStatePhase.REJECTED,
value: undefined,
error,
});
export const reload = <T>(prevState: AsyncState<T>): AsyncState<T> => {
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 = <T>(prevState: AsyncState<T>): AsyncState<T> => {
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 = <T>(prevState: AsyncState<T>, value: T): AsyncState<T> => {
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 = <T>(prevState: AsyncState<T>, error: Error): AsyncState<T> => {
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 = <T>(state: AsyncState<T>): T | undefined => state.value;
export const error = <T>(state: AsyncState<T>): Error | undefined => state.error;

@ -0,0 +1,3 @@
export { AsyncState } from './AsyncState';
export { AsyncStatePhase } from './AsyncStatePhase';
export * as asyncState from './functions';

@ -0,0 +1,55 @@
import { MessageList } from './MessageList';
import type { IMessage } from '../../../definition/IMessage';
import { escapeRegExp } from '../../../lib/escapeRegExp';
type DiscussionMessage = Omit<IMessage, 'drid'> & Required<Pick<IMessage, 'drid'>>;
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();
}
}

@ -0,0 +1,12 @@
import type { IMessage } from '../../../definition/IMessage';
import { RecordList } from './RecordList';
export class MessageList extends RecordList<IMessage> {
protected filter(message: IMessage): boolean {
return message._hidden !== true;
}
protected compare(a: IMessage, b: IMessage): number {
return a.ts.getTime() - b.ts.getTime();
}
}

@ -0,0 +1,169 @@
import { Emitter } from '@rocket.chat/emitter';
import type { IRocketChatRecord } from '../../../definition/IRocketChatRecord';
import { AsyncStatePhase } from '../asyncState';
export type RecordListBatchChanges<T> = {
items?: T[];
itemCount?: number;
};
export class RecordList<T extends IRocketChatRecord> extends Emitter {
#hasChanges = false;
#index = new Map<T['_id'], T>();
#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<void> = Promise.resolve();
protected async mutate(mutation: () => void | Promise<void>): Promise<void> {
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<RecordListBatchChanges<T>>): Promise<void> {
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<void> {
return this.mutate(() => {
for (const item of this.#index.values()) {
if (matchCriteria(item)) {
this.delete(item._id);
}
}
});
}
public handle(item: T): Promise<void> {
return this.mutate(() => {
this.push(item);
});
}
public remove(_id: T['_id']): Promise<void> {
return this.mutate(() => {
if (!this.#index.has(_id)) {
return;
}
this.delete(_id);
});
}
public clear(): Promise<void> {
return this.mutate(() => {
if (this.#index.size === 0) {
return;
}
this.#index.clear();
this.#items = undefined;
this.#itemCount = undefined;
this.#hasChanges = true;
this.emit('cleared');
});
}
}

@ -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<IMessage, 'tcount'> & Required<Pick<IMessage, 'tcount'>>;
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();
}
}

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

@ -0,0 +1,184 @@
import { BSONType } from './types';
export const getBSONType = <T>(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<string, unknown>)),
Array.prototype.concat.call([], ...Object.entries(b as Record<string, unknown>)),
);
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');
};

@ -0,0 +1,84 @@
export const equals = <T>(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<string, unknown>)[key], (b as Record<string, unknown>)[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 = <T>(x: T[] | T, f: (x: T) => boolean): boolean => {
if (Array.isArray(x)) {
return x.some(f);
}
return f(x);
};
export const some = <T>(x: T | T[], f: (x: T | T[]) => boolean): boolean => {
if (f(x)) {
return true;
}
return Array.isArray(x) && x.some(f);
};
export const isEmptyArray = <T>(value: unknown): value is T[] & { length: 0 } =>
Array.isArray(value) && value.length === 0;

@ -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';

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

@ -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<string, unknown> =>
doc !== undefined && doc !== null && (typeof doc === 'object' || typeof doc === 'function');
const isIndexedByNumber = <T>(value: unknown, isIndexedByNumber: boolean): value is T[] =>
Array.isArray(value) || isIndexedByNumber;
export const createLookupFunction = <T>(key: string): ((doc: T) => unknown[]) => {
const [first, rest] = key.split(/\.(.+)/);
if (!rest) {
return <T>(doc: T): unknown[] => {
if (isNullDocument(doc) || !isRecordDocument(doc)) {
return [undefined];
}
return [doc[first]];
};
}
const lookupRest = createLookupFunction(rest);
const nextIsNumeric = /^\d+(\.|$)/.test(rest);
return <T>(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));
};
};

@ -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 = <T>(values: unknown[]): values is T[] =>
values.every((value) => ['number', 'string', 'symbol'].includes(typeof value));
const $in = <T extends string>(operand: T[], _options: undefined): ((value: T) => boolean) => {
let index: Record<T, T> | null = null;
if (isArrayOfFields<T>(operand)) {
index = {} as Record<T, T>;
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 = <T extends string>(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 = <T>(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 = <T>(operand: T, _options: undefined): ((value: T) => boolean) =>
(value: T): boolean => flatSome(value, (x) => compareBSONValues(x, operand) < 0);
const $lte = <T>(operand: T, _options: undefined): ((value: T) => boolean) =>
(value: T): boolean => flatSome(value, (x) => compareBSONValues(x, operand) <= 0);
const $gt = <T>(operand: T, _options: undefined): ((value: T) => boolean) =>
(value: T): boolean => flatSome(value, (x) => compareBSONValues(x, operand) > 0);
const $gte = <T>(operand: T, _options: undefined): ((value: T) => boolean) =>
(value: T): boolean => flatSome(value, (x) => compareBSONValues(x, operand) >= 0);
const $ne = <T>(operand: T, _options: undefined): ((value: T) => boolean) =>
(value: T): boolean => !some(value, (x) => equals(x, operand));
const $exists = <T>(operand: boolean, _options: undefined): ((value: T) => boolean) =>
(value: T): boolean => operand === (value !== undefined);
const $mod = <T>([divisor, remainder]: [number, number], _options: undefined): ((value: T) => boolean) =>
(value: T): boolean => flatSome(value, (x) => Number(x) % divisor === remainder);
const $size = <T>(operand: number, _options: undefined): ((value: T) => boolean) =>
(value: T): boolean => Array.isArray(value) && operand === value.length;
const $type = <T>(operand: BSONType, _options: undefined): ((value: T) => boolean) =>
(value: T): boolean => {
if (value === undefined) {
return false;
}
return flatSome(value, (x) => getBSONType(x) === operand);
};
const $regex = <T>(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 = <T>(operand: Query<T>, _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 = <T>(operand: FieldExpression<T>, _options: undefined): ((value: T) => boolean) => {
const matcher = compileValueSelector(operand);
return (value: T): boolean => !matcher(value);
};
const dummyOperator = <T>(_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 = <T>(subSelector: Query<T>[]): ((doc: T) => boolean) => {
const subSelectorFunctions = subSelector.map(compileDocumentSelector);
return (doc: T): boolean => subSelectorFunctions.every((f) => f(doc));
};
const $or = <T>(subSelector: Query<T>[]): ((doc: T) => boolean) => {
const subSelectorFunctions = subSelector.map(compileDocumentSelector);
return (doc: T): boolean => subSelectorFunctions.some((f) => f(doc));
};
const $nor = <T>(subSelector: Query<T>[]): ((doc: T) => boolean) => {
const subSelectorFunctions = subSelector.map(compileDocumentSelector);
return (doc: T): boolean => subSelectorFunctions.every((f) => !f(doc));
};
const $where = <T>(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 = <T>(valueSelector: FieldExpression<T>): boolean =>
Object.keys(valueSelector).every((key) => key.slice(0, 1) === '$');
const compileUndefinedOrNullSelector = <T>(): ((value: T) => boolean) =>
(value: T): boolean => flatSome(value, (x) => x === undefined || x === null);
const compilePrimitiveSelector = <T>(primitive: T) =>
(value: T): boolean => flatSome(value, (x) => x === primitive);
const compileRegexSelector = <T>(regex: RegExp) =>
(value: T): boolean => {
if (value === undefined) {
return false;
}
return flatSome(value, (x) => regex.test(String(x)));
};
const compileArraySelector = <T>(expected: T) =>
(value: T): boolean => {
if (!Array.isArray(value)) {
return false;
}
return some(value, (x) => equals(expected, x));
};
const compileValueOperatorsSelector = <T>(expression: FieldExpression<T>): ((value: T) => boolean) => {
const operatorFunctions: ((value: T) => boolean)[] = [];
for (const operator of Object.keys(expression) as (keyof FieldExpression<T>)[]) {
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 = <T>(valueSelector: FieldExpression<T>[keyof FieldExpression<T>]): ((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<T>(valueSelector)) {
return compileValueOperatorsSelector(valueSelector);
}
return (value: T): boolean => flatSome(value, (x) => equals(valueSelector, x as unknown as object));
};
export const compileDocumentSelector = <T>(docSelector: Query<T> | FieldExpression<T>['$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));
};

@ -0,0 +1,75 @@
import { compareBSONValues } from './bson';
import { isEmptyArray } from './comparisons';
import { createLookupFunction } from './lookups';
import { Sort } from './types';
const createSortSpecParts = <T>(spec: Sort): {
lookup: (doc: T) => unknown[];
ascending: boolean;
}[] => {
if (Array.isArray(spec)) {
return spec.map((value) => {
if (typeof value === 'string') {
return {
lookup: createLookupFunction<T>(value),
ascending: true,
};
}
return {
lookup: createLookupFunction<T>(value[0]),
ascending: value[1] !== 'desc',
};
});
}
return Object.entries(spec).map(([key, value]) => ({
lookup: createLookupFunction<T>(key),
ascending: value >= 0,
}));
};
const reduceValue = (branchValues: unknown[], ascending: boolean): unknown =>
([] as unknown[]).concat(
...branchValues.map<unknown[]>((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;
};
};

@ -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<T> = {
$eq?: T;
$gt?: T;
$gte?: T;
$lt?: T;
$lte?: T;
$in?: T[];
$nin?: T[];
$ne?: T;
$exists?: boolean;
$type?: BSONType[] | BSONType;
$not?: FieldExpression<T>;
$expr?: FieldExpression<T>;
$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<T> : FieldExpression<T>;
$size?: number;
$bitsAllClear?: unknown;
$bitsAllSet?: unknown;
$bitsAnyClear?: unknown;
$bitsAnySet?: unknown;
$comment?: string;
};
export type Flatten<T> = T extends unknown[] ? T[0] : T;
export type Query<T> = {
[P in keyof T]?: Flatten<T[P]> | RegExp | FieldExpression<Flatten<T[P]>>
} & {
$or?: Query<T>[];
$and?: Query<T>[];
$nor?: Query<T>[];
} & Record<string, FieldExpression<unknown>>;
export type Sort = (string | [string, 'asc' | 'desc'])[] | {
[key: string]: -1 | 1;
};

@ -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,

@ -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 ? <Header.State title={encryptedLabel} icon='language' color={colors.b500} tiny ghost/> : null;
return autoTranslateEnabled && autoTranslate && autoTranslateLanguage ? <Header.State title={encryptedLabel} icon='language' color={colors.b500} /> : null;
};
export default memo(Translate);

@ -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 }) => <WrappedComponent replies={tcount} username={username} msg={msg} ts={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}</List>
)}

@ -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,
};
};

@ -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 }) => <WrappedComponent replies={tcount} participants={replies.length} username={username} msg={msg} ts={ts} {...props}/>;
return ({ msg, username, replies = [], tcount, ts, ...props }) => (
<WrappedComponent
replies={tcount}
participants={replies?.length}
username={username}
msg={msg}
ts={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 <WrappedComponent
{...props}
unread={subscription?.tunread}
unreadUser={subscription?.tunreadUser}
unreadGroup={subscription?.tunreadGroup}
userId={userId}
error={error}
threads={threads}
total={count}
loading={state === AsyncStatePhase.LOADING}
loadMoreItems={loadMoreItems}
room={room}
text={text}
setText={handleTextChange}
type={type}
setType={setType}
onClose={onClose}
/>;
return (
<WrappedComponent
{...props}
unread={subscription?.tunread}
unreadUser={subscription?.tunreadUser}
unreadGroup={subscription?.tunreadGroup}
userId={userId}
error={error}
threads={threads}
total={totalItemCount}
initial={initialItemCount}
loading={phase === AsyncStatePhase.LOADING}
loadMoreItems={loadMoreItems}
room={room}
text={text}
setText={handleTextChange}
type={type}
setType={setType}
onClose={onClose}
/>
);
};
}
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 <Skeleton style={style}/>;
return <Skeleton style={style} />;
}
const thread = data[index];
const msg = normalizeThreadMessage(thread);
const { name = thread.u.username } = thread.u;
return <Thread
{ ...thread }
name={showRealNames ? name : thread.u.username }
username={ thread.u.username }
style={style}
unread={unread.includes(thread._id)}
mention={unreadUser.includes(thread._id)}
all={unreadGroup.includes(thread._id)}
following={thread.replies && thread.replies.includes(userId)}
data-id={thread._id}
msg={msg}
t={t}
formatDate={formatDate}
handleFollowButton={handleFollowButton} onClick={onClick}
/>;
return (
<Thread
{...thread}
name={showRealNames ? name : thread.u.username}
username={thread.u.username}
style={style}
unread={unread.includes(thread._id)}
mention={unreadUser.includes(thread._id)}
all={unreadGroup.includes(thread._id)}
following={thread.replies && thread.replies.includes(userId)}
data-id={thread._id}
msg={msg}
t={t}
formatDate={formatDate}
handleFollowButton={handleFollowButton}
onClick={onClick}
/>
);
});
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 }) => <Row
data={data}
index={index}
style={style}
showRealNames={showRealNames}
unread={unread}
unreadUser={unreadUser}
unreadGroup={unreadGroup}
userId={userId}
onClick={onClick}
/>, [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 }) => (
<Row
data={data}
index={index}
style={style}
showRealNames={showRealNames}
unread={unread}
unreadUser={unreadUser}
unreadGroup={unreadGroup}
userId={userId}
onClick={onClick}
/>
),
[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 <>
<VerticalBar.Header>
<VerticalBar.Icon name='thread'/>
<VerticalBar.Text>{t('Threads')}</VerticalBar.Text>
<VerticalBar.Close onClick={onClose}/>
</VerticalBar.Header>
<VerticalBar.Content paddingInline={0}>
<Box display='flex' flexDirection='row' p='x24' borderBlockEndWidth='x2' borderBlockEndStyle='solid' borderBlockEndColor='neutral-200' flexShrink={0}>
<Box display='flex' flexDirection='row' flexGrow={1} mi='neg-x4'>
<Margins inline='x4'>
<TextInput placeholder={t('Search_Messages')} value={text} onChange={setText} addon={<Icon name='magnifier' size='x20'/>}/>
<Select flexGrow={0} width='110px' onChange={setType} value={type} options={options} />
</Margins>
return (
<>
<VerticalBar.Header>
<VerticalBar.Icon name='thread' />
<VerticalBar.Text>{t('Threads')}</VerticalBar.Text>
<VerticalBar.Close onClick={onClose} />
</VerticalBar.Header>
<VerticalBar.Content paddingInline={0}>
<Box
display='flex'
flexDirection='row'
p='x24'
borderBlockEndWidth='x2'
borderBlockEndStyle='solid'
borderBlockEndColor='neutral-200'
flexShrink={0}
>
<Box display='flex' flexDirection='row' flexGrow={1} mi='neg-x4'>
<Margins inline='x4'>
<TextInput
placeholder={t('Search_Messages')}
value={text}
onChange={setText}
addon={<Icon name='magnifier' size='x20' />}
/>
<Select
flexGrow={0}
width='110px'
onChange={setType}
value={type}
options={options}
/>
</Margins>
</Box>
</Box>
</Box>
<Box flexGrow={1} flexShrink={1} ref={ref} overflow='hidden' display='flex'>
{error && <Callout mi='x24' type='danger'>{error.toString()}</Callout>}
{total === 0 && <Box p='x24'>{t('No_Threads')}</Box>}
{!error && total > 0 && <InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={total}
loadMoreItems={ loading ? () => {} : loadMoreItems}
<Box
flexGrow={1}
flexShrink={1}
ref={ref}
overflow='hidden'
display='flex'
>
{({ onItemsRendered, ref }) => (<List
outerElementType={ScrollableContentWrapper}
height={blockSize}
width={inlineSize}
itemCount={total}
itemData={threads}
itemSize={124}
ref={ref}
minimumBatchSize={LIST_SIZE}
onItemsRendered={onItemsRendered}
>{rowRenderer}</List>
{error && (
<Callout mi='x24' type='danger'>
{error.toString()}
</Callout>
)}
</InfiniteLoader>}
</Box>
</VerticalBar.Content>
{ mid && <VerticalBar.InnerContent><ThreadComponent mid={mid} jump={jump} room={room}/></VerticalBar.InnerContent> }
</>;
{total === 0 && <Box p='x24'>{t('No_Threads')}</Box>}
{!error && total > 0 && (
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={total}
loadMoreItems={loading ? () => {} : loadMoreItems}
>
{({ onItemsRendered, ref }) => (
<List
outerElementType={ScrollableContentWrapper}
height={blockSize}
width={inlineSize}
itemCount={total}
itemData={threads}
itemSize={124}
ref={ref}
minimumBatchSize={initial}
onItemsRendered={onItemsRendered}
>
{rowRenderer}
</List>
)}
</InfiniteLoader>
)}
</Box>
</VerticalBar.Content>
{mid && (
<VerticalBar.InnerContent>
<ThreadComponent mid={mid} jump={jump} room={room} />
</VerticalBar.InnerContent>
)}
</>
);
}
export default withData(ThreadList);

@ -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,
};
};

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

@ -0,0 +1,3 @@
export type ObjectFromApi<T> = {
[K in keyof T]: T[K] extends Date ? string : T[K];
};
Loading…
Cancel
Save