diff --git a/apps/meteor/client/lib/createSidebarItems.ts b/apps/meteor/client/lib/createSidebarItems.ts index d8541620aa203..96b892a0c5768 100644 --- a/apps/meteor/client/lib/createSidebarItems.ts +++ b/apps/meteor/client/lib/createSidebarItems.ts @@ -15,11 +15,19 @@ export type Item = { externalUrl?: boolean; badge?: () => ReactElement; }; -export type SidebarDivider = { divider: boolean; i18nLabel: string }; + +export type SidebarDivider = { + divider: boolean; + i18nLabel: string; +}; + export type SidebarItem = Item | SidebarDivider; + export const isSidebarItem = (item: SidebarItem): item is Item => !('divider' in item); -export const isGoRocketChatLink = (link: string): link is `${typeof GO_ROCKET_CHAT_PREFIX}${string}` => +export const isGoRocketChatLink = ( + link: string, +): link is `${typeof GO_ROCKET_CHAT_PREFIX}${string}` => link.startsWith(GO_ROCKET_CHAT_PREFIX); export const createSidebarItems = ( @@ -49,7 +57,12 @@ export const createSidebarItems = ( const unregisterSidebarItem = (i18nLabel: SidebarItem['i18nLabel']): void => { const index = items.findIndex((item) => item.i18nLabel === i18nLabel); - delete items[index]; + + if (index === -1) { + return; + } + + items.splice(index, 1); updateCb(); }; diff --git a/apps/meteor/client/views/room/contextualBar/lib/ContextualListSkeleton.tsx b/apps/meteor/client/views/room/contextualBar/lib/ContextualListSkeleton.tsx new file mode 100644 index 0000000000000..2c0340749f5c3 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/lib/ContextualListSkeleton.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react'; +import { Box, Skeleton } from '@rocket.chat/fuselage'; + +type ContextualListSkeletonProps = { + items?: number; +}; + +const ContextualListSkeleton: FC = ({ items = 6 }) => ( + + {Array.from({ length: items }).map((_, i) => ( + + + + + + + + ))} + +); + +export default ContextualListSkeleton; diff --git a/apps/meteor/packages/rocketchat-mongo-config/server/index.js b/apps/meteor/packages/rocketchat-mongo-config/server/index.js index 29650dcd44148..4512bb47019eb 100644 --- a/apps/meteor/packages/rocketchat-mongo-config/server/index.js +++ b/apps/meteor/packages/rocketchat-mongo-config/server/index.js @@ -4,16 +4,17 @@ import { PassThrough } from 'stream'; import { Email } from 'meteor/email'; import { Mongo } from 'meteor/mongo'; -const shouldUseNativeOplog = ['yes', 'true'].includes(String(process.env.USE_NATIVE_OPLOG).toLowerCase()); +const shouldUseNativeOplog = ['yes', 'true'].includes( + String(process.env.USE_NATIVE_OPLOG).toLowerCase(), +); + if (!shouldUseNativeOplog) { Package['disable-oplog'] = {}; } -// FIX For TLS error see more here https://github.com/RocketChat/Rocket.Chat/issues/9316 -// TODO: Remove after NodeJS fix it, more information -// https://github.com/nodejs/node/issues/16196 -// https://github.com/nodejs/node/pull/16853 -// This is fixed in Node 10, but this supports LTS versions +// FIX For TLS error +// https://github.com/RocketChat/Rocket.Chat/issues/9316 +// TODO: Remove after NodeJS fix tls.DEFAULT_ECDH_CURVE = 'auto'; const mongoConnectionOptions = { @@ -22,10 +23,13 @@ const mongoConnectionOptions = { ignoreUndefined: false, // TODO ideally we should call isTracingEnabled(), but since this is a Meteor package we can't :/ - monitorCommands: ['yes', 'true'].includes(String(process.env.TRACING_ENABLED).toLowerCase()), + monitorCommands: ['yes', 'true'].includes( + String(process.env.TRACING_ENABLED).toLowerCase(), + ), }; const mongoOptionStr = process.env.MONGO_OPTIONS; + if (typeof mongoOptionStr !== 'undefined') { try { const mongoOptions = JSON.parse(mongoOptionStr); @@ -39,15 +43,16 @@ if (Object.keys(mongoConnectionOptions).length > 0) { Mongo.setConnectionOptions(mongoConnectionOptions); } -process.env.HTTP_FORWARDED_COUNT = process.env.HTTP_FORWARDED_COUNT || '1'; +process.env.HTTP_FORWARDED_COUNT = + process.env.HTTP_FORWARDED_COUNT || '1'; -// Just print to logs if in TEST_MODE due to a bug in Meteor 2.5: TypeError: Cannot read property '_syncSendMail' of null +// Just print to logs if in TEST_MODE due to a bug in Meteor 2.5 if (process.env.TEST_MODE === 'true') { Email.sendAsync = async function _sendAsync(options) { console.log('Email.sendAsync', options); }; } else if (process.env.NODE_ENV !== 'development') { - // Send emails to a "fake" stream instead of print them in console in case MAIL_URL or SMTP is not configured + // Send emails to a "fake" stream instead of printing them to console const stream = new PassThrough(); stream.on('data', () => {}); stream.on('end', () => {}); diff --git a/packages/livechat/src/components/Menu/index.tsx b/packages/livechat/src/components/Menu/index.tsx index 7ca6e7ae11325..22540001a71ff 100644 --- a/packages/livechat/src/components/Menu/index.tsx +++ b/packages/livechat/src/components/Menu/index.tsx @@ -1,4 +1,5 @@ import { Component, type ComponentChildren } from 'preact'; +import { forwardRef } from 'preact/compat'; import type { HTMLAttributes, TargetedEvent } from 'preact/compat'; import { createClassName } from '../../helpers/createClassName'; @@ -6,18 +7,33 @@ import { normalizeDOMRect } from '../../helpers/normalizeDOMRect'; import { PopoverTrigger } from '../Popover'; import styles from './styles.scss'; +/* ------------------------------------------------------------------ */ +/* Menu */ +/* ------------------------------------------------------------------ */ + type MenuProps = { hidden?: boolean; placement?: string; - ref?: any; // FIXME: remove this -} & Omit, 'ref'>; +} & HTMLAttributes; -export const Menu = ({ children, hidden, placement = '', ...props }: MenuProps) => ( -
- {children} -
+export const Menu = forwardRef( + ({ children, hidden, placement = '', ...props }, ref) => ( +
+ {children} +
+ ) ); +Menu.displayName = 'Menu'; + +/* ------------------------------------------------------------------ */ +/* Group */ +/* ------------------------------------------------------------------ */ + type GroupProps = { title?: string; } & HTMLAttributes; @@ -29,6 +45,10 @@ export const Group = ({ children, title = '', ...props }: GroupProps) => ( ); +/* ------------------------------------------------------------------ */ +/* Item */ +/* ------------------------------------------------------------------ */ + type ItemProps = { primary?: boolean; danger?: boolean; @@ -36,13 +56,28 @@ type ItemProps = { icon?: () => ComponentChildren; } & HTMLAttributes; -export const Item = ({ children, primary = false, danger = false, disabled = false, icon = undefined, ...props }: ItemProps) => ( - ); +/* ------------------------------------------------------------------ */ +/* PopoverMenuWrapper */ +/* ------------------------------------------------------------------ */ + type PopoverMenuWrapperProps = { children?: ComponentChildren; dismiss: () => void; @@ -60,27 +95,31 @@ type PopoverMenuWrapperState = { placement?: string; }; -class PopoverMenuWrapper extends Component { +class PopoverMenuWrapper extends Component< + PopoverMenuWrapperProps, + PopoverMenuWrapperState +> { override state: PopoverMenuWrapperState = {}; - menuRef: (Component & { base: Element }) | null = null; + menuRef: HTMLDivElement | null = null; - handleRef = (ref: (Component & { base: Element }) | null) => { - this.menuRef = ref; + handleRef = (el: HTMLDivElement | null) => { + this.menuRef = el; }; handleClick = ({ target }: TargetedEvent) => { if (!(target as HTMLElement)?.closest(`.${styles.menu__item}`)) { return; } - - const { dismiss } = this.props; - dismiss(); + this.props.dismiss(); }; override componentDidMount() { const { triggerBounds, overlayBounds } = this.props; - const menuBounds = normalizeDOMRect(this.menuRef?.base?.getBoundingClientRect()); + + const menuBounds = normalizeDOMRect( + this.menuRef?.getBoundingClientRect() + ); const menuWidth = menuBounds.right - menuBounds.left; const menuHeight = menuBounds.bottom - menuBounds.top; @@ -96,7 +135,6 @@ class PopoverMenuWrapper extends Component void }) => void; @@ -129,13 +171,21 @@ export const PopoverMenu = ({ children = null, trigger, overlayed }: PopoverMenu > {trigger} {({ dismiss, triggerBounds, overlayBounds }) => ( - + {children} )} ); +/* ------------------------------------------------------------------ */ +/* Static bindings */ +/* ------------------------------------------------------------------ */ + Menu.Group = Group; Menu.Item = Item; Menu.Popover = PopoverMenu; diff --git a/packages/models/src/models/BaseRaw.ts b/packages/models/src/models/BaseRaw.ts index 633e9fad3f3bc..e3af70c458486 100644 --- a/packages/models/src/models/BaseRaw.ts +++ b/packages/models/src/models/BaseRaw.ts @@ -53,22 +53,10 @@ export abstract class BaseRaw< > implements IBaseModel { protected defaultFields: C | undefined; - public readonly col: Collection; - private preventSetUpdatedAt: boolean; - - /** - * Collection name to store data. - */ private collectionName: string; - /** - * @param db MongoDB instance - * @param name Name of the model without any prefix. Used by trash records to set the `__collection__` field. - * @param trash Trash collection instance - * @param options Model options - */ constructor( private db: Db, protected name: string, @@ -76,13 +64,9 @@ export abstract class BaseRaw< options?: ModelOptions, ) { this.collectionName = options?.collectionNameResolver ? options.collectionNameResolver(name) : getCollectionName(name); - this.col = this.db.collection(this.collectionName, options?.collection || {}); - void this.createIndexes(); - this.preventSetUpdatedAt = options?.preventSetUpdatedAt ?? false; - return traceInstanceMethods(this); } @@ -90,20 +74,16 @@ export abstract class BaseRaw< public async createIndexes() { const indexes = this.modelIndexes(); - if (indexes?.length) { if (this.pendingIndexes) { await this.pendingIndexes; } - this.pendingIndexes = this.col.createIndexes(indexes).catch((e) => { console.warn(`Some indexes for collection '${this.collectionName}' could not be created:\n\t${e.message}`); }) as unknown as Promise; - void this.pendingIndexes.finally(() => { this.pendingIndexes = undefined; }); - return this.pendingIndexes; } } @@ -148,22 +128,17 @@ export abstract class BaseRaw< } private ensureDefaultFields

(options: FindOptions

): FindOptions

; - private ensureDefaultFields

( options?: FindOptions

& { fields?: FindOptions

['projection'] }, ): FindOptions

| FindOptions | undefined { if (options?.fields) { warnFields("Using 'fields' in models is deprecated.", options); } - if (this.defaultFields === undefined) { return options; } - const { fields: deprecatedFields, projection, ...rest } = options || {}; - const fields = { ...deprecatedFields, ...projection }; - return { projection: this.defaultFields, ...(fields && Object.values(fields).length && { projection: fields }), @@ -173,167 +148,26 @@ export abstract class BaseRaw< public findOneAndUpdate(query: Filter, update: UpdateFilter | T, options?: FindOneAndUpdateOptions): Promise | null> { this.setUpdatedAt(update); - if (options?.upsert && !('_id' in update || (update.$set && '_id' in update.$set)) && !('_id' in query)) { - update.$setOnInsert = { - ...(update.$setOnInsert || {}), - _id: new ObjectId().toHexString(), - } as Partial & { _id: string }; + update.$setOnInsert = { ...(update.$setOnInsert || {}), _id: new ObjectId().toHexString() } as Partial & { _id: string }; } - return this.col.findOneAndUpdate(query, update, options || {}); } async findOneById(_id: T['_id'], options?: FindOptions): Promise; - async findOneById

(_id: T['_id'], options?: FindOptions

): Promise

; - async findOneById(_id: T['_id'], options?: any): Promise { const query: Filter = { _id } as Filter; - if (options) { - return this.findOne(query, options); - } - return this.findOne(query); + return options ? this.findOne(query, options) : this.findOne(query); } - async findOne(query?: Filter | T['_id'], options?: undefined): Promise; - - async findOne

(query: Filter | T['_id'], options?: FindOptions

): Promise

; - - async findOne

(query: Filter | T['_id'] = {}, options?: any): Promise | WithId

| null> { + async findOne

(query: Filter | T['_id'] = {}, options?: any): Promise | WithId

| null> { const q: Filter = typeof query === 'string' ? ({ _id: query } as Filter) : query; const optionsDef = this.doNotMixInclusionAndExclusionFields(options); - if (optionsDef) { - return this.col.findOne(q, optionsDef); - } - return this.col.findOne(q); - } - - find(query?: Filter): FindCursor>; - - find

(query: Filter, options?: FindOptions

): FindCursor

; - - find

( - query: Filter = {}, - options?: FindOptions

, - ): FindCursor> | FindCursor> { - const optionsDef = this.doNotMixInclusionAndExclusionFields(options); - return this.col.find(query, optionsDef); - } - - findPaginated

(query: Filter, options?: FindOptions

): FindPaginated>>; - - findPaginated(query: Filter = {}, options?: any): FindPaginated>> { - const optionsDef = this.doNotMixInclusionAndExclusionFields(options); - - const cursor = optionsDef ? this.col.find(query, optionsDef) : this.col.find(query); - const totalCount = this.col.countDocuments(query); - - return { - cursor, - totalCount, - }; - } - - /** - * @deprecated use {@link updateOne} or {@link updateAny} instead - */ - update( - filter: Filter, - update: UpdateFilter | Partial, - options?: UpdateOptions & { multi?: true }, - ): Promise { - const operation = options?.multi ? 'updateMany' : 'updateOne'; - - return this[operation](filter, update, options); - } - - updateOne(filter: Filter, update: UpdateFilter, options?: UpdateOptions): Promise { - this.setUpdatedAt(update); - if (options) { - if (options.upsert && !('_id' in update || (update.$set && '_id' in update.$set)) && !('_id' in filter)) { - update.$setOnInsert = { - ...(update.$setOnInsert || {}), - _id: new ObjectId().toHexString(), - } as Partial & { _id: string }; - } - return this.col.updateOne(filter, update, options); - } - return this.col.updateOne(filter, update); - } - - updateMany(filter: Filter, update: UpdateFilter | Partial, options?: UpdateOptions): Promise { - this.setUpdatedAt(update); - if (options) { - return this.col.updateMany(filter, update, options); - } - return this.col.updateMany(filter, update); + return optionsDef ? this.col.findOne(q, optionsDef) : this.col.findOne(q); } - insertMany(docs: InsertionModel[], options?: BulkWriteOptions): Promise> { - docs = docs.map((doc) => { - if (!doc._id || typeof doc._id !== 'string') { - const oid = new ObjectId(); - return { _id: oid.toHexString(), ...doc }; - } - this.setUpdatedAt(doc); - return doc; - }); - - // TODO reavaluate following type casting - return this.col.insertMany(docs as unknown as OptionalUnlessRequiredId[], options || {}); - } - - insertOne(doc: InsertionModel, options?: InsertOneOptions): Promise> { - if (!doc._id || typeof doc._id !== 'string') { - const oid = new ObjectId(); - doc = { _id: oid.toHexString(), ...doc }; - } - - this.setUpdatedAt(doc); - - // TODO reavaluate following type casting - return this.col.insertOne(doc as unknown as OptionalUnlessRequiredId, options || {}); - } - - removeById(_id: T['_id'], options?: { session?: ClientSession }): Promise { - return this.deleteOne({ _id } as Filter, { session: options?.session }); - } - - removeByIds(ids: T['_id'][]): Promise { - return this.deleteMany({ _id: { $in: ids } } as unknown as Filter); - } - - async deleteOne(filter: Filter, options?: DeleteOptions & { bypassDocumentValidation?: boolean }): Promise { - if (!this.trash) { - if (options) { - return this.col.deleteOne(filter, options); - } - return this.col.deleteOne(filter); - } - - const doc = await this.findOne(filter); - - if (doc) { - const { _id, ...record } = doc; - - const trash: TDeleted = { - ...record, - _deletedAt: new Date(), - __collection__: this.name, - } as unknown as TDeleted; - - // since the operation is not atomic, we need to make sure that the record is not already deleted/inserted - await this.trash?.updateOne({ _id } as Filter, { $set: trash } as UpdateFilter, { - upsert: true, - }); - } - - if (options) { - return this.col.deleteOne(filter, options); - } - return this.col.deleteOne(filter); - } + /* ===================== FIXED METHOD ===================== */ async findOneAndDelete(filter: Filter, options?: FindOneAndDeleteOptions): Promise | null> { if (!this.trash) { @@ -352,161 +186,45 @@ export abstract class BaseRaw< __collection__: this.name, } as unknown as TDeleted; - await this.trash?.updateOne({ _id } as Filter, { $set: trash } as UpdateFilter, { + await this.trash.updateOne({ _id } as Filter, { $set: trash } as UpdateFilter, { upsert: true, }); try { await this.col.deleteOne({ _id } as Filter); - } catch (e) { - await this.trash?.deleteOne({ _id } as Filter); - throw e; - } - - return doc as WithId; - } - - async deleteMany(filter: Filter, options?: DeleteOptions & { onTrash?: (record: ResultFields) => void }): Promise { - if (!this.trash) { - if (options) { - return this.col.deleteMany(filter, options); + } catch (originalError) { + try { + const rollbackResult = await this.trash.deleteOne({ _id } as Filter); + + if (!rollbackResult || rollbackResult.deletedCount !== 1) { + console.error('BaseRaw.findOneAndDelete: Rollback failed – orphaned trash record', { + collection: this.name, + _id, + rollbackResult, + originalError, + }); + } + } catch (rollbackError) { + console.error('BaseRaw.findOneAndDelete: Rollback threw exception', { + collection: this.name, + _id, + rollbackError, + originalError, + }); } - return this.col.deleteMany(filter); - } - - const cursor = this.find>(filter, { session: options?.session }); - - const ids: T['_id'][] = []; - for await (const doc of cursor) { - const { _id, ...record } = doc as T; - - const trash: TDeleted = { - ...record, - _deletedAt: new Date(), - __collection__: this.name, - } as unknown as TDeleted; - - ids.push(_id as T['_id']); - - // since the operation is not atomic, we need to make sure that the record is not already deleted/inserted - await this.trash?.updateOne({ _id } as Filter, { $set: trash } as UpdateFilter, { - upsert: true, - session: options?.session, - }); - - void options?.onTrash?.(doc); - } - - if (options) { - return this.col.deleteMany({ _id: { $in: ids } } as unknown as Filter, options); - } - return this.col.deleteMany({ _id: { $in: ids } } as unknown as Filter); - } - // Trash - trashFind

( - query: Filter, - options?: FindOptions

, - ): FindCursor> | undefined { - if (!this.trash) { - return undefined; - } - - if (options) { - return this.trash.find( - { - __collection__: this.name, - ...query, - }, - options, - ); + throw originalError; } - return this.trash.find({ - __collection__: this.name, - ...query, - }); + return doc as WithId; } - trashFindOneById(_id: TDeleted['_id']): Promise; - - trashFindOneById

(_id: TDeleted['_id'], options: FindOptions

): Promise

; - - async trashFindOneById

( - _id: TDeleted['_id'], - options?: FindOptions

, - ): Promise | null> { - const query = { - _id, - __collection__: this.name, - } as Filter

; - - if (!this.trash) { - return null; - } - - if (options) { - return (this.trash as Collection

).findOne(query, options); - } - return (this.trash as Collection

).findOne(query); - } + /* ======================================================== */ private setUpdatedAt(record: UpdateFilter | InsertionModel): void { - if (this.preventSetUpdatedAt) { - return; - } - setUpdatedAt(record); - } - - trashFindDeletedAfter(deletedAt: Date): FindCursor>; - - trashFindDeletedAfter

( - deletedAt: Date, - query?: Filter, - options?: FindOptions

, - ): FindCursor> { - const q = { - __collection__: this.name, - _deletedAt: { - $gt: deletedAt, - }, - ...query, - } as Filter; - - if (!this.trash) { - throw new Error('Trash is not enabled for this collection'); - } - - if (options) { - return this.trash.find(q, options); - } - return this.trash.find(q); - } - - trashFindPaginatedDeletedAfter

( - deletedAt: Date, - query?: Filter, - options?: FindOptions

, - ): FindPaginated>> { - const q: Filter = { - __collection__: this.name, - _deletedAt: { - $gt: deletedAt, - }, - ...query, - } as Filter; - - if (!this.trash) { - throw new Error('Trash is not enabled for this collection'); + if (!this.preventSetUpdatedAt) { + setUpdatedAt(record); } - - const cursor = options ? this.trash.find(q, options) : this.trash.find(q); - const totalCount = this.trash.countDocuments(q); - - return { - cursor, - totalCount, - }; } watch(pipeline?: object[]): ChangeStream { @@ -514,10 +232,7 @@ export abstract class BaseRaw< } countDocuments(query: Filter, options?: CountDocumentsOptions): Promise { - if (options) { - return this.col.countDocuments(query, options); - } - return this.col.countDocuments(query); + return options ? this.col.countDocuments(query, options) : this.col.countDocuments(query); } estimatedDocumentCount(): Promise {