Skip to content

Commit 60d14e8

Browse files
committed
feat(user-status): change to online/away on system idle and lock
Signed-off-by: Grigorii K. Shartsev <me@shgk.me>
1 parent 69cad81 commit 60d14e8

File tree

8 files changed

+182
-2
lines changed

8 files changed

+182
-2
lines changed

src/main.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ ipcMain.on('app:relaunch', () => {
103103
})
104104
ipcMain.handle('app:config:get', (event, key) => getAppConfig(key))
105105
ipcMain.handle('app:config:set', (event, key, value) => setAppConfig(key, value))
106+
ipcMain.on('app:grantUserGesturedPermission', (event, id) => {
107+
return event.sender.executeJavaScript(`document.getElementById('${id}')?.click()`, true)
108+
})
106109
ipcMain.on('app:toggleDevTools', (event) => event.sender.toggleDevTools())
107110
ipcMain.handle('app:anything', () => { /* Put any code here to run it from UI */ })
108111
ipcMain.on('app:openChromeWebRtcInternals', () => openChromeWebRtcInternals())

src/preload.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ const TALK_DESKTOP = {
108108
* @param {(event: import('electron').IpcRedererEvent, payload: { key: string, value: unknown, appConfig: import('./app/AppConfig.ts').AppConfig}) => void} callback - Callback
109109
*/
110110
onAppConfigChange: (callback) => ipcRenderer.on('app:config:change', callback),
111+
/**
112+
* Grant a permission requiring a user gesture
113+
*
114+
* @param {string} id - Button ID to click on
115+
*/
116+
grantUserGesturedPermission: (id) => ipcRenderer.send('app:grantUserGesturedPermission', id),
111117
/**
112118
* Trigger download of a URL
113119
*
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
/**
7+
* Grant a permission requiring a user gesture:
8+
* Create a hidden button that will be clicked by the main process to request the permission.
9+
*
10+
* @param requester - Function to request the permission
11+
* @return - Promise that resolves with the result of the permission request
12+
*/
13+
export async function grantUserGesturedPermission<T>(requester: () => T) {
14+
return new Promise<T>((resolve) => {
15+
const id = `request-user-gestured-permission-${Math.random().toString(36).slice(2, 6)}`
16+
17+
const button = document.createElement('button')
18+
document.body.appendChild(button)
19+
button.id = id
20+
button.className = 'visually-hidden'
21+
button.addEventListener('click', () => {
22+
const result = requester()
23+
button.remove()
24+
resolve(result)
25+
}, { once: true, passive: true })
26+
27+
window.TALK_DESKTOP.grantUserGesturedPermission(id)
28+
})
29+
}

src/shared/utils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
/* eslint-disable @typescript-eslint/no-explicit-any */
7+
8+
export function once<F extends () => any>(func: F): F
9+
export function once<F extends (...args: any[]) => void>(func: F): F
10+
/**
11+
* Singletone function decorator
12+
*
13+
* @param func - Function
14+
*/
15+
export function once<F extends (() => any) | ((...args: any[]) => void)>(func: F): F {
16+
let wasCalled = false
17+
let result: ReturnType<F>
18+
19+
return ((...args: Parameters<F>): ReturnType<F> => {
20+
if (!wasCalled) {
21+
wasCalled = true
22+
result = func(...args)
23+
}
24+
return result
25+
}) as F
26+
}
27+
28+
/* eslint-enable @typescript-eslint/no-explicit-any */
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
// See: https://wicg.github.io/idle-detection/#api-idledetector
7+
8+
type UserIdleState = 'active' | 'idle'
9+
10+
type ScreenIdleState = 'locked' | 'unlocked'
11+
12+
type IdleOptions = {
13+
/**
14+
* Min 60_000 ms
15+
*/
16+
threshold?: number
17+
signal?: AbortSignal
18+
}
19+
20+
interface IdleDetector extends EventTarget {
21+
userState: UserIdleState | null
22+
screenState: ScreenIdleState | null
23+
24+
start(options?: IdleOptions): Promise<void>
25+
onchange(callback: () => void): void
26+
}
27+
28+
interface IdleDetectorConstructor {
29+
requestPermission(): Promise<PermissionState>
30+
31+
new (): IdleDetector
32+
prototype: IdleDetector
33+
}
34+
35+
declare const IdleDetector: IdleDetectorConstructor
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { createSharedComposable } from '@vueuse/core'
7+
import { onBeforeMount, onBeforeUnmount, readonly, ref } from 'vue'
8+
import { grantUserGesturedPermission } from '../../../../shared/grantUserGesturedPermission.ts'
9+
import { once } from '../../../../shared/utils.ts'
10+
11+
/**
12+
* Grant IdleDetector permission
13+
*/
14+
const grantIdleDetectorPermission = once(async () => {
15+
const permission = await grantUserGesturedPermission(() => IdleDetector.requestPermission())
16+
if (permission !== 'granted') {
17+
throw new Error('Unexpected permission denied for IdleDetector')
18+
}
19+
})
20+
21+
/**
22+
* Use IdleDetector API
23+
*
24+
* @param threshold - IdleDetector's threshold
25+
*/
26+
export const useIdleDetector = createSharedComposable((threshold: number = 60_000) => {
27+
const userState = ref<UserIdleState | undefined>(undefined)
28+
const screenState = ref<ScreenIdleState | undefined>(undefined)
29+
const abortController = new AbortController()
30+
31+
onBeforeMount(async () => {
32+
try {
33+
await grantIdleDetectorPermission()
34+
35+
const idleDetector = new IdleDetector()
36+
idleDetector.addEventListener('change', () => {
37+
userState.value = idleDetector.userState!
38+
screenState.value = idleDetector.screenState!
39+
})
40+
await idleDetector.start({
41+
threshold,
42+
signal: abortController.signal,
43+
})
44+
} catch (error) {
45+
console.error('Unexpected error on starting IdleDetector:', error)
46+
}
47+
})
48+
49+
onBeforeUnmount(() => abortController.abort())
50+
51+
return {
52+
screenState: readonly(screenState),
53+
userState: readonly(userState),
54+
}
55+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { createSharedComposable } from '@vueuse/core'
7+
import { computed } from 'vue'
8+
import { useAppIdle } from './useAppIdle.ts'
9+
import { useIdleDetector } from './useIdleDetector.ts'
10+
11+
/**
12+
* Whether the user is away or active
13+
*
14+
* @param threshold - How long user is considered active
15+
*/
16+
export const useIsAway = createSharedComposable((threshold = 60_000) => {
17+
const { userState, screenState } = useIdleDetector(threshold)
18+
const isAppIdle = useAppIdle(threshold)
19+
20+
return computed(() => {
21+
return screenState.value === 'locked' // System Locked - the user is away immediately
22+
|| (userState.value !== 'active' && isAppIdle.value) // Check both to cover unavailable IdleDetector
23+
})
24+
})

src/talk/renderer/UserStatus/useHeartbeat.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { createSharedComposable } from '@vueuse/core'
77
import { onBeforeUnmount, watch } from 'vue'
8-
import { useAppIdle } from './composables/useAppIdle.ts'
8+
import { useIsAway } from './composables/useIsAway.ts'
99
import { useUserStatusStore } from './userStatus.store.ts'
1010

1111
// General notes:
@@ -30,7 +30,7 @@ const AWAY_THRESHOLD = 2 * 60 * 1000 // 2 minutes
3030
*/
3131
export const useHeartbeat = createSharedComposable(() => {
3232
const userStatusStore = useUserStatusStore()
33-
const isAway = useAppIdle(AWAY_THRESHOLD)
33+
const isAway = useIsAway(AWAY_THRESHOLD)
3434

3535
let heartbeatTimeout: number | undefined
3636

0 commit comments

Comments
 (0)