[IMPROVE] Message Collection Hooks (#20121)
parent
28a25778b4
commit
4bfa63cf53
@ -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]); |
||||
}; |
||||
@ -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; |
||||
}; |
||||
@ -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, |
||||
}; |
||||
}; |
||||
@ -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, |
||||
}; |
||||
}; |
||||
@ -0,0 +1,3 @@ |
||||
export type ObjectFromApi<T> = { |
||||
[K in keyof T]: T[K] extends Date ? string : T[K]; |
||||
}; |
||||
Loading…
Reference in new issue