@morscherlab/mint-sdk 1.1.10 → 1.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/components/AppTopBarPresence.test.d.ts +1 -0
- package/dist/__tests__/composables/usePresenceHeartbeat.test.d.ts +1 -0
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
- package/dist/components/index.js +1 -1
- package/dist/{components-HXV-ZkBg.js → components-DjJsjKqe.js} +6 -2
- package/dist/components-DjJsjKqe.js.map +1 -0
- package/dist/composables/index.d.ts +1 -0
- package/dist/composables/index.js +2 -2
- package/dist/composables/usePresenceHeartbeat.d.ts +44 -0
- package/dist/index.js +3 -3
- package/dist/install.js +1 -1
- package/dist/{useProtocolTemplates-D3IZKLqr.js → useProtocolTemplates-CIzDGj2R.js} +106 -3
- package/dist/useProtocolTemplates-CIzDGj2R.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/AppTopBarPresence.test.ts +47 -0
- package/src/__tests__/composables/usePresenceHeartbeat.test.ts +147 -0
- package/src/components/AppTopBar.vue +6 -0
- package/src/composables/index.ts +7 -0
- package/src/composables/usePresenceHeartbeat.ts +194 -0
- package/dist/components-HXV-ZkBg.js.map +0 -1
- package/dist/useProtocolTemplates-D3IZKLqr.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morscherlab/mint-sdk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.11",
|
|
4
4
|
"description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { computed, ref } from 'vue'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import AppTopBar from '../../components/AppTopBar.vue'
|
|
6
|
+
|
|
7
|
+
const heartbeat = vi.hoisted(() => vi.fn((_options: unknown) => ({
|
|
8
|
+
clientId: 'presence-tab',
|
|
9
|
+
isRunning: ref(false),
|
|
10
|
+
sendNow: vi.fn(),
|
|
11
|
+
})))
|
|
12
|
+
|
|
13
|
+
vi.mock('../../composables/usePresenceHeartbeat', () => ({
|
|
14
|
+
usePresenceHeartbeat: heartbeat,
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
vi.mock('../../composables/usePlatformContext', () => ({
|
|
18
|
+
usePlatformContext: vi.fn(() => ({
|
|
19
|
+
isIntegrated: computed(() => true),
|
|
20
|
+
context: ref({ isIntegrated: true, theme: 'system' }),
|
|
21
|
+
plugin: computed(() => ({ id: 'LEAF', name: 'Leaf Analysis' })),
|
|
22
|
+
user: computed(() => undefined),
|
|
23
|
+
theme: computed(() => 'system' as const),
|
|
24
|
+
features: computed(() => undefined),
|
|
25
|
+
navigate: vi.fn(),
|
|
26
|
+
notify: vi.fn(),
|
|
27
|
+
sendToPlatform: vi.fn(),
|
|
28
|
+
})),
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
describe('AppTopBar presence', () => {
|
|
32
|
+
beforeEach(() => vi.clearAllMocks())
|
|
33
|
+
|
|
34
|
+
it('should report the integrated plugin id as the presence scope', () => {
|
|
35
|
+
const wrapper = mount(AppTopBar, { props: { title: 'LEAF' } })
|
|
36
|
+
|
|
37
|
+
const options = heartbeat.mock.calls[0]?.[0] as {
|
|
38
|
+
enabled: { value: boolean }
|
|
39
|
+
scope: { value: string }
|
|
40
|
+
}
|
|
41
|
+
expect({ enabled: options.enabled.value, scope: options.scope.value }).toEqual({
|
|
42
|
+
enabled: true,
|
|
43
|
+
scope: 'LEAF',
|
|
44
|
+
})
|
|
45
|
+
wrapper.unmount()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { defineComponent, nextTick } from 'vue'
|
|
3
|
+
import { mount } from '@vue/test-utils'
|
|
4
|
+
|
|
5
|
+
import { usePresenceHeartbeat } from '../../composables/usePresenceHeartbeat'
|
|
6
|
+
|
|
7
|
+
class MemoryStorage implements Pick<Storage, 'getItem' | 'setItem'> {
|
|
8
|
+
private readonly values = new Map<string, string>()
|
|
9
|
+
|
|
10
|
+
getItem(key: string): string | null {
|
|
11
|
+
return this.values.get(key) ?? null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
setItem(key: string, value: string): void {
|
|
15
|
+
this.values.set(key, value)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mountHeartbeat(options: Parameters<typeof usePresenceHeartbeat>[0]) {
|
|
20
|
+
let heartbeat: ReturnType<typeof usePresenceHeartbeat> | undefined
|
|
21
|
+
const wrapper = mount(defineComponent({
|
|
22
|
+
setup() {
|
|
23
|
+
heartbeat = usePresenceHeartbeat(options)
|
|
24
|
+
return () => null
|
|
25
|
+
},
|
|
26
|
+
}))
|
|
27
|
+
return { wrapper, get heartbeat() { return heartbeat! } }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('usePresenceHeartbeat', () => {
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.useRealTimers()
|
|
33
|
+
vi.restoreAllMocks()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should reuse one sessionStorage client id and send the integrated scope immediately', async () => {
|
|
37
|
+
const storage = new MemoryStorage()
|
|
38
|
+
const send = vi.fn().mockResolvedValue(undefined)
|
|
39
|
+
const first = mountHeartbeat({
|
|
40
|
+
scope: 'LEAF',
|
|
41
|
+
enabled: true,
|
|
42
|
+
storage,
|
|
43
|
+
createClientId: () => 'tab-1',
|
|
44
|
+
send,
|
|
45
|
+
})
|
|
46
|
+
await nextTick()
|
|
47
|
+
|
|
48
|
+
expect(send).toHaveBeenCalledWith({ client_id: 'tab-1', scope: 'LEAF' })
|
|
49
|
+
|
|
50
|
+
first.wrapper.unmount()
|
|
51
|
+
send.mockClear()
|
|
52
|
+
const second = mountHeartbeat({
|
|
53
|
+
scope: 'HMDB',
|
|
54
|
+
enabled: true,
|
|
55
|
+
storage,
|
|
56
|
+
createClientId: () => 'tab-2',
|
|
57
|
+
send,
|
|
58
|
+
})
|
|
59
|
+
await nextTick()
|
|
60
|
+
|
|
61
|
+
expect({ clientId: second.heartbeat.clientId, payload: send.mock.calls[0]?.[0] }).toEqual({
|
|
62
|
+
clientId: 'tab-1',
|
|
63
|
+
payload: { client_id: 'tab-1', scope: 'HMDB' },
|
|
64
|
+
})
|
|
65
|
+
second.wrapper.unmount()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should heartbeat every 30 seconds only while the page is visible', async () => {
|
|
69
|
+
vi.useFakeTimers()
|
|
70
|
+
const send = vi.fn().mockResolvedValue(undefined)
|
|
71
|
+
const visibility = { value: 'visible' }
|
|
72
|
+
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(
|
|
73
|
+
() => visibility.value as DocumentVisibilityState,
|
|
74
|
+
)
|
|
75
|
+
const { wrapper } = mountHeartbeat({
|
|
76
|
+
scope: 'platform',
|
|
77
|
+
enabled: true,
|
|
78
|
+
storage: new MemoryStorage(),
|
|
79
|
+
createClientId: () => 'platform-tab',
|
|
80
|
+
send,
|
|
81
|
+
})
|
|
82
|
+
await nextTick()
|
|
83
|
+
expect(send).toHaveBeenCalledTimes(1)
|
|
84
|
+
|
|
85
|
+
await vi.advanceTimersByTimeAsync(30_000)
|
|
86
|
+
expect(send).toHaveBeenCalledTimes(2)
|
|
87
|
+
|
|
88
|
+
visibility.value = 'hidden'
|
|
89
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
90
|
+
await vi.advanceTimersByTimeAsync(60_000)
|
|
91
|
+
expect(send).toHaveBeenCalledTimes(2)
|
|
92
|
+
|
|
93
|
+
visibility.value = 'visible'
|
|
94
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
95
|
+
await nextTick()
|
|
96
|
+
expect(send).toHaveBeenCalledTimes(3)
|
|
97
|
+
|
|
98
|
+
wrapper.unmount()
|
|
99
|
+
await vi.advanceTimersByTimeAsync(60_000)
|
|
100
|
+
expect(send).toHaveBeenCalledTimes(3)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should make no request when the caller is standalone', async () => {
|
|
104
|
+
vi.useFakeTimers()
|
|
105
|
+
const send = vi.fn().mockResolvedValue(undefined)
|
|
106
|
+
const { wrapper } = mountHeartbeat({
|
|
107
|
+
scope: 'LEAF',
|
|
108
|
+
enabled: false,
|
|
109
|
+
storage: new MemoryStorage(),
|
|
110
|
+
createClientId: () => 'standalone-tab',
|
|
111
|
+
send,
|
|
112
|
+
})
|
|
113
|
+
await nextTick()
|
|
114
|
+
await vi.advanceTimersByTimeAsync(90_000)
|
|
115
|
+
|
|
116
|
+
expect(send).not.toHaveBeenCalled()
|
|
117
|
+
wrapper.unmount()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should keep a page-local id when sessionStorage is unavailable', async () => {
|
|
121
|
+
const storage = {
|
|
122
|
+
getItem: vi.fn(() => { throw new Error('blocked') }),
|
|
123
|
+
setItem: vi.fn(() => { throw new Error('blocked') }),
|
|
124
|
+
}
|
|
125
|
+
const send = vi.fn().mockResolvedValue(undefined)
|
|
126
|
+
const first = mountHeartbeat({
|
|
127
|
+
scope: 'LEAF',
|
|
128
|
+
enabled: true,
|
|
129
|
+
storage,
|
|
130
|
+
createClientId: () => 'fallback-tab',
|
|
131
|
+
send,
|
|
132
|
+
})
|
|
133
|
+
await nextTick()
|
|
134
|
+
first.wrapper.unmount()
|
|
135
|
+
const second = mountHeartbeat({
|
|
136
|
+
scope: 'HMDB',
|
|
137
|
+
enabled: true,
|
|
138
|
+
storage,
|
|
139
|
+
createClientId: () => 'different-tab',
|
|
140
|
+
send,
|
|
141
|
+
})
|
|
142
|
+
await nextTick()
|
|
143
|
+
|
|
144
|
+
expect(second.heartbeat.clientId).toBe('fallback-tab')
|
|
145
|
+
second.wrapper.unmount()
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -22,6 +22,7 @@ import AppAvatarMenu from './AppAvatarMenu.vue'
|
|
|
22
22
|
import AppPluginSwitcher from './AppPluginSwitcher.vue'
|
|
23
23
|
import PluginIcon from './PluginIcon.vue'
|
|
24
24
|
import { usePlatformContext } from '../composables/usePlatformContext'
|
|
25
|
+
import { usePresenceHeartbeat } from '../composables/usePresenceHeartbeat'
|
|
25
26
|
import { APP_EXPERIMENT_KEY } from '../composables/useAppExperiment'
|
|
26
27
|
import {
|
|
27
28
|
currentItemIdFromLocation,
|
|
@@ -115,6 +116,11 @@ const emit = defineEmits<{
|
|
|
115
116
|
const settingsOpen = ref(false)
|
|
116
117
|
const { isIntegrated, plugin } = usePlatformContext()
|
|
117
118
|
const isStandalone = computed(() => !isIntegrated.value)
|
|
119
|
+
const presenceScope = computed(() => plugin.value?.id || plugin.value?.name || '')
|
|
120
|
+
usePresenceHeartbeat({
|
|
121
|
+
enabled: isIntegrated,
|
|
122
|
+
scope: presenceScope,
|
|
123
|
+
})
|
|
118
124
|
const appExperiment = inject(APP_EXPERIMENT_KEY, null)
|
|
119
125
|
const canBindExperiment = computed(() => plugin.value?.plugin_type !== 'static')
|
|
120
126
|
|
package/src/composables/index.ts
CHANGED
|
@@ -10,6 +10,13 @@ export { useTheme } from './useTheme'
|
|
|
10
10
|
export { useToast } from './useToast'
|
|
11
11
|
export { usePlatformContext } from './usePlatformContext'
|
|
12
12
|
export { resolvePluginFrontendBase } from './pluginFrontendBase'
|
|
13
|
+
export {
|
|
14
|
+
getPresenceClientId,
|
|
15
|
+
usePresenceHeartbeat,
|
|
16
|
+
type PresenceHeartbeatOptions,
|
|
17
|
+
type PresenceHeartbeatPayload,
|
|
18
|
+
type UsePresenceHeartbeatReturn,
|
|
19
|
+
} from './usePresenceHeartbeat'
|
|
13
20
|
export {
|
|
14
21
|
useForm,
|
|
15
22
|
type ValidationRule,
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
onMounted,
|
|
4
|
+
onScopeDispose,
|
|
5
|
+
readonly,
|
|
6
|
+
ref,
|
|
7
|
+
toValue,
|
|
8
|
+
watch,
|
|
9
|
+
type MaybeRefOrGetter,
|
|
10
|
+
type Ref,
|
|
11
|
+
} from 'vue'
|
|
12
|
+
|
|
13
|
+
import { useApi } from './useApi'
|
|
14
|
+
|
|
15
|
+
const PRESENCE_CLIENT_ID_KEY = 'mint:presence:client-id'
|
|
16
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000
|
|
17
|
+
|
|
18
|
+
let pageFallbackClientId: string | null = null
|
|
19
|
+
|
|
20
|
+
export interface PresenceHeartbeatPayload {
|
|
21
|
+
client_id: string
|
|
22
|
+
scope: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface PresenceDocument {
|
|
26
|
+
readonly visibilityState: DocumentVisibilityState
|
|
27
|
+
addEventListener(type: 'visibilitychange', listener: EventListener): void
|
|
28
|
+
removeEventListener(type: 'visibilitychange', listener: EventListener): void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PresenceStorage {
|
|
32
|
+
getItem(key: string): string | null
|
|
33
|
+
setItem(key: string, value: string): void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PresenceHeartbeatOptions {
|
|
37
|
+
/** Platform, plugin id, or plugin name reported for this browser tab. */
|
|
38
|
+
scope: MaybeRefOrGetter<string | null | undefined>
|
|
39
|
+
/** Disable all traffic, including for standalone plugins. */
|
|
40
|
+
enabled?: MaybeRefOrGetter<boolean>
|
|
41
|
+
/** Heartbeat interval. Defaults to 30 seconds. */
|
|
42
|
+
intervalMs?: number
|
|
43
|
+
/** Test or host storage seam. Defaults to sessionStorage. */
|
|
44
|
+
storage?: PresenceStorage | null
|
|
45
|
+
/** Test or host document seam. Defaults to the current document. */
|
|
46
|
+
document?: PresenceDocument | null
|
|
47
|
+
/** Test seam for generating the per-tab id. */
|
|
48
|
+
createClientId?: () => string
|
|
49
|
+
/** Test or custom transport seam. Defaults to authenticated POST /api/presence/heartbeat. */
|
|
50
|
+
send?: (payload: PresenceHeartbeatPayload) => Promise<unknown> | unknown
|
|
51
|
+
/** Test timing seam. Defaults to the browser scheduler. */
|
|
52
|
+
setInterval?: (callback: () => void, intervalMs: number) => unknown
|
|
53
|
+
/** Test timing seam. Defaults to the browser scheduler. */
|
|
54
|
+
clearInterval?: (intervalId: unknown) => void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface UsePresenceHeartbeatReturn {
|
|
58
|
+
clientId: string
|
|
59
|
+
isRunning: Readonly<Ref<boolean>>
|
|
60
|
+
sendNow: () => Promise<void>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function defaultClientId(): string {
|
|
64
|
+
if (typeof globalThis.crypto?.randomUUID === 'function') {
|
|
65
|
+
return globalThis.crypto.randomUUID()
|
|
66
|
+
}
|
|
67
|
+
return `presence-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function defaultStorage(): PresenceStorage | null {
|
|
71
|
+
try {
|
|
72
|
+
return globalThis.sessionStorage ?? null
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function defaultDocument(): PresenceDocument | null {
|
|
79
|
+
return typeof globalThis.document === 'undefined' ? null : globalThis.document
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Return the stable id shared by presence callers in the current browser tab. */
|
|
83
|
+
export function getPresenceClientId(
|
|
84
|
+
storage: PresenceStorage | null = defaultStorage(),
|
|
85
|
+
createClientId: () => string = defaultClientId,
|
|
86
|
+
): string {
|
|
87
|
+
try {
|
|
88
|
+
const stored = storage?.getItem(PRESENCE_CLIENT_ID_KEY)
|
|
89
|
+
if (stored) return stored
|
|
90
|
+
} catch {
|
|
91
|
+
// Keep the tab usable when storage is blocked.
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const nextClientId = createClientId()
|
|
95
|
+
try {
|
|
96
|
+
if (storage) {
|
|
97
|
+
storage.setItem(PRESENCE_CLIENT_ID_KEY, nextClientId)
|
|
98
|
+
return nextClientId
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Fall through to the page-local fallback when storage is blocked.
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!pageFallbackClientId) pageFallbackClientId = nextClientId
|
|
105
|
+
return pageFallbackClientId
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Report one visible integrated MINT browser tab to the platform presence tracker. */
|
|
109
|
+
export function usePresenceHeartbeat(
|
|
110
|
+
options: PresenceHeartbeatOptions,
|
|
111
|
+
): UsePresenceHeartbeatReturn {
|
|
112
|
+
const presenceDocument = options.document === undefined ? defaultDocument() : options.document
|
|
113
|
+
const clientId = getPresenceClientId(
|
|
114
|
+
options.storage === undefined ? defaultStorage() : options.storage,
|
|
115
|
+
options.createClientId ?? defaultClientId,
|
|
116
|
+
)
|
|
117
|
+
const intervalMs = options.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
|
|
118
|
+
const scheduleInterval = options.setInterval ?? ((callback: () => void, delayMs: number) => (
|
|
119
|
+
globalThis.setInterval(callback, delayMs)
|
|
120
|
+
))
|
|
121
|
+
const cancelInterval = options.clearInterval ?? ((intervalId: unknown) => {
|
|
122
|
+
globalThis.clearInterval(intervalId as ReturnType<typeof globalThis.setInterval>)
|
|
123
|
+
})
|
|
124
|
+
if (intervalMs <= 0) throw new Error('Presence heartbeat interval must be positive')
|
|
125
|
+
|
|
126
|
+
const api = options.send ? null : useApi({ baseUrl: '/api' })
|
|
127
|
+
const send = options.send ?? ((payload: PresenceHeartbeatPayload) => (
|
|
128
|
+
api!.post('/presence/heartbeat', payload)
|
|
129
|
+
))
|
|
130
|
+
const enabled = computed(() => toValue(options.enabled ?? true))
|
|
131
|
+
const scope = computed(() => toValue(options.scope)?.trim() ?? '')
|
|
132
|
+
const isRunning = ref(false)
|
|
133
|
+
let intervalId: unknown | null = null
|
|
134
|
+
let sending = false
|
|
135
|
+
let mounted = false
|
|
136
|
+
|
|
137
|
+
function isVisible(): boolean {
|
|
138
|
+
return presenceDocument?.visibilityState !== 'hidden'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function shouldRun(): boolean {
|
|
142
|
+
return mounted && enabled.value && Boolean(scope.value) && isVisible()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function sendNow(): Promise<void> {
|
|
146
|
+
if (!shouldRun() || sending) return
|
|
147
|
+
sending = true
|
|
148
|
+
try {
|
|
149
|
+
await send({ client_id: clientId, scope: scope.value })
|
|
150
|
+
} catch {
|
|
151
|
+
// Presence is advisory and must never break plugin or platform UI.
|
|
152
|
+
} finally {
|
|
153
|
+
sending = false
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function stop(): void {
|
|
158
|
+
if (intervalId !== null) cancelInterval(intervalId)
|
|
159
|
+
intervalId = null
|
|
160
|
+
isRunning.value = false
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function start(): void {
|
|
164
|
+
stop()
|
|
165
|
+
if (!shouldRun()) return
|
|
166
|
+
isRunning.value = true
|
|
167
|
+
void sendNow()
|
|
168
|
+
intervalId = scheduleInterval(() => void sendNow(), intervalMs)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function handleVisibilityChange(): void {
|
|
172
|
+
start()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
watch([enabled, scope], start)
|
|
176
|
+
|
|
177
|
+
onMounted(() => {
|
|
178
|
+
mounted = true
|
|
179
|
+
presenceDocument?.addEventListener('visibilitychange', handleVisibilityChange)
|
|
180
|
+
start()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
onScopeDispose(() => {
|
|
184
|
+
mounted = false
|
|
185
|
+
presenceDocument?.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
186
|
+
stop()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
clientId,
|
|
191
|
+
isRunning: readonly(isRunning),
|
|
192
|
+
sendNow,
|
|
193
|
+
}
|
|
194
|
+
}
|