Skip to content
Merged
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions .changeset/wicked-moons-reconnect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes users being set back to online after a websocket reconnection (connection drop, network change, server restart) even though they had gone idle and never interacted with the UI again. The client now tracks the last UI interaction across connection drops and restates the away status as soon as the reconnected session is authenticated, instead of assuming the new session is online and restarting the idle countdown from scratch.
227 changes: 227 additions & 0 deletions apps/meteor/client/lib/userPresence.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import type { IUser } from '@rocket.chat/core-typings';
import { UserStatus } from '@rocket.chat/core-typings';
import { renderHook } from '@testing-library/react';

import { UserPresence } from './userPresence';

const IDLE_TIME_LIMIT = 300;
const IDLE_TIME_LIMIT_MS = IDLE_TIME_LIMIT * 1000;
const DEBOUNCE_WAIT = 1000;

const state = {
connected: true,
isLoggingIn: false,
user: { _id: 'john.doe', status: UserStatus.ONLINE, statusDefault: UserStatus.ONLINE } as unknown as IUser | undefined,
preferences: { enableAutoAway: true, idleTimeLimit: IDLE_TIME_LIMIT } as Record<string, unknown>,
};

const goOnline = jest.fn(async (): Promise<boolean | undefined> => true);
const goAway = jest.fn(async (): Promise<boolean | undefined> => true);
const storeUser = jest.fn();

jest.mock('@rocket.chat/ui-contexts', () => ({
useUser: () => state.user,
useConnectionStatus: () => ({ connected: state.connected }),
useIsLoggingIn: () => state.isLoggingIn,
useUserPreference: (key: string) => state.preferences[key],
useMethod: (method: string) => (method === 'UserPresence:online' ? goOnline : goAway),
}));

jest.mock('../stores', () => ({
Users: { use: (selector: (state: { store: unknown }) => unknown) => selector({ store: storeUser }) },
}));

describe('UserPresence', () => {
let userPresence: UserPresence;

beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));

goOnline.mockReset().mockResolvedValue(true);
goAway.mockReset().mockResolvedValue(true);
storeUser.mockReset();

state.connected = true;
state.isLoggingIn = false;
state.user = { _id: 'john.doe', status: UserStatus.ONLINE, statusDefault: UserStatus.ONLINE } as unknown as IUser;
state.preferences = { enableAutoAway: true, idleTimeLimit: IDLE_TIME_LIMIT };

userPresence = new UserPresence();
});

afterEach(() => {
jest.useRealTimers();
});

const render = () => renderHook(() => userPresence.use());

const dropConnection = (rerender: () => void) => {
state.connected = false;
rerender();
};

const restoreConnection = (rerender: () => void) => {
state.connected = true;
rerender();
};

const goIdle = async () => {
await jest.advanceTimersByTimeAsync(IDLE_TIME_LIMIT_MS + DEBOUNCE_WAIT);
};

it('should set the user away once the idle time limit is reached', async () => {
render();

await jest.advanceTimersByTimeAsync(IDLE_TIME_LIMIT_MS - 1000);
expect(goAway).not.toHaveBeenCalled();

await jest.advanceTimersByTimeAsync(1000 + DEBOUNCE_WAIT);
expect(goAway).toHaveBeenCalledTimes(1);
});

it('should keep an idle user away after a reconnection', async () => {
const { rerender } = render();

await goIdle();
expect(goAway).toHaveBeenCalledTimes(1);

dropConnection(rerender);
await jest.advanceTimersByTimeAsync(30_000);

restoreConnection(rerender);
await jest.advanceTimersByTimeAsync(0);

expect(goAway).toHaveBeenCalledTimes(2);
expect(goOnline).not.toHaveBeenCalled();
});

it('should not re-assert presence before the reconnected session is logged in', async () => {
const { rerender } = render();

await goIdle();
expect(goAway).toHaveBeenCalledTimes(1);

dropConnection(rerender);

state.connected = true;
state.isLoggingIn = true;
rerender();
await jest.advanceTimersByTimeAsync(0);
expect(goAway).toHaveBeenCalledTimes(1);

state.isLoggingIn = false;
rerender();
await jest.advanceTimersByTimeAsync(0);
expect(goAway).toHaveBeenCalledTimes(2);
});

