canopy-ui 0.6.1 → 0.6.2
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/package.json +3 -6
- package/src/chat/protocol.ts +8 -3
- package/src/chat/sessionReducer.test.ts +45 -0
- package/src/chat/sessionReducer.ts +17 -0
- package/src/presence/PresenceBadge.test.tsx +0 -84
- package/src/presence/PresenceBadge.tsx +0 -111
- package/src/presence/avatar.test.ts +0 -42
- package/src/presence/avatar.ts +0 -36
- package/src/presence/index.ts +0 -4
- package/src/presence/pageKey.test.ts +0 -53
- package/src/presence/pageKey.ts +0 -37
- package/src/presence/usePresence.test.ts +0 -199
- package/src/presence/usePresence.ts +0 -188
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canopy-ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "git+https://github.com/
|
|
7
|
+
"url": "git+https://github.com/dimagi-internal/canopy-web.git",
|
|
8
8
|
"directory": "frontend/packages/canopy-ui"
|
|
9
9
|
},
|
|
10
10
|
"exports": {
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
"./lib": "./src/lib/index.ts",
|
|
13
13
|
"./ui": "./src/ui/index.ts",
|
|
14
14
|
"./chat": "./src/chat/index.ts",
|
|
15
|
-
"./presence": "./src/presence/index.ts",
|
|
16
15
|
"./shell": "./src/shell/index.ts",
|
|
17
16
|
"./tokens": "./src/tokens/index.ts",
|
|
18
17
|
"./tokens/preset.css": "./src/tokens/preset.css",
|
|
@@ -43,7 +42,5 @@
|
|
|
43
42
|
"devDependencies": {
|
|
44
43
|
"sonner": "^2.0.7"
|
|
45
44
|
},
|
|
46
|
-
"files": [
|
|
47
|
-
"src"
|
|
48
|
-
]
|
|
45
|
+
"files": ["src"]
|
|
49
46
|
}
|
package/src/chat/protocol.ts
CHANGED
|
@@ -64,8 +64,13 @@ export interface SessionState {
|
|
|
64
64
|
messages: Message[];
|
|
65
65
|
/** Live agent activity, from the runner's turn-boundary hooks. Undefined when
|
|
66
66
|
* no hook has reported yet — the caller then falls back to the server's
|
|
67
|
-
* coarser `running` flag.
|
|
68
|
-
|
|
67
|
+
* coarser `running` flag.
|
|
68
|
+
*
|
|
69
|
+
* "blocked" means the agent wants a human: a permission prompt, or an idle
|
|
70
|
+
* wait for input. It is deliberately coarse — a hook observer cannot tell
|
|
71
|
+
* those apart — but it is the difference between "still thinking, wait" and
|
|
72
|
+
* "it is waiting on YOU", which previously rendered identically. */
|
|
73
|
+
activity?: "working" | "idle" | "blocked";
|
|
69
74
|
active_draft: Draft | null;
|
|
70
75
|
participants: Participant[];
|
|
71
76
|
presence_user_ids: number[];
|
|
@@ -91,7 +96,7 @@ export type WsEvent =
|
|
|
91
96
|
| { event: "chat.stream_start"; data: { message_id: string; turn_index: number } }
|
|
92
97
|
// The agent started or finished a turn. Distinct from tool events: it fires
|
|
93
98
|
// while Claude is THINKING, before any content exists to show.
|
|
94
|
-
| { event: "session.activity"; data: { state: "working" | "idle" } }
|
|
99
|
+
| { event: "session.activity"; data: { state: "working" | "idle" | "blocked" } }
|
|
95
100
|
// A human typed into emdash rather than into this page. No client echoed it,
|
|
96
101
|
// so this is the only way it reaches the browser before a reload.
|
|
97
102
|
| { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string } }
|
|
@@ -539,3 +539,48 @@ describe("sessionReducer — a web send arriving twice", () => {
|
|
|
539
539
|
expect(next.messages).toHaveLength(2)
|
|
540
540
|
})
|
|
541
541
|
})
|
|
542
|
+
|
|
543
|
+
describe("blocked — the agent is waiting on YOU", () => {
|
|
544
|
+
it("renders as its own state, not as working", () => {
|
|
545
|
+
const state = sessionReducer(makeState(), {
|
|
546
|
+
event: "session.activity",
|
|
547
|
+
data: { state: "blocked" },
|
|
548
|
+
});
|
|
549
|
+
expect(state.activity).toBe("blocked");
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("clears once the agent produces a row again", () => {
|
|
553
|
+
// Notification fires on the way INTO a wait and nothing fires on the way
|
|
554
|
+
// out — approving a permission prompt emits no hook at all. A row is the
|
|
555
|
+
// only proof the wait ended, so without this the chip says "needs you" for
|
|
556
|
+
// the rest of the turn, long after you answered.
|
|
557
|
+
const blocked = sessionReducer(makeState(), {
|
|
558
|
+
event: "session.activity",
|
|
559
|
+
data: { state: "blocked" },
|
|
560
|
+
});
|
|
561
|
+
const after = sessionReducer(blocked, {
|
|
562
|
+
event: "chat.tool_use",
|
|
563
|
+
data: {
|
|
564
|
+
parent_message_id: null,
|
|
565
|
+
tool_message_id: "m1",
|
|
566
|
+
turn_index: 3,
|
|
567
|
+
block: { id: "toolu_1", name: "Bash" },
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
expect(after.activity).toBe("working");
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("is not cleared by traffic that proves nothing about the agent", () => {
|
|
574
|
+
// A teammate typing, or presence churn, says nothing about whether the
|
|
575
|
+
// agent is still waiting for an answer.
|
|
576
|
+
const blocked = sessionReducer(makeState(), {
|
|
577
|
+
event: "session.activity",
|
|
578
|
+
data: { state: "blocked" },
|
|
579
|
+
});
|
|
580
|
+
const after = sessionReducer(blocked, {
|
|
581
|
+
event: "presence.joined",
|
|
582
|
+
data: { user_id: 2, display_name: "Someone" },
|
|
583
|
+
});
|
|
584
|
+
expect(after.activity).toBe("blocked");
|
|
585
|
+
});
|
|
586
|
+
});
|
|
@@ -13,7 +13,24 @@ import type { Draft, Message, SessionState, WsEvent } from "./protocol";
|
|
|
13
13
|
// `draft.committed` carries only `user_message_id` (no assistant id to
|
|
14
14
|
// pre-insert), so the assistant row is created lazily when its first stream
|
|
15
15
|
// frame arrives.
|
|
16
|
+
/** Frames that prove the agent is producing again.
|
|
17
|
+
*
|
|
18
|
+
* `Notification` fires on the way IN to a wait and NOTHING fires on the way
|
|
19
|
+
* out — approving a permission prompt emits no hook at all. So without this,
|
|
20
|
+
* a session shows "needs you" for the rest of the turn, long after you
|
|
21
|
+
* answered. Any row the agent emits afterwards is the proof the hook cannot
|
|
22
|
+
* give. */
|
|
23
|
+
const UNBLOCKING_FRAMES = new Set([
|
|
24
|
+
"chat.stream_start",
|
|
25
|
+
"chat.delta",
|
|
26
|
+
"chat.tool_use",
|
|
27
|
+
"chat.tool_result",
|
|
28
|
+
]);
|
|
29
|
+
|
|
16
30
|
export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState {
|
|
31
|
+
if (prev.activity === "blocked" && UNBLOCKING_FRAMES.has(frame.event)) {
|
|
32
|
+
prev = { ...prev, activity: "working" };
|
|
33
|
+
}
|
|
17
34
|
switch (frame.event) {
|
|
18
35
|
case "session.state":
|
|
19
36
|
return frame.data;
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
3
|
-
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
-
import { PresenceBadge } from './PresenceBadge'
|
|
5
|
-
import type { Viewer } from './usePresence'
|
|
6
|
-
|
|
7
|
-
// NOTE ON CONVENTION: canopy-web has no @testing-library/jest-dom and no
|
|
8
|
-
// user-event package. Assertions use toBeTruthy(), interactions use
|
|
9
|
-
// fireEvent, and every DOM test carries the `@vitest-environment jsdom`
|
|
10
|
-
// docblock above — the vitest config sets no global environment. Do not
|
|
11
|
-
// introduce toBeInTheDocument() here; it will not exist.
|
|
12
|
-
|
|
13
|
-
const viewer = (n: number, over: Partial<Viewer> = {}): Viewer => ({
|
|
14
|
-
email: `u${n}@x.com`,
|
|
15
|
-
name: `User ${n}`,
|
|
16
|
-
subLocation: 'run overview',
|
|
17
|
-
idle: false,
|
|
18
|
-
self: false,
|
|
19
|
-
...over,
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
describe('PresenceBadge', () => {
|
|
23
|
-
afterEach(cleanup)
|
|
24
|
-
|
|
25
|
-
it('renders nothing when you are the only viewer', () => {
|
|
26
|
-
const { container } = render(<PresenceBadge viewers={[viewer(1, { self: true })]} />)
|
|
27
|
-
expect(container.innerHTML).toBe('')
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('renders nothing when the roster is empty', () => {
|
|
31
|
-
const { container } = render(<PresenceBadge viewers={[]} />)
|
|
32
|
-
expect(container.innerHTML).toBe('')
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('shows at most three avatars and collapses the rest into +N', () => {
|
|
36
|
-
render(<PresenceBadge viewers={[viewer(1), viewer(2), viewer(3), viewer(4), viewer(5)]} />)
|
|
37
|
-
expect(screen.getByText('+2')).toBeTruthy()
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('labels the control with the viewer count for screen readers', () => {
|
|
41
|
-
render(<PresenceBadge viewers={[viewer(1), viewer(2)]} />)
|
|
42
|
-
expect(screen.getByRole('button', { name: /2 people viewing this page/i })).toBeTruthy()
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('expands to a named list on click, listing you first and marked', () => {
|
|
46
|
-
render(<PresenceBadge viewers={[viewer(1), viewer(2, { self: true, name: 'Me' })]} />)
|
|
47
|
-
fireEvent.click(screen.getByRole('button'))
|
|
48
|
-
expect(screen.getByText(/Me/)).toBeTruthy()
|
|
49
|
-
expect(screen.getByText('(you)')).toBeTruthy()
|
|
50
|
-
expect(screen.getByText('User 1')).toBeTruthy()
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('renders two viewers with no email without colliding their React keys', () => {
|
|
54
|
-
// Email is not guaranteed non-empty (the server sends "" for a user with
|
|
55
|
-
// none), so it cannot be the row key. A collision here shows up as a
|
|
56
|
-
// React duplicate-key error, not as a visibly missing row.
|
|
57
|
-
const errors: unknown[][] = []
|
|
58
|
-
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
59
|
-
errors.push(args)
|
|
60
|
-
})
|
|
61
|
-
try {
|
|
62
|
-
render(
|
|
63
|
-
<PresenceBadge
|
|
64
|
-
viewers={[
|
|
65
|
-
viewer(1, { email: '', name: 'Ana' }),
|
|
66
|
-
viewer(2, { email: '', name: 'Bo' }),
|
|
67
|
-
]}
|
|
68
|
-
/>,
|
|
69
|
-
)
|
|
70
|
-
fireEvent.click(screen.getByRole('button'))
|
|
71
|
-
expect(screen.getByText('Ana')).toBeTruthy()
|
|
72
|
-
expect(screen.getByText('Bo')).toBeTruthy()
|
|
73
|
-
} finally {
|
|
74
|
-
spy.mockRestore()
|
|
75
|
-
}
|
|
76
|
-
expect(errors.flat().join(' ')).not.toMatch(/same key/i)
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('marks idle viewers in the expanded list', () => {
|
|
80
|
-
render(<PresenceBadge viewers={[viewer(1, { idle: true }), viewer(2)]} />)
|
|
81
|
-
fireEvent.click(screen.getByRole('button'))
|
|
82
|
-
expect(screen.getByText('idle')).toBeTruthy()
|
|
83
|
-
})
|
|
84
|
-
})
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import { avatarFor } from './avatar'
|
|
3
|
-
import type { Viewer } from './usePresence'
|
|
4
|
-
|
|
5
|
-
const MAX_AVATARS = 3
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Collapsed viewer cluster that expands into a named list.
|
|
9
|
-
*
|
|
10
|
-
* Renders nothing when you are alone — a badge that permanently reads "1"
|
|
11
|
-
* is noise, and the account menu already tells you who you are.
|
|
12
|
-
*/
|
|
13
|
-
export function PresenceBadge({ viewers }: { viewers: Viewer[] }) {
|
|
14
|
-
const [open, setOpen] = useState(false)
|
|
15
|
-
const rootRef = useRef<HTMLDivElement>(null)
|
|
16
|
-
|
|
17
|
-
useEffect(() => {
|
|
18
|
-
if (!open) return
|
|
19
|
-
const onDown = (e: MouseEvent) => {
|
|
20
|
-
if (!rootRef.current?.contains(e.target as Node)) setOpen(false)
|
|
21
|
-
}
|
|
22
|
-
const onKey = (e: KeyboardEvent) => {
|
|
23
|
-
if (e.key === 'Escape') setOpen(false)
|
|
24
|
-
}
|
|
25
|
-
document.addEventListener('mousedown', onDown)
|
|
26
|
-
document.addEventListener('keydown', onKey)
|
|
27
|
-
return () => {
|
|
28
|
-
document.removeEventListener('mousedown', onDown)
|
|
29
|
-
document.removeEventListener('keydown', onKey)
|
|
30
|
-
}
|
|
31
|
-
}, [open])
|
|
32
|
-
|
|
33
|
-
if (viewers.length < 2) return null
|
|
34
|
-
|
|
35
|
-
// You first, then everyone else in roster order.
|
|
36
|
-
//
|
|
37
|
-
// Rows are keyed by POSITION, not by email: email is the only plausible
|
|
38
|
-
// identity field on a Viewer and it is not guaranteed non-empty (the
|
|
39
|
-
// server sends "" for a user with no email), so two such viewers would
|
|
40
|
-
// collide on one key. The list is short, server-ordered, and re-rendered
|
|
41
|
-
// wholesale on every roster broadcast, so index keys cost nothing here.
|
|
42
|
-
const ordered = [...viewers].sort((a, b) => Number(b.self) - Number(a.self))
|
|
43
|
-
const shown = ordered.slice(0, MAX_AVATARS)
|
|
44
|
-
const overflow = ordered.length - shown.length
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<div className="relative" ref={rootRef}>
|
|
48
|
-
<button
|
|
49
|
-
type="button"
|
|
50
|
-
onClick={() => setOpen((v) => !v)}
|
|
51
|
-
aria-expanded={open}
|
|
52
|
-
aria-label={`${viewers.length} people viewing this page`}
|
|
53
|
-
className="flex items-center -space-x-2 rounded-full p-0.5 hover:opacity-90"
|
|
54
|
-
>
|
|
55
|
-
{shown.map((v, i) => {
|
|
56
|
-
const { initials, colorClass } = avatarFor(v.email, v.name)
|
|
57
|
-
return (
|
|
58
|
-
<span
|
|
59
|
-
key={i}
|
|
60
|
-
className={`inline-flex h-6 w-6 items-center justify-center rounded-full
|
|
61
|
-
ring-2 ring-card text-[10px] font-semibold text-white ${colorClass}
|
|
62
|
-
${v.idle ? 'opacity-45' : ''}`}
|
|
63
|
-
>
|
|
64
|
-
{initials}
|
|
65
|
-
</span>
|
|
66
|
-
)
|
|
67
|
-
})}
|
|
68
|
-
{overflow > 0 && (
|
|
69
|
-
<span
|
|
70
|
-
className="inline-flex h-6 w-6 items-center justify-center rounded-full
|
|
71
|
-
bg-muted ring-2 ring-card text-[10px] font-semibold text-muted-foreground"
|
|
72
|
-
>
|
|
73
|
-
+{overflow}
|
|
74
|
-
</span>
|
|
75
|
-
)}
|
|
76
|
-
</button>
|
|
77
|
-
|
|
78
|
-
{open && (
|
|
79
|
-
<div
|
|
80
|
-
className="absolute right-0 z-50 mt-2 w-64 rounded-md border border-border
|
|
81
|
-
bg-card p-1 shadow-md"
|
|
82
|
-
>
|
|
83
|
-
{ordered.map((v, i) => {
|
|
84
|
-
const { initials, colorClass } = avatarFor(v.email, v.name)
|
|
85
|
-
return (
|
|
86
|
-
<div key={i} className="flex items-center gap-2 rounded px-2 py-1.5">
|
|
87
|
-
<span
|
|
88
|
-
className={`inline-flex h-6 w-6 shrink-0 items-center justify-center
|
|
89
|
-
rounded-full text-[10px] font-semibold text-white ${colorClass}
|
|
90
|
-
${v.idle ? 'opacity-45' : ''}`}
|
|
91
|
-
>
|
|
92
|
-
{initials}
|
|
93
|
-
</span>
|
|
94
|
-
<span className="min-w-0 flex-1">
|
|
95
|
-
<span className="block truncate text-sm text-foreground">
|
|
96
|
-
{v.name || v.email}
|
|
97
|
-
{v.self && <span className="ml-1 text-muted-foreground">(you)</span>}
|
|
98
|
-
</span>
|
|
99
|
-
<span className="block truncate text-xs text-muted-foreground">
|
|
100
|
-
{v.subLocation}
|
|
101
|
-
</span>
|
|
102
|
-
</span>
|
|
103
|
-
{v.idle && <span className="text-[10px] text-muted-foreground">idle</span>}
|
|
104
|
-
</div>
|
|
105
|
-
)
|
|
106
|
-
})}
|
|
107
|
-
</div>
|
|
108
|
-
)}
|
|
109
|
-
</div>
|
|
110
|
-
)
|
|
111
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { avatarFor } from './avatar'
|
|
3
|
-
|
|
4
|
-
describe('avatarFor', () => {
|
|
5
|
-
it('takes initials from a two-part display name', () => {
|
|
6
|
-
expect(avatarFor('alice@x.com', 'Alice Chen').initials).toBe('AC')
|
|
7
|
-
})
|
|
8
|
-
|
|
9
|
-
it('falls back to the email local-part when there is no name', () => {
|
|
10
|
-
expect(avatarFor('bob.ali@x.com', '').initials).toBe('BA')
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
it('produces a single initial for a one-word identity', () => {
|
|
14
|
-
expect(avatarFor('ace@x.com', 'ACE').initials).toBe('A')
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it('is deterministic: the same email is always the same color', () => {
|
|
18
|
-
expect(avatarFor('alice@x.com', 'Alice Chen').colorClass)
|
|
19
|
-
.toBe(avatarFor('alice@x.com', 'Different Name').colorClass)
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('color is a deterministic function of email via djb2', () => {
|
|
23
|
-
const email = 'alice@example.com'
|
|
24
|
-
const COLORS = [
|
|
25
|
-
'bg-sky-600',
|
|
26
|
-
'bg-emerald-600',
|
|
27
|
-
'bg-violet-600',
|
|
28
|
-
'bg-amber-600',
|
|
29
|
-
'bg-rose-600',
|
|
30
|
-
'bg-teal-600',
|
|
31
|
-
'bg-indigo-600',
|
|
32
|
-
'bg-fuchsia-600',
|
|
33
|
-
]
|
|
34
|
-
|
|
35
|
-
// Compute expected color using djb2 formula
|
|
36
|
-
let h = 5381
|
|
37
|
-
for (let i = 0; i < email.length; i++) h = ((h << 5) + h + email.charCodeAt(i)) | 0
|
|
38
|
-
const expectedColorClass = COLORS[Math.abs(h) % COLORS.length]
|
|
39
|
-
|
|
40
|
-
expect(avatarFor(email, 'Any Name').colorClass).toBe(expectedColorClass)
|
|
41
|
-
})
|
|
42
|
-
})
|
package/src/presence/avatar.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// Fixed palette. Chosen for legibility against white text in both themes;
|
|
2
|
-
// index is picked by a stable hash of the email so a person keeps one color
|
|
3
|
-
// everywhere, in every app, across sessions.
|
|
4
|
-
const COLORS = [
|
|
5
|
-
'bg-sky-600',
|
|
6
|
-
'bg-emerald-600',
|
|
7
|
-
'bg-violet-600',
|
|
8
|
-
'bg-amber-600',
|
|
9
|
-
'bg-rose-600',
|
|
10
|
-
'bg-teal-600',
|
|
11
|
-
'bg-indigo-600',
|
|
12
|
-
'bg-fuchsia-600',
|
|
13
|
-
]
|
|
14
|
-
|
|
15
|
-
function hash(value: string): number {
|
|
16
|
-
// djb2. Not cryptographic — we only need stable bucketing.
|
|
17
|
-
let h = 5381
|
|
18
|
-
for (let i = 0; i < value.length; i++) h = ((h << 5) + h + value.charCodeAt(i)) | 0
|
|
19
|
-
return Math.abs(h)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Initials + a stable color class for one person.
|
|
24
|
-
*
|
|
25
|
-
* Color keys on email rather than display name so changing your name does
|
|
26
|
-
* not change your color out from under people who have learned it.
|
|
27
|
-
*/
|
|
28
|
-
export function avatarFor(email: string, name: string): { initials: string; colorClass: string } {
|
|
29
|
-
const source = (name || email.split('@')[0] || '?').trim()
|
|
30
|
-
const words = source.split(/[\s._-]+/).filter(Boolean)
|
|
31
|
-
const initials =
|
|
32
|
-
words.length >= 2
|
|
33
|
-
? (words[0][0] + words[1][0]).toUpperCase()
|
|
34
|
-
: (words[0]?.[0] ?? '?').toUpperCase()
|
|
35
|
-
return { initials, colorClass: COLORS[hash(email) % COLORS.length] }
|
|
36
|
-
}
|
package/src/presence/index.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { pageKeyFor, type RouteRule } from './pageKey'
|
|
3
|
-
|
|
4
|
-
const ACE_RULES: RouteRule[] = [
|
|
5
|
-
{
|
|
6
|
-
pattern: /^\/w\/([^/]+)\/opps\/([^/]+)\/runs\/([^/]+)\/steps\/([^/]+)/,
|
|
7
|
-
build: (m) => ({ workspace: m[1], resource: `opp:${m[2]}/${m[3]}`, subLocation: m[4] }),
|
|
8
|
-
},
|
|
9
|
-
{
|
|
10
|
-
pattern: /^\/w\/([^/]+)\/opps\/([^/]+)\/runs\/([^/]+)/,
|
|
11
|
-
build: (m) => ({ workspace: m[1], resource: `opp:${m[2]}/${m[3]}`, subLocation: 'run overview' }),
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
pattern: /^\/w\/([^/]+)\/activity/,
|
|
15
|
-
build: (m) => ({ workspace: m[1], resource: 'activity', subLocation: 'Activity' }),
|
|
16
|
-
},
|
|
17
|
-
]
|
|
18
|
-
|
|
19
|
-
describe('pageKeyFor', () => {
|
|
20
|
-
it('collapses every step of a run onto one key, keeping the step as sub-location', () => {
|
|
21
|
-
const a = pageKeyFor('ace', '/w/dimagi-team/opps/bednet/runs/run-001/steps/idea-to-pdd', ACE_RULES)
|
|
22
|
-
const b = pageKeyFor('ace', '/w/dimagi-team/opps/bednet/runs/run-001', ACE_RULES)
|
|
23
|
-
expect(a?.pageKey).toBe('ace:dimagi-team:opp:bednet/run-001')
|
|
24
|
-
expect(b?.pageKey).toBe(a?.pageKey)
|
|
25
|
-
expect(a?.subLocation).toBe('idea-to-pdd')
|
|
26
|
-
expect(b?.subLocation).toBe('run overview')
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('keeps different runs of the same opp on different keys', () => {
|
|
30
|
-
const a = pageKeyFor('ace', '/w/dimagi-team/opps/bednet/runs/run-001', ACE_RULES)
|
|
31
|
-
const b = pageKeyFor('ace', '/w/dimagi-team/opps/bednet/runs/run-002', ACE_RULES)
|
|
32
|
-
expect(a?.pageKey).not.toBe(b?.pageKey)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('namespaces by app so two apps never collide', () => {
|
|
36
|
-
const ace = pageKeyFor('ace', '/w/dimagi-team/activity', ACE_RULES)
|
|
37
|
-
const canopy = pageKeyFor('canopy', '/w/dimagi-team/activity', ACE_RULES)
|
|
38
|
-
expect(ace?.pageKey).toBe('ace:dimagi-team:activity')
|
|
39
|
-
expect(canopy?.pageKey).toBe('canopy:dimagi-team:activity')
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('returns null for unmatched routes rather than a catch-all key', () => {
|
|
43
|
-
expect(pageKeyFor('ace', '/totally/unknown', ACE_RULES)).toBeNull()
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
it('is order-sensitive: the first matching rule wins', () => {
|
|
47
|
-
const loose: RouteRule[] = [
|
|
48
|
-
{ pattern: /^\/w\/([^/]+)\/opps/, build: (m) => ({ workspace: m[1], resource: 'opps', subLocation: 'Opps' }) },
|
|
49
|
-
...ACE_RULES,
|
|
50
|
-
]
|
|
51
|
-
expect(pageKeyFor('ace', '/w/x/opps/bednet/runs/run-001', loose)?.pageKey).toBe('ace:x:opps')
|
|
52
|
-
})
|
|
53
|
-
})
|
package/src/presence/pageKey.ts
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/** A resolved presence location: which roster to join, and where in it you are. */
|
|
2
|
-
export interface PageLocation {
|
|
3
|
-
/** `<app>:<workspace|global>:<resource>` — the roster identity. */
|
|
4
|
-
pageKey: string
|
|
5
|
-
/** Human-readable position within the resource, for the expanded panel. */
|
|
6
|
-
subLocation: string
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface RouteRule {
|
|
10
|
-
pattern: RegExp
|
|
11
|
-
build: (m: RegExpMatchArray) => { workspace: string; resource: string; subLocation: string }
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Resolve a pathname to a presence location. Pure — the single place
|
|
16
|
-
* grouping correctness lives.
|
|
17
|
-
*
|
|
18
|
-
* Rules are evaluated in order and the first match wins, so more specific
|
|
19
|
-
* patterns must be listed before looser ones.
|
|
20
|
-
*
|
|
21
|
-
* Returns null when nothing matches. Callers render no badge in that case;
|
|
22
|
-
* grouping every unrecognised route under one catch-all key would put
|
|
23
|
-
* unrelated strangers in the same roster.
|
|
24
|
-
*/
|
|
25
|
-
export function pageKeyFor(
|
|
26
|
-
app: string,
|
|
27
|
-
pathname: string,
|
|
28
|
-
rules: RouteRule[],
|
|
29
|
-
): PageLocation | null {
|
|
30
|
-
for (const rule of rules) {
|
|
31
|
-
const m = pathname.match(rule.pattern)
|
|
32
|
-
if (!m) continue
|
|
33
|
-
const { workspace, resource, subLocation } = rule.build(m)
|
|
34
|
-
return { pageKey: `${app}:${workspace}:${resource}`, subLocation }
|
|
35
|
-
}
|
|
36
|
-
return null
|
|
37
|
-
}
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { act, renderHook } from '@testing-library/react'
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
-
import { usePresence } from './usePresence'
|
|
5
|
-
|
|
6
|
-
class FakeSocket {
|
|
7
|
-
static last: FakeSocket | null = null
|
|
8
|
-
static created = 0
|
|
9
|
-
static OPEN = 1
|
|
10
|
-
readyState = 0
|
|
11
|
-
sent: string[] = []
|
|
12
|
-
onopen: (() => void) | null = null
|
|
13
|
-
onmessage: ((e: { data: string }) => void) | null = null
|
|
14
|
-
onclose: (() => void) | null = null
|
|
15
|
-
constructor(public url: string) {
|
|
16
|
-
FakeSocket.last = this
|
|
17
|
-
FakeSocket.created += 1
|
|
18
|
-
}
|
|
19
|
-
send(frame: string) {
|
|
20
|
-
this.sent.push(frame)
|
|
21
|
-
}
|
|
22
|
-
close() {
|
|
23
|
-
this.readyState = 3
|
|
24
|
-
}
|
|
25
|
-
open() {
|
|
26
|
-
this.readyState = FakeSocket.OPEN
|
|
27
|
-
this.onopen?.()
|
|
28
|
-
}
|
|
29
|
-
deliver(payload: unknown) {
|
|
30
|
-
this.onmessage?.({ data: JSON.stringify(payload) })
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
beforeEach(() => {
|
|
35
|
-
vi.stubGlobal('WebSocket', FakeSocket as unknown as typeof WebSocket)
|
|
36
|
-
vi.useFakeTimers()
|
|
37
|
-
FakeSocket.created = 0
|
|
38
|
-
})
|
|
39
|
-
afterEach(() => {
|
|
40
|
-
vi.useRealTimers()
|
|
41
|
-
vi.unstubAllGlobals()
|
|
42
|
-
FakeSocket.last = null
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
const LOC = { pageKey: 'ace:ws:opp:a/run-001', subLocation: 'run overview' }
|
|
46
|
-
|
|
47
|
-
describe('usePresence', () => {
|
|
48
|
-
it('sends presence.enter once the socket opens', () => {
|
|
49
|
-
renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
|
|
50
|
-
act(() => FakeSocket.last!.open())
|
|
51
|
-
expect(JSON.parse(FakeSocket.last!.sent[0])).toEqual({
|
|
52
|
-
type: 'presence.enter',
|
|
53
|
-
page_key: LOC.pageKey,
|
|
54
|
-
sub_location: LOC.subLocation,
|
|
55
|
-
})
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('exposes the roster the server broadcasts', () => {
|
|
59
|
-
const { result } = renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
|
|
60
|
-
act(() => FakeSocket.last!.open())
|
|
61
|
-
act(() =>
|
|
62
|
-
FakeSocket.last!.deliver({
|
|
63
|
-
event: 'presence.roster',
|
|
64
|
-
data: {
|
|
65
|
-
page_key: LOC.pageKey,
|
|
66
|
-
viewers: [{ email: 'a@x.com', name: 'A', sub_location: 'idea-to-pdd', idle: false, self: true }],
|
|
67
|
-
},
|
|
68
|
-
}),
|
|
69
|
-
)
|
|
70
|
-
expect(result.current.viewers).toEqual([
|
|
71
|
-
{ email: 'a@x.com', name: 'A', subLocation: 'idea-to-pdd', idle: false, self: true },
|
|
72
|
-
])
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it('ignores a roster for a page key it is no longer on', () => {
|
|
76
|
-
const { result } = renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
|
|
77
|
-
act(() => FakeSocket.last!.open())
|
|
78
|
-
act(() =>
|
|
79
|
-
FakeSocket.last!.deliver({
|
|
80
|
-
event: 'presence.roster',
|
|
81
|
-
data: { page_key: 'ace:ws:opp:STALE/run-999', viewers: [
|
|
82
|
-
{ email: 'z@x.com', name: 'Z', sub_location: '', idle: false, self: false }] },
|
|
83
|
-
}),
|
|
84
|
-
)
|
|
85
|
-
expect(result.current.viewers).toEqual([])
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('re-enters without reconnecting when the location changes', () => {
|
|
89
|
-
const { rerender } = renderHook(
|
|
90
|
-
({ location }) => usePresence({ url: 'ws://x/ws/presence/', location }),
|
|
91
|
-
{ initialProps: { location: LOC } },
|
|
92
|
-
)
|
|
93
|
-
act(() => FakeSocket.last!.open())
|
|
94
|
-
const socket = FakeSocket.last!
|
|
95
|
-
rerender({ location: { pageKey: 'ace:ws:activity', subLocation: 'Activity' } })
|
|
96
|
-
expect(FakeSocket.last).toBe(socket) // same socket, no reconnect
|
|
97
|
-
expect(JSON.parse(socket.sent[socket.sent.length - 1])).toEqual({
|
|
98
|
-
type: 'presence.enter',
|
|
99
|
-
page_key: 'ace:ws:activity',
|
|
100
|
-
sub_location: 'Activity',
|
|
101
|
-
})
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('heartbeats every 20 seconds', () => {
|
|
105
|
-
renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
|
|
106
|
-
act(() => FakeSocket.last!.open())
|
|
107
|
-
act(() => void vi.advanceTimersByTime(20_000))
|
|
108
|
-
expect(JSON.parse(FakeSocket.last!.sent[1])).toEqual({ type: 'presence.heartbeat', idle: false })
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
it('clears the roster and sends nothing when there is no location', () => {
|
|
112
|
-
const { result } = renderHook(() =>
|
|
113
|
-
usePresence({ url: 'ws://x/ws/presence/', location: null }),
|
|
114
|
-
)
|
|
115
|
-
expect(result.current.viewers).toEqual([])
|
|
116
|
-
expect(FakeSocket.last).toBeNull()
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
it('backs off exponentially while reconnects keep failing, up to a ceiling', () => {
|
|
120
|
-
// A tab left open past session expiry is closed with 4001 on every
|
|
121
|
-
// attempt. A flat retry hammers a handshake every 2s forever; the delay
|
|
122
|
-
// must grow and then cap.
|
|
123
|
-
renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
|
|
124
|
-
expect(FakeSocket.created).toBe(1)
|
|
125
|
-
|
|
126
|
-
const failOnce = (expectedDelay: number) => {
|
|
127
|
-
const before = FakeSocket.created
|
|
128
|
-
act(() => FakeSocket.last!.onclose!())
|
|
129
|
-
// Nothing reconnects a millisecond early...
|
|
130
|
-
act(() => void vi.advanceTimersByTime(expectedDelay - 1))
|
|
131
|
-
expect(FakeSocket.created).toBe(before)
|
|
132
|
-
// ...and exactly one reconnect lands on the deadline.
|
|
133
|
-
act(() => void vi.advanceTimersByTime(1))
|
|
134
|
-
expect(FakeSocket.created).toBe(before + 1)
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
failOnce(2_000)
|
|
138
|
-
failOnce(4_000)
|
|
139
|
-
failOnce(8_000)
|
|
140
|
-
failOnce(16_000)
|
|
141
|
-
failOnce(32_000)
|
|
142
|
-
failOnce(60_000) // ceiling, not 64_000
|
|
143
|
-
failOnce(60_000) // and it stays there
|
|
144
|
-
})
|
|
145
|
-
|
|
146
|
-
it('resets the backoff once a socket actually opens', () => {
|
|
147
|
-
renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
|
|
148
|
-
act(() => FakeSocket.last!.onclose!())
|
|
149
|
-
act(() => void vi.advanceTimersByTime(2_000))
|
|
150
|
-
act(() => FakeSocket.last!.onclose!())
|
|
151
|
-
act(() => void vi.advanceTimersByTime(4_000)) // now at a 4s delay
|
|
152
|
-
|
|
153
|
-
act(() => FakeSocket.last!.open()) // a good connection clears the streak
|
|
154
|
-
|
|
155
|
-
const before = FakeSocket.created
|
|
156
|
-
act(() => FakeSocket.last!.onclose!())
|
|
157
|
-
act(() => void vi.advanceTimersByTime(2_000))
|
|
158
|
-
expect(FakeSocket.created).toBe(before + 1)
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
it('clears the previous page\'s viewers on navigation even when the socket is not open', () => {
|
|
162
|
-
// Otherwise the old page's avatars linger on the new page until a roster
|
|
163
|
-
// for it arrives — which, on a dead socket, may be never.
|
|
164
|
-
const { result, rerender } = renderHook(
|
|
165
|
-
({ location }) => usePresence({ url: 'ws://x/ws/presence/', location }),
|
|
166
|
-
{ initialProps: { location: LOC } },
|
|
167
|
-
)
|
|
168
|
-
act(() => FakeSocket.last!.open())
|
|
169
|
-
act(() =>
|
|
170
|
-
FakeSocket.last!.deliver({
|
|
171
|
-
event: 'presence.roster',
|
|
172
|
-
data: {
|
|
173
|
-
page_key: LOC.pageKey,
|
|
174
|
-
viewers: [{ email: 'a@x.com', name: 'A', sub_location: '', idle: false, self: false }],
|
|
175
|
-
},
|
|
176
|
-
}),
|
|
177
|
-
)
|
|
178
|
-
expect(result.current.viewers).toHaveLength(1)
|
|
179
|
-
|
|
180
|
-
FakeSocket.last!.readyState = 3 // socket dropped, reconnect pending
|
|
181
|
-
rerender({ location: { pageKey: 'ace:ws:activity', subLocation: 'Activity' } })
|
|
182
|
-
expect(result.current.viewers).toEqual([])
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
it('swallows a synchronous WebSocket constructor throw and stays on an empty roster', () => {
|
|
186
|
-
class ThrowingSocket {
|
|
187
|
-
constructor() {
|
|
188
|
-
throw new Error('mixed content: refused to connect to insecure WebSocket')
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
vi.stubGlobal('WebSocket', ThrowingSocket as unknown as typeof WebSocket)
|
|
192
|
-
|
|
193
|
-
let hook!: ReturnType<typeof renderHook<{ viewers: unknown[] }, void>>
|
|
194
|
-
expect(() => {
|
|
195
|
-
hook = renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
|
|
196
|
-
}).not.toThrow()
|
|
197
|
-
expect(hook.result.current.viewers).toEqual([])
|
|
198
|
-
})
|
|
199
|
-
})
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import type { PageLocation } from './pageKey'
|
|
3
|
-
|
|
4
|
-
export interface Viewer {
|
|
5
|
-
email: string
|
|
6
|
-
name: string
|
|
7
|
-
subLocation: string
|
|
8
|
-
idle: boolean
|
|
9
|
-
self: boolean
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface UsePresenceOptions {
|
|
13
|
-
url: string
|
|
14
|
-
location: PageLocation | null
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const HEARTBEAT_MS = 20_000
|
|
18
|
-
const IDLE_AFTER_MS = 120_000
|
|
19
|
-
// Reconnect backoff. A flat retry is wrong for the permanent-failure case:
|
|
20
|
-
// a tab left open past session expiry is closed with 4001 on every attempt,
|
|
21
|
-
// so a flat 2s retry hammers a handshake forever. Double up to a ceiling and
|
|
22
|
-
// reset on a socket that actually opens.
|
|
23
|
-
const RECONNECT_BASE_MS = 2_000
|
|
24
|
-
const RECONNECT_MAX_MS = 60_000
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* One presence socket per tab.
|
|
28
|
-
*
|
|
29
|
-
* Navigation re-keys the existing connection with a fresh `presence.enter`
|
|
30
|
-
* rather than reconnecting — a socket per page would churn handshakes on
|
|
31
|
-
* every click.
|
|
32
|
-
*
|
|
33
|
-
* Every failure path degrades to an empty roster. Presence is an
|
|
34
|
-
* enhancement; it must never surface an error to the user.
|
|
35
|
-
*/
|
|
36
|
-
export function usePresence({ url, location }: UsePresenceOptions): { viewers: Viewer[] } {
|
|
37
|
-
const [viewers, setViewers] = useState<Viewer[]>([])
|
|
38
|
-
const wsRef = useRef<WebSocket | null>(null)
|
|
39
|
-
const locationRef = useRef(location)
|
|
40
|
-
const idleRef = useRef(false)
|
|
41
|
-
locationRef.current = location
|
|
42
|
-
|
|
43
|
-
// Socket lifecycle. Deliberately NOT keyed on `location` — the socket
|
|
44
|
-
// outlives navigation.
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
if (!location) {
|
|
47
|
-
setViewers([])
|
|
48
|
-
return
|
|
49
|
-
}
|
|
50
|
-
let closedByCleanup = false
|
|
51
|
-
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
52
|
-
let heartbeat: ReturnType<typeof setInterval> | null = null
|
|
53
|
-
let reconnectDelay = RECONNECT_BASE_MS
|
|
54
|
-
|
|
55
|
-
const scheduleReconnect = () => {
|
|
56
|
-
if (closedByCleanup) return
|
|
57
|
-
reconnectTimer = setTimeout(open, reconnectDelay)
|
|
58
|
-
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const send = (frame: unknown) => {
|
|
62
|
-
const ws = wsRef.current
|
|
63
|
-
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame))
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const enter = () => {
|
|
67
|
-
const loc = locationRef.current
|
|
68
|
-
if (loc) send({ type: 'presence.enter', page_key: loc.pageKey, sub_location: loc.subLocation })
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function open() {
|
|
72
|
-
let sock: WebSocket
|
|
73
|
-
try {
|
|
74
|
-
sock = new WebSocket(url)
|
|
75
|
-
} catch {
|
|
76
|
-
// Synchronous construction failure (malformed URL, mixed-content
|
|
77
|
-
// scheme mismatch, …): treat exactly like any other connection
|
|
78
|
-
// failure — stay on the empty roster and retry on the normal
|
|
79
|
-
// reconnect cadence, never propagate.
|
|
80
|
-
setViewers([])
|
|
81
|
-
scheduleReconnect()
|
|
82
|
-
return
|
|
83
|
-
}
|
|
84
|
-
wsRef.current = sock
|
|
85
|
-
sock.onopen = () => {
|
|
86
|
-
// A socket that actually opened means the failure that got us here
|
|
87
|
-
// is over; start the next backoff series from the bottom.
|
|
88
|
-
reconnectDelay = RECONNECT_BASE_MS
|
|
89
|
-
enter()
|
|
90
|
-
heartbeat = setInterval(
|
|
91
|
-
() => send({ type: 'presence.heartbeat', idle: idleRef.current }),
|
|
92
|
-
HEARTBEAT_MS,
|
|
93
|
-
)
|
|
94
|
-
}
|
|
95
|
-
sock.onmessage = (e) => {
|
|
96
|
-
try {
|
|
97
|
-
const msg = JSON.parse(e.data)
|
|
98
|
-
if (msg.event !== 'presence.roster') return
|
|
99
|
-
// Drop rosters for a page we have already navigated away from —
|
|
100
|
-
// an in-flight broadcast can land after the re-key.
|
|
101
|
-
if (msg.data?.page_key !== locationRef.current?.pageKey) return
|
|
102
|
-
setViewers(
|
|
103
|
-
(msg.data.viewers ?? []).map((v: Record<string, unknown>) => ({
|
|
104
|
-
email: String(v.email ?? ''),
|
|
105
|
-
name: String(v.name ?? ''),
|
|
106
|
-
subLocation: String(v.sub_location ?? ''),
|
|
107
|
-
idle: Boolean(v.idle),
|
|
108
|
-
self: Boolean(v.self),
|
|
109
|
-
})),
|
|
110
|
-
)
|
|
111
|
-
} catch {
|
|
112
|
-
// Malformed frame: ignore. Never surface.
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
sock.onclose = () => {
|
|
116
|
-
if (wsRef.current === sock) wsRef.current = null
|
|
117
|
-
if (heartbeat) clearInterval(heartbeat)
|
|
118
|
-
setViewers([])
|
|
119
|
-
scheduleReconnect()
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
open()
|
|
123
|
-
|
|
124
|
-
return () => {
|
|
125
|
-
closedByCleanup = true
|
|
126
|
-
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
127
|
-
if (heartbeat) clearInterval(heartbeat)
|
|
128
|
-
wsRef.current?.close()
|
|
129
|
-
wsRef.current = null
|
|
130
|
-
}
|
|
131
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
132
|
-
}, [url, location === null])
|
|
133
|
-
|
|
134
|
-
// Re-key on navigation. Deps are the primitive fields, not `location`
|
|
135
|
-
// itself — callers (e.g. pageKeyFor) may return a fresh object each
|
|
136
|
-
// render, and keying on identity would re-send presence.enter on every
|
|
137
|
-
// render instead of only on an actual location change.
|
|
138
|
-
useEffect(() => {
|
|
139
|
-
// Clear FIRST, unconditionally. If the socket is not open yet the old
|
|
140
|
-
// page's avatars are already wrong — leaving them up until a roster for
|
|
141
|
-
// the new page arrives (which, on a dead socket, may be never) shows
|
|
142
|
-
// strangers as viewing the page you just navigated to.
|
|
143
|
-
setViewers([])
|
|
144
|
-
const ws = wsRef.current
|
|
145
|
-
if (!location || !ws || ws.readyState !== WebSocket.OPEN) return
|
|
146
|
-
ws.send(
|
|
147
|
-
JSON.stringify({
|
|
148
|
-
type: 'presence.enter',
|
|
149
|
-
page_key: location.pageKey,
|
|
150
|
-
sub_location: location.subLocation,
|
|
151
|
-
}),
|
|
152
|
-
)
|
|
153
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
154
|
-
}, [location?.pageKey, location?.subLocation])
|
|
155
|
-
|
|
156
|
-
// Idle tracking: hidden for longer than IDLE_AFTER_MS. Reports an
|
|
157
|
-
// observable fact (the tab is not frontmost), never an attention claim.
|
|
158
|
-
useEffect(() => {
|
|
159
|
-
let timer: ReturnType<typeof setTimeout> | null = null
|
|
160
|
-
const flush = () => {
|
|
161
|
-
const ws = wsRef.current
|
|
162
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
163
|
-
ws.send(JSON.stringify({ type: 'presence.heartbeat', idle: idleRef.current }))
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
const onVisibility = () => {
|
|
167
|
-
if (document.hidden) {
|
|
168
|
-
timer = setTimeout(() => {
|
|
169
|
-
idleRef.current = true
|
|
170
|
-
flush()
|
|
171
|
-
}, IDLE_AFTER_MS)
|
|
172
|
-
} else {
|
|
173
|
-
if (timer) clearTimeout(timer)
|
|
174
|
-
if (idleRef.current) {
|
|
175
|
-
idleRef.current = false
|
|
176
|
-
flush()
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
document.addEventListener('visibilitychange', onVisibility)
|
|
181
|
-
return () => {
|
|
182
|
-
if (timer) clearTimeout(timer)
|
|
183
|
-
document.removeEventListener('visibilitychange', onVisibility)
|
|
184
|
-
}
|
|
185
|
-
}, [])
|
|
186
|
-
|
|
187
|
-
return { viewers }
|
|
188
|
-
}
|