Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1bba949
fix(sidebar): avoid sparse arrays when unregistering sidebar items
sharanyamahajan Jan 10, 2026
1d2d5ad
feat(ui): add skeleton loader to room members contextual bar
sharanyamahajan Jan 13, 2026
3d88da3
Merge branch 'develop' into feat/contextualbar-skeleton-loader
sharanyamahajan Jan 13, 2026
e024740
fix: prevent silent rollback failure in findOneAndDelete
sharanyamahajan Jan 14, 2026
4a77384
Merge pull request #1 from sharanyamahajan/fix-admin-home-mobile-card
sharanyamahajan Jan 15, 2026
7965766
Merge pull request #2 from sharanyamahajan/feat/contextualbar-skeleto…
sharanyamahajan Jan 16, 2026
396febe
fix: handle invalid JSON in MONGO_OPTIONS safely
sharanyamahajan Jan 17, 2026
04a703b
Merge pull request #3 from sharanyamahajan/fix/handle-invalid-mongo-o…
sharanyamahajan Jan 17, 2026
1d3fc02
refactor: remove setState from componentDidMount in PopoverMenuWrapper
sharanyamahajan Jan 23, 2026
5a13c99
fix: ignore whitespace-only edits in profile form initial values
sharanyamahajan Jan 24, 2026
33e5a1c
fix: stabilize public cached store cache token
sharanyamahajan Jan 25, 2026
a65a23b
refactor(livechat): replace any types in store state
sharanyamahajan Jan 26, 2026
5739d85
refactor: reduce complexity in RegisterForm
sharanyamahajan Jan 27, 2026
b037618
Merge branch 'develop' into feature/xyz
sharanyamahajan Jan 27, 2026
dcf8003
chore: standardize MONGO_OPTIONS error handling
sharanyamahajan Jan 27, 2026
55c6715
chore: finalize refactor and config updates
sharanyamahajan Jan 27, 2026
a60ecb7
Merge pull request #4 from sharanyamahajan/feature/xyz
sharanyamahajan Jan 27, 2026
55c862d
docs: add high-level architecture overview for contributors
sharanyamahajan Jan 28, 2026
78469d8
fix(ui): prevent horizontal overflow on home page mobile
sharanyamahajan Jan 29, 2026
2c13183
refactor: fix ABAC attributes typing in useRoomIcon
sharanyamahajan Jan 29, 2026
fa57ec5
Merge branch 'develop' into refactor/fix-abac-typing-useRoomIcon
sharanyamahajan Jan 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions apps/meteor/client/hooks/useRoomIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import type { IRoom } from '@rocket.chat/core-typings';
import type { IRoom, IAbacAttributeDefinition } from '@rocket.chat/core-typings';
import { isRoomFederated, isDirectMessageRoom } from '@rocket.chat/core-typings';
import type { Icon } from '@rocket.chat/fuselage';
import type { ComponentProps, ReactElement } from 'react';

import { ReactiveUserStatus } from '../components/UserStatus';