it('should bring the user back online on reconnection if they interacted while disconnected', async () => {
const { rerender } = render();

await goIdle();
expect(goAway).toHaveBeenCalledTimes(1);

dropConnection(rerender);

document.dispatchEvent(new MouseEvent('mousemove'));

restoreConnection(rerender);
await jest.advanceTimersByTimeAsync(DEBOUNCE_WAIT);

// the reconnected session is already online on the server, so nothing else has to be asserted
expect(goAway).toHaveBeenCalledTimes(1);
});

it('should not restart the idle countdown from scratch on a reconnection', async () => {
const { rerender } = render();

await jest.advanceTimersByTimeAsync(IDLE_TIME_LIMIT_MS - 60_000);
expect(goAway).not.toHaveBeenCalled();

dropConnection(rerender);
restoreConnection(rerender);
await jest.advanceTimersByTimeAsync(0);

// still online, but only the remaining minute of inactivity is left
expect(goAway).not.toHaveBeenCalled();

await jest.advanceTimersByTimeAsync(60_000 + DEBOUNCE_WAIT);
expect(goAway).toHaveBeenCalledTimes(1);
});

it('should count the time spent disconnected as inactivity', async () => {
const { rerender } = render();

await jest.advanceTimersByTimeAsync(60_000);

dropConnection(rerender);
await jest.advanceTimersByTimeAsync(IDLE_TIME_LIMIT_MS);
expect(goAway).not.toHaveBeenCalled();

restoreConnection(rerender);
await jest.advanceTimersByTimeAsync(0);

expect(goAway).toHaveBeenCalledTimes(1);
});

it('should keep a desktop user away when the desktop app reported them idle while disconnected', async () => {
let setUserOnline: ((online: boolean) => void) | undefined;

Object.assign(window, {
RocketChatDesktop: {
setUserPresenceDetection: (options: { setUserOnline: (online: boolean) => void }) => {
setUserOnline = options.setUserOnline;
},
},
});

try {
const { rerender } = render();
expect(setUserOnline).toBeDefined();

dropConnection(rerender);

setUserOnline?.(false);
await jest.advanceTimersByTimeAsync(DEBOUNCE_WAIT);
expect(goAway).not.toHaveBeenCalled();

restoreConnection(rerender);
await jest.advanceTimersByTimeAsync(0);

expect(goAway).toHaveBeenCalledTimes(1);
} finally {
delete (window as { RocketChatDesktop?: unknown }).RocketChatDesktop;
}
});

it('should not force the user away on reconnection when auto away is disabled', async () => {
state.preferences = { enableAutoAway: false, idleTimeLimit: IDLE_TIME_LIMIT };

const { rerender } = render();

await jest.advanceTimersByTimeAsync(IDLE_TIME_LIMIT_MS * 2);

dropConnection(rerender);
restoreConnection(rerender);
await jest.advanceTimersByTimeAsync(DEBOUNCE_WAIT);

expect(goAway).not.toHaveBeenCalled();
});

