From 920bcecaff90f11b22dfb68138bc17caa60ede27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Fri, 16 Oct 2026 18:55:19 +0200 Subject: [PATCH] fix: Mark session as inactive again after rejoin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5.5 Signed-off-by: Marcel Müller Signed-off-by: Maksim Sukharev --- .../__tests__/useActiveSession.spec.js | 72 ++++++++++++++++++- src/composables/useActiveSession.ts | 18 +++-- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/composables/__tests__/useActiveSession.spec.js b/src/composables/__tests__/useActiveSession.spec.js index 32eb53db385..1ca556de8cf 100644 --- a/src/composables/__tests__/useActiveSession.spec.js +++ b/src/composables/__tests__/useActiveSession.spec.js @@ -5,7 +5,7 @@ import { flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { ref } from 'vue' +import { reactive, ref } from 'vue' import { useStore } from 'vuex' import { SESSION } from '../../constants.ts' @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ token: { current: null }, isInCall: { current: null }, joinedConversation: { current: null }, + actorStore: { current: null }, })) vi.mock('vuex', async () => { @@ -28,6 +29,9 @@ vi.mock('../useIsInCall.js', () => ({ vi.mock('../useJoinedConversation.ts', () => ({ useJoinedConversation: () => mocks.joinedConversation.current, })) +vi.mock('../../stores/actor.ts', () => ({ + useActorStore: () => mocks.actorStore.current, +})) vi.mock('../../services/participantsService.js', () => ({ setSessionState: vi.fn(), })) @@ -90,6 +94,7 @@ describe('useActiveSession', () => { mocks.token.current = ref('XXTOKENXX') mocks.isInCall.current = ref(false) mocks.joinedConversation.current = ref(null) + mocks.actorStore.current = reactive({ sessionId: 'session-1' }) useStore.mockReturnValue({ dispatch: vi.fn() }) await mountActiveSession() }) @@ -199,4 +204,69 @@ describe('useActiveSession', () => { expect(setSessionState).toHaveBeenCalledWith('YYTOKENYY', SESSION.STATE.INACTIVE) expect(useIsSessionActive().value).toBe(false) }) + + describe('rejoining the conversation with a new session', () => { + beforeEach(async () => { + mocks.joinedConversation.current.value = 'XXTOKENXX' + await flushPromises() + }) + + test('marks the new session as inactive right away when it was inactive before', async () => { + blurWindow() + await runInactiveTimer() + setSessionState.mockClear() + + mocks.actorStore.current.sessionId = 'session-2' + await flushPromises() + + expect(setSessionState).toHaveBeenCalledExactlyOnceWith('XXTOKENXX', SESSION.STATE.INACTIVE) + expect(useIsSessionActive().value).toBe(false) + }) + + test('keeps counting down when the session was still active before', async () => { + blurWindow() + + mocks.actorStore.current.sessionId = 'session-2' + await flushPromises() + + expect(setSessionState).not.toHaveBeenCalled() + expect(useIsSessionActive().value).toBe(true) + + await runInactiveTimer() + + expect(setSessionState).toHaveBeenCalledExactlyOnceWith('XXTOKENXX', SESSION.STATE.INACTIVE) + }) + + test('follows the new session when the window is active again', async () => { + // console.error is set up to fail the test otherwise + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + blurWindow() + await runInactiveTimer() + // Marking as active failed, so the client still has the old session as inactive + setSessionState.mockImplementationOnce(() => Promise.reject(new Error('Network Error'))) + focusWindow() + await flushPromises() + expect(useIsSessionActive().value).toBe(false) + setSessionState.mockClear() + + mocks.actorStore.current.sessionId = 'session-2' + await flushPromises() + + expect(setSessionState).not.toHaveBeenCalled() + expect(useIsSessionActive().value).toBe(true) + consoleError.mockRestore() + }) + + test('ignores a new session of a conversation that is not joined yet', async () => { + blurWindow() + await runInactiveTimer() + setSessionState.mockClear() + + mocks.token.current.value = 'YYTOKENYY' + mocks.actorStore.current.sessionId = 'session-2' + await flushPromises() + + expect(setSessionState).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/composables/useActiveSession.ts b/src/composables/useActiveSession.ts index 23d98babdbf..8e9fd97072c 100644 --- a/src/composables/useActiveSession.ts +++ b/src/composables/useActiveSession.ts @@ -8,6 +8,7 @@ import { useStore } from 'vuex' import { SESSION } from '../constants.ts' import { hasTalkFeature } from '../services/CapabilitiesManager.ts' import { setSessionState } from '../services/participantsService.js' +import { useActorStore } from '../stores/actor.ts' import { useTokenStore } from '../stores/token.ts' import { isAxiosErrorResponse } from '../types/guards.ts' import { useDocumentVisibility } from './useDocumentVisibility.ts' @@ -44,6 +45,7 @@ export function useActiveSession() { const store = useStore() const token = useGetToken() const tokenStore = useTokenStore() + const actorStore = useActorStore() // Without 'session-state' feature support - conversation is always considered active const supportSessionState = computed(() => hasTalkFeature(token.value, 'session-state')) @@ -68,14 +70,22 @@ export function useActiveSession() { inactiveTimer = setTimeout(setSessionAsInactive, INACTIVE_TIME_MS) } - // Wait for the token to actually be joined - watch(currentJoinedConversation, (joinedToken) => { + // Joined conversation has active state by default; a rejoin creates a new session, which is active on the server + watch([currentJoinedConversation, () => actorStore.sessionId], ([joinedToken], [previousJoinedToken]) => { if (joinedToken !== token.value || !supportSessionState.value) { return } - // Joined conversation has active state by default + const isRejoin = joinedToken === previousJoinedToken + if (isRejoin && currentState.value !== SESSION.STATE.INACTIVE) { + return + } currentState.value = SESSION.STATE.ACTIVE - if (!isWindowActive()) { + if (isWindowActive()) { + return + } + if (isRejoin) { + setSessionAsInactive() + } else { scheduleSessionAsInactive() } })