From fbbc16bbde1a62d8df8f6d9cd68b2fcb5074ade5 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Fri, 28 Aug 2026 17:30:28 +0200 Subject: [PATCH 1/6] fix: drop reactivity for inactiveTimer Signed-off-by: Maksim Sukharev --- src/composables/useActiveSession.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/composables/useActiveSession.js b/src/composables/useActiveSession.js index e6e02c5cc2b..fa36be7b653 100644 --- a/src/composables/useActiveSession.js +++ b/src/composables/useActiveSession.js @@ -52,12 +52,12 @@ export function useActiveSession() { const isInCall = useIsInCall() const isDocumentVisible = useDocumentVisibility() - const inactiveTimer = ref(null) + let inactiveTimer = null const isWindowActive = () => document.hasFocus() && isDocumentVisible.value const scheduleSessionAsInactive = () => { - clearTimeout(inactiveTimer.value) - inactiveTimer.value = setTimeout(setSessionAsInactive, INACTIVE_TIME_MS) + clearTimeout(inactiveTimer) + inactiveTimer = setTimeout(setSessionAsInactive, INACTIVE_TIME_MS) } watch(token, () => { @@ -98,7 +98,7 @@ export function useActiveSession() { const setSessionAsActive = async () => { // Without re-arming, a background window stays active until the next focus if (isWindowActive()) { - clearTimeout(inactiveTimer.value) + clearTimeout(inactiveTimer) } else { scheduleSessionAsInactive() } @@ -135,8 +135,8 @@ export function useActiveSession() { // Sessions in a call stay active, the isInCall watcher repeats the update return } - clearTimeout(inactiveTimer.value) - inactiveTimer.value = null + clearTimeout(inactiveTimer) + inactiveTimer = null currentState.value = SESSION.STATE.INACTIVE try { @@ -159,7 +159,7 @@ export function useActiveSession() { } const handleWindowFocus = ({ type }) => { - clearTimeout(inactiveTimer.value) + clearTimeout(inactiveTimer) if (type === 'focus') { setSessionAsActive() From 61fa5214d7f9f9f56f8e9240a44578946239d697 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Fri, 28 Aug 2026 17:51:16 +0200 Subject: [PATCH 2/6] fix: ensure federations support - endpoint is supporting federated conversations since v20 - correctly listen to changes (start/stop tracking) - do not make an early return Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Maksim Sukharev --- src/composables/useActiveSession.js | 39 +++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/composables/useActiveSession.js b/src/composables/useActiveSession.js index fa36be7b653..ecf5a6c1bf9 100644 --- a/src/composables/useActiveSession.js +++ b/src/composables/useActiveSession.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from 'vue' +import { computed, onBeforeUnmount, ref, watch } from 'vue' import { useStore } from 'vuex' import { SESSION } from '../constants.ts' import { hasTalkFeature } from '../services/CapabilitiesManager.ts' @@ -42,7 +42,7 @@ export function useActiveSession() { const store = useStore() const token = useGetToken() const tokenStore = useTokenStore() - // FIXME has no API support on federated conversations + // Without 'session-state' feature support - conversation is always considered active const supportSessionState = computed(() => hasTalkFeature(token.value, 'session-state')) if (!supportSessionState.value) { @@ -61,6 +61,9 @@ export function useActiveSession() { } watch(token, () => { + if (!supportSessionState.value) { + return + } // Joined conversation has active state by default currentState.value = SESSION.STATE.ACTIVE // Updating right away would race with joining the conversation @@ -85,17 +88,34 @@ export function useActiveSession() { } }) - onBeforeMount(() => { - window.addEventListener('focus', handleWindowFocus) - window.addEventListener('blur', handleWindowFocus) - }) + watch(supportSessionState, (value) => { + if (value) { + window.addEventListener('focus', handleWindowFocus) + window.addEventListener('blur', handleWindowFocus) + if (!isWindowActive()) { + scheduleSessionAsInactive() + } + } else { + stopTrackingSessionState() + } + }, { immediate: true }) + + onBeforeUnmount(stopTrackingSessionState) - onBeforeUnmount(() => { + function stopTrackingSessionState() { window.removeEventListener('focus', handleWindowFocus) window.removeEventListener('blur', handleWindowFocus) - }) + document.body.removeEventListener('mouseenter', handleMouseEnter) + document.body.removeEventListener('mouseleave', handleMouseLeave) + clearTimeout(inactiveTimer) + inactiveTimer = null + currentState.value = SESSION.STATE.ACTIVE + } const setSessionAsActive = async () => { + if (!supportSessionState.value) { + return + } // Without re-arming, a background window stays active until the next focus if (isWindowActive()) { clearTimeout(inactiveTimer) @@ -127,6 +147,9 @@ export function useActiveSession() { } const setSessionAsInactive = async () => { + if (!supportSessionState.value) { + return + } if (currentState.value === SESSION.STATE.INACTIVE || !token.value) { return From 8b74566d4e4898c4d4fd552c32db06cfde02b783 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Fri, 28 Aug 2026 17:52:06 +0200 Subject: [PATCH 3/6] chore: drop return support state - unused by sole consumer, covered by reactivity Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Maksim Sukharev --- src/App.vue | 2 +- src/composables/useActiveSession.js | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/App.vue b/src/App.vue index ff5c05352c9..218c19fb9be 100644 --- a/src/App.vue +++ b/src/App.vue @@ -103,6 +103,7 @@ export default { // Add provided value to check if we're in the main app or plugin provide('Talk:isMainApp', true) useDocumentFullscreen() + useActiveSession() return { token: useGetToken(), @@ -111,7 +112,6 @@ export default { isLeavingAfterSessionIssue: useSessionIssueHandler(), isMobile: useIsMobile(), isNextcloudTalkHashDirty: useHashCheck(), - supportSessionState: useActiveSession(), callViewStore: useCallViewStore(), sidebarStore: useSidebarStore(), actorStore: useActorStore(), diff --git a/src/composables/useActiveSession.js b/src/composables/useActiveSession.js index ecf5a6c1bf9..4ecb1d5150d 100644 --- a/src/composables/useActiveSession.js +++ b/src/composables/useActiveSession.js @@ -35,8 +35,6 @@ export function useIsSessionActive() { * - tab or browser window was moved to background or minimized * - there was no movement within tab window for a long time * - work for both ChatView and CallView - * - * @return {boolean|undefined} */ export function useActiveSession() { const store = useStore() @@ -45,10 +43,6 @@ export function useActiveSession() { // Without 'session-state' feature support - conversation is always considered active const supportSessionState = computed(() => hasTalkFeature(token.value, 'session-state')) - if (!supportSessionState.value) { - return false - } - const isInCall = useIsInCall() const isDocumentVisible = useDocumentVisibility() @@ -206,6 +200,4 @@ export function useActiveSession() { // Restart timer, if mouse leaves the tab scheduleSessionAsInactive() } - - return true } From e3a8c4ab6c42479a7a036fd25754c052a99fe848 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Fri, 28 Aug 2026 18:00:35 +0200 Subject: [PATCH 4/6] chore: migrate to typescript Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Maksim Sukharev --- src/App.vue | 2 +- src/components/MessagesList/MessagesList.vue | 2 +- ...seActiveSession.js => useActiveSession.ts} | 61 ++++++++++++++----- 3 files changed, 49 insertions(+), 16 deletions(-) rename src/composables/{useActiveSession.js => useActiveSession.ts} (79%) diff --git a/src/App.vue b/src/App.vue index 218c19fb9be..d698b64fbf9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -40,7 +40,7 @@ import PollManager from './components/PollViewer/PollManager.vue' import RightSidebar from './components/RightSidebar/RightSidebar.vue' import SettingsDialog from './components/SettingsDialog/SettingsDialog.vue' import ConfirmDialog from './components/UIShared/ConfirmDialog.vue' -import { useActiveSession } from './composables/useActiveSession.js' +import { useActiveSession } from './composables/useActiveSession.ts' import { toggleFullscreen, useDocumentFullscreen, diff --git a/src/components/MessagesList/MessagesList.vue b/src/components/MessagesList/MessagesList.vue index 138e262b881..d4e8e6db174 100644 --- a/src/components/MessagesList/MessagesList.vue +++ b/src/components/MessagesList/MessagesList.vue @@ -115,7 +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 { useIsSessionActive } from '../../composables/useActiveSession.ts' import { useDocumentVisibility } from '../../composables/useDocumentVisibility.ts' import { useGetMessages } from '../../composables/useGetMessages.ts' import { useGetThreadId } from '../../composables/useGetThreadId.ts' diff --git a/src/composables/useActiveSession.js b/src/composables/useActiveSession.ts similarity index 79% rename from src/composables/useActiveSession.js rename to src/composables/useActiveSession.ts index 4ecb1d5150d..4e7f7e62948 100644 --- a/src/composables/useActiveSession.js +++ b/src/composables/useActiveSession.ts @@ -9,14 +9,17 @@ import { SESSION } from '../constants.ts' import { hasTalkFeature } from '../services/CapabilitiesManager.ts' import { setSessionState } from '../services/participantsService.js' import { useTokenStore } from '../stores/token.ts' +import { isAxiosErrorResponse } from '../types/guards.ts' import { useDocumentVisibility } from './useDocumentVisibility.ts' import { useGetToken } from './useGetToken.ts' import { useIsInCall } from './useIsInCall.js' +type SessionStateValue = typeof SESSION.STATE[keyof typeof SESSION.STATE] + 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) +const currentState = ref(SESSION.STATE.ACTIVE) /** * Whether the session of the current conversation is active on the server. @@ -24,7 +27,7 @@ const currentState = ref(SESSION.STATE.ACTIVE) * 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 + * @return whether the session is active */ export function useIsSessionActive() { return computed(() => currentState.value === SESSION.STATE.ACTIVE) @@ -46,10 +49,19 @@ export function useActiveSession() { const isInCall = useIsInCall() const isDocumentVisible = useDocumentVisibility() - let inactiveTimer = null - const isWindowActive = () => document.hasFocus() && isDocumentVisible.value + let inactiveTimer: NodeJS.Timeout | undefined + + /** + * Whether the window is focused and visible right now. + */ + function isWindowActive() { + return document.hasFocus() && isDocumentVisible.value + } - const scheduleSessionAsInactive = () => { + /** + * (Re)start the countdown to mark the session as inactive. + */ + function scheduleSessionAsInactive() { clearTimeout(inactiveTimer) inactiveTimer = setTimeout(setSessionAsInactive, INACTIVE_TIME_MS) } @@ -96,17 +108,23 @@ export function useActiveSession() { onBeforeUnmount(stopTrackingSessionState) + /** + * Undo everything set up while 'session-state' was supported. + */ function stopTrackingSessionState() { window.removeEventListener('focus', handleWindowFocus) window.removeEventListener('blur', handleWindowFocus) document.body.removeEventListener('mouseenter', handleMouseEnter) document.body.removeEventListener('mouseleave', handleMouseLeave) clearTimeout(inactiveTimer) - inactiveTimer = null + inactiveTimer = undefined currentState.value = SESSION.STATE.ACTIVE } - const setSessionAsActive = async () => { + /** + * Mark the session as active, on the client and on the server. + */ + async function setSessionAsActive() { if (!supportSessionState.value) { return } @@ -128,7 +146,7 @@ export function useActiveSession() { console.info('Session has been marked as active') } catch (error) { console.error(error) - if (error?.response?.status === 404) { + if (isAxiosErrorResponse(error) && 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 @@ -140,7 +158,10 @@ export function useActiveSession() { } } - const setSessionAsInactive = async () => { + /** + * Mark the session as inactive, on the client and on the server. + */ + async function setSessionAsInactive() { if (!supportSessionState.value) { return } @@ -153,7 +174,7 @@ export function useActiveSession() { return } clearTimeout(inactiveTimer) - inactiveTimer = null + inactiveTimer = undefined currentState.value = SESSION.STATE.INACTIVE try { @@ -163,7 +184,7 @@ export function useActiveSession() { 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) { + if (isAxiosErrorResponse(error) && 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 @@ -175,7 +196,13 @@ export function useActiveSession() { } } - const handleWindowFocus = ({ type }) => { + /** + * Handle the window gaining or losing focus. + * + * @param event the focus/blur event + * @param event.type the event type, 'focus' or 'blur' + */ + function handleWindowFocus({ type }: FocusEvent) { clearTimeout(inactiveTimer) if (type === 'focus') { setSessionAsActive() @@ -191,12 +218,18 @@ export function useActiveSession() { } } - const handleMouseEnter = (event) => { + /** + * Handle the mouse entering the tab while it is in the background. + */ + function handleMouseEnter() { // The window is not focused, so hovering it only postpones the update setSessionAsActive() } - const handleMouseLeave = (event) => { + /** + * Handle the mouse leaving the tab while it is in the background. + */ + function handleMouseLeave() { // Restart timer, if mouse leaves the tab scheduleSessionAsInactive() } From cb6e659add653734ed2a68a2ae2d52207aa05d9f Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Mon, 31 Aug 2026 18:37:53 +0200 Subject: [PATCH 5/6] fix: watcher after joined token instead of route token Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Maksim Sukharev --- src/composables/__tests__/useActiveSession.spec.js | 12 +++++++++++- src/composables/useActiveSession.ts | 8 +++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/composables/__tests__/useActiveSession.spec.js b/src/composables/__tests__/useActiveSession.spec.js index b837190788d..40f8a686c48 100644 --- a/src/composables/__tests__/useActiveSession.spec.js +++ b/src/composables/__tests__/useActiveSession.spec.js @@ -12,6 +12,7 @@ import { SESSION } from '../../constants.ts' const mocks = vi.hoisted(() => ({ token: { current: null }, isInCall: { current: null }, + joinedConversation: { current: null }, })) vi.mock('vuex', async () => { @@ -24,6 +25,9 @@ vi.mock('../useGetToken.ts', () => ({ vi.mock('../useIsInCall.js', () => ({ useIsInCall: () => mocks.isInCall.current, })) +vi.mock('../useJoinedConversation.ts', () => ({ + useJoinedConversation: () => mocks.joinedConversation.current, +})) vi.mock('../../services/participantsService.js', () => ({ setSessionState: vi.fn(), })) @@ -85,6 +89,7 @@ describe('useActiveSession', () => { windowHasFocus = true mocks.token.current = ref('XXTOKENXX') mocks.isInCall.current = ref(false) + mocks.joinedConversation.current = ref(null) useStore.mockReturnValue({ dispatch: vi.fn() }) await mountActiveSession() }) @@ -179,9 +184,14 @@ describe('useActiveSession', () => { await runInactiveTimer() setSessionState.mockClear() - // Joining another conversation creates a new session, which is active again + // The route changes right away, but the session state waits for the join to complete mocks.token.current.value = 'YYTOKENYY' await flushPromises() + expect(useIsSessionActive().value).toBe(false) + + // Joining another conversation creates a new session, which is active again + mocks.joinedConversation.current.value = 'YYTOKENYY' + await flushPromises() expect(useIsSessionActive().value).toBe(true) await runInactiveTimer() diff --git a/src/composables/useActiveSession.ts b/src/composables/useActiveSession.ts index 4e7f7e62948..9f43bed32f6 100644 --- a/src/composables/useActiveSession.ts +++ b/src/composables/useActiveSession.ts @@ -13,6 +13,7 @@ import { isAxiosErrorResponse } from '../types/guards.ts' import { useDocumentVisibility } from './useDocumentVisibility.ts' import { useGetToken } from './useGetToken.ts' import { useIsInCall } from './useIsInCall.js' +import { useJoinedConversation } from './useJoinedConversation.ts' type SessionStateValue = typeof SESSION.STATE[keyof typeof SESSION.STATE] @@ -48,6 +49,7 @@ export function useActiveSession() { const isInCall = useIsInCall() const isDocumentVisible = useDocumentVisibility() + const currentJoinedConversation = useJoinedConversation() let inactiveTimer: NodeJS.Timeout | undefined @@ -66,13 +68,13 @@ export function useActiveSession() { inactiveTimer = setTimeout(setSessionAsInactive, INACTIVE_TIME_MS) } - watch(token, () => { - if (!supportSessionState.value) { + // Wait for the token to actually be joined + watch(currentJoinedConversation, (joinedToken) => { + if (joinedToken !== token.value || !supportSessionState.value) { return } // Joined conversation has active state by default currentState.value = SESSION.STATE.ACTIVE - // Updating right away would race with joining the conversation if (!isWindowActive()) { scheduleSessionAsInactive() } From 85b8bea7aff619137a47c7ffb0856e9799ea823a Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Fri, 28 Aug 2026 18:00:52 +0200 Subject: [PATCH 6/6] fix: scroll inactive chat to half the screen Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Maksim Sukharev --- src/components/MessagesList/MessagesList.vue | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/components/MessagesList/MessagesList.vue b/src/components/MessagesList/MessagesList.vue index d4e8e6db174..2806df535c0 100644 --- a/src/components/MessagesList/MessagesList.vue +++ b/src/components/MessagesList/MessagesList.vue @@ -960,13 +960,12 @@ export default { } else if (!this.isSticky) { // Reading old messages return - } 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 - // Single new line from the previous author is 35px so scroll half a line (10px) - // Single new line from the new author is 75px so scroll half an avatar (40px) - newTop = this.$refs.scroller.scrollTop + scrollBy + } else if (!this.isChatActive && this.getVisualLastReadMessageElement()) { + // In inactive chat, anchor scrolling at the first unread message, + // 'Unread messages' delimiter does not go above the center of the viewport + newTop = this.getVisualLastReadMessageElement().getBoundingClientRect().bottom + - this.$refs.scrollerLoader.getBoundingClientRect().top + - this.$refs.scroller.offsetHeight / 2 this.setChatScrolledToBottom(false) } else { newTop = this.$refs.scroller.scrollHeight