export const useRoomIcon = (
room: Pick<IRoom, 't' | 'prid' | 'teamMain' | 'uids' | 'u'>,
room: Pick<
IRoom,
't' | 'prid' | 'teamMain' | 'uids' | 'u' | 'abacAttributes'
>,
): ComponentProps<typeof Icon> | ReactElement | null => {
// @ts-expect-error TODO: Implement ABAC attributes in rooms
if (room.abacAttributes) {
if (room.abacAttributes && room.abacAttributes.length > 0) {
if (room.teamMain) {
return { name: 'team-shield' };
}
Expand Down
147 changes: 81 additions & 66 deletions apps/meteor/client/lib/cachedStores/CachedStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,24 @@ import { getConfig } from '../utils/getConfig';

type Name = 'rooms' | 'subscriptions' | 'permissions' | 'public-settings' | 'private-settings';

const hasId = <T>(record: T): record is T & { _id: string } => typeof record === 'object' && record !== null && '_id' in record;
const hasId = <T>(record: T): record is T & { _id: string } =>
typeof record === 'object' && record !== null && '_id' in record;

const hasUpdatedAt = <T>(record: T): record is T & { _updatedAt: Date } =>
typeof record === 'object' &&
record !== null &&
'_updatedAt' in record &&
(record as unknown as { _updatedAt: unknown })._updatedAt instanceof Date;

const hasDeletedAt = <T>(record: T): record is T & { _deletedAt: Date } =>
typeof record === 'object' &&
record !== null &&
'_deletedAt' in record &&
(record as unknown as { _deletedAt: unknown })._deletedAt instanceof Date;
const hasUnserializedUpdatedAt = <T>(record: T): record is T & { _updatedAt: ConstructorParameters<typeof Date>[0] } =>

const hasUnserializedUpdatedAt = <T>(
record: T,
): record is T & { _updatedAt: ConstructorParameters<typeof Date>[0] } =>
typeof record === 'object' &&
record !== null &&
'_updatedAt' in record &&
Expand All @@ -41,31 +47,42 @@ export interface IWithManageableCache {
clearCacheOnLogout(): void;
}

export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements IWithManageableCache {
export abstract class CachedStore<T extends IRocketChatRecord, U = T>
implements IWithManageableCache
{
private static readonly MAX_CACHE_TIME = 60 * 60 * 24 * 30;

readonly store: UseBoundStore<StoreApi<IDocumentMapStore<T>>>;

protected name: Name;

protected eventType: StreamNames;

private readonly version = 18;

private updatedAt = new Date(0);

protected log: (...args: any[]) => void;

private timer: ReturnType<typeof setTimeout>;

readonly useReady = create(() => false);

constructor({ name, eventType, store }: { name: Name; eventType: StreamNames; store: UseBoundStore<StoreApi<IDocumentMapStore<T>>> }) {
constructor({
name,
eventType,
store,
}: {
name: Name;
eventType: StreamNames;
store: UseBoundStore<StoreApi<IDocumentMapStore<T>>>;
}) {
this.name = name;
this.eventType = eventType;
this.store = store;

this.log = [getConfig(`debugCachedCollection-${this.name}`), getConfig('debugCachedCollection'), getConfig('debug')].includes('true')
this.log = [
getConfig(`debugCachedCollection-${this.name}`),
getConfig('debugCachedCollection'),
getConfig('debug'),
].includes('true')
? console.log.bind(console, `%cCachedCollection ${this.name}`, `color: navy; font-weight: bold;`)
: () => undefined;

Expand All @@ -82,21 +99,27 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements
protected abstract getToken(): unknown;

private async loadFromCache() {
const data = await localforage.getItem<{ version: number; token: unknown; records: unknown[]; updatedAt: Date | string }>(this.name);
const data = await localforage.getItem<{
version: number;
token: unknown;
records: unknown[];
updatedAt: Date | string;
}>(this.name);

if (!data) {
return false;
}

if (data.version < this.version || data.token !== this.getToken()) {
const token = this.getToken();

if (data.version < this.version || (token !== undefined && data.token !== token)) {
return false;
}

if (data.records.length <= 0) {
return false;
}

// updatedAt may be a Date or a string depending on the used localForage backend
if (!(data.updatedAt instanceof Date)) {
data.updatedAt = new Date(data.updatedAt);
}
Expand All @@ -107,16 +130,19 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements

this.log(`${data.records.length} records loaded from cache`);

const deserializedRecords = data.records.map((record) => this.deserializeFromCache(record)).filter(isTruthy);
const deserializedRecords = data.records
.map((record) => this.deserializeFromCache(record))
.filter(isTruthy);

const updatedAt = Math.max(...deserializedRecords.filter(hasUpdatedAt).map((record) => record?._updatedAt.getTime() ?? 0));
const updatedAt = Math.max(
...deserializedRecords.filter(hasUpdatedAt).map((record) => record._updatedAt.getTime()),
);

if (updatedAt > this.updatedAt.getTime()) {
this.updatedAt = new Date(updatedAt);
}

this.store.getState().replaceAll(deserializedRecords.filter(hasId));

this.updatedAt = data.updatedAt || this.updatedAt;

return true;
Expand All @@ -128,28 +154,27 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements
}

return {
...(record as unknown as T),
...(record as T),
...(hasUnserializedUpdatedAt(record) && {
_updatedAt: new Date(record._updatedAt),
}),
};
}

private async callLoad() {
// TODO: workaround for bad function overload
const data = await sdk.call(`${this.name}/get`);
return data as unknown as U[];
return data as U[];
}

private async callSync(updatedSince: Date) {
// TODO: workaround for bad function overload
const data = await sdk.call(`${this.name}/get`, updatedSince);
return data as unknown as { update: U[]; remove: U[] };
return data as { update: U[]; remove: U[] };
}

private async loadFromServer() {
const startTime = new Date();
const lastTime = this.updatedAt;

const data = await this.callLoad();
this.log(`${data.length} records loaded from server`);

Expand All @@ -170,16 +195,12 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements
}

protected mapRecord(record: U): T {
return record as unknown as T;
return record as T;
}

protected handleLoadedFromServer(_records: T[]): void {
// This method can be overridden to handle records after they are loaded from the server
}
protected handleLoadedFromServer(_records: T[]): void {}

protected handleSyncEvent(_action: 'removed' | 'changed', _record: T): void {
// This method can be overridden to handle sync events
}
protected handleSyncEvent(_action: 'removed' | 'changed', _record: T): void {}

private async loadFromServerAndPopulate() {
await this.loadFromServer();
Expand All @@ -206,10 +227,14 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements
}

protected setupListener() {
return sdk.stream(this.eventType, [this.eventName], (async (action: 'removed' | 'changed', record: U) => {
this.log('record received', action, record);
await this.handleRecordEvent(action, record);
}) as (...args: unknown[]) => void);
return sdk.stream(
this.eventType,
[this.eventName],
(async (action: 'removed' | 'changed', record: U) => {
this.log('record received', action, record);
await this.handleRecordEvent(action, record);
}) as (...args: unknown[]) => void,
);
}

protected async handleRecordEvent(action: 'removed' | 'changed', record: U) {
Expand All @@ -226,7 +251,6 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements

private trySync(delay = 10) {
clearTimeout(this.timer);
// Wait for an empty queue to load data again and sync
this.timer = setTimeout(async () => {
if (!(await this.sync())) {
return this.trySync(delay);
Expand All @@ -246,14 +270,13 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements
this.log(`syncing from ${this.updatedAt}`);

const data = await this.callSync(this.updatedAt);
const changes = [];
const changes: { action: () => void; timestamp: number }[] = [];

if (data.update && data.update.length > 0) {
this.log(`${data.update.length} records updated in sync`);
if (data.update?.length) {
for (const record of data.update) {
const newRecord = this.mapRecord(record);

const actionTime = hasUpdatedAt(newRecord) ? newRecord._updatedAt : startTime;

changes.push({
action: () => {
this.store.getState().store(newRecord);
Expand All @@ -267,16 +290,13 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements
}
}

if (data.remove && data.remove.length > 0) {
this.log(`${data.remove.length} records removed in sync`);
if (data.remove?.length) {
for (const record of data.remove) {
const newRecord = this.mapRecord(record);

if (!hasDeletedAt(newRecord)) {
continue;
}
if (!hasDeletedAt(newRecord)) continue;

const actionTime = newRecord._deletedAt;

changes.push({
action: () => {
this.store.getState().delete(newRecord._id);
Expand All @@ -290,18 +310,31 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements
}
}

changes
.sort((a, b) => a.timestamp - b.timestamp)
.forEach((c) => {
c.action();
});
changes.sort((a, b) => a.timestamp - b.timestamp).forEach((c) => c.action());

this.updatedAt = this.updatedAt === lastTime ? startTime : this.updatedAt;

return true;
}

private listenerUnsubscriber: (() => void) | undefined;
private reconnectionComputation: Tracker.Computation | undefined;
private initializationPromise: Promise<void> | undefined;

init() {
if (this.initializationPromise) {
return this.initializationPromise;
}

this.initializationPromise = this.performInitialization()
.catch(console.error)
.finally(() => {
this.initializationPromise = undefined;
this.setReady(true);
});

return this.initializationPromise;
Comment on lines +321 to +336
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Init resolves “ready” even on failure—consider preserving failure state.
Because errors are swallowed and setReady(true) runs in finally, a failed initialization still marks the store ready, which can mask missing data.

Suggested fix
-		this.initializationPromise = this.performInitialization()
-			.catch(console.error)
-			.finally(() => {
-				this.initializationPromise = undefined;
-				this.setReady(true);
-			});
+		this.initializationPromise = this.performInitialization()
+			.then(() => {
+				this.setReady(true);
+			})
+			.catch((err) => {
+				console.error(err);
+				throw err;
+			})
+			.finally(() => {
+				this.initializationPromise = undefined;
+			});
🤖 Prompt for AI Agents
In `@apps/meteor/client/lib/cachedStores/CachedStore.ts` around lines 321 - 336,
The init method currently swallows errors (via .catch(console.error)) and always
calls setReady(true) in .finally, which marks the store ready even when
performInitialization failed; change init so initializationPromise preserves
rejection (remove the silent swallow), call setReady(true) only on successful
completion of performInitialization (e.g., in .then), and call setReady(false)
or leave it unset on rejection (e.g., in .catch rethrow after logging), while
still clearing initializationPromise in a .finally block; locate symbols: init,
initializationPromise, performInitialization, setReady, and
reconnectionComputation to update the control flow accordingly.

}

private async performInitialization() {
if (await this.loadFromCache()) {
Expand All @@ -312,6 +345,7 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements

this.reconnectionComputation?.stop();
let wentOffline = Tracker.nonreactive(() => Meteor.status().status === 'offline');

this.reconnectionComputation = Tracker.autorun(() => {
const { status } = Meteor.status();

Expand All @@ -331,23 +365,6 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements
};
}

private initializationPromise: Promise<void> | undefined;

init() {
if (this.initializationPromise) {
return this.initializationPromise;
}

this.initializationPromise = this.performInitialization()
.catch(console.error)
.finally(() => {
this.initializationPromise = undefined;
this.setReady(true);
});

return this.initializationPromise;
}

async release() {
if (this.initializationPromise) {
await this.initializationPromise;
Expand All @@ -357,16 +374,14 @@ export abstract class CachedStore<T extends IRocketChatRecord, U = T> implements
this.setReady(false);
}

private reconnectionComputation: Tracker.Computation | undefined;

setReady(ready: boolean) {
this.useReady.setState(ready);
}
}

export class PublicCachedStore<T extends IRocketChatRecord, U = T> extends CachedStore<T, U> {
protected override getToken() {
return undefined;
return 'public';
}

override clearCacheOnLogout() {
Expand Down
19 changes: 16 additions & 3 deletions apps/meteor/client/lib/createSidebarItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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();
};

Expand Down
Loading