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
3 changes: 3 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ ipcMain.on('app:relaunch', () => {
})
ipcMain.handle('app:config:get', (event, key) => getAppConfig(key))
ipcMain.handle('app:config:set', (event, key, value) => setAppConfig(key, value))
ipcMain.on('app:grantUserGesturedPermission', (event, id) => {
return event.sender.executeJavaScript(`document.getElementById('${id}')?.click()`, true)
})
ipcMain.on('app:toggleDevTools', (event) => event.sender.toggleDevTools())
ipcMain.handle('app:anything', () => { /* Put any code here to run it from UI */ })
ipcMain.on('app:openChromeWebRtcInternals', () => openChromeWebRtcInternals())
Expand Down
6 changes: 6 additions & 0 deletions src/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ const TALK_DESKTOP = {
* @param {(event: import('electron').IpcRedererEvent, payload: { key: string, value: unknown, appConfig: import('./app/AppConfig.ts').AppConfig}) => void} callback - Callback
*/
onAppConfigChange: (callback) => ipcRenderer.on('app:config:change', callback),
/**
* Grant a permission requiring a user gesture
*
* @param {string} id - Button ID to click on
*/
grantUserGesturedPermission: (id) => ipcRenderer.send('app:grantUserGesturedPermission', id),
/**
* Trigger download of a URL
*
Expand Down
29 changes: 29 additions & 0 deletions src/shared/grantUserGesturedPermission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/**
* Grant a permission requiring a user gesture:
* Create a hidden button that will be clicked by the main process to request the permission.
*
* @param requester - Function to request the permission
* @return - Promise that resolves with the result of the permission request
*/
export async function grantUserGesturedPermission<T>(requester: () => T) {
return new Promise<T>((resolve) => {
const id = `request-user-gestured-permission-${Math.random().toString(36).slice(2, 6)}`

const button = document.createElement('button')
document.body.appendChild(button)
button.id = id
button.className = 'visually-hidden'
button.addEventListener('click', () => {
const result = requester()
button.remove()
resolve(result)
}, { once: true, passive: true })

window.TALK_DESKTOP.grantUserGesturedPermission(id)
})
}
28 changes: 28 additions & 0 deletions src/shared/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/* eslint-disable @typescript-eslint/no-explicit-any */

export function once<F extends () => any>(func: F): F
export function once<F extends (...args: any[]) => void>(func: F): F
/**
* Singletone function decorator
*
* @param func - Function
*/
export function once<F extends (() => any) | ((...args: any[]) => void)>(func: F): F {
let wasCalled = false
let result: ReturnType<F>

return ((...args: Parameters<F>): ReturnType<F> => {
if (!wasCalled) {
wasCalled = true
result = func(...args)
}
return result
}) as F
}

/* eslint-enable @typescript-eslint/no-explicit-any */
4 changes: 2 additions & 2 deletions src/talk/renderer/TitleBar/TitleBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import { appData } from '../../../app/AppData.js'
import { BUILD_CONFIG } from '../../../shared/build.config.ts'
import { useDevMode } from '../../../shared/useDevMode.ts'
import { useAppConfigStore } from '../Settings/appConfig.store.ts'
import { useHeartbeat } from '../UserStatus/useHeartbeat.ts'
import { useUserStatusStore } from '../UserStatus/userStatus.store.ts'
import { useUserStatusHeartbeat } from '../UserStatus/useUserStatusHeartbeat.ts'

useUserStatusStore()
useUserStatusHeartbeat()
useHeartbeat()
useAppConfigStore()

const channel = __CHANNEL__
Expand Down
35 changes: 35 additions & 0 deletions src/talk/renderer/UserStatus/composables/IdleDetector.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

// See: https://wicg.github.io/idle-detection/#api-idledetector

type UserIdleState = 'active' | 'idle'

type ScreenIdleState = 'locked' | 'unlocked'

type IdleOptions = {
/**
* Min 60_000 ms
*/
threshold?: number
signal?: AbortSignal
}

interface IdleDetector extends EventTarget {
userState: UserIdleState | null
screenState: ScreenIdleState | null

start(options?: IdleOptions): Promise<void>
onchange(callback: () => void): void
}

interface IdleDetectorConstructor {
requestPermission(): Promise<PermissionState>

new (): IdleDetector
prototype: IdleDetector
}

declare const IdleDetector: IdleDetectorConstructor
51 changes: 51 additions & 0 deletions src/talk/renderer/UserStatus/composables/useAppIdle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { createSharedComposable, useEventListener } from '@vueuse/core'
import { onBeforeUnmount, ref } from 'vue'

const WINDOW_ACTIVE_EVENTS: (keyof WindowEventMap)[] = ['mousemove', 'mousedown', 'resize', 'keydown', 'touchstart', 'wheel']

/**
* Whether user is idle in the app (away) - interacted with the app (mouse, keyboard, touch) in the last THRESHOLD minutes
* or made document visible
*
* @param threshold - How long user is considered active after interaction in ms, default is 1 minute
*/
export const useAppIdle = createSharedComposable((threshold: number = 60_000) => {
const isIdle = ref(false)

let activityTimeout: number

/**
* Set new isIdle value and start
*
* @param newIsIdle - New isIdle
*/
function setIsIdle(newIsIdle: boolean) {
isIdle.value = newIsIdle
clearTimeout(activityTimeout)
if (!newIsIdle) {
// TODO: separate tsconfig for main process (Node.js Environment) and renderer process (Browser Environment)
activityTimeout = setTimeout(() => setIsIdle(true), threshold) as unknown as number
}
}

useEventListener(WINDOW_ACTIVE_EVENTS, () => {
setIsIdle(false)
})

useEventListener(document, 'visibilitychange', () => {
setIsIdle(document.hidden)
})

setIsIdle(false)

onBeforeUnmount(() => {
clearTimeout(activityTimeout)
})

return isIdle
})
55 changes: 55 additions & 0 deletions src/talk/renderer/UserStatus/composables/useIdleDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { createSharedComposable } from '@vueuse/core'
import { onBeforeMount, onBeforeUnmount, readonly, ref } from 'vue'
import { grantUserGesturedPermission } from '../../../../shared/grantUserGesturedPermission.ts'
import { once } from '../../../../shared/utils.ts'

/**
* Grant IdleDetector permission
*/
const grantIdleDetectorPermission = once(async () => {
const permission = await grantUserGesturedPermission(() => IdleDetector.requestPermission())
if (permission !== 'granted') {
throw new Error('Unexpected permission denied for IdleDetector')
}
})

/**
* Use IdleDetector API
*
* @param threshold - IdleDetector's threshold
*/
export const useIdleDetector = createSharedComposable((threshold: number = 60_000) => {
const userState = ref<UserIdleState | undefined>(undefined)
const screenState = ref<ScreenIdleState | undefined>(undefined)
const abortController = new AbortController()

onBeforeMount(async () => {
try {
await grantIdleDetectorPermission()

const idleDetector = new IdleDetector()
idleDetector.addEventListener('change', () => {
userState.value = idleDetector.userState!
screenState.value = idleDetector.screenState!
})
await idleDetector.start({
threshold,
signal: abortController.signal,
})
} catch (error) {
console.error('Unexpected error on starting IdleDetector:', error)
}
})

onBeforeUnmount(() => abortController.abort())

return {
screenState: readonly(screenState),
userState: readonly(userState),
}
})
24 changes: 24 additions & 0 deletions src/talk/renderer/UserStatus/composables/useIsAway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { createSharedComposable } from '@vueuse/core'
import { computed } from 'vue'
import { useAppIdle } from './useAppIdle.ts'
import { useIdleDetector } from './useIdleDetector.ts'

/**
* Whether the user is away or active
*
* @param threshold - How long user is considered active
*/
export const useIsAway = createSharedComposable((threshold = 60_000) => {
const { userState, screenState } = useIdleDetector(threshold)
const isAppIdle = useAppIdle(threshold)

return computed(() => {
return screenState.value === 'locked' // System Locked - the user is away immediately
|| (userState.value !== 'active' && isAppIdle.value) // Check both to cover unavailable IdleDetector
})
})
71 changes: 71 additions & 0 deletions src/talk/renderer/UserStatus/useHeartbeat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { createSharedComposable } from '@vueuse/core'
import { onBeforeUnmount, watch } from 'vue'
import { useIsAway } from './composables/useIsAway.ts'
import { useUserStatusStore } from './userStatus.store.ts'

// General notes:
// - Server has INVALIDATE_STATUS_THRESHOLD with 15 minutes, preventing immediate status update on heartbeat request
// See: https://github.com/nextcloud/server/blob/v31.0.5/apps/user_status/lib/Service/StatusService.php
// - However, "online" status has higher priority than "away"
// - Thus:
// - Changing "Away -> Online" is immediate
// - Changing "Online -> Away" has a 15 minutes threshold
// - See: https://github.com/nextcloud/server/blob/v31.0.5/apps/user_status/lib/Service/StatusService.php#L41-L48
// and: https://github.com/nextcloud/server/blob/master/apps/user_status/lib/Listener/UserLiveStatusListener.php#L75-L87
// - This might change in future to have symmetric behavior on heartbeat

/** How often to send the heartbeat. Must be less than 15 min. */
const HEARTBEAT_INTERVAL = 5 * 60 * 1000 // 5 minutes

/** How long user is considered active before going away */
const AWAY_THRESHOLD = 2 * 60 * 1000 // 2 minutes

/**
* Background heartbeat with user status update
*/
export const useHeartbeat = createSharedComposable(() => {
const userStatusStore = useUserStatusStore()
const isAway = useIsAway(AWAY_THRESHOLD)

let heartbeatTimeout: number | undefined

/**
* Send a heartbeat
*/
async function heartbeat() {
try {
await userStatusStore.updateUserStatusWithHeartbeat(isAway.value)
} catch (error) {
console.error('Error on heartbeat:', error)
}
}

/**
* Start heartbeat interval
*/
async function restartHeartbeat() {
if (heartbeatTimeout) {
clearTimeout(heartbeatTimeout)
}
await heartbeat()
// TODO: fix when main and renderer process have separate tsconfig
heartbeatTimeout = setTimeout(heartbeat, HEARTBEAT_INTERVAL) as unknown as number
}

// Restart heartbeat to immediately notify server on state change
watch(isAway, () => {
// Note: both app and system level activity state changes to inactive with a threshold.
// Only lock/unlock state can be changed many times in a short period, but it is unlikely
// Thus it unlikely overloads with heartbeat, no need to debounce
restartHeartbeat()
}, { immediate: true })

onBeforeUnmount(() => {
clearTimeout(heartbeatTimeout)
})
})
Loading