Skip to content
Open
Changes from all commits
Commits
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
67 changes: 51 additions & 16 deletions app/lib/methods/getUsersPresence.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { InteractionManager } from 'react-native';
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import { type Model, Q } from '@nozbe/watermelondb';

import { type IActiveUsers } from '../../reducers/activeUsers';
import { store as reduxStore } from '../store/auxStore';
import { setActiveUsers } from '../../actions/activeUsers';
import { setUser } from '../../actions/login';
import database from '../database';
import { type IUser } from '../../definitions';
import { type IUser, type TUserModel } from '../../definitions';
import sdk from '../services/sdk';
import { compareServerVersion } from './helpers';
import userPreferences from './userPreferences';
import { NOTIFICATION_PRESENCE_CAP } from '../constants/notifications';
import { setNotificationPresenceCap } from '../../actions/app';
import log from './helpers/log';

export const _activeUsersSubTimeout: { activeUsersSubTimeout: boolean | ReturnType<typeof setTimeout> | number } = {
activeUsersSubTimeout: false
Expand Down Expand Up @@ -87,27 +89,60 @@ export async function getUsersPresence(usersParams: string[]) {

const db = database.active;
const userCollection = db.get('users');
users.forEach(async (user: IUser) => {
try {
const userRecord = await userCollection.find(user._id);
await db.write(async () => {
await userRecord.update(u => {
try {
const userIds = users.map((u: IUser) => u._id);
const chunks: string[][] = [];
for (let i = 0; i < userIds.length; i += 900) {
chunks.push(userIds.slice(i, i + 900));
}
Comment on lines +95 to +97
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

The chunk size 900 is a magic number with no explanation. If this is to stay under SQLite/WatermelonDB parameter limits (commonly 999), please extract it into a named constant (e.g., SQLITE_MAX_BIND_PARAMS_MARGIN) and add a short comment explaining why this value is chosen so future changes don’t accidentally break large presence updates.

Copilot uses AI. Check for mistakes.
const existingRecords = (await Promise.all(chunks.map(chunk => userCollection.query(Q.where('id', Q.oneOf(chunk))).fetch()))).flat();
const existingRecordsMap = new Map<string, TUserModel>(existingRecords.map(u => [u.id, u]));

const operations: Model[] = users.map((user: IUser) => {
const existingRecord = existingRecordsMap.get(user._id);
if (existingRecord) {
return existingRecord.prepareUpdate((u: TUserModel) => {
Object.assign(u, user);
});
}
return userCollection.prepareCreate(u => {
u._raw = sanitizedRaw({ id: user._id }, userCollection.schema);
Object.assign(u, user);
Comment on lines +104 to +110
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== User model fields (check declared `@field/`@json columns) =="
cat -n app/lib/database/model/User.js 2>/dev/null | head -250

echo
echo "== Schema definition for users table =="
find . -type f \( -name "*.js" -o -name "*.ts" \) -path "*/database/*" | xargs grep -l "users" | head -5
echo "Searching for users schema definition..."
rg -n "users|status_text|statusText" app/lib/database -B2 -A2 --type ts --type js | head -100

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 7245


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== getUsersPresence.ts imports and context =="
cat -n app/lib/methods/getUsersPresence.ts | head -120

echo
echo "== Check which User model is imported =="
rg -n "import.*User|from.*database" app/lib/methods/getUsersPresence.ts

echo
echo "== Full app/lib/database/schema/app.js users table schema =="
rg -A20 "name: 'users'" app/lib/database/schema/app.js | head -40

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 5996


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Database initialization/configuration =="
cat -n app/lib/database/index.ts 2>/dev/null || cat -n app/lib/database/index.js 2>/dev/null | head -100

echo
echo "== Search for database.active definition =="
rg -n "database.*active|\.active\s*=" app/lib/database -B3 -A3 | head -50

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 6978


Add status and statusText field declarations to the User model to persist presence data.

Lines 105 and 110 assign the full server user payload via Object.assign(u, user). The User model at app/lib/database/model/User.js lacks @field decorators for status and statusText, so WatermelonDB silently discards these writes. This leaves presence data inconsistent—Redux holds the values but the database does not. Add these fields to the User model and ensure the app schema includes the corresponding columns.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/lib/methods/getUsersPresence.ts` around lines 104 - 110, The User model
is missing `@field` declarations for status and statusText so Object.assign(u,
user) in getUsersPresence.ts (inside existingRecord.prepareUpdate and
userCollection.prepareCreate) writes are dropped by WatermelonDB; add two new
fields (status and statusText) with `@field` decorators to the User model class
(e.g., class User / User.js) and update the database schema to include
corresponding columns (status, statusText) and migration if needed so presence
values persisted; ensure any TypeScript types/interfaces for TUserModel or the
model exports are updated to include these properties.

});
} catch (e) {
// User not found
});
Comment on lines +101 to +112
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

operations is built based on a pre-fetch of existing records. If getUsersPresence can run concurrently (it’s called from setTimeout without awaiting), two in-flight calls can both decide a user doesn’t exist and both prepare a create for the same id, causing the same unique-constraint crash this PR aims to avoid. Consider serializing getUsersPresence executions (e.g., an in-flight promise/mutex) and/or handling unique-constraint failures by re-fetching and retrying as an update instead of a create.

Copilot uses AI. Check for mistakes.

try {
await db.write(async () => {
await userCollection.create(u => {
u._raw = sanitizedRaw({ id: user._id }, userCollection.schema);
Object.assign(u, user);
});
await db.batch(...operations);
});
} catch (e) {
log(e);
const failedOperations: Model[] = [];
for (const operation of operations) {
try {
// eslint-disable-next-line no-await-in-loop
await db.write(() => db.batch(operation));
} catch (operationError: any) {
log({
message: 'Fallback per-operation write failed',
operationId: operation.id,
operationTable: operation.collection.table,
error: operationError?.message || operationError
});
failedOperations.push(operation);
}
}
Comment on lines +121 to +134
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

The fallback path retries by writing each operation individually (db.write(() => db.batch(operation))). If the initial batch fails due to a single bad record, this can reintroduce the same performance issue (many writes) and significantly delay UI/sync work. A more scalable fallback is to retry in smaller batches (e.g., split operations into chunks or bisect to isolate the failing op) and only fall back to per-item writes for the minimal failing subset.

Suggested change
for (const operation of operations) {
try {
// eslint-disable-next-line no-await-in-loop
await db.write(() => db.batch(operation));
} catch (operationError: any) {
log({
message: 'Fallback per-operation write failed',
operationId: operation.id,
operationTable: operation.collection.table,
error: operationError?.message || operationError
});
failedOperations.push(operation);
}
}
const processBatchWithFallback = async (ops: Model[]): Promise<void> => {
if (!ops.length) {
return;
}
try {
await db.write(async () => {
await db.batch(...ops);
});
} catch (batchError: any) {
if (ops.length === 1) {
const operation = ops[0];
log({
message: 'Fallback per-operation write failed',
operationId: operation.id,
operationTable: operation.collection.table,
error: batchError?.message || batchError
});
failedOperations.push(operation);
return;
}
const mid = Math.floor(ops.length / 2);
const left = ops.slice(0, mid);
const right = ops.slice(mid);
await processBatchWithFallback(left);
await processBatchWithFallback(right);
}
};
await processBatchWithFallback(operations);

Copilot uses AI. Check for mistakes.
// If we want to escalate all failures at the end:
if (failedOperations.length > 0) {
log({ message: `Fallback completed with ${failedOperations.length} failed operations` });
}
}
});
} catch (e) {
log(e);
}
}
} catch {
// do nothing
} catch (e) {
log(e);
}
}
}
Expand All @@ -117,7 +152,7 @@ let usersTimer: ReturnType<typeof setTimeout> | null = null;
export function getUserPresence(uid: string) {
if (!usersTimer) {
usersTimer = setTimeout(() => {
getUsersPresence(usersBatch);
getUsersPresence([...new Set(usersBatch)]);
usersBatch = [];
usersTimer = null;
}, 2000);
Expand Down