diff --git a/src/App.vue b/src/App.vue index ff5c05352c9..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, @@ -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/components/MessagesList/MessagesList.vue b/src/components/MessagesList/MessagesList.vue index 138e262b881..2806df535c0 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' @@ -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 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.js b/src/composables/useActiveSession.ts similarity index 61% rename from src/composables/useActiveSession.js rename to src/composables/useActiveSession.ts index e6e02c5cc2b..9f43bed32f6 100644 --- a/src/composables/useActiveSession.js +++ b/src/composables/useActiveSession.ts @@ -3,20 +3,24 @@ * 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' 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' +import { useJoinedConversation } from './useJoinedConversation.ts' + +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 +28,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) @@ -35,35 +39,42 @@ 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() 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) { - return false - } - const isInCall = useIsInCall() const isDocumentVisible = useDocumentVisibility() + const currentJoinedConversation = useJoinedConversation() + + let inactiveTimer: NodeJS.Timeout | undefined - const inactiveTimer = ref(null) - const isWindowActive = () => document.hasFocus() && isDocumentVisible.value + /** + * Whether the window is focused and visible right now. + */ + function isWindowActive() { + return document.hasFocus() && isDocumentVisible.value + } - const scheduleSessionAsInactive = () => { - clearTimeout(inactiveTimer.value) - inactiveTimer.value = setTimeout(setSessionAsInactive, INACTIVE_TIME_MS) + /** + * (Re)start the countdown to mark the session as inactive. + */ + function scheduleSessionAsInactive() { + clearTimeout(inactiveTimer) + inactiveTimer = setTimeout(setSessionAsInactive, INACTIVE_TIME_MS) } - watch(token, () => { + // 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() } @@ -85,20 +96,43 @@ 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(() => { + 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 = 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 + } // Without re-arming, a background window stays active until the next focus if (isWindowActive()) { - clearTimeout(inactiveTimer.value) + clearTimeout(inactiveTimer) } else { scheduleSessionAsInactive() } @@ -114,7 +148,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 @@ -126,7 +160,13 @@ 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 + } if (currentState.value === SESSION.STATE.INACTIVE || !token.value) { return @@ -135,8 +175,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 = undefined currentState.value = SESSION.STATE.INACTIVE try { @@ -146,7 +186,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 @@ -158,8 +198,14 @@ export function useActiveSession() { } } - const handleWindowFocus = ({ type }) => { - clearTimeout(inactiveTimer.value) + /** + * 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() @@ -174,15 +220,19 @@ 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() } - - return true }