canopy-ui 0.3.0 → 0.6.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.
@@ -0,0 +1,170 @@
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
+ const RECONNECT_MS = 2_000
20
+
21
+ /**
22
+ * One presence socket per tab.
23
+ *
24
+ * Navigation re-keys the existing connection with a fresh `presence.enter`
25
+ * rather than reconnecting — a socket per page would churn handshakes on
26
+ * every click.
27
+ *
28
+ * Every failure path degrades to an empty roster. Presence is an
29
+ * enhancement; it must never surface an error to the user.
30
+ */
31
+ export function usePresence({ url, location }: UsePresenceOptions): { viewers: Viewer[] } {
32
+ const [viewers, setViewers] = useState<Viewer[]>([])
33
+ const wsRef = useRef<WebSocket | null>(null)
34
+ const locationRef = useRef(location)
35
+ const idleRef = useRef(false)
36
+ locationRef.current = location
37
+
38
+ // Socket lifecycle. Deliberately NOT keyed on `location` — the socket
39
+ // outlives navigation.
40
+ useEffect(() => {
41
+ if (!location) {
42
+ setViewers([])
43
+ return
44
+ }
45
+ let closedByCleanup = false
46
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
47
+ let heartbeat: ReturnType<typeof setInterval> | null = null
48
+
49
+ const send = (frame: unknown) => {
50
+ const ws = wsRef.current
51
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame))
52
+ }
53
+
54
+ const enter = () => {
55
+ const loc = locationRef.current
56
+ if (loc) send({ type: 'presence.enter', page_key: loc.pageKey, sub_location: loc.subLocation })
57
+ }
58
+
59
+ function open() {
60
+ let sock: WebSocket
61
+ try {
62
+ sock = new WebSocket(url)
63
+ } catch {
64
+ // Synchronous construction failure (malformed URL, mixed-content
65
+ // scheme mismatch, …): treat exactly like any other connection
66
+ // failure — stay on the empty roster and retry on the normal
67
+ // reconnect cadence, never propagate.
68
+ setViewers([])
69
+ if (!closedByCleanup) reconnectTimer = setTimeout(open, RECONNECT_MS)
70
+ return
71
+ }
72
+ wsRef.current = sock
73
+ sock.onopen = () => {
74
+ enter()
75
+ heartbeat = setInterval(
76
+ () => send({ type: 'presence.heartbeat', idle: idleRef.current }),
77
+ HEARTBEAT_MS,
78
+ )
79
+ }
80
+ sock.onmessage = (e) => {
81
+ try {
82
+ const msg = JSON.parse(e.data)
83
+ if (msg.event !== 'presence.roster') return
84
+ // Drop rosters for a page we have already navigated away from —
85
+ // an in-flight broadcast can land after the re-key.
86
+ if (msg.data?.page_key !== locationRef.current?.pageKey) return
87
+ setViewers(
88
+ (msg.data.viewers ?? []).map((v: Record<string, unknown>) => ({
89
+ email: String(v.email ?? ''),
90
+ name: String(v.name ?? ''),
91
+ subLocation: String(v.sub_location ?? ''),
92
+ idle: Boolean(v.idle),
93
+ self: Boolean(v.self),
94
+ })),
95
+ )
96
+ } catch {
97
+ // Malformed frame: ignore. Never surface.
98
+ }
99
+ }
100
+ sock.onclose = () => {
101
+ if (wsRef.current === sock) wsRef.current = null
102
+ if (heartbeat) clearInterval(heartbeat)
103
+ setViewers([])
104
+ if (closedByCleanup) return
105
+ reconnectTimer = setTimeout(open, RECONNECT_MS)
106
+ }
107
+ }
108
+ open()
109
+
110
+ return () => {
111
+ closedByCleanup = true
112
+ if (reconnectTimer) clearTimeout(reconnectTimer)
113
+ if (heartbeat) clearInterval(heartbeat)
114
+ wsRef.current?.close()
115
+ wsRef.current = null
116
+ }
117
+ // eslint-disable-next-line react-hooks/exhaustive-deps
118
+ }, [url, location === null])
119
+
120
+ // Re-key on navigation. Deps are the primitive fields, not `location`
121
+ // itself — callers (e.g. pageKeyFor) may return a fresh object each
122
+ // render, and keying on identity would re-send presence.enter on every
123
+ // render instead of only on an actual location change.
124
+ useEffect(() => {
125
+ const ws = wsRef.current
126
+ if (!location || !ws || ws.readyState !== WebSocket.OPEN) return
127
+ setViewers([])
128
+ ws.send(
129
+ JSON.stringify({
130
+ type: 'presence.enter',
131
+ page_key: location.pageKey,
132
+ sub_location: location.subLocation,
133
+ }),
134
+ )
135
+ // eslint-disable-next-line react-hooks/exhaustive-deps
136
+ }, [location?.pageKey, location?.subLocation])
137
+
138
+ // Idle tracking: hidden for longer than IDLE_AFTER_MS. Reports an
139
+ // observable fact (the tab is not frontmost), never an attention claim.
140
+ useEffect(() => {
141
+ let timer: ReturnType<typeof setTimeout> | null = null
142
+ const flush = () => {
143
+ const ws = wsRef.current
144
+ if (ws && ws.readyState === WebSocket.OPEN) {
145
+ ws.send(JSON.stringify({ type: 'presence.heartbeat', idle: idleRef.current }))
146
+ }
147
+ }
148
+ const onVisibility = () => {
149
+ if (document.hidden) {
150
+ timer = setTimeout(() => {
151
+ idleRef.current = true
152
+ flush()
153
+ }, IDLE_AFTER_MS)
154
+ } else {
155
+ if (timer) clearTimeout(timer)
156
+ if (idleRef.current) {
157
+ idleRef.current = false
158
+ flush()
159
+ }
160
+ }
161
+ }
162
+ document.addEventListener('visibilitychange', onVisibility)
163
+ return () => {
164
+ if (timer) clearTimeout(timer)
165
+ document.removeEventListener('visibilitychange', onVisibility)
166
+ }
167
+ }, [])
168
+
169
+ return { viewers }
170
+ }