From b7a6696c5d47abc81878a98e3cc7bdbe192a08f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Wed, 26 Aug 2026 18:29:21 +0200 Subject: [PATCH 1/2] fix: Sending read-marker while session is inactive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Müller --- src/components/MessagesList/MessagesList.vue | 11 +- .../__tests__/useActiveSession.spec.js | 192 ++++++++++++++++++ src/composables/useActiveSession.js | 64 ++++-- 3 files changed, 252 insertions(+), 15 deletions(-) create mode 100644 src/composables/__tests__/useActiveSession.spec.js diff --git a/src/components/MessagesList/MessagesList.vue b/src/components/MessagesList/MessagesList.vue index d0fbf61b825..00f7cc50473 100644 --- a/src/components/MessagesList/MessagesList.vue +++ b/src/components/MessagesList/MessagesList.vue @@ -115,6 +115,7 @@ import TransitionWrapper from '../UIShared/TransitionWrapper.vue' import MessagesGroup from './MessagesGroup/MessagesGroup.vue' import MessagesSystemGroup from './MessagesGroup/MessagesSystemGroup.vue' import PinnedMessage from './PinnedMessage/PinnedMessage.vue' +import { useIsSessionActive } from '../../composables/useActiveSession.js' import { useDocumentVisibility } from '../../composables/useDocumentVisibility.ts' import { useGetMessages } from '../../composables/useGetMessages.ts' import { useGetThreadId } from '../../composables/useGetThreadId.ts' @@ -199,6 +200,9 @@ export default { const isDocumentVisible = useDocumentVisibility() const isChatVisible = computed(() => isDocumentVisible.value && props.isVisible) + // A covered window is still visible, but its session is already inactive + const isSessionActive = useIsSessionActive() + const isChatActive = computed(() => isChatVisible.value && isSessionActive.value) const threadId = useGetThreadId() const settingsStore = useSettingsStore() const isSplitViewEnabled = computed(() => settingsStore.chatStyle === CHAT_STYLE.SPLIT) @@ -209,6 +213,7 @@ export default { chatExtrasStore: useChatExtrasStore(), chatStore: useChatStore(), isChatVisible, + isChatActive, threadId, contextMessageId, @@ -891,7 +896,7 @@ export default { * conversation in refreshReadMarkerPosition() */ updateReadMarkerPosition() { - if (!this.conversation) { + if (!this.conversation || !this.isChatActive) { return } @@ -965,7 +970,7 @@ export default { } else if (!this.isSticky) { // Reading old messages return - } else if (!this.isChatVisible) { + } else if (!this.isChatActive) { const firstUnreadMessageHeight = this.$refs.scroller.scrollHeight - this.$refs.scroller.scrollTop - this.$refs.scroller.offsetHeight const scrollBy = firstUnreadMessageHeight < 40 ? 10 : 40 // We jump half a message and stop autoscrolling, so the user can read up @@ -991,7 +996,7 @@ export default { }) // If it is a forced scroll to bottom, we need to update the read marker immediately - if (options?.force) { + if (options?.force && this.isChatActive) { this.$store.dispatch('clearLastReadMessage', { token: this.token, updateVisually: true }) } }) diff --git a/src/composables/__tests__/useActiveSession.spec.js b/src/composables/__tests__/useActiveSession.spec.js new file mode 100644 index 00000000000..b837190788d --- /dev/null +++ b/src/composables/__tests__/useActiveSession.spec.js @@ -0,0 +1,192 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { ref } from 'vue' +import { useStore } from 'vuex' +import { SESSION } from '../../constants.ts' + +const mocks = vi.hoisted(() => ({ + token: { current: null }, + isInCall: { current: null }, +})) + +vi.mock('vuex', async () => { + const vuex = await vi.importActual('vuex') + return { ...vuex, useStore: vi.fn() } +}) +vi.mock('../useGetToken.ts', () => ({ + useGetToken: () => mocks.token.current, +})) +vi.mock('../useIsInCall.js', () => ({ + useIsInCall: () => mocks.isInCall.current, +})) +vi.mock('../../services/participantsService.js', () => ({ + setSessionState: vi.fn(), +})) + +// jsdom does not track window focus, so document.hasFocus() has to be faked +let windowHasFocus = true +document.hasFocus = () => windowHasFocus + +/** + * Move the window to the background. + */ +function blurWindow() { + windowHasFocus = false + window.dispatchEvent(new Event('blur')) +} + +/** + * Bring the window back to the foreground. + */ +function focusWindow() { + windowHasFocus = true + window.dispatchEvent(new Event('focus')) +} + +/** + * Run out the pending inactivity timer, whatever its configured duration is. + */ +function runInactiveTimer() { + return vi.advanceTimersToNextTimerAsync() +} + +describe('useActiveSession', () => { + let wrapper + let setSessionState + let useIsSessionActive + + /** + * Mount a component driving the session state, as App.vue does. + */ + async function mountActiveSession() { + // The state is shared between all consumers, so it has to be reset per test + vi.resetModules() + const composable = await import('../useActiveSession.js') + useIsSessionActive = composable.useIsSessionActive + setSessionState = (await import('../../services/participantsService.js')).setSessionState + setSessionState.mockResolvedValue({}) + + wrapper = mount({ + setup() { + composable.useActiveSession() + return () => null + }, + }) + await flushPromises() + } + + beforeEach(async () => { + vi.useFakeTimers() + windowHasFocus = true + mocks.token.current = ref('XXTOKENXX') + mocks.isInCall.current = ref(false) + useStore.mockReturnValue({ dispatch: vi.fn() }) + await mountActiveSession() + }) + + afterEach(() => { + wrapper?.unmount() + vi.useRealTimers() + vi.clearAllMocks() + }) + + test('marks the session as inactive when the window stays in the background', async () => { + blurWindow() + expect(setSessionState).not.toHaveBeenCalled() + + await runInactiveTimer() + + expect(setSessionState).toHaveBeenCalledWith('XXTOKENXX', SESSION.STATE.INACTIVE) + expect(useIsSessionActive().value).toBe(false) + }) + + test('keeps counting down when the mouse enters the window in the background', async () => { + blurWindow() + await runInactiveTimer() + + document.body.dispatchEvent(new MouseEvent('mouseenter')) + await flushPromises() + expect(setSessionState).toHaveBeenLastCalledWith('XXTOKENXX', SESSION.STATE.ACTIVE) + expect(useIsSessionActive().value).toBe(true) + + // Hovering an unfocused window only postpones the update + await runInactiveTimer() + + expect(setSessionState).toHaveBeenLastCalledWith('XXTOKENXX', SESSION.STATE.INACTIVE) + expect(useIsSessionActive().value).toBe(false) + }) + + test('follows the server state and retries when the request failed', async () => { + // console.error is set up to fail the test otherwise + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + setSessionState.mockImplementationOnce(() => Promise.reject(new Error('Network Error'))) + blurWindow() + + await runInactiveTimer() + + // The server still has the session as active, so notifications are still sent + expect(setSessionState).toHaveBeenCalledTimes(1) + expect(useIsSessionActive().value).toBe(true) + + await runInactiveTimer() + + expect(setSessionState).toHaveBeenCalledTimes(2) + expect(useIsSessionActive().value).toBe(false) + consoleError.mockRestore() + }) + + test('follows the server state when marking as active failed', async () => { + // console.error is set up to fail the test otherwise + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + blurWindow() + await runInactiveTimer() + expect(useIsSessionActive().value).toBe(false) + + setSessionState.mockImplementationOnce(() => Promise.reject(new Error('Network Error'))) + focusWindow() + await flushPromises() + + // The server still has the session as inactive and keeps notifying + expect(setSessionState).toHaveBeenLastCalledWith('XXTOKENXX', SESSION.STATE.ACTIVE) + expect(useIsSessionActive().value).toBe(false) + consoleError.mockRestore() + }) + + test('repeats the skipped update when the call ended in the background', async () => { + mocks.isInCall.current.value = true + blurWindow() + + await runInactiveTimer() + + // Sessions in a call stay active + expect(setSessionState).not.toHaveBeenCalled() + expect(useIsSessionActive().value).toBe(true) + + mocks.isInCall.current.value = false + await flushPromises() + + expect(setSessionState).toHaveBeenCalledWith('XXTOKENXX', SESSION.STATE.INACTIVE) + expect(useIsSessionActive().value).toBe(false) + }) + + test('marks a conversation opened in the background as inactive', async () => { + blurWindow() + await runInactiveTimer() + setSessionState.mockClear() + + // Joining another conversation creates a new session, which is active again + mocks.token.current.value = 'YYTOKENYY' + await flushPromises() + expect(useIsSessionActive().value).toBe(true) + + await runInactiveTimer() + + expect(setSessionState).toHaveBeenCalledWith('YYTOKENYY', SESSION.STATE.INACTIVE) + expect(useIsSessionActive().value).toBe(false) + }) +}) diff --git a/src/composables/useActiveSession.js b/src/composables/useActiveSession.js index 78e9530a321..ea6309255a7 100644 --- a/src/composables/useActiveSession.js +++ b/src/composables/useActiveSession.js @@ -15,6 +15,21 @@ import { useIsInCall } from './useIsInCall.js' const INACTIVE_TIME_MS = 3 * 60 * 1000 +// Sessions are created as active on the server (talk_sessions.state defaults to 1) +const currentState = ref(SESSION.STATE.ACTIVE) + +/** + * Whether the session of the current conversation is active on the server. + * + * The server only notifies about messages with an inactive session, so marking + * them as read has to follow the same signal, not the document visibility. + * + * @return {import('vue').ComputedRef} whether the session is active + */ +export function useIsSessionActive() { + return computed(() => currentState.value === SESSION.STATE.ACTIVE) +} + /** * Check whether the current session is active or not: * - tab or browser window was moved to background or minimized @@ -30,7 +45,7 @@ export function useActiveSession() { // FIXME has no API support on federated conversations const supportSessionState = computed(() => hasTalkFeature(token.value, 'session-state')) - if (!supportSessionState) { + if (!supportSessionState.value) { return false } @@ -38,11 +53,20 @@ export function useActiveSession() { const isDocumentVisible = useDocumentVisibility() const inactiveTimer = ref(null) - const currentState = ref(SESSION.STATE.ACTIVE) + const isWindowActive = () => document.hasFocus() && isDocumentVisible.value + + const scheduleSessionAsInactive = () => { + clearTimeout(inactiveTimer.value) + inactiveTimer.value = setTimeout(setSessionAsInactive, INACTIVE_TIME_MS) + } watch(token, () => { // Joined conversation has active state by default currentState.value = SESSION.STATE.ACTIVE + // Updating right away would race with joining the conversation + if (!isWindowActive()) { + scheduleSessionAsInactive() + } }) watch(isDocumentVisible, (value) => { @@ -54,6 +78,13 @@ export function useActiveSession() { } }) + watch(isInCall, (value) => { + // Repeat the update which was skipped for the duration of the call + if (!value && !isWindowActive()) { + setSessionAsInactive() + } + }) + onBeforeMount(() => { window.addEventListener('focus', handleWindowFocus) window.addEventListener('blur', handleWindowFocus) @@ -65,12 +96,17 @@ export function useActiveSession() { }) const setSessionAsActive = async () => { + // Without re-arming, a background window stays active until the next focus + if (isWindowActive()) { + clearTimeout(inactiveTimer.value) + } else { + scheduleSessionAsInactive() + } + if (currentState.value === SESSION.STATE.ACTIVE || !token.value) { return } - clearTimeout(inactiveTimer.value) - inactiveTimer.value = null currentState.value = SESSION.STATE.ACTIVE try { @@ -83,6 +119,9 @@ export function useActiveSession() { tokenStore.updateLastJoinedConversationToken('') // Automatically try to join the conversation again store.dispatch('joinConversation', { token: token.value }) + } else { + // Follow the server, which keeps notifying about new messages + currentState.value = SESSION.STATE.INACTIVE } } } @@ -93,6 +132,7 @@ export function useActiveSession() { return } if (isInCall.value) { + // Sessions in a call stay active, the isInCall watcher repeats the update return } clearTimeout(inactiveTimer.value) @@ -104,12 +144,17 @@ export function useActiveSession() { console.info('Session has been marked as inactive') } catch (error) { console.error(error) + // The server still has it active, so it would keep swallowing notifications + currentState.value = SESSION.STATE.ACTIVE if (error?.response?.status === 404) { // In case of 404 - participant did not have a session, block UI to join call tokenStore.updateLastJoinedConversationToken('') // Automatically try to join the conversation again store.dispatch('joinConversation', { token: token.value }) } + if (!isWindowActive()) { + scheduleSessionAsInactive() + } } } @@ -121,9 +166,7 @@ export function useActiveSession() { document.body.removeEventListener('mouseenter', handleMouseEnter) document.body.removeEventListener('mouseleave', handleMouseLeave) } else if (type === 'blur') { - inactiveTimer.value = setTimeout(() => { - setSessionAsInactive() - }, INACTIVE_TIME_MS) + scheduleSessionAsInactive() // Listen for mouse events to track activity on tab document.body.addEventListener('mouseenter', handleMouseEnter) @@ -132,16 +175,13 @@ export function useActiveSession() { } const handleMouseEnter = (event) => { + // The window is not focused, so hovering it only postpones the update setSessionAsActive() - clearTimeout(inactiveTimer.value) - inactiveTimer.value = null } const handleMouseLeave = (event) => { // Restart timer, if mouse leaves the tab - inactiveTimer.value = setTimeout(() => { - setSessionAsInactive() - }, INACTIVE_TIME_MS) + scheduleSessionAsInactive() } return true From 7f7a20bceada027b7e8a46fa4cb20f9501e07318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Wed, 26 Aug 2026 18:29:39 +0200 Subject: [PATCH 2/2] fix: Set session inactive after 1 minute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcel Müller --- src/composables/useActiveSession.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/composables/useActiveSession.js b/src/composables/useActiveSession.js index ea6309255a7..e6e02c5cc2b 100644 --- a/src/composables/useActiveSession.js +++ b/src/composables/useActiveSession.js @@ -13,7 +13,7 @@ import { useDocumentVisibility } from './useDocumentVisibility.ts' import { useGetToken } from './useGetToken.ts' import { useIsInCall } from './useIsInCall.js' -const INACTIVE_TIME_MS = 3 * 60 * 1000 +const INACTIVE_TIME_MS = 60_000 // Sessions are created as active on the server (talk_sessions.state defaults to 1) const currentState = ref(SESSION.STATE.ACTIVE)