it('should go online again after interacting with the UI', async () => {
render();

await goIdle();
expect(goAway).toHaveBeenCalledTimes(1);

document.dispatchEvent(new MouseEvent('mousemove'));
await jest.advanceTimersByTimeAsync(DEBOUNCE_WAIT);

expect(goOnline).toHaveBeenCalledTimes(1);

// and goes away again after a new idle period
await jest.advanceTimersByTimeAsync(IDLE_TIME_LIMIT_MS + DEBOUNCE_WAIT);
expect(goAway).toHaveBeenCalledTimes(2);
});
});
94 changes: 68 additions & 26 deletions apps/meteor/client/lib/userPresence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export class UserPresence {

private connected = true;

private lastActivityAt = Date.now();
Comment thread
rodrigok marked this conversation as resolved.

private idle = false;

private goOnline: () => Promise<boolean | undefined> = async () => undefined;

private goAway: () => Promise<boolean | undefined> = async () => undefined;
Expand All @@ -29,20 +33,38 @@ export class UserPresence {
startTimer() {
this.stopTimer();
if (!this.awayTime) return;

this.timer = setTimeout(this.setAway, this.awayTime);
const remaining = Math.max(this.awayTime - (Date.now() - this.lastActivityAt), 0);
this.timer = setTimeout(this.setAway, remaining);
}

private stopTimer() {
clearTimeout(this.timer);
}

private readonly setOnline = () => this.setStatus(UserStatus.ONLINE);
private readonly registerActivity = () => {
this.lastActivityAt = Date.now();
this.idle = false;
this.setStatus(UserStatus.ONLINE);
};

private readonly setAway = () => {
this.idle = true;
this.setStatus(UserStatus.AWAY);
};

private isIdle(): boolean {
if (this.awayTime) {
return Date.now() - this.lastActivityAt >= this.awayTime;
}
return this.idle;
}

private readonly setAway = () => this.setStatus(UserStatus.AWAY);
private readonly applyStatus = async (newStatus: UserStatus.ONLINE | UserStatus.AWAY, { force = false } = {}) => {
if (!this.connected) {
return;
}

private readonly setStatus = withDebouncing({ wait: 1000 })(async (newStatus: UserStatus.ONLINE | UserStatus.AWAY) => {
if (!this.connected || newStatus === this.status) {
if (newStatus === this.status && !force) {
this.startTimer();
return;
}
Expand All @@ -53,18 +75,37 @@ export class UserPresence {

switch (newStatus) {
case UserStatus.ONLINE:
await this.goOnline();
this.startTimer();
await this.goOnline();
break;

case UserStatus.AWAY:
await this.goAway();
this.stopTimer();
await this.goAway();
break;
}

this.status = newStatus;
});
};

private readonly setStatus = withDebouncing({ wait: 1000 })(this.applyStatus);

/**
* When auto-away is enabled, after a dropped socket, reconnection, or network change,
* the server sets the user as online. Since we may have marked the user away in the UI,
* we need to re-send away to the server if still idle.
*/
private readonly reassertPresence = () => {
this.setStatus.cancel();

if (!this.isIdle()) {
this.status = UserStatus.ONLINE;
Comment thread
rodrigok marked this conversation as resolved.
this.startTimer();
return;
}

void this.applyStatus(UserStatus.AWAY, { force: true });
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

readonly use = () => {
const user = useUser() ?? undefined;
Expand All @@ -74,9 +115,10 @@ export class UserPresence {
const idleTimeLimit = useUserPreference<number>('idleTimeLimit') ?? 300;
const { RocketChatDesktop } = window;

const awayTime = enableAutoAway && !RocketChatDesktop ? idleTimeLimit * 1000 : undefined;

this.user = user;
this.connected = connected;
this.awayTime = enableAutoAway && !RocketChatDesktop ? idleTimeLimit * 1000 : undefined;
this.goOnline = useMethod('UserPresence:online');
this.goAway = useMethod('UserPresence:away');
this.storeUser = Users.use((state) => state.store);
Expand All @@ -89,10 +131,10 @@ export class UserPresence {
idleThreshold: idleTimeLimit,
setUserOnline: (online) => {
if (!online) {
this.goAway();
this.setAway();
Comment thread
rodrigok marked this conversation as resolved.
return;
}
this.goOnline();
this.registerActivity();
},
});

Expand All @@ -109,28 +151,28 @@ export class UserPresence {
if (RocketChatDesktop) return;

const documentEvents = ['mousemove', 'mousedown', 'touchend', 'keydown'] as const;
documentEvents.forEach((key) => document.addEventListener(key, this.setOnline));
window.addEventListener('focus', this.setOnline);
documentEvents.forEach((key) => document.addEventListener(key, this.registerActivity));
window.addEventListener('focus', this.registerActivity);

return () => {
documentEvents.forEach((key) => document.removeEventListener(key, this.setOnline));
window.removeEventListener('focus', this.setOnline);
documentEvents.forEach((key) => document.removeEventListener(key, this.registerActivity));
window.removeEventListener('focus', this.registerActivity);
};
}, [RocketChatDesktop]);

useEffect(() => {
if (!user?._id || !connected || isLoggingIn) return;
this.startTimer();
}, [connected, isLoggingIn, user?._id]);
this.awayTime = awayTime;

useEffect(() => {
if (connected) {
this.startTimer();
this.status = UserStatus.ONLINE;
if (!connected) {
this.setStatus.cancel();
this.stopTimer();
this.status = UserStatus.OFFLINE;
return;
}
this.stopTimer();
this.status = UserStatus.OFFLINE;
}, [connected]);

if (!user?._id || isLoggingIn) return;

this.reassertPresence();
}, [connected, isLoggingIn, user?._id, awayTime]);
};
}
Loading