regression: Threaded messages preview (#36165)

This commit is contained in:
Tasso Evangelista 2025-06-05 23:53:56 -03:00 committed by GitHub
parent d6df5f4a54
commit d6fa67e492
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 257 additions and 25 deletions

View File

@ -146,7 +146,7 @@ export const createAutoTranslateMessageStreamHandler = (): ((message: ITranslate
(!message.translations ||
(!hasTranslationLanguageInMessage(message, language) && !hasTranslationLanguageInAttachments(message.attachments, language)))
) {
Messages.store.update(
Messages.state.update(
(record) => record._id === message._id,
(record) => ({
...record,
@ -154,7 +154,7 @@ export const createAutoTranslateMessageStreamHandler = (): ((message: ITranslate
}),
);
} else if (AutoTranslate.messageIdsToWait[message._id] !== undefined && subscription && subscription.autoTranslate !== true) {
Messages.store.update(
Messages.state.update(
(record) => record._id === message._id,
({ autoTranslateFetching: _, ...record }) => ({
...record,
@ -163,7 +163,7 @@ export const createAutoTranslateMessageStreamHandler = (): ((message: ITranslate
);
delete AutoTranslate.messageIdsToWait[message._id];
} else if (message.autoTranslateFetching === true) {
Messages.store.update(
Messages.state.update(
(record) => record._id === message._id,
({ autoTranslateFetching: _, ...record }) => record,
);

View File

@ -303,7 +303,7 @@ export class E2ERoom extends Emitter {
}
async decryptPendingMessages() {
await Messages.store.updateAsync(
await Messages.state.updateAsync(
(record) => record.rid === this.roomId && record.t === 'e2e' && record.e2e === 'pending',
(record) => this.decryptMessage(record),
);

View File

@ -716,7 +716,7 @@ class E2E extends Emitter {
}
async decryptPendingMessages(): Promise<void> {
await Messages.store.updateAsync(
await Messages.state.updateAsync(
(record) => record.t === 'e2e' && record.e2e === 'pending',
(record) => this.decryptMessage(record),
);

View File

@ -25,7 +25,7 @@ class CachedChatRoom extends PrivateCachedCollection<IRoom> {
}
private mergeWithSubscription(room: IRoom): IRoom {
CachedChatSubscription.collection.store.update(
CachedChatSubscription.collection.state.update(
(record) => record.rid === room._id,
(sub) => ({
...sub,

View File

@ -33,7 +33,7 @@ class CachedChatSubscription extends PrivateCachedCollection<SubscriptionWithRoo
}
private mergeWithRoom(subscription: ISubscription): SubscriptionWithRoom {
const room = CachedChatRoom.collection.store.find((record) => record._id === subscription.rid);
const room = CachedChatRoom.collection.state.find((record) => record._id === subscription.rid);
const lastRoomUpdate = room?.lm || subscription.ts || room?.ts;

View File

@ -30,7 +30,7 @@ const processMessage = async (msg: IMessage & { ignored?: boolean }, { subscript
};
export async function upsertMessage({ msg, subscription }: { msg: IMessage & { ignored?: boolean }; subscription?: ISubscription }) {
Messages.store.store(await processMessage(msg, { subscription }));
Messages.state.store(await processMessage(msg, { subscription }));
}
export async function upsertMessageBulk({
@ -41,7 +41,7 @@ export async function upsertMessageBulk({
subscription?: ISubscription;
}) {
const processedMsgs = await Promise.all(msgs.map(async (msg) => processMessage(msg, { subscription })));
Messages.store.storeMany(processedMsgs);
Messages.state.storeMany(processedMsgs);
}
const defaultLimit = parseInt(getConfig('roomListLimit') ?? '50') || 50;

View File

@ -109,7 +109,7 @@ export abstract class CachedCollection<T extends IRocketChatRecord, U = T> {
this.updatedAt = new Date(updatedAt);
}
this.collection.store.replaceAll(deserializedRecords.filter(hasId));
this.collection.state.replaceAll(deserializedRecords.filter(hasId));
this.updatedAt = data.updatedAt || this.updatedAt;
@ -155,7 +155,7 @@ export abstract class CachedCollection<T extends IRocketChatRecord, U = T> {
}
});
this.collection.store.storeMany(newRecords);
this.collection.state.storeMany(newRecords);
this.updatedAt = this.updatedAt === lastTime ? startTime : this.updatedAt;
}
@ -178,7 +178,7 @@ export abstract class CachedCollection<T extends IRocketChatRecord, U = T> {
private save = withDebouncing({ wait: 1000 })(async () => {
this.log('saving cache');
const data = this.collection.store.records;
const data = this.collection.state.records;
await localforage.setItem(this.name, {
updatedAt: this.updatedAt,
version: this.version,
@ -193,7 +193,7 @@ export abstract class CachedCollection<T extends IRocketChatRecord, U = T> {
protected async clearCache() {
this.log('clearing cache');
await localforage.removeItem(this.name);
this.collection.store.replaceAll([]);
this.collection.state.replaceAll([]);
}
protected async setupListener() {
@ -211,11 +211,11 @@ export abstract class CachedCollection<T extends IRocketChatRecord, U = T> {
}
if (action === 'removed') {
this.collection.store.delete(newRecord);
this.collection.state.delete(newRecord);
} else {
const { _id } = newRecord;
if (!_id) return;
this.collection.store.store(newRecord);
this.collection.state.store(newRecord);
}
await this.save();
}
@ -257,7 +257,7 @@ export abstract class CachedCollection<T extends IRocketChatRecord, U = T> {
const actionTime = hasUpdatedAt(newRecord) ? newRecord._updatedAt : startTime;
changes.push({
action: () => {
this.collection.store.store(newRecord);
this.collection.state.store(newRecord);
if (actionTime > this.updatedAt) {
this.updatedAt = actionTime;
}
@ -280,7 +280,7 @@ export abstract class CachedCollection<T extends IRocketChatRecord, U = T> {
const actionTime = newRecord._deletedAt;
changes.push({
action: () => {
this.collection.store.delete(newRecord);
this.collection.state.delete(newRecord);
if (actionTime > this.updatedAt) {
this.updatedAt = actionTime;
}

View File

@ -89,7 +89,7 @@ export class MinimongoCollection<T extends { _id: string }> extends Mongo.Collec
*
* It's a convenience method to access the Zustand store directly i.e. outside of React components.
*/
get store() {
get state() {
return this.use.getState();
}

View File

@ -10,7 +10,7 @@ Meteor.startup(() => {
onLoggedIn(async () => {
const { roles } = await sdk.rest.get('/v1/roles.list');
// if a role is checked before this collection is populated, it will return undefined
Roles.store.replaceAll(roles);
Roles.state.replaceAll(roles);
Roles.ready.set(true);
});

View File

@ -15,7 +15,7 @@ export const useGoToRoom = ({ replace = false }: { replace?: boolean } = {}): ((
return;
}
const subscription: ISubscription | undefined = Subscriptions.store.find((record) => record.rid === rid);
const subscription: ISubscription | undefined = Subscriptions.state.find((record) => record.rid === rid);
if (subscription) {
roomCoordinator.openRouteLink(subscription.t, subscription, router.getSearchParameters(), { replace });

View File

@ -14,7 +14,7 @@ export function useRoomQuery(
const queryResult = useQuery({
queryKey: roomsQueryKeys.room(rid),
queryFn: async () => {
const room = Rooms.store.get(rid);
const room = Rooms.state.get(rid);
if (!room) {
throw new RoomNotFoundError(undefined, { rid });

View File

@ -0,0 +1,6 @@
import server from '@rocket.chat/jest-presets/server';
import type { Config } from 'jest';
export default {
preset: server.preset,
} satisfies Config;

View File

@ -3,14 +3,18 @@
"version": "0.0.2",
"private": true,
"devDependencies": {
"@rocket.chat/jest-presets": "workspace:~",
"eslint": "~8.45.0",
"jest": "~29.7.0",
"typescript": "~5.8.3"
},
"scripts": {
"lint": "eslint --ext .js,.jsx,.ts,.tsx .",
"lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix",
"build": "rm -rf dist && tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"test": "jest",
"testunit": "jest"
},
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",

View File

@ -0,0 +1,203 @@
import { compileFilter } from './filter';
describe('compileFilter', () => {
it('matches simple equality', () => {
const filter = { foo: 'bar' };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 'bar' })).toBe(true);
expect(fn({ foo: 'baz' })).toBe(false);
});
it('matches $in', () => {
const filter = { foo: { $in: ['a', 'b'] } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 'a' })).toBe(true);
expect(fn({ foo: 'b' })).toBe(true);
expect(fn({ foo: 'c' })).toBe(false);
});
it('matches $nin', () => {
const filter = { foo: { $nin: ['a', 'b'] } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 'a' })).toBe(false);
expect(fn({ foo: 'c' })).toBe(true);
expect(fn({})).toBe(true);
});
it('matches $all', () => {
const filter = { foo: { $all: ['a', 'b'] } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: ['a', 'b', 'c'] })).toBe(true);
expect(fn({ foo: ['a', 'c'] })).toBe(false);
});
it('matches $lt/$lte/$gt/$gte', () => {
const filter = { foo: { $lt: 5 } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 4 })).toBe(true);
expect(fn({ foo: 5 })).toBe(false);
const filter2 = { foo: { $lte: 5 } };
const fn2 = compileFilter<any>(filter2);
expect(fn2({ foo: 5 })).toBe(true);
const filter3 = { foo: { $gt: 5 } };
const fn3 = compileFilter<any>(filter3);
expect(fn3({ foo: 6 })).toBe(true);
const filter4 = { foo: { $gte: 5 } };
const fn4 = compileFilter<any>(filter4);
expect(fn4({ foo: 5 })).toBe(true);
});
it('matches $ne', () => {
const filter = { foo: { $ne: 1 } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 2 })).toBe(true);
expect(fn({ foo: 1 })).toBe(false);
});
it('matches $exists', () => {
const filter = { foo: { $exists: true } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 1 })).toBe(true);
expect(fn({})).toBe(false);
const filter2 = { foo: { $exists: false } };
const fn2 = compileFilter<any>(filter2);
expect(fn2({ foo: 1 })).toBe(false);
expect(fn2({})).toBe(true);
});
it('matches $mod', () => {
const filter = { foo: { $mod: [2, 0] } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 4 })).toBe(true);
expect(fn({ foo: 5 })).toBe(false);
});
it('matches $size', () => {
const filter = { foo: { $size: 2 } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: [1, 2] })).toBe(true);
expect(fn({ foo: [1] })).toBe(false);
});
it('matches $type', () => {
const filter = { foo: { $type: 2 } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 'bar' })).toBe(true);
expect(fn({ foo: 1 })).toBe(false);
});
it('matches $regex', () => {
const filter = { foo: { $regex: '^b' } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 'bar' })).toBe(true);
expect(fn({ foo: 'baz' })).toBe(true);
expect(fn({ foo: 'car' })).toBe(false);
});
it('matches $elemMatch', () => {
const filter = { foo: { $elemMatch: { bar: 1 } } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: [{ bar: 1 }, { bar: 2 }] })).toBe(true);
expect(fn({ foo: [{ bar: 2 }] })).toBe(false);
});
it('matches $eq', () => {
const filter = { foo: { $eq: 1 } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 1 })).toBe(true);
expect(fn({ foo: 2 })).toBe(false);
});
it('matches $not', () => {
const filter = { foo: { $not: { $eq: 1 } } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 2 })).toBe(true);
expect(fn({ foo: 1 })).toBe(false);
});
it('matches $and', () => {
const filter = { $and: [{ foo: 1 }, { bar: 2 }] };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 1, bar: 2 })).toBe(true);
expect(fn({ foo: 1, bar: 3 })).toBe(false);
});
it('matches $or', () => {
const filter = { $or: [{ foo: 1 }, { bar: 2 }] };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 1 })).toBe(true);
expect(fn({ bar: 2 })).toBe(true);
expect(fn({ foo: 3, bar: 4 })).toBe(false);
});
it('matches $nor', () => {
const filter = { $nor: [{ foo: 1 }, { bar: 2 }] };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 1 })).toBe(false);
expect(fn({ bar: 2 })).toBe(false);
expect(fn({ foo: 3, bar: 4 })).toBe(true);
});
it('matches $where (function)', () => {
const filter = { $where: (doc: any) => doc.foo === 1 };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 1 })).toBe(true);
expect(fn({ foo: 2 })).toBe(false);
});
it('matches $where (string)', () => {
const filter = { $where: 'this.foo === 1' };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 1 })).toBe(true);
expect(fn({ foo: 2 })).toBe(false);
});
it('handles undefined and null', () => {
const filter = { foo: undefined, bar: null };
const fn = compileFilter<any>(filter);
expect(fn({})).toBe(true);
expect(fn({ foo: null, bar: null })).toBe(true);
expect(fn({ foo: 1, bar: 2 })).toBe(false);
});
it('handles nested fields', () => {
const filter = { 'foo.bar': 1 };
const fn = compileFilter<any>(filter);
expect(fn({ foo: { bar: 1 } })).toBe(true);
expect(fn({ foo: { bar: 2 } })).toBe(false);
});
it('handles arrays for $in/$all', () => {
const filter = { foo: { $in: [1, 2] }, bar: { $all: [3, 4] } };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 1, bar: [3, 4, 5] })).toBe(true);
expect(fn({ foo: 3, bar: [3, 4, 5] })).toBe(false);
});
it('returns true for empty filter', () => {
const fn = compileFilter<any>({});
expect(fn({})).toBe(true);
expect(fn({ foo: 1 })).toBe(true);
});
it('handles multiple keys', () => {
const filter = { foo: 1, bar: 2 };
const fn = compileFilter<any>(filter);
expect(fn({ foo: 1, bar: 2 })).toBe(true);
expect(fn({ foo: 1, bar: 3 })).toBe(false);
});
it('handles complex nested structures', () => {
const filter = { $or: [{ a: { $exists: false } }, { b: { $eq: true } }] };
const fn = compileFilter<any>(filter);
expect(fn({ a: undefined, b: true })).toBe(true);
expect(fn({ a: '123', b: true })).toBe(true);
expect(fn({ a: '123', b: false })).toBe(false);
expect(fn({ a: undefined, b: false })).toBe(true);
});
it('throws error for unsupported operators', () => {
const filter = { $unsupported: 'value' };
expect(() => compileFilter<any>(filter)).toThrow('Unrecognized logical operator: $unsupported');
});
});

