Skip to content
Merged
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: 2 additions & 2 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand All @@ -111,7 +112,6 @@ export default {
isLeavingAfterSessionIssue: useSessionIssueHandler(),
isMobile: useIsMobile(),
isNextcloudTalkHashDirty: useHashCheck(),
supportSessionState: useActiveSession(),
callViewStore: useCallViewStore(),
sidebarStore: useSidebarStore(),
actorStore: useActorStore(),
Expand Down
15 changes: 7 additions & 8 deletions src/components/MessagesList/MessagesList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/composables/__tests__/useActiveSession.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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(),
}))
Expand Down Expand Up @@ -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()
})
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,32 @@
* 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<SessionStateValue>(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<boolean>} whether the session is active
* @return whether the session is active
*/
export function useIsSessionActive() {
return computed(() => currentState.value === SESSION.STATE.ACTIVE)
Expand All @@ -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()
}
Expand All @@ -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()
}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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()

Expand All @@ -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
}
Loading