@meistrari/tela-build 1.68.0 → 1.69.0
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/components/tela/presence/presence-avatars.mdx +123 -0
- package/components/tela/presence/presence-avatars.vue +192 -0
- package/components/tela/tooltip-group/tooltip-group-trigger.vue +2 -1
- package/composables/__tests__/presence.test.ts +632 -0
- package/composables/presence.ts +336 -0
- package/package.json +5 -2
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# TelaPresenceAvatars
|
|
2
|
+
|
|
3
|
+
Shows who else is currently viewing the same page. Two variants:
|
|
4
|
+
|
|
5
|
+
- **`pill`** (default): a "N Viewing" count followed by up to `max` overlapping round avatars on a `bg-muted` pill (8px radius, 4px vertical / 10px left / 8px right padding). Hovering the count lists viewer names (up to `tooltipMax`, then a "x more" line).
|
|
6
|
+
- **`avatars`**: just the overlapping avatar stack — no count label, no pill surface — at a larger default size (`xs`, 24px). Meant for page headers (canvas, workflow, agent) where the count pill is too heavy. When more than `max` viewers are present, the last visible avatar gets an 80% `neutral-950` mask with a white "+N" count on top (no extra bubble); hovering it shows the same tooltip as the pill count (up to `tooltipMax` names, then the "x more" line).
|
|
7
|
+
|
|
8
|
+
Hovering an avatar shows the viewer's name and live status on separate lines: `Viewing now`, or for away viewers a relative time like "3 min ago" when `awaySinceLabel` is provided (falling back to the plain `awayLabel`). All tooltips share one `TelaTooltipGroup`, so moving between triggers skips the open delay instead of re-animating each tooltip. Viewers with `active: false` (page open, tab hidden) render dimmed instead of disappearing. Renders nothing when the viewers list is empty, so mount points need no guards.
|
|
9
|
+
|
|
10
|
+
Overlapping avatars are separated with a `mask-image` radial-gradient (the tags-select dot technique): each avatar except the last carves a transparent 2px crescent where the next avatar overlaps, so the gap shows whatever is behind the stack instead of a hardcoded ring color. Overlap scales with avatar size (16px→4px, 24px→6px, 32px→8px, 40px→10px).
|
|
11
|
+
|
|
12
|
+
Pair it with the `usePresence` composable, which joins a Yjs awareness room over websockets (channel switching, hard stop on repeated connection failures) and exposes the other viewers as `PresenceViewer[]` — deduped per user across tabs, sorted with active viewers first, then by join time, so people who tab away drop to the back of the stack and active viewers hold the visible slots. The connection stays open while the tab is hidden; the composable publishes `active: false` so peers dim and demote the avatar rather than dropping it. The consuming app provides the websocket `url`, the auth `getToken`, and the local `self` identity.
|
|
13
|
+
|
|
14
|
+
The component is i18n-free: pass pre-translated `countLabel`, `viewingLabel`, `awayLabel`, and `moreLabel` strings, and the `awaySinceLabel` formatter, from the app layer.
|
|
15
|
+
|
|
16
|
+
## Examples
|
|
17
|
+
|
|
18
|
+
### Basic Usage
|
|
19
|
+
|
|
20
|
+
```vue
|
|
21
|
+
<TelaPresenceAvatars
|
|
22
|
+
:viewers="[
|
|
23
|
+
{ name: 'Ada Lovelace', email: 'ada@example.com', image: 'https://example.com/ada.jpg' },
|
|
24
|
+
{ name: 'Grace Hopper', email: 'grace@example.com' },
|
|
25
|
+
]"
|
|
26
|
+
count-label="2 Viewing"
|
|
27
|
+
/>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Avatars-only variant
|
|
31
|
+
|
|
32
|
+
No count label, no pill chrome, larger avatars (defaults to `xs` / 24px). Used inside canvas, workflow, and agent headers. Overflow beyond `max` darkens the last visible avatar with an 80% mask and a "+N" count, with the name-list tooltip on hover.
|
|
33
|
+
|
|
34
|
+
```vue
|
|
35
|
+
<TelaPresenceAvatars
|
|
36
|
+
variant="avatars"
|
|
37
|
+
:viewers="others"
|
|
38
|
+
label="Viewing this canvas"
|
|
39
|
+
/>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Away viewers
|
|
43
|
+
|
|
44
|
+
A viewer with `active: false` renders at reduced opacity, moves behind the active viewers (`usePresence` sorts active-first), and their tooltip status switches to `awaySinceLabel(viewer.awaySince)` (e.g. "3 min ago") or the `awayLabel` when no timestamp/formatter is available. `usePresence` publishes `awaySince` when the tab goes hidden and clears it on return; pass a formatter that reads a reactive clock (e.g. `useNow`) so the label stays fresh while the tooltip is open.
|
|
45
|
+
|
|
46
|
+
```vue
|
|
47
|
+
<TelaPresenceAvatars
|
|
48
|
+
:viewers="[
|
|
49
|
+
{ name: 'Ada Lovelace', email: 'ada@example.com' },
|
|
50
|
+
{ name: 'Grace Hopper', email: 'grace@example.com', active: false },
|
|
51
|
+
]"
|
|
52
|
+
count-label="2 Viewing"
|
|
53
|
+
viewing-label="Viewing now"
|
|
54
|
+
away-label="Away"
|
|
55
|
+
/>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Overflow
|
|
59
|
+
|
|
60
|
+
Only the first `max` viewers render as avatars (priority to active viewers, then the earliest joiners — the input order). The count tooltip lists up to `tooltipMax` emails, then a final "x more" line built with `moreLabel`.
|
|
61
|
+
|
|
62
|
+
```vue
|
|
63
|
+
<TelaPresenceAvatars
|
|
64
|
+
:viewers="manyViewers"
|
|
65
|
+
:max="3"
|
|
66
|
+
:tooltip-max="7"
|
|
67
|
+
count-label="9 Viewing"
|
|
68
|
+
:more-label="count => `${count} more`"
|
|
69
|
+
/>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### With usePresence
|
|
73
|
+
|
|
74
|
+
The composable joins the live-collaboration room for the channel; `others` is already sorted (active first, then join time) and feeds straight into the component.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const { others } = usePresence({
|
|
78
|
+
channel: computed(() => `prompt:${route.params.promptId}`),
|
|
79
|
+
url: 'wss://collab.example.com',
|
|
80
|
+
self: computed(() => ({ id: user.id, email: user.email, name: user.name, image: user.image })),
|
|
81
|
+
getToken: async () => await auth.getToken(),
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```vue
|
|
86
|
+
<TelaPresenceAvatars
|
|
87
|
+
:viewers="others"
|
|
88
|
+
:count-label="`${others.length} Viewing`"
|
|
89
|
+
label="Viewing this canvas"
|
|
90
|
+
/>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Props
|
|
94
|
+
|
|
95
|
+
| Prop | Type | Default | Description |
|
|
96
|
+
| -------------- | ------------------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------ |
|
|
97
|
+
| `viewers` | `{ name?: string, email?: string, image?: string, active?: boolean, joinedAt?: number \| null }[]` | required | The other people in the room, pre-sorted (`usePresence` output) |
|
|
98
|
+
| `max` | `number` | `3` | Visible avatar slots |
|
|
99
|
+
| `tooltipMax` | `number` | `7` | Names listed in the count tooltip before the "x more" line |
|
|
100
|
+
| `variant` | `'pill' \| 'avatars'` | `'pill'` | `pill` shows the count on a muted surface; `avatars` is just the stack |
|
|
101
|
+
| `size` | `'2xs' \| 'xs' \| 'sm' \| 'md'` | `'2xs'` pill / `'xs'` avatars | Avatar size |
|
|
102
|
+
| `label` | `string` | `'Viewing this page'` | aria-label suffix; pass a translated string |
|
|
103
|
+
| `countLabel` | `string` | viewer count | Pre-translated count text, e.g. "3 Viewing" (pill variant only) |
|
|
104
|
+
| `viewingLabel` | `string` | `'Viewing now'` | Status word in the avatar tooltip for active viewers |
|
|
105
|
+
| `awayLabel` | `string` | `'Away'` | Status word in the avatar tooltip for hidden-tab viewers |
|
|
106
|
+
| `awaySinceLabel` | `(awaySince: number) => string` | — | Formats the away timestamp (e.g. "3 min ago"); falls back to `awayLabel` |
|
|
107
|
+
| `moreLabel` | `(count: number) => string` | `` count => `${count} more` `` | Builds the overflow line in the count tooltip |
|
|
108
|
+
|
|
109
|
+
## Features
|
|
110
|
+
|
|
111
|
+
- Pill variant surface: `bg-muted`, 8px radius, 4px vertical / 10px left / 8px right padding, `body-12-medium` count text
|
|
112
|
+
- Overlapping avatars separated by a mask-carved 2px transparent crescent (no hardcoded ring color), overlap proportional to avatar size
|
|
113
|
+
- Falls back to initials when a viewer has no image (via `TelaAvatar`)
|
|
114
|
+
- Count tooltip lists viewer names (capped at `tooltipMax` + "x more"); avatar tooltip stacks the name and Viewing/Away status on separate lines
|
|
115
|
+
- Shared `TelaTooltipGroup`: hovering across triggers skips the tooltip delay/animation
|
|
116
|
+
- Away viewers (`active: false`) dim the avatar to 50% with a 160ms transition and reorder to the back of the stack instead of leaving
|
|
117
|
+
- Subtle 160ms enter/leave/move transitions when viewers join, drop off, or reorder
|
|
118
|
+
- Empty viewers list renders nothing
|
|
119
|
+
|
|
120
|
+
## Accessibility
|
|
121
|
+
|
|
122
|
+
- Container exposes `role="group"` with an `aria-label` of `"{count} {label}"`
|
|
123
|
+
- Each avatar's accessible name comes from its `alt` (viewer name or email)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
export type PresenceAvatarSize = '2xs' | 'xs' | 'sm' | 'md'
|
|
3
|
+
export type PresenceAvatarsVariant = 'pill' | 'avatars'
|
|
4
|
+
|
|
5
|
+
export interface PresenceAvatarViewer {
|
|
6
|
+
name?: string
|
|
7
|
+
email?: string
|
|
8
|
+
image?: string
|
|
9
|
+
active?: boolean
|
|
10
|
+
joinedAt?: number | null
|
|
11
|
+
awaySince?: number | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const props = withDefaults(defineProps<{
|
|
15
|
+
viewers: PresenceAvatarViewer[]
|
|
16
|
+
max?: number
|
|
17
|
+
tooltipMax?: number
|
|
18
|
+
size?: PresenceAvatarSize
|
|
19
|
+
variant?: PresenceAvatarsVariant
|
|
20
|
+
label?: string
|
|
21
|
+
countLabel?: string
|
|
22
|
+
viewingLabel?: string
|
|
23
|
+
awayLabel?: string
|
|
24
|
+
awaySinceLabel?: (awaySince: number) => string
|
|
25
|
+
moreLabel?: (count: number) => string
|
|
26
|
+
}>(), {
|
|
27
|
+
max: 3,
|
|
28
|
+
tooltipMax: 7,
|
|
29
|
+
variant: 'pill',
|
|
30
|
+
label: 'Viewing this page',
|
|
31
|
+
viewingLabel: 'Viewing now',
|
|
32
|
+
awayLabel: 'Away',
|
|
33
|
+
moreLabel: (count: number) => `${count} more`,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const STACK_METRICS: Record<PresenceAvatarSize, { size: number, overlap: number, fontSize: number }> = {
|
|
37
|
+
'2xs': { size: 16, overlap: 4, fontSize: 8 },
|
|
38
|
+
'xs': { size: 24, overlap: 6, fontSize: 10 },
|
|
39
|
+
'sm': { size: 32, overlap: 8, fontSize: 12 },
|
|
40
|
+
'md': { size: 40, overlap: 10, fontSize: 14 },
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const resolvedSize = computed<PresenceAvatarSize>(() => props.size ?? (props.variant === 'avatars' ? 'xs' : '2xs'))
|
|
44
|
+
|
|
45
|
+
// Overlapped avatars get a crescent carved out with a mask (same technique as
|
|
46
|
+
// tags-select dots): the mask circle is centered on the next avatar's center,
|
|
47
|
+
// with a 2px ring of transparency around it.
|
|
48
|
+
const stackStyle = computed(() => {
|
|
49
|
+
const { size, overlap } = STACK_METRICS[resolvedSize.value]
|
|
50
|
+
return {
|
|
51
|
+
'--presence-overlap': `${overlap}px`,
|
|
52
|
+
'--presence-mask-radius': `${size / 2 + 2}px`,
|
|
53
|
+
'--presence-mask-x': `calc(100% + ${size / 2 - overlap}px)`,
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const visibleViewers = computed(() => props.viewers.slice(0, props.max))
|
|
58
|
+
const tooltipViewers = computed(() => props.viewers.slice(0, props.tooltipMax))
|
|
59
|
+
const hiddenTooltipCount = computed(() => Math.max(props.viewers.length - props.tooltipMax, 0))
|
|
60
|
+
const resolvedCountLabel = computed(() => props.countLabel ?? `${props.viewers.length}`)
|
|
61
|
+
|
|
62
|
+
const overflowCount = computed(() => props.variant === 'avatars' ? Math.max(props.viewers.length - props.max, 0) : 0)
|
|
63
|
+
|
|
64
|
+
const overflowTextStyle = computed(() => ({ fontSize: `${STACK_METRICS[resolvedSize.value].fontSize}px` }))
|
|
65
|
+
|
|
66
|
+
function isOverflowSlot(index: number) {
|
|
67
|
+
return overflowCount.value > 0 && index === visibleViewers.value.length - 1
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isActive(viewer: PresenceAvatarViewer) {
|
|
71
|
+
return viewer.active !== false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function displayName(viewer: PresenceAvatarViewer) {
|
|
75
|
+
return viewer.name || viewer.email || 'Unknown'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function viewerKey(viewer: PresenceAvatarViewer, index: number) {
|
|
79
|
+
return viewer.email || viewer.name || `viewer-${index}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function statusLabel(viewer: PresenceAvatarViewer) {
|
|
83
|
+
if (isActive(viewer))
|
|
84
|
+
return props.viewingLabel
|
|
85
|
+
if (viewer.awaySince != null && props.awaySinceLabel)
|
|
86
|
+
return props.awaySinceLabel(viewer.awaySince)
|
|
87
|
+
return props.awayLabel
|
|
88
|
+
}
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<template>
|
|
92
|
+
<TelaTooltipGroup v-if="viewers.length > 0">
|
|
93
|
+
<div
|
|
94
|
+
role="group"
|
|
95
|
+
:aria-label="`${viewers.length} ${label}`"
|
|
96
|
+
flex items-center
|
|
97
|
+
:class="variant === 'pill' ? 'gap-6px rounded-8px bg-muted py-4px pl-10px pr-8px' : ''"
|
|
98
|
+
>
|
|
99
|
+
<TelaTooltipGroupTrigger v-if="variant === 'pill'" side="bottom" trigger-class="flex">
|
|
100
|
+
<span body-12-medium text-primary leading-16px>{{ resolvedCountLabel }}</span>
|
|
101
|
+
<template #content>
|
|
102
|
+
<div flex="~ col" gap-4px text-left>
|
|
103
|
+
<p
|
|
104
|
+
v-for="(viewer, index) in tooltipViewers"
|
|
105
|
+
:key="viewerKey(viewer, index)"
|
|
106
|
+
body-12-regular text-white leading-16px
|
|
107
|
+
>
|
|
108
|
+
{{ displayName(viewer) }}
|
|
109
|
+
</p>
|
|
110
|
+
<p v-if="hiddenTooltipCount > 0" body-12-regular text-white leading-16px>
|
|
111
|
+
{{ moreLabel(hiddenTooltipCount) }}
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
</template>
|
|
115
|
+
</TelaTooltipGroupTrigger>
|
|
116
|
+
|
|
117
|
+
<TransitionGroup name="tela-presence" tag="div" flex items-center :style="stackStyle">
|
|
118
|
+
<div
|
|
119
|
+
v-for="(viewer, index) in visibleViewers"
|
|
120
|
+
:key="viewerKey(viewer, index)"
|
|
121
|
+
class="tela-presence-avatar"
|
|
122
|
+
flex
|
|
123
|
+
>
|
|
124
|
+
<TelaTooltipGroupTrigger side="bottom" trigger-class="flex">
|
|
125
|
+
<div relative flex>
|
|
126
|
+
<TelaAvatar
|
|
127
|
+
:image="viewer.image"
|
|
128
|
+
:alt="displayName(viewer)"
|
|
129
|
+
:size="resolvedSize"
|
|
130
|
+
rounded-full transition-opacity duration-160 ease-out
|
|
131
|
+
:class="isActive(viewer) || isOverflowSlot(index) ? '' : 'op-50'"
|
|
132
|
+
/>
|
|
133
|
+
<div
|
|
134
|
+
v-if="isOverflowSlot(index)"
|
|
135
|
+
absolute inset-0 rounded-full
|
|
136
|
+
class="bg-neutral-950/80"
|
|
137
|
+
flex items-center justify-center
|
|
138
|
+
text-white font-500 leading-none select-none
|
|
139
|
+
:style="overflowTextStyle"
|
|
140
|
+
>
|
|
141
|
+
+{{ overflowCount }}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
<template #content>
|
|
145
|
+
<div v-if="isOverflowSlot(index)" flex="~ col" gap-4px text-left>
|
|
146
|
+
<p
|
|
147
|
+
v-for="(tooltipViewer, tooltipIndex) in tooltipViewers"
|
|
148
|
+
:key="viewerKey(tooltipViewer, tooltipIndex)"
|
|
149
|
+
body-12-regular text-white leading-16px
|
|
150
|
+
>
|
|
151
|
+
{{ displayName(tooltipViewer) }}
|
|
152
|
+
</p>
|
|
153
|
+
<p v-if="hiddenTooltipCount > 0" body-12-regular text-white leading-16px>
|
|
154
|
+
{{ moreLabel(hiddenTooltipCount) }}
|
|
155
|
+
</p>
|
|
156
|
+
</div>
|
|
157
|
+
<div v-else flex="~ col" gap-2px text-left>
|
|
158
|
+
<span body-12-medium text-white>{{ displayName(viewer) }}</span>
|
|
159
|
+
<span body-12-regular text-neutral-200>{{ statusLabel(viewer) }}</span>
|
|
160
|
+
</div>
|
|
161
|
+
</template>
|
|
162
|
+
</TelaTooltipGroupTrigger>
|
|
163
|
+
</div>
|
|
164
|
+
</TransitionGroup>
|
|
165
|
+
</div>
|
|
166
|
+
</TelaTooltipGroup>
|
|
167
|
+
</template>
|
|
168
|
+
|
|
169
|
+
<style scoped>
|
|
170
|
+
.tela-presence-avatar:not(:first-child) {
|
|
171
|
+
margin-left: calc(var(--presence-overlap) * -1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.tela-presence-avatar:not(:last-child) {
|
|
175
|
+
mask-image: radial-gradient(circle var(--presence-mask-radius) at var(--presence-mask-x) center, transparent var(--presence-mask-radius), #fff var(--presence-mask-radius));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.tela-presence-enter-active,
|
|
179
|
+
.tela-presence-leave-active {
|
|
180
|
+
transition: opacity 160ms cubic-bezier(0.215, 0.61, 0.355, 1), transform 160ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.tela-presence-enter-from,
|
|
184
|
+
.tela-presence-leave-to {
|
|
185
|
+
opacity: 0;
|
|
186
|
+
transform: scale(0.8);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.tela-presence-move {
|
|
190
|
+
transition: transform 160ms cubic-bezier(0.215, 0.61, 0.355, 1);
|
|
191
|
+
}
|
|
192
|
+
</style>
|
|
@@ -19,6 +19,7 @@ export type TooltipProps = {
|
|
|
19
19
|
title?: string
|
|
20
20
|
description?: string
|
|
21
21
|
disableClosingTrigger?: boolean
|
|
22
|
+
triggerClass?: string
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
const props = withDefaults(defineProps<TooltipProps>(), {
|
|
@@ -54,7 +55,7 @@ const variantClasses = computed(() => {
|
|
|
54
55
|
|
|
55
56
|
<template>
|
|
56
57
|
<TelaTooltipRoot v-bind="tooltipRootProps">
|
|
57
|
-
<TelaTooltipTrigger>
|
|
58
|
+
<TelaTooltipTrigger :class="triggerClass">
|
|
58
59
|
<slot />
|
|
59
60
|
</TelaTooltipTrigger>
|
|
60
61
|
<TelaTooltipContent
|
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
import type { Ref } from 'vue'
|
|
5
|
+
import { effectScope, nextTick, ref } from 'vue'
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
|
|
8
|
+
import * as Y from 'yjs'
|
|
9
|
+
import type { PresenceConnectionContext, PresenceConnectionHooks, PresenceUser } from '../presence'
|
|
10
|
+
import { usePresence } from '../presence'
|
|
11
|
+
|
|
12
|
+
const SELF: PresenceUser = { id: 'user-self', email: 'self@tela.com', name: 'Self' }
|
|
13
|
+
const USER_A: PresenceUser = { id: 'user-a', email: 'a@tela.com', name: 'Ada' }
|
|
14
|
+
const USER_B: PresenceUser = { id: 'user-b', email: 'b@tela.com', name: 'Grace' }
|
|
15
|
+
const USER_C: PresenceUser = { id: 'user-c', email: 'c@tela.com', name: 'Lin' }
|
|
16
|
+
|
|
17
|
+
function setVisibility(state: 'visible' | 'hidden') {
|
|
18
|
+
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
|
|
19
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function flush() {
|
|
23
|
+
await Promise.resolve()
|
|
24
|
+
await Promise.resolve()
|
|
25
|
+
await nextTick()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createFakeConnection(hooks: PresenceConnectionHooks) {
|
|
29
|
+
const doc = new Y.Doc()
|
|
30
|
+
const awareness = new Awareness(doc)
|
|
31
|
+
|
|
32
|
+
const connection = {
|
|
33
|
+
awareness,
|
|
34
|
+
hooks,
|
|
35
|
+
connectCalls: 0,
|
|
36
|
+
disconnectCalls: 0,
|
|
37
|
+
destroyed: false,
|
|
38
|
+
tokens: [] as (string | null)[],
|
|
39
|
+
connect() {
|
|
40
|
+
connection.connectCalls++
|
|
41
|
+
},
|
|
42
|
+
disconnect() {
|
|
43
|
+
connection.disconnectCalls++
|
|
44
|
+
},
|
|
45
|
+
setToken(token: string | null) {
|
|
46
|
+
connection.tokens.push(token)
|
|
47
|
+
},
|
|
48
|
+
destroy() {
|
|
49
|
+
connection.destroyed = true
|
|
50
|
+
awareness.destroy()
|
|
51
|
+
doc.destroy()
|
|
52
|
+
},
|
|
53
|
+
addPeer(user: PresenceUser, extra?: { active?: boolean, joinedAt?: number, awaySince?: number }) {
|
|
54
|
+
const peerDoc = new Y.Doc()
|
|
55
|
+
const peer = new Awareness(peerDoc)
|
|
56
|
+
peer.setLocalStateField('user', user)
|
|
57
|
+
if (extra?.active !== undefined)
|
|
58
|
+
peer.setLocalStateField('active', extra.active)
|
|
59
|
+
if (extra?.joinedAt !== undefined)
|
|
60
|
+
peer.setLocalStateField('joinedAt', extra.joinedAt)
|
|
61
|
+
if (extra?.awaySince !== undefined)
|
|
62
|
+
peer.setLocalStateField('awaySince', extra.awaySince)
|
|
63
|
+
applyAwarenessUpdate(awareness, encodeAwarenessUpdate(peer, [peer.clientID]), 'test')
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
clientId: peer.clientID,
|
|
67
|
+
setField: (field: string, value: unknown) => {
|
|
68
|
+
peer.setLocalStateField(field, value)
|
|
69
|
+
applyAwarenessUpdate(awareness, encodeAwarenessUpdate(peer, [peer.clientID]), 'test')
|
|
70
|
+
},
|
|
71
|
+
remove: () => {
|
|
72
|
+
removeAwarenessStates(awareness, [peer.clientID], 'test')
|
|
73
|
+
peer.destroy()
|
|
74
|
+
peerDoc.destroy()
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return connection
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type FakeConnection = ReturnType<typeof createFakeConnection>
|
|
84
|
+
|
|
85
|
+
describe('usePresence', () => {
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
setVisibility('visible')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
function createPresence(input?: {
|
|
91
|
+
channel?: Ref<string | null | undefined>
|
|
92
|
+
url?: Ref<string | null | undefined>
|
|
93
|
+
self?: Ref<PresenceUser | null | undefined>
|
|
94
|
+
enabled?: Ref<boolean>
|
|
95
|
+
getToken?: () => Promise<string | null>
|
|
96
|
+
maxConsecutiveFailures?: number
|
|
97
|
+
}) {
|
|
98
|
+
const channel = input?.channel ?? ref<string | null | undefined>('prompt:p1')
|
|
99
|
+
const url = input?.url ?? ref<string | null | undefined>('ws://localhost:4005')
|
|
100
|
+
const self = input?.self ?? ref<PresenceUser | null | undefined>(SELF)
|
|
101
|
+
const enabled = input?.enabled ?? ref(true)
|
|
102
|
+
|
|
103
|
+
const connections: FakeConnection[] = []
|
|
104
|
+
const contexts: PresenceConnectionContext[] = []
|
|
105
|
+
const createConnection = vi.fn((context: PresenceConnectionContext, hooks: PresenceConnectionHooks) => {
|
|
106
|
+
contexts.push(context)
|
|
107
|
+
const connection = createFakeConnection(hooks)
|
|
108
|
+
connections.push(connection)
|
|
109
|
+
return connection
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const scope = effectScope()
|
|
113
|
+
const presence = scope.run(() => usePresence({
|
|
114
|
+
channel,
|
|
115
|
+
url,
|
|
116
|
+
self,
|
|
117
|
+
enabled,
|
|
118
|
+
getToken: input?.getToken,
|
|
119
|
+
createConnection,
|
|
120
|
+
...(input?.maxConsecutiveFailures ? { maxConsecutiveFailures: input.maxConsecutiveFailures } : {}),
|
|
121
|
+
}))!
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
channel,
|
|
125
|
+
url,
|
|
126
|
+
self,
|
|
127
|
+
enabled,
|
|
128
|
+
presence,
|
|
129
|
+
scope,
|
|
130
|
+
createConnection,
|
|
131
|
+
connections,
|
|
132
|
+
contexts,
|
|
133
|
+
current: () => connections[connections.length - 1]!,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
it('connects with the resolved token and sets the local awareness user', async () => {
|
|
138
|
+
const { presence, createConnection, contexts, current } = createPresence({
|
|
139
|
+
getToken: async () => 'jwt-token',
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
await flush()
|
|
143
|
+
|
|
144
|
+
expect(createConnection).toHaveBeenCalledTimes(1)
|
|
145
|
+
expect(contexts[0]).toEqual({ url: 'ws://localhost:4005', channel: 'prompt:p1', token: 'jwt-token' })
|
|
146
|
+
expect(current().awareness.getLocalState()?.user).toEqual(SELF)
|
|
147
|
+
expect(current().awareness.getLocalState()?.active).toBe(true)
|
|
148
|
+
expect(current().awareness.getLocalState()?.joinedAt).toEqual(expect.any(Number))
|
|
149
|
+
expect(presence.others.value).toEqual([])
|
|
150
|
+
expect(presence.isAlone.value).toBe(true)
|
|
151
|
+
|
|
152
|
+
presence.stop()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('lists other viewers from awareness states', async () => {
|
|
156
|
+
const { presence, current } = createPresence()
|
|
157
|
+
|
|
158
|
+
await flush()
|
|
159
|
+
const peerA = current().addPeer(USER_A)
|
|
160
|
+
current().addPeer(USER_B)
|
|
161
|
+
await flush()
|
|
162
|
+
|
|
163
|
+
expect(presence.others.value.map(user => user.id).sort()).toEqual(['user-a', 'user-b'])
|
|
164
|
+
// Peers that never published the new fields surface with defaults.
|
|
165
|
+
expect(presence.others.value.every(viewer => viewer.active === true && viewer.joinedAt === null)).toBe(true)
|
|
166
|
+
expect(presence.isAlone.value).toBe(false)
|
|
167
|
+
|
|
168
|
+
peerA.remove()
|
|
169
|
+
await flush()
|
|
170
|
+
|
|
171
|
+
expect(presence.others.value.map(user => user.id)).toEqual(['user-b'])
|
|
172
|
+
|
|
173
|
+
presence.stop()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('dedupes users across tabs and never shows yourself', async () => {
|
|
177
|
+
const { presence, current } = createPresence()
|
|
178
|
+
|
|
179
|
+
await flush()
|
|
180
|
+
current().addPeer(SELF)
|
|
181
|
+
current().addPeer(USER_A)
|
|
182
|
+
current().addPeer(USER_A)
|
|
183
|
+
await flush()
|
|
184
|
+
|
|
185
|
+
expect(presence.others.value.map(user => user.id)).toEqual(['user-a'])
|
|
186
|
+
|
|
187
|
+
presence.stop()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('does not connect while disabled or without a channel or url', async () => {
|
|
191
|
+
const channel = ref<string | null | undefined>(null)
|
|
192
|
+
const enabled = ref(false)
|
|
193
|
+
const { createConnection, presence } = createPresence({ channel, enabled })
|
|
194
|
+
|
|
195
|
+
await flush()
|
|
196
|
+
expect(createConnection).not.toHaveBeenCalled()
|
|
197
|
+
|
|
198
|
+
channel.value = 'prompt:p1'
|
|
199
|
+
await flush()
|
|
200
|
+
expect(createConnection).not.toHaveBeenCalled()
|
|
201
|
+
|
|
202
|
+
enabled.value = true
|
|
203
|
+
await flush()
|
|
204
|
+
expect(createConnection).toHaveBeenCalledTimes(1)
|
|
205
|
+
|
|
206
|
+
presence.stop()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('recreates the connection when the channel changes', async () => {
|
|
210
|
+
const channel = ref<string | null | undefined>('prompt:p1')
|
|
211
|
+
const { presence, connections, contexts, current } = createPresence({ channel })
|
|
212
|
+
|
|
213
|
+
await flush()
|
|
214
|
+
current().addPeer(USER_A)
|
|
215
|
+
await flush()
|
|
216
|
+
expect(presence.others.value).toHaveLength(1)
|
|
217
|
+
|
|
218
|
+
channel.value = 'agent:a1'
|
|
219
|
+
await flush()
|
|
220
|
+
|
|
221
|
+
expect(connections).toHaveLength(2)
|
|
222
|
+
expect(connections[0]!.destroyed).toBe(true)
|
|
223
|
+
expect(contexts[1]!.channel).toBe('agent:a1')
|
|
224
|
+
expect(presence.others.value).toEqual([])
|
|
225
|
+
|
|
226
|
+
presence.stop()
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('destroys the connection when the channel becomes null', async () => {
|
|
230
|
+
const channel = ref<string | null | undefined>('prompt:p1')
|
|
231
|
+
const { presence, current, connections } = createPresence({ channel })
|
|
232
|
+
|
|
233
|
+
await flush()
|
|
234
|
+
|
|
235
|
+
channel.value = null
|
|
236
|
+
await flush()
|
|
237
|
+
|
|
238
|
+
expect(connections).toHaveLength(1)
|
|
239
|
+
expect(current().destroyed).toBe(true)
|
|
240
|
+
expect(presence.isConnected.value).toBe(false)
|
|
241
|
+
|
|
242
|
+
presence.stop()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('stays connected while the tab is hidden and publishes the away state', async () => {
|
|
246
|
+
const { presence, current, connections } = createPresence()
|
|
247
|
+
|
|
248
|
+
await flush()
|
|
249
|
+
expect(current().awareness.getLocalState()?.active).toBe(true)
|
|
250
|
+
|
|
251
|
+
setVisibility('hidden')
|
|
252
|
+
await flush()
|
|
253
|
+
expect(current().disconnectCalls).toBe(0)
|
|
254
|
+
expect(current().destroyed).toBe(false)
|
|
255
|
+
expect(current().awareness.getLocalState()?.active).toBe(false)
|
|
256
|
+
|
|
257
|
+
setVisibility('visible')
|
|
258
|
+
await flush()
|
|
259
|
+
expect(current().awareness.getLocalState()?.active).toBe(true)
|
|
260
|
+
expect(connections).toHaveLength(1)
|
|
261
|
+
|
|
262
|
+
presence.stop()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('keeps listing peers while the local tab is hidden', async () => {
|
|
266
|
+
const { presence, current } = createPresence()
|
|
267
|
+
|
|
268
|
+
await flush()
|
|
269
|
+
setVisibility('hidden')
|
|
270
|
+
await flush()
|
|
271
|
+
current().addPeer(USER_A)
|
|
272
|
+
await flush()
|
|
273
|
+
|
|
274
|
+
expect(presence.others.value.map(viewer => viewer.id)).toEqual(['user-a'])
|
|
275
|
+
|
|
276
|
+
presence.stop()
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('orders viewers by join time with unknown join times last', async () => {
|
|
280
|
+
const { presence, current } = createPresence()
|
|
281
|
+
|
|
282
|
+
await flush()
|
|
283
|
+
current().addPeer(USER_A, { joinedAt: 300 })
|
|
284
|
+
current().addPeer(USER_B, { joinedAt: 100 })
|
|
285
|
+
current().addPeer(USER_C)
|
|
286
|
+
await flush()
|
|
287
|
+
|
|
288
|
+
expect(presence.others.value.map(viewer => viewer.id)).toEqual(['user-b', 'user-a', 'user-c'])
|
|
289
|
+
expect(presence.others.value[2]!.joinedAt).toBeNull()
|
|
290
|
+
|
|
291
|
+
presence.stop()
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('orders peers without join time by client id', async () => {
|
|
295
|
+
const { presence, current } = createPresence()
|
|
296
|
+
|
|
297
|
+
await flush()
|
|
298
|
+
const peerA = current().addPeer(USER_A)
|
|
299
|
+
const peerB = current().addPeer(USER_B)
|
|
300
|
+
await flush()
|
|
301
|
+
|
|
302
|
+
const expected = [
|
|
303
|
+
{ id: USER_A.id, clientId: peerA.clientId },
|
|
304
|
+
{ id: USER_B.id, clientId: peerB.clientId },
|
|
305
|
+
].sort((a, b) => a.clientId - b.clientId).map(entry => entry.id)
|
|
306
|
+
|
|
307
|
+
expect(presence.others.value.map(viewer => viewer.id)).toEqual(expected)
|
|
308
|
+
|
|
309
|
+
presence.stop()
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('pushes away viewers behind active ones and restores join order on return', async () => {
|
|
313
|
+
const { presence, current } = createPresence()
|
|
314
|
+
|
|
315
|
+
await flush()
|
|
316
|
+
const peerA = current().addPeer(USER_A, { joinedAt: 100 })
|
|
317
|
+
current().addPeer(USER_B, { joinedAt: 200 })
|
|
318
|
+
await flush()
|
|
319
|
+
expect(presence.others.value.map(viewer => viewer.id)).toEqual(['user-a', 'user-b'])
|
|
320
|
+
|
|
321
|
+
peerA.setField('active', false)
|
|
322
|
+
await flush()
|
|
323
|
+
expect(presence.others.value.map(viewer => viewer.id)).toEqual(['user-b', 'user-a'])
|
|
324
|
+
expect(presence.others.value[1]!.active).toBe(false)
|
|
325
|
+
|
|
326
|
+
peerA.setField('active', true)
|
|
327
|
+
await flush()
|
|
328
|
+
expect(presence.others.value.map(viewer => viewer.id)).toEqual(['user-a', 'user-b'])
|
|
329
|
+
expect(presence.others.value[0]!.active).toBe(true)
|
|
330
|
+
|
|
331
|
+
presence.stop()
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('publishes awaySince while hidden and clears it when visible again', async () => {
|
|
335
|
+
const { presence, current } = createPresence()
|
|
336
|
+
|
|
337
|
+
await flush()
|
|
338
|
+
expect(current().awareness.getLocalState()?.awaySince).toBeNull()
|
|
339
|
+
|
|
340
|
+
setVisibility('hidden')
|
|
341
|
+
await flush()
|
|
342
|
+
expect(current().awareness.getLocalState()?.awaySince).toEqual(expect.any(Number))
|
|
343
|
+
|
|
344
|
+
setVisibility('visible')
|
|
345
|
+
await flush()
|
|
346
|
+
expect(current().awareness.getLocalState()?.awaySince).toBeNull()
|
|
347
|
+
|
|
348
|
+
presence.stop()
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('surfaces each away viewer\'s awaySince and null for active or legacy peers', async () => {
|
|
352
|
+
const { presence, current } = createPresence()
|
|
353
|
+
|
|
354
|
+
await flush()
|
|
355
|
+
current().addPeer(USER_A, { active: false, awaySince: 1234, joinedAt: 100 })
|
|
356
|
+
current().addPeer(USER_B, { active: true, joinedAt: 200 })
|
|
357
|
+
current().addPeer(USER_C, { active: false, joinedAt: 300 })
|
|
358
|
+
await flush()
|
|
359
|
+
|
|
360
|
+
const byId = new Map(presence.others.value.map(viewer => [viewer.id, viewer]))
|
|
361
|
+
expect(byId.get('user-a')!.awaySince).toBe(1234)
|
|
362
|
+
expect(byId.get('user-b')!.awaySince).toBeNull()
|
|
363
|
+
expect(byId.get('user-c')!.awaySince).toBeNull()
|
|
364
|
+
|
|
365
|
+
presence.stop()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('merges multi-tab awaySince to the most recent, dropping it if any tab is active', async () => {
|
|
369
|
+
const { presence, current } = createPresence()
|
|
370
|
+
|
|
371
|
+
await flush()
|
|
372
|
+
current().addPeer(USER_A, { active: false, awaySince: 100 })
|
|
373
|
+
const tabTwo = current().addPeer(USER_A, { active: false, awaySince: 500 })
|
|
374
|
+
await flush()
|
|
375
|
+
|
|
376
|
+
expect(presence.others.value).toHaveLength(1)
|
|
377
|
+
expect(presence.others.value[0]!.awaySince).toBe(500)
|
|
378
|
+
|
|
379
|
+
tabTwo.setField('active', true)
|
|
380
|
+
await flush()
|
|
381
|
+
|
|
382
|
+
expect(presence.others.value[0]!.active).toBe(true)
|
|
383
|
+
expect(presence.others.value[0]!.awaySince).toBeNull()
|
|
384
|
+
|
|
385
|
+
presence.stop()
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('keeps join order within the active and away groups', async () => {
|
|
389
|
+
const { presence, current } = createPresence()
|
|
390
|
+
|
|
391
|
+
await flush()
|
|
392
|
+
current().addPeer(USER_A, { joinedAt: 100, active: false })
|
|
393
|
+
current().addPeer(USER_B, { joinedAt: 200, active: true })
|
|
394
|
+
current().addPeer(USER_C, { joinedAt: 300, active: false })
|
|
395
|
+
await flush()
|
|
396
|
+
|
|
397
|
+
expect(presence.others.value.map(viewer => viewer.id)).toEqual(['user-b', 'user-a', 'user-c'])
|
|
398
|
+
|
|
399
|
+
presence.stop()
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('merges multiple tabs of the same user with active OR and earliest join', async () => {
|
|
403
|
+
const { presence, current } = createPresence()
|
|
404
|
+
|
|
405
|
+
await flush()
|
|
406
|
+
current().addPeer(USER_A, { active: false, joinedAt: 200 })
|
|
407
|
+
const tabTwo = current().addPeer(USER_A, { active: true, joinedAt: 500 })
|
|
408
|
+
await flush()
|
|
409
|
+
|
|
410
|
+
expect(presence.others.value).toHaveLength(1)
|
|
411
|
+
expect(presence.others.value[0]).toMatchObject({ id: 'user-a', active: true, joinedAt: 200 })
|
|
412
|
+
|
|
413
|
+
tabTwo.setField('active', false)
|
|
414
|
+
await flush()
|
|
415
|
+
|
|
416
|
+
expect(presence.others.value[0]!.active).toBe(false)
|
|
417
|
+
|
|
418
|
+
presence.stop()
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('publishes a fresh join time on each new connection', async () => {
|
|
422
|
+
const channel = ref<string | null | undefined>('prompt:p1')
|
|
423
|
+
const { presence, current, connections } = createPresence({ channel })
|
|
424
|
+
|
|
425
|
+
await flush()
|
|
426
|
+
expect(current().awareness.getLocalState()?.joinedAt).toEqual(expect.any(Number))
|
|
427
|
+
|
|
428
|
+
channel.value = 'agent:a1'
|
|
429
|
+
await flush()
|
|
430
|
+
|
|
431
|
+
expect(connections).toHaveLength(2)
|
|
432
|
+
expect(current().awareness.getLocalState()?.joinedAt).toEqual(expect.any(Number))
|
|
433
|
+
|
|
434
|
+
presence.stop()
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('updates the local awareness state when self changes', async () => {
|
|
438
|
+
const self = ref<PresenceUser | null | undefined>(SELF)
|
|
439
|
+
const { presence, current } = createPresence({ self })
|
|
440
|
+
|
|
441
|
+
await flush()
|
|
442
|
+
|
|
443
|
+
self.value = { ...SELF, name: 'Renamed' }
|
|
444
|
+
await flush()
|
|
445
|
+
|
|
446
|
+
expect(current().awareness.getLocalState()?.user?.name).toBe('Renamed')
|
|
447
|
+
|
|
448
|
+
presence.stop()
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('tracks connection status', async () => {
|
|
452
|
+
const { presence, current } = createPresence()
|
|
453
|
+
|
|
454
|
+
await flush()
|
|
455
|
+
expect(presence.isConnected.value).toBe(false)
|
|
456
|
+
|
|
457
|
+
current().hooks.onStatusChange(true)
|
|
458
|
+
await flush()
|
|
459
|
+
expect(presence.isConnected.value).toBe(true)
|
|
460
|
+
|
|
461
|
+
current().hooks.onStatusChange(false)
|
|
462
|
+
await flush()
|
|
463
|
+
expect(presence.isConnected.value).toBe(false)
|
|
464
|
+
|
|
465
|
+
presence.stop()
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('hard-stops after repeated connection failures without ever connecting', async () => {
|
|
469
|
+
const { presence, current } = createPresence({ maxConsecutiveFailures: 3 })
|
|
470
|
+
|
|
471
|
+
await flush()
|
|
472
|
+
current().hooks.onConnectionError()
|
|
473
|
+
current().hooks.onConnectionError()
|
|
474
|
+
current().hooks.onConnectionError()
|
|
475
|
+
await flush()
|
|
476
|
+
|
|
477
|
+
expect(presence.hasError.value).toBe(true)
|
|
478
|
+
expect(current().disconnectCalls).toBeGreaterThan(0)
|
|
479
|
+
|
|
480
|
+
presence.stop()
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
it('resets the failure counter once connected', async () => {
|
|
484
|
+
const { presence, current } = createPresence({ maxConsecutiveFailures: 3 })
|
|
485
|
+
|
|
486
|
+
await flush()
|
|
487
|
+
current().hooks.onConnectionError()
|
|
488
|
+
current().hooks.onConnectionError()
|
|
489
|
+
current().hooks.onStatusChange(true)
|
|
490
|
+
current().hooks.onConnectionError()
|
|
491
|
+
current().hooks.onConnectionError()
|
|
492
|
+
await flush()
|
|
493
|
+
|
|
494
|
+
expect(presence.hasError.value).toBe(false)
|
|
495
|
+
|
|
496
|
+
presence.stop()
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('recovers from a hard stop when the channel changes', async () => {
|
|
500
|
+
const channel = ref<string | null | undefined>('prompt:p1')
|
|
501
|
+
const { presence, connections, current } = createPresence({ channel, maxConsecutiveFailures: 2 })
|
|
502
|
+
|
|
503
|
+
await flush()
|
|
504
|
+
current().hooks.onConnectionError()
|
|
505
|
+
current().hooks.onConnectionError()
|
|
506
|
+
await flush()
|
|
507
|
+
expect(presence.hasError.value).toBe(true)
|
|
508
|
+
|
|
509
|
+
channel.value = 'prompt:p2'
|
|
510
|
+
await flush()
|
|
511
|
+
|
|
512
|
+
expect(presence.hasError.value).toBe(false)
|
|
513
|
+
expect(connections).toHaveLength(2)
|
|
514
|
+
|
|
515
|
+
presence.stop()
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it('ignores events from a stale connection after a channel switch', async () => {
|
|
519
|
+
const channel = ref<string | null | undefined>('prompt:p1')
|
|
520
|
+
const { presence, connections } = createPresence({ channel })
|
|
521
|
+
|
|
522
|
+
await flush()
|
|
523
|
+
const stale = connections[0]!
|
|
524
|
+
|
|
525
|
+
channel.value = 'prompt:p2'
|
|
526
|
+
await flush()
|
|
527
|
+
|
|
528
|
+
stale.hooks.onStatusChange(true)
|
|
529
|
+
await flush()
|
|
530
|
+
|
|
531
|
+
expect(presence.isConnected.value).toBe(false)
|
|
532
|
+
|
|
533
|
+
presence.stop()
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
it('destroys the connection on scope dispose', async () => {
|
|
537
|
+
const { current, scope } = createPresence()
|
|
538
|
+
|
|
539
|
+
await flush()
|
|
540
|
+
|
|
541
|
+
scope.stop()
|
|
542
|
+
await flush()
|
|
543
|
+
|
|
544
|
+
expect(current().destroyed).toBe(true)
|
|
545
|
+
})
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
describe('usePresence token lifecycle', () => {
|
|
549
|
+
function createPresence(getToken: () => Promise<string | null>) {
|
|
550
|
+
const connections: FakeConnection[] = []
|
|
551
|
+
const contexts: PresenceConnectionContext[] = []
|
|
552
|
+
|
|
553
|
+
const scope = effectScope()
|
|
554
|
+
const presence = scope.run(() => usePresence({
|
|
555
|
+
channel: ref('prompt:p1'),
|
|
556
|
+
url: ref('ws://localhost:4005'),
|
|
557
|
+
self: ref(SELF),
|
|
558
|
+
getToken,
|
|
559
|
+
maxConsecutiveFailures: 10,
|
|
560
|
+
createConnection: (context, hooks) => {
|
|
561
|
+
contexts.push(context)
|
|
562
|
+
const connection = createFakeConnection(hooks)
|
|
563
|
+
connections.push(connection)
|
|
564
|
+
return connection
|
|
565
|
+
},
|
|
566
|
+
}))!
|
|
567
|
+
|
|
568
|
+
return { presence, scope, connections, contexts, current: () => connections[connections.length - 1]! }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
it('pushes a fresh token into the provider after a failed connection', async () => {
|
|
572
|
+
let issued = 0
|
|
573
|
+
const { scope, current } = createPresence(async () => `jwt-${++issued}`)
|
|
574
|
+
await flush()
|
|
575
|
+
|
|
576
|
+
expect(current().tokens).toEqual([])
|
|
577
|
+
|
|
578
|
+
current().hooks.onConnectionError()
|
|
579
|
+
await flush()
|
|
580
|
+
current().hooks.onConnectionError()
|
|
581
|
+
await flush()
|
|
582
|
+
|
|
583
|
+
expect(current().tokens).toEqual(['jwt-2', 'jwt-3'])
|
|
584
|
+
scope.stop()
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
it('stops refreshing once the failure budget is spent', async () => {
|
|
588
|
+
let issued = 0
|
|
589
|
+
const { presence, scope, current } = createPresence(async () => `jwt-${++issued}`)
|
|
590
|
+
await flush()
|
|
591
|
+
|
|
592
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
593
|
+
current().hooks.onConnectionError()
|
|
594
|
+
await flush()
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
expect(presence.hasError.value).toBe(true)
|
|
598
|
+
// Nine refreshes, then the tenth failure gives up instead of asking again.
|
|
599
|
+
expect(current().tokens).toHaveLength(9)
|
|
600
|
+
scope.stop()
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
it('survives a token refresh that rejects', async () => {
|
|
604
|
+
let issued = 0
|
|
605
|
+
const { presence, scope, current } = createPresence(async () => {
|
|
606
|
+
issued++
|
|
607
|
+
if (issued > 1)
|
|
608
|
+
throw new Error('auth down')
|
|
609
|
+
return 'jwt-1'
|
|
610
|
+
})
|
|
611
|
+
await flush()
|
|
612
|
+
|
|
613
|
+
current().hooks.onConnectionError()
|
|
614
|
+
await flush()
|
|
615
|
+
|
|
616
|
+
expect(presence.hasError.value).toBe(false)
|
|
617
|
+
expect(current().tokens).toEqual([])
|
|
618
|
+
scope.stop()
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
it('surfaces a rejected initial token as an error rather than an unhandled rejection', async () => {
|
|
622
|
+
const { presence, scope, connections } = createPresence(async () => {
|
|
623
|
+
throw new Error('auth down')
|
|
624
|
+
})
|
|
625
|
+
await flush()
|
|
626
|
+
|
|
627
|
+
expect(connections).toHaveLength(0)
|
|
628
|
+
expect(presence.hasError.value).toBe(true)
|
|
629
|
+
expect(presence.isConnected.value).toBe(false)
|
|
630
|
+
scope.stop()
|
|
631
|
+
})
|
|
632
|
+
})
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import type { ComputedRef, MaybeRef, Ref } from 'vue'
|
|
2
|
+
import { tryOnScopeDispose } from '@vueuse/core'
|
|
3
|
+
import { computed, readonly, ref, unref, watch } from 'vue'
|
|
4
|
+
import type { Awareness } from 'y-protocols/awareness'
|
|
5
|
+
import { WebsocketProvider } from 'y-websocket'
|
|
6
|
+
import * as Y from 'yjs'
|
|
7
|
+
|
|
8
|
+
const PRESENCE_MAX_CONSECUTIVE_FAILURES = 10
|
|
9
|
+
|
|
10
|
+
export interface PresenceUser {
|
|
11
|
+
id: string
|
|
12
|
+
email: string
|
|
13
|
+
name?: string
|
|
14
|
+
image?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PresenceViewer extends PresenceUser {
|
|
18
|
+
active: boolean
|
|
19
|
+
joinedAt: number | null
|
|
20
|
+
awaySince: number | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PresenceConnectionContext {
|
|
24
|
+
url: string
|
|
25
|
+
channel: string
|
|
26
|
+
token: string | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PresenceConnectionHooks {
|
|
30
|
+
onStatusChange: (connected: boolean) => void
|
|
31
|
+
onConnectionError: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PresenceConnection {
|
|
35
|
+
awareness: Awareness
|
|
36
|
+
connect: () => void
|
|
37
|
+
disconnect: () => void
|
|
38
|
+
setToken: (token: string | null) => void
|
|
39
|
+
destroy: () => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type PresenceConnectionFactory = (
|
|
43
|
+
context: PresenceConnectionContext,
|
|
44
|
+
hooks: PresenceConnectionHooks,
|
|
45
|
+
) => PresenceConnection
|
|
46
|
+
|
|
47
|
+
export interface UsePresenceOptions {
|
|
48
|
+
channel: MaybeRef<string | null | undefined>
|
|
49
|
+
url: MaybeRef<string | null | undefined>
|
|
50
|
+
self: MaybeRef<PresenceUser | null | undefined>
|
|
51
|
+
getToken?: () => Promise<string | null | undefined>
|
|
52
|
+
enabled?: MaybeRef<boolean>
|
|
53
|
+
maxConsecutiveFailures?: number
|
|
54
|
+
createConnection?: PresenceConnectionFactory
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface UsePresenceReturn {
|
|
58
|
+
others: ComputedRef<PresenceViewer[]>
|
|
59
|
+
isAlone: ComputedRef<boolean>
|
|
60
|
+
isConnected: Readonly<Ref<boolean>>
|
|
61
|
+
hasError: Readonly<Ref<boolean>>
|
|
62
|
+
stop: () => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const createWebsocketConnection: PresenceConnectionFactory = (context, hooks) => {
|
|
66
|
+
const doc = new Y.Doc()
|
|
67
|
+
const provider = new WebsocketProvider(context.url, context.channel, doc, {
|
|
68
|
+
params: context.token ? { token: context.token } : {},
|
|
69
|
+
disableBc: true,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
provider.on('status', event => hooks.onStatusChange(event.status === 'connected'))
|
|
73
|
+
provider.on('connection-error', () => hooks.onConnectionError())
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
awareness: provider.awareness,
|
|
77
|
+
connect: () => provider.connect(),
|
|
78
|
+
disconnect: () => provider.disconnect(),
|
|
79
|
+
// y-websocket rebuilds its url from `params` on every reconnect attempt, so
|
|
80
|
+
// replacing the entry is enough to make the next attempt use a fresh token.
|
|
81
|
+
setToken: (token) => {
|
|
82
|
+
provider.params = token ? { token } : {}
|
|
83
|
+
},
|
|
84
|
+
destroy: () => {
|
|
85
|
+
provider.destroy()
|
|
86
|
+
doc.destroy()
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function usePresence(options: UsePresenceOptions): UsePresenceReturn {
|
|
92
|
+
const others = ref<PresenceViewer[]>([])
|
|
93
|
+
const isConnected = ref(false)
|
|
94
|
+
const hasError = ref(false)
|
|
95
|
+
const isStopped = ref(false)
|
|
96
|
+
const isDocumentVisible = ref(typeof document === 'undefined' || document.visibilityState === 'visible')
|
|
97
|
+
|
|
98
|
+
const channel = computed(() => unref(options.channel) ?? null)
|
|
99
|
+
const url = computed(() => unref(options.url) ?? null)
|
|
100
|
+
const self = computed(() => unref(options.self) ?? null)
|
|
101
|
+
const maxConsecutiveFailures = options.maxConsecutiveFailures ?? PRESENCE_MAX_CONSECUTIVE_FAILURES
|
|
102
|
+
const createConnection = options.createConnection ?? createWebsocketConnection
|
|
103
|
+
|
|
104
|
+
const isEnabled = computed(() =>
|
|
105
|
+
Boolean(unref(options.enabled) ?? true)
|
|
106
|
+
&& Boolean(channel.value)
|
|
107
|
+
&& Boolean(url.value),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
let current: { connection: PresenceConnection, key: string, cleanup: () => void } | null = null
|
|
111
|
+
let establishSeq = 0
|
|
112
|
+
let consecutiveFailures = 0
|
|
113
|
+
|
|
114
|
+
function recomputeOthers() {
|
|
115
|
+
if (!current) {
|
|
116
|
+
others.value = []
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { awareness } = current.connection
|
|
121
|
+
const selfId = self.value?.id
|
|
122
|
+
const byId = new Map<string, { user: PresenceUser, active: boolean, joinedAt: number | null, awaySince: number | null, minClientId: number }>()
|
|
123
|
+
|
|
124
|
+
for (const [clientId, state] of awareness.getStates()) {
|
|
125
|
+
if (clientId === awareness.clientID)
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
const presenceState = state as { user?: PresenceUser, active?: boolean, joinedAt?: number, awaySince?: number } | null
|
|
129
|
+
const user = presenceState?.user
|
|
130
|
+
if (!user?.id || user.id === selfId)
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
// Peers on older clients never publish these fields: assume active, unknown join time.
|
|
134
|
+
const active = presenceState?.active !== false
|
|
135
|
+
const joinedAt = typeof presenceState?.joinedAt === 'number' ? presenceState.joinedAt : null
|
|
136
|
+
const awaySince = typeof presenceState?.awaySince === 'number' ? presenceState.awaySince : null
|
|
137
|
+
|
|
138
|
+
const existing = byId.get(user.id)
|
|
139
|
+
if (!existing) {
|
|
140
|
+
byId.set(user.id, { user, active, joinedAt, awaySince, minClientId: clientId })
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Same user in multiple tabs: active if any tab is, joined when the first tab did,
|
|
145
|
+
// away since the most recent tab activity.
|
|
146
|
+
existing.active = existing.active || active
|
|
147
|
+
if (joinedAt !== null)
|
|
148
|
+
existing.joinedAt = existing.joinedAt === null ? joinedAt : Math.min(existing.joinedAt, joinedAt)
|
|
149
|
+
if (awaySince !== null)
|
|
150
|
+
existing.awaySince = existing.awaySince === null ? awaySince : Math.max(existing.awaySince, awaySince)
|
|
151
|
+
existing.minClientId = Math.min(existing.minClientId, clientId)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
others.value = Array.from(byId.values())
|
|
155
|
+
.sort((a, b) => {
|
|
156
|
+
if (a.active !== b.active)
|
|
157
|
+
return a.active ? -1 : 1
|
|
158
|
+
if (a.joinedAt !== null && b.joinedAt !== null && a.joinedAt !== b.joinedAt)
|
|
159
|
+
return a.joinedAt - b.joinedAt
|
|
160
|
+
if ((a.joinedAt === null) !== (b.joinedAt === null))
|
|
161
|
+
return a.joinedAt === null ? 1 : -1
|
|
162
|
+
return a.minClientId - b.minClientId
|
|
163
|
+
})
|
|
164
|
+
.map(({ user, active, joinedAt, awaySince }) => ({ ...user, active, joinedAt, awaySince: active ? null : awaySince }))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function teardown() {
|
|
168
|
+
establishSeq++
|
|
169
|
+
|
|
170
|
+
if (current) {
|
|
171
|
+
current.cleanup()
|
|
172
|
+
current.connection.destroy()
|
|
173
|
+
current = null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
others.value = []
|
|
177
|
+
isConnected.value = false
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function establish(targetChannel: string, targetUrl: string) {
|
|
181
|
+
const seq = ++establishSeq
|
|
182
|
+
|
|
183
|
+
hasError.value = false
|
|
184
|
+
consecutiveFailures = 0
|
|
185
|
+
|
|
186
|
+
let token: string | null = null
|
|
187
|
+
if (options.getToken) {
|
|
188
|
+
token = (await options.getToken()) ?? null
|
|
189
|
+
|
|
190
|
+
if (seq !== establishSeq || isStopped.value || !isEnabled.value)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let establishedConnection: PresenceConnection | null = null
|
|
195
|
+
const isStale = () => !current || current.connection !== establishedConnection
|
|
196
|
+
|
|
197
|
+
async function refreshToken() {
|
|
198
|
+
if (!options.getToken)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
const fresh = (await options.getToken()) ?? null
|
|
203
|
+
if (!isStale())
|
|
204
|
+
establishedConnection?.setToken(fresh)
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// Nothing to do: the next failure retries this alongside the reconnect.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const hooks: PresenceConnectionHooks = {
|
|
212
|
+
onStatusChange: (connected) => {
|
|
213
|
+
if (isStale())
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
isConnected.value = connected
|
|
217
|
+
|
|
218
|
+
if (connected)
|
|
219
|
+
consecutiveFailures = 0
|
|
220
|
+
},
|
|
221
|
+
onConnectionError: () => {
|
|
222
|
+
if (isStale())
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
consecutiveFailures++
|
|
226
|
+
|
|
227
|
+
if (consecutiveFailures >= maxConsecutiveFailures) {
|
|
228
|
+
hasError.value = true
|
|
229
|
+
isConnected.value = false
|
|
230
|
+
establishedConnection?.disconnect()
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The token that opened this connection expires long before a canvas tab
|
|
235
|
+
// does; without refreshing it every reconnect would 401 until the failure
|
|
236
|
+
// budget runs out and presence stops for good.
|
|
237
|
+
void refreshToken()
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const connection = createConnection({ url: targetUrl, channel: targetChannel, token }, hooks)
|
|
242
|
+
establishedConnection = connection
|
|
243
|
+
|
|
244
|
+
const handleAwarenessChange = () => recomputeOthers()
|
|
245
|
+
connection.awareness.on('change', handleAwarenessChange)
|
|
246
|
+
|
|
247
|
+
current = {
|
|
248
|
+
connection,
|
|
249
|
+
key: `${targetChannel}|${targetUrl}`,
|
|
250
|
+
cleanup: () => {
|
|
251
|
+
connection.awareness.off('change', handleAwarenessChange)
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
connection.awareness.setLocalStateField('user', self.value)
|
|
256
|
+
connection.awareness.setLocalStateField('joinedAt', Date.now())
|
|
257
|
+
connection.awareness.setLocalStateField('active', isDocumentVisible.value)
|
|
258
|
+
connection.awareness.setLocalStateField('awaySince', isDocumentVisible.value ? null : Date.now())
|
|
259
|
+
recomputeOthers()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
watch([channel, url, isEnabled], () => {
|
|
263
|
+
if (isStopped.value)
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
if (!isEnabled.value) {
|
|
267
|
+
// Missing channel/url means the page context is gone — drop the room.
|
|
268
|
+
// An explicit disabled flag keeps the connection for a cheap resume.
|
|
269
|
+
if (!channel.value || !url.value)
|
|
270
|
+
teardown()
|
|
271
|
+
else if (current)
|
|
272
|
+
current.connection.disconnect()
|
|
273
|
+
|
|
274
|
+
isConnected.value = false
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const key = `${channel.value}|${url.value}`
|
|
279
|
+
|
|
280
|
+
if (current && current.key === key && !hasError.value) {
|
|
281
|
+
current.connection.connect()
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
teardown()
|
|
286
|
+
establish(channel.value!, url.value!).catch(() => {
|
|
287
|
+
hasError.value = true
|
|
288
|
+
isConnected.value = false
|
|
289
|
+
})
|
|
290
|
+
}, { immediate: true })
|
|
291
|
+
|
|
292
|
+
watch(self, () => {
|
|
293
|
+
if (!current)
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
current.connection.awareness.setLocalStateField('user', self.value)
|
|
297
|
+
recomputeOthers()
|
|
298
|
+
}, { deep: true })
|
|
299
|
+
|
|
300
|
+
watch(isDocumentVisible, (visible) => {
|
|
301
|
+
current?.connection.awareness.setLocalStateField('active', visible)
|
|
302
|
+
current?.connection.awareness.setLocalStateField('awaySince', visible ? null : Date.now())
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
function handleVisibilityChange() {
|
|
306
|
+
isDocumentVisible.value = document.visibilityState === 'visible'
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (typeof document !== 'undefined') {
|
|
310
|
+
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function stop() {
|
|
314
|
+
if (isStopped.value)
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
isStopped.value = true
|
|
318
|
+
teardown()
|
|
319
|
+
|
|
320
|
+
if (typeof document !== 'undefined') {
|
|
321
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
tryOnScopeDispose(() => {
|
|
326
|
+
stop()
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
others: computed(() => others.value),
|
|
331
|
+
isAlone: computed(() => others.value.length === 0),
|
|
332
|
+
isConnected: readonly(isConnected),
|
|
333
|
+
hasError: readonly(hasError),
|
|
334
|
+
stop,
|
|
335
|
+
}
|
|
336
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meistrari/tela-build",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.69.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"app.config.ts",
|
|
@@ -76,7 +76,10 @@
|
|
|
76
76
|
"vue-component-meta": "3.0.8",
|
|
77
77
|
"vue-docgen-api": "4.78.0",
|
|
78
78
|
"vue-input-otp": "0.3.2",
|
|
79
|
-
"vue-router": "4.5.0"
|
|
79
|
+
"vue-router": "4.5.0",
|
|
80
|
+
"y-protocols": "^1.0.6",
|
|
81
|
+
"y-websocket": "^2.0.4",
|
|
82
|
+
"yjs": "13.6.24"
|
|
80
83
|
},
|
|
81
84
|
"peerDependencies": {
|
|
82
85
|
"nuxt": "^3.17.0 || ^4.0.0",
|