View File

@ -47,6 +47,16 @@ const $all =
return operand.every((operandElement) => value.some((valueElement) => equals(operandElement, valueElement)));
};
const $eq =
<T>(operand: T, _options: undefined): ((value: T) => boolean) =>
(value: T): boolean => {
if (value === undefined) {
return false;
}
return flatSome(value, (x) => equals(x, operand));
};
const $lt =
<T>(operand: T, _options: undefined): ((value: T) => boolean) =>
(value: T): boolean =>
@ -130,7 +140,9 @@ const $elemMatch = <T>(operand: Filter<T>, _options: undefined): ((value: T) =>
const $not = <T>(operand: FieldExpression<T>, _options: undefined): ((value: T) => boolean) => {
const matcher = compileValueSelector(operand);
return (value: T): boolean => !matcher(value);
return (value: T): boolean => {
return !matcher(value);
};
};
const dummyOperator =
@ -143,6 +155,7 @@ const $near = dummyOperator;
const $geoIntersects = dummyOperator;
const valueOperators = {
$eq,
$in,
$nin,
$all,
@ -179,8 +192,8 @@ const $nor = <T>(subSelector: Filter<T>[]): ((doc: T) => boolean) => {
};
const $where = <T>(selectorValue: string | ((doc: T) => boolean)): ((doc: T) => boolean) => {
const fn = selectorValue instanceof Function ? selectorValue : Function(`return ${selectorValue}`);
return (doc: T): boolean => !!fn.call(doc);
const fn = selectorValue instanceof Function ? selectorValue : Function(`doc`, `return ${selectorValue}`);
return (doc: T): boolean => !!fn.call(doc, doc);
};
const logicalOperators = {
@ -287,6 +300,10 @@ export const compileFilter = <T>(filter: Filter<T> | FieldExpression<T>['$where'
}
}
if (key.slice(0, 1) === '$') {
throw new Error(`Unrecognized logical operator: ${key}`);
}
const lookUpByIndex = createLookupFunction(key);
const valueSelectorFunc = compileValueSelector(subSelector);
return (doc: T): boolean => {

View File

@ -9233,7 +9233,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "@rocket.chat/mongo-adapter@workspace:packages/mongo-adapter"
dependencies:
"@rocket.chat/jest-presets": "workspace:~"
eslint: "npm:~8.45.0"
jest: "npm:~29.7.0"
typescript: "npm:~5.8.3"
languageName: unknown
linkType: soft