Skip to content
Open
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
4 changes: 1 addition & 3 deletions apps/user_status/lib/Listener/UserLiveStatusListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,7 @@ public function handle(Event $event): void {

$needsUpdate = false;

// If the current status is older than 5 minutes,
// treat it as outdated and update
if ($userStatus->getStatusTimestamp() < ($this->timeFactory->getTime() - StatusService::INVALIDATE_STATUS_THRESHOLD)) {
if ($userStatus->getStatusTimestamp() < ($this->timeFactory->getTime() - StatusService::REFRESH_STATUS_THRESHOLD)) {
$needsUpdate = true;
}

Expand Down
7 changes: 7 additions & 0 deletions apps/user_status/lib/Service/StatusService.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ class StatusService {
/** @var int */
public const INVALIDATE_STATUS_THRESHOLD = 15 /* minutes */ * 60 /* seconds */;

/**
* Has to stay at least one client heartbeat interval below INVALIDATE_STATUS_THRESHOLD.
*
* @var int
*/
public const REFRESH_STATUS_THRESHOLD = 7 /* minutes */ * 60 /* seconds */;

/** @var int */
public const MAXIMUM_MESSAGE_LENGTH = 80;

Expand Down
41 changes: 7 additions & 34 deletions apps/user_status/src/UserStatus.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@
<script>
import { getCurrentUser } from '@nextcloud/auth'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import debounce from 'debounce'
import { defineAsyncComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcListItem from '@nextcloud/vue/components/NcListItem'
import NcUserStatusIcon from '@nextcloud/vue/components/NcUserStatusIcon'
import { logger } from './logger.ts'
import OnlineStatusMixin from './mixins/OnlineStatusMixin.js'
import { startHeartbeat } from './services/heartbeatScheduler.ts'
import { sendHeartbeat } from './services/heartbeatService.js'
export default {
Expand Down Expand Up @@ -75,11 +75,8 @@ export default {
data() {
return {
heartbeatInterval: null,
isAway: false,
isModalOpen: false,
mouseMoveListener: null,
setAwayTimeout: null,
stopHeartbeat: null,
}
},
Expand All @@ -91,31 +88,7 @@ export default {
this.$store.dispatch('loadStatusFromInitialState')
if (OC.config.session_keepalive) {
// Send the latest status to the server every 5 minutes
this.heartbeatInterval = setInterval(this._backgroundHeartbeat.bind(this), 1000 * 60 * 5)
this.setAwayTimeout = () => {
this.isAway = true
}
// Catch mouse movements, but debounce to once every 30 seconds
this.mouseMoveListener = debounce(() => {
const wasAway = this.isAway
this.isAway = false
// Reset the two minute counter
clearTimeout(this.setAwayTimeout)
// If the user did not move the mouse within two minutes,
// mark them as away
setTimeout(this.setAwayTimeout, 1000 * 60 * 2)
if (wasAway) {
this._backgroundHeartbeat()
}
}, 1000 * 2, { immediate: true })
window.addEventListener('mousemove', this.mouseMoveListener, {
capture: true,
passive: true,
})
this._backgroundHeartbeat()
this.stopHeartbeat = startHeartbeat((isAway) => this._backgroundHeartbeat(isAway))
}
subscribe('user_status:status.updated', this.handleUserStatusUpdated)
},
Expand All @@ -124,8 +97,7 @@ export default {
* Some housekeeping before destroying the component
*/
beforeUnmount() {
window.removeEventListener('mouseMove', this.mouseMoveListener)
clearInterval(this.heartbeatInterval)
this.stopHeartbeat?.()
unsubscribe('user_status:status.updated', this.handleUserStatusUpdated)
},
Expand All @@ -147,12 +119,13 @@ export default {
/**
* Sends the status heartbeat to the server
*
* @param {boolean} isAway Whether the user is currently away
* @return {Promise<void>}
* @private
*/
async _backgroundHeartbeat() {
async _backgroundHeartbeat(isAway) {
try {
const status = await sendHeartbeat(this.isAway)
const status = await sendHeartbeat(isAway)
if (status?.userId) {
this.$store.dispatch('setStatusFromHeartbeat', status)
} else {
Expand Down
121 changes: 121 additions & 0 deletions apps/user_status/src/services/heartbeatScheduler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import {
AWAY_TIMEOUT,
HEARTBEAT_INTERVAL,
MOUSE_MOVE_DEBOUNCE,
startHeartbeat,
} from './heartbeatScheduler.ts'

const HOUR = 60 * 60 * 1000

let stop: (() => void) | undefined

/**
* Move the mouse for a second, then hold still.
*
* @param gap - Milliseconds of stillness after the burst
*/
async function moveThenRest(gap: number): Promise<void> {
for (let i = 0; i < 10; i++) {
window.dispatchEvent(new MouseEvent('mousemove'))
await vi.advanceTimersByTimeAsync(100)
}
await vi.advanceTimersByTimeAsync(gap)
}

describe('heartbeat scheduler', () => {
beforeAll(() => {
// `debounce` compares Date.now() against its own timestamp, so Date has to stay faked alongside the timers
vi.useFakeTimers()
})

beforeEach(() => {
vi.clearAllTimers()
vi.resetAllMocks()
})

afterEach(() => {
stop?.()
stop = undefined
})

it('sends a heartbeat on start', () => {
const beat = vi.fn()
stop = startHeartbeat(beat)

expect(beat).toHaveBeenCalledTimes(1)
expect(beat).toHaveBeenCalledWith(false)
})

it('sends a heartbeat every five minutes', async () => {
const beat = vi.fn()
stop = startHeartbeat(beat)

await vi.advanceTimersByTimeAsync(HEARTBEAT_INTERVAL - 1000)
expect(beat).toHaveBeenCalledTimes(1)

await vi.advanceTimersByTimeAsync(1000)
expect(beat).toHaveBeenCalledTimes(2)

await vi.advanceTimersByTimeAsync(HEARTBEAT_INTERVAL)
expect(beat).toHaveBeenCalledTimes(3)
})

it('does not send extra heartbeats while the user keeps moving the mouse', async () => {
const beat = vi.fn()
stop = startHeartbeat(beat)

const cycle = 1000 + 5000
for (let elapsed = 0; elapsed < HOUR; elapsed += cycle) {
await moveThenRest(5000)
}

expect(beat).toHaveBeenCalledTimes(1 + HOUR / HEARTBEAT_INTERVAL)
// the away countdown is restarted, never accumulated
expect(vi.getTimerCount()).toBeLessThanOrEqual(3)
})

it('reports the user as away after two minutes without mouse movement', async () => {
const beat = vi.fn()
stop = startHeartbeat(beat)

window.dispatchEvent(new MouseEvent('mousemove'))
await vi.advanceTimersByTimeAsync(AWAY_TIMEOUT + 1000)

await vi.advanceTimersByTimeAsync(HEARTBEAT_INTERVAL - AWAY_TIMEOUT - 1000)
expect(beat).toHaveBeenLastCalledWith(true)
})

it('sends one heartbeat when the user comes back from being away', async () => {
const beat = vi.fn()
stop = startHeartbeat(beat)

window.dispatchEvent(new MouseEvent('mousemove'))
await vi.advanceTimersByTimeAsync(AWAY_TIMEOUT + MOUSE_MOVE_DEBOUNCE)
const beforeReturn = beat.mock.calls.length

window.dispatchEvent(new MouseEvent('mousemove'))
expect(beat).toHaveBeenCalledTimes(beforeReturn + 1)
expect(beat).toHaveBeenLastCalledWith(false)
})

it('stops the interval, the away countdown and the mouse listener', async () => {
const beat = vi.fn()
const stopHeartbeat = startHeartbeat(beat)
window.dispatchEvent(new MouseEvent('mousemove'))

stopHeartbeat()

await vi.advanceTimersByTimeAsync(3 * HEARTBEAT_INTERVAL)
window.dispatchEvent(new MouseEvent('mousemove'))
await vi.advanceTimersByTimeAsync(3 * HEARTBEAT_INTERVAL)

expect(beat).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
})
53 changes: 53 additions & 0 deletions apps/user_status/src/services/heartbeatScheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import debounce from 'debounce'

/** Has to stay below the server margin between `StatusService::REFRESH_STATUS_THRESHOLD` and `StatusService::INVALIDATE_STATUS_THRESHOLD`. */
export const HEARTBEAT_INTERVAL = 5 * 60 * 1000

export const AWAY_TIMEOUT = 2 * 60 * 1000

export const MOUSE_MOVE_DEBOUNCE = 2 * 1000

/**
* Send heartbeats on a fixed interval, and once more whenever the user comes back from being away.
*
* @param beat - Called with the current away state when a heartbeat is due
* @return Function that stops the heartbeat and removes every timer and listener
*/
export function startHeartbeat(beat: (isAway: boolean) => void): () => void {
let isAway = false
let awayTimeout: ReturnType<typeof setTimeout> | undefined

const onMouseMove = debounce(() => {
const wasAway = isAway
isAway = false

clearTimeout(awayTimeout)
awayTimeout = setTimeout(() => {
isAway = true
}, AWAY_TIMEOUT)

if (wasAway) {
beat(isAway)
}
}, MOUSE_MOVE_DEBOUNCE, { immediate: true })

const interval = setInterval(() => beat(isAway), HEARTBEAT_INTERVAL)
window.addEventListener('mousemove', onMouseMove, {
capture: true,
passive: true,
})

beat(isAway)

return () => {
clearInterval(interval)
clearTimeout(awayTimeout)
onMouseMove.clear()
window.removeEventListener('mousemove', onMouseMove, { capture: true })
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\UserStatus\Tests\Integration\Listener;

use OCA\DAV\CalDAV\Status\StatusService as CalendarStatusService;
use OCA\UserStatus\Db\UserStatusMapper;
use OCA\UserStatus\Listener\UserLiveStatusListener;
use OCA\UserStatus\Service\StatusService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IDBConnection;
use OCP\IUser;
use OCP\Server;
use OCP\User\Events\UserLiveStatusEvent;
use OCP\UserStatus\IUserStatus;
use Psr\Log\LoggerInterface;
use Test\TestCase;
use function time;

#[\PHPUnit\Framework\Attributes\Group(name: 'DB')]
class UserLiveStatusListenerIntegrationTest extends TestCase {

private const USER_ID = 'test123';

/** HEARTBEAT_INTERVAL in apps/user_status/src/services/heartbeatScheduler.ts */
private const CLIENT_HEARTBEAT_INTERVAL = 5 * 60;

/** ClearOldStatusesBackgroundJob::setInterval() */
private const CLEANUP_JOB_INTERVAL = 60;

private UserStatusMapper $mapper;
private StatusService $service;
private UserLiveStatusListener $listener;

protected function setUp(): void {
parent::setUp();

$this->mapper = Server::get(UserStatusMapper::class);
$this->service = Server::get(StatusService::class);

$db = Server::get(IDBConnection::class);
$qb = $db->getQueryBuilder();
$qb->delete('user_status')->executeStatement();

$this->listener = new UserLiveStatusListener(
$this->mapper,
$this->service,
Server::get(ITimeFactory::class),
$this->createMock(CalendarStatusService::class),
$this->createMock(LoggerInterface::class),
);
}

public function testActiveUserSurvivesTheInvalidationSweep(): void {
$this->service->setStatus(self::USER_ID, IUserStatus::ONLINE, time(), false);

for ($minute = 1; $minute <= 30; $minute++) {
$this->passTime(self::CLEANUP_JOB_INTERVAL);
$this->runCleanupJob();

self::assertSame(
IUserStatus::ONLINE,
$this->mapper->findByUserId(self::USER_ID)->getStatus(),
"User went offline after $minute minutes while still sending heartbeats",
);

if ($minute % (self::CLIENT_HEARTBEAT_INTERVAL / self::CLEANUP_JOB_INTERVAL) === 0) {
$this->heartbeat();
}
}
}

/** Equivalent to letting time pass, without faking the clock of every collaborator. */
private function passTime(int $seconds): void {
$status = $this->mapper->findByUserId(self::USER_ID);
$status->setStatusTimestamp($status->getStatusTimestamp() - $seconds);
$this->mapper->update($status);
}

private function heartbeat(): void {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn(self::USER_ID);

$this->listener->handle(new UserLiveStatusEvent($user, IUserStatus::ONLINE, time()));
}

private function runCleanupJob(): void {
$now = time();
$this->mapper->clearStatusesOlderThan($now - StatusService::INVALIDATE_STATUS_THRESHOLD, $now);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ public static function handleEventWithCorrectEventDataProvider(): array {
['john.doe', 'online', 5000, false, 'away', 5000, true, false],
['john.doe', 'away', 5000, true, 'online', 5000, true, false],
['john.doe', 'online', 5000, true, 'away', 5000, true, false],
// a status older than REFRESH_STATUS_THRESHOLD is refreshed, a younger one is not
['john.doe', 'online', 4500, false, 'online', 5000, true, true],
['john.doe', 'online', 4700, false, 'online', 5000, true, false],
];
}
}
Loading
Loading