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,77 @@
1
+ import { useCallback, useEffect, useRef, type RefObject } from "react";
2
+
3
+ /**
4
+ * Sticky-bottom auto-scroll for a streaming message list.
5
+ *
6
+ * Behaviour:
7
+ * - When the user is at (or within `thresholdPx` of) the bottom of the
8
+ * scroll container, growth of `dep` (e.g. messages array, streaming
9
+ * text length) snaps the view to the new bottom.
10
+ * - When the user has scrolled up to read history, growth does NOT
11
+ * yank them back. Auto-follow resumes once they scroll back near
12
+ * the bottom themselves.
13
+ *
14
+ * The "near bottom" predicate is updated in two places:
15
+ * - on the user's `scroll` event (so manual scroll-up disables follow)
16
+ * - immediately after an auto-scroll write (so we stay sticky even
17
+ * though the scroll event for our own write would temporarily make
18
+ * `scrollHeight - scrollTop - clientHeight` larger by a few pixels)
19
+ *
20
+ * Use "instant" scroll behavior for streaming chunks — smooth scroll
21
+ * cannot keep up with high-frequency updates and the view drifts.
22
+ *
23
+ * Returns:
24
+ * - `containerRef`: attach to the scrollable element.
25
+ * - `onScroll`: attach to `onScroll` on the same element.
26
+ * - `scrollToBottom`: force a snap (e.g. on send).
27
+ */
28
+ export function useStickyBottom<T>(
29
+ dep: T,
30
+ options: { thresholdPx?: number; enabled?: boolean } = {},
31
+ ): {
32
+ containerRef: RefObject<HTMLDivElement | null>;
33
+ onScroll: () => void;
34
+ scrollToBottom: () => void;
35
+ } {
36
+ const { thresholdPx = 100, enabled = true } = options;
37
+ const containerRef = useRef<HTMLDivElement>(null);
38
+ // Default true: when the container first mounts there's no history
39
+ // to read, so the user is "at the bottom" by definition.
40
+ const wasNearBottomRef = useRef(true);
41
+
42
+ const isNearBottom = useCallback(
43
+ (el: HTMLElement): boolean =>
44
+ el.scrollHeight - el.scrollTop - el.clientHeight < thresholdPx,
45
+ [thresholdPx],
46
+ );
47
+
48
+ const onScroll = useCallback(() => {
49
+ const el = containerRef.current;
50
+ if (!el) return;
51
+ wasNearBottomRef.current = isNearBottom(el);
52
+ }, [isNearBottom]);
53
+
54
+ const scrollToBottom = useCallback(() => {
55
+ const el = containerRef.current;
56
+ if (!el) return;
57
+ el.scrollTop = el.scrollHeight;
58
+ wasNearBottomRef.current = true;
59
+ }, []);
60
+
61
+ useEffect(() => {
62
+ if (!enabled) return;
63
+ const el = containerRef.current;
64
+ if (!el) return;
65
+ if (wasNearBottomRef.current) {
66
+ // "instant" by direct scrollTop write — smooth scroll falls
67
+ // behind during streaming and the view drifts.
68
+ el.scrollTop = el.scrollHeight;
69
+ }
70
+ // dep is intentionally the only signal that triggers a follow;
71
+ // it should change whenever the message list grows (length or
72
+ // the last message's content length).
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ }, [dep, enabled]);
75
+
76
+ return { containerRef, onScroll, scrollToBottom };
77
+ }
@@ -0,0 +1,58 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
3
+ import { afterEach, describe, expect, it } 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('marks idle viewers in the expanded list', () => {
54
+ render(<PresenceBadge viewers={[viewer(1, { idle: true }), viewer(2)]} />)
55
+ fireEvent.click(screen.getByRole('button'))
56
+ expect(screen.getByText('idle')).toBeTruthy()
57
+ })
58
+ })
@@ -0,0 +1,105 @@
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
+ const ordered = [...viewers].sort((a, b) => Number(b.self) - Number(a.self))
37
+ const shown = ordered.slice(0, MAX_AVATARS)
38
+ const overflow = ordered.length - shown.length
39
+
40
+ return (
41
+ <div className="relative" ref={rootRef}>
42
+ <button
43
+ type="button"
44
+ onClick={() => setOpen((v) => !v)}
45
+ aria-expanded={open}
46
+ aria-label={`${viewers.length} people viewing this page`}
47
+ className="flex items-center -space-x-2 rounded-full p-0.5 hover:opacity-90"
48
+ >
49
+ {shown.map((v) => {
50
+ const { initials, colorClass } = avatarFor(v.email, v.name)
51
+ return (
52
+ <span
53
+ key={v.email}
54
+ className={`inline-flex h-6 w-6 items-center justify-center rounded-full
55
+ ring-2 ring-card text-[10px] font-semibold text-white ${colorClass}
56
+ ${v.idle ? 'opacity-45' : ''}`}
57
+ >
58
+ {initials}
59
+ </span>
60
+ )
61
+ })}
62
+ {overflow > 0 && (
63
+ <span
64
+ className="inline-flex h-6 w-6 items-center justify-center rounded-full
65
+ bg-muted ring-2 ring-card text-[10px] font-semibold text-muted-foreground"
66
+ >
67
+ +{overflow}
68
+ </span>
69
+ )}
70
+ </button>
71
+
72
+ {open && (
73
+ <div
74
+ className="absolute right-0 z-50 mt-2 w-64 rounded-md border border-border
75
+ bg-card p-1 shadow-md"
76
+ >
77
+ {ordered.map((v) => {
78
+ const { initials, colorClass } = avatarFor(v.email, v.name)
79
+ return (
80
+ <div key={v.email} className="flex items-center gap-2 rounded px-2 py-1.5">
81
+ <span
82
+ className={`inline-flex h-6 w-6 shrink-0 items-center justify-center
83
+ rounded-full text-[10px] font-semibold text-white ${colorClass}
84
+ ${v.idle ? 'opacity-45' : ''}`}
85
+ >
86
+ {initials}
87
+ </span>
88
+ <span className="min-w-0 flex-1">
89
+ <span className="block truncate text-sm text-foreground">
90
+ {v.name || v.email}
91
+ {v.self && <span className="ml-1 text-muted-foreground">(you)</span>}
92
+ </span>
93
+ <span className="block truncate text-xs text-muted-foreground">
94
+ {v.subLocation}
95
+ </span>
96
+ </span>
97
+ {v.idle && <span className="text-[10px] text-muted-foreground">idle</span>}
98
+ </div>
99
+ )
100
+ })}
101
+ </div>
102
+ )}
103
+ </div>
104
+ )
105
+ }
@@ -0,0 +1,42 @@
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
+ })
@@ -0,0 +1,36 @@
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
+ }
@@ -0,0 +1,4 @@
1
+ export { PresenceBadge } from './PresenceBadge'
2
+ export { usePresence, type Viewer, type UsePresenceOptions } from './usePresence'
3
+ export { pageKeyFor, type PageLocation, type RouteRule } from './pageKey'
4
+ export { avatarFor } from './avatar'
@@ -0,0 +1,53 @@
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
+ })
@@ -0,0 +1,37 @@
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
+ }
@@ -0,0 +1,130 @@
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 OPEN = 1
9
+ readyState = 0
10
+ sent: string[] = []
11
+ onopen: (() => void) | null = null
12
+ onmessage: ((e: { data: string }) => void) | null = null
13
+ onclose: (() => void) | null = null
14
+ constructor(public url: string) {
15
+ FakeSocket.last = this
16
+ }
17
+ send(frame: string) {
18
+ this.sent.push(frame)
19
+ }
20
+ close() {
21
+ this.readyState = 3
22
+ }
23
+ open() {
24
+ this.readyState = FakeSocket.OPEN
25
+ this.onopen?.()
26
+ }
27
+ deliver(payload: unknown) {
28
+ this.onmessage?.({ data: JSON.stringify(payload) })
29
+ }
30
+ }
31
+
32
+ beforeEach(() => {
33
+ vi.stubGlobal('WebSocket', FakeSocket as unknown as typeof WebSocket)
34
+ vi.useFakeTimers()
35
+ })
36
+ afterEach(() => {
37
+ vi.useRealTimers()
38
+ vi.unstubAllGlobals()
39
+ FakeSocket.last = null
40
+ })
41
+
42
+ const LOC = { pageKey: 'ace:ws:opp:a/run-001', subLocation: 'run overview' }
43
+
44
+ describe('usePresence', () => {
45
+ it('sends presence.enter once the socket opens', () => {
46
+ renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
47
+ act(() => FakeSocket.last!.open())
48
+ expect(JSON.parse(FakeSocket.last!.sent[0])).toEqual({
49
+ type: 'presence.enter',
50
+ page_key: LOC.pageKey,
51
+ sub_location: LOC.subLocation,
52
+ })
53
+ })
54
+
55
+ it('exposes the roster the server broadcasts', () => {
56
+ const { result } = renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
57
+ act(() => FakeSocket.last!.open())
58
+ act(() =>
59
+ FakeSocket.last!.deliver({
60
+ event: 'presence.roster',
61
+ data: {
62
+ page_key: LOC.pageKey,
63
+ viewers: [{ email: 'a@x.com', name: 'A', sub_location: 'idea-to-pdd', idle: false, self: true }],
64
+ },
65
+ }),
66
+ )
67
+ expect(result.current.viewers).toEqual([
68
+ { email: 'a@x.com', name: 'A', subLocation: 'idea-to-pdd', idle: false, self: true },
69
+ ])
70
+ })
71
+
72
+ it('ignores a roster for a page key it is no longer on', () => {
73
+ const { result } = renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
74
+ act(() => FakeSocket.last!.open())
75
+ act(() =>
76
+ FakeSocket.last!.deliver({
77
+ event: 'presence.roster',
78
+ data: { page_key: 'ace:ws:opp:STALE/run-999', viewers: [
79
+ { email: 'z@x.com', name: 'Z', sub_location: '', idle: false, self: false }] },
80
+ }),
81
+ )
82
+ expect(result.current.viewers).toEqual([])
83
+ })
84
+
85
+ it('re-enters without reconnecting when the location changes', () => {
86
+ const { rerender } = renderHook(
87
+ ({ location }) => usePresence({ url: 'ws://x/ws/presence/', location }),
88
+ { initialProps: { location: LOC } },
89
+ )
90
+ act(() => FakeSocket.last!.open())
91
+ const socket = FakeSocket.last!
92
+ rerender({ location: { pageKey: 'ace:ws:activity', subLocation: 'Activity' } })
93
+ expect(FakeSocket.last).toBe(socket) // same socket, no reconnect
94
+ expect(JSON.parse(socket.sent[socket.sent.length - 1])).toEqual({
95
+ type: 'presence.enter',
96
+ page_key: 'ace:ws:activity',
97
+ sub_location: 'Activity',
98
+ })
99
+ })
100
+
101
+ it('heartbeats every 20 seconds', () => {
102
+ renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
103
+ act(() => FakeSocket.last!.open())
104
+ act(() => void vi.advanceTimersByTime(20_000))
105
+ expect(JSON.parse(FakeSocket.last!.sent[1])).toEqual({ type: 'presence.heartbeat', idle: false })
106
+ })
107
+
108
+ it('clears the roster and sends nothing when there is no location', () => {
109
+ const { result } = renderHook(() =>
110
+ usePresence({ url: 'ws://x/ws/presence/', location: null }),
111
+ )
112
+ expect(result.current.viewers).toEqual([])
113
+ expect(FakeSocket.last).toBeNull()
114
+ })
115
+
116
+ it('swallows a synchronous WebSocket constructor throw and stays on an empty roster', () => {
117
+ class ThrowingSocket {
118
+ constructor() {
119
+ throw new Error('mixed content: refused to connect to insecure WebSocket')
120
+ }
121
+ }
122
+ vi.stubGlobal('WebSocket', ThrowingSocket as unknown as typeof WebSocket)
123
+
124
+ let hook!: ReturnType<typeof renderHook<{ viewers: unknown[] }, void>>
125
+ expect(() => {
126
+ hook = renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
127
+ }).not.toThrow()
128
+ expect(hook.result.current.viewers).toEqual([])
129
+ })
130
+ })