canopy-ui 0.13.0 → 0.14.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/package.json +1 -1
- package/src/chat/ChatPanel.multiplayer.test.tsx +7 -0
- package/src/chat/ChatPanel.tsx +10 -1
- package/src/chat/SendBox.test.tsx +27 -0
- package/src/chat/SendBox.tsx +38 -1
- package/src/chat/TypingRows.test.tsx +33 -0
- package/src/chat/TypingRows.tsx +14 -7
- package/src/chat/drafts.test.ts +42 -0
- package/src/chat/drafts.ts +42 -0
- package/src/chat/index.ts +3 -0
- package/src/chat/protocol.ts +21 -1
- package/src/chat/sessionReducer.test.ts +47 -0
- package/src/chat/sessionReducer.ts +12 -2
- package/src/chat/useSessionSocket.ts +121 -4
- package/src/chat/useSessionSocket.typingVisibility.test.tsx +280 -0
package/package.json
CHANGED
|
@@ -38,6 +38,13 @@ describe("multiplayer composer", () => {
|
|
|
38
38
|
expect(screen.queryByTestId("coedit-banner")).toBeNull();
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
it("shows 'is typing…' with no words when a teammate withholds them", () => {
|
|
42
|
+
render(<ChatPanel {...props} state={state({ peer_drafts: [{ author: { id: 2, name: "Bo B" }, body: "", at: null, typing: true }] })} />);
|
|
43
|
+
const row = screen.getByTestId("typing-row");
|
|
44
|
+
expect(row.textContent).toContain("Bo B");
|
|
45
|
+
expect(row.textContent).toContain("is typing…");
|
|
46
|
+
});
|
|
47
|
+
|
|
41
48
|
it("send stays available while the agent is replying", () => {
|
|
42
49
|
const streaming = { id: "a1", turn_index: 2, role: "assistant" as const, content: {}, plaintext: "working", status: "streaming" as const, error_detail: null, started_at: null, completed_at: null, created_at: "" };
|
|
43
50
|
render(<ChatPanel {...props} state={state({ messages: [streaming] })} />);
|
package/src/chat/ChatPanel.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useMemo, type ReactNode } from "react";
|
|
2
2
|
|
|
3
|
-
import type { SessionState } from "./protocol";
|
|
3
|
+
import type { SessionState, TypingVisibility } from "./protocol";
|
|
4
4
|
import type { RenderMarkdown } from "./MessageItem";
|
|
5
5
|
import { ConnectionStatus } from "./ConnectionStatus";
|
|
6
6
|
import { MessageList } from "./MessageList";
|
|
@@ -49,6 +49,11 @@ export interface ChatPanelProps {
|
|
|
49
49
|
draftPersistKey?: string;
|
|
50
50
|
/** Storage backing `draftPersistKey`; defaults to localStorage. */
|
|
51
51
|
draftStorage?: DraftStorage | null;
|
|
52
|
+
/** This person's chosen typing-visibility mode, and its setter. Both
|
|
53
|
+
* optional; the control renders only when both are given (see SendBox) —
|
|
54
|
+
* a host with no draft sync (a contact) passes neither. */
|
|
55
|
+
typingVisibility?: TypingVisibility;
|
|
56
|
+
onTypingVisibilityChange?: (visibility: TypingVisibility) => void;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
/**
|
|
@@ -76,6 +81,8 @@ export function ChatPanel({
|
|
|
76
81
|
historySlot,
|
|
77
82
|
draftPersistKey,
|
|
78
83
|
draftStorage,
|
|
84
|
+
typingVisibility,
|
|
85
|
+
onTypingVisibilityChange,
|
|
79
86
|
}: ChatPanelProps) {
|
|
80
87
|
// `onDiscard` is part of the public surface (co-edit teardown) even though
|
|
81
88
|
// the default composer doesn't render a discard button. Referenced to keep
|
|
@@ -254,6 +261,8 @@ export function ChatPanel({
|
|
|
254
261
|
onRemoveAttachment={onRemoveAttachment}
|
|
255
262
|
persistKey={draftPersistKey}
|
|
256
263
|
storage={draftStorage}
|
|
264
|
+
typingVisibility={typingVisibility}
|
|
265
|
+
onTypingVisibilityChange={onTypingVisibilityChange}
|
|
257
266
|
/>
|
|
258
267
|
</div>
|
|
259
268
|
);
|
|
@@ -582,3 +582,30 @@ describe("SendBox — draft persistence across unmount", () => {
|
|
|
582
582
|
expect(box().value).toBe("still typeable");
|
|
583
583
|
});
|
|
584
584
|
});
|
|
585
|
+
|
|
586
|
+
describe("SendBox — typing visibility control", () => {
|
|
587
|
+
it("is absent when neither prop is given", () => {
|
|
588
|
+
setup();
|
|
589
|
+
expect(screen.queryByTestId("typing-visibility")).toBeNull();
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("is absent when only the value is given, with no handler", () => {
|
|
593
|
+
setup({ typingVisibility: "live" });
|
|
594
|
+
expect(screen.queryByTestId("typing-visibility")).toBeNull();
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("shows the control when both props are given, and calls the handler on selection", () => {
|
|
598
|
+
const onChange = vi.fn();
|
|
599
|
+
setup({ typingVisibility: "live", onTypingVisibilityChange: onChange });
|
|
600
|
+
const control = screen.getByTestId("typing-visibility") as HTMLSelectElement;
|
|
601
|
+
expect(control.value).toBe("live");
|
|
602
|
+
fireEvent.change(control, { target: { value: "typing" } });
|
|
603
|
+
expect(onChange).toHaveBeenCalledWith("typing");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("has a tooltip explaining what it does", () => {
|
|
607
|
+
setup({ typingVisibility: "hidden", onTypingVisibilityChange: vi.fn() });
|
|
608
|
+
const control = screen.getByTestId("typing-visibility") as HTMLSelectElement;
|
|
609
|
+
expect(control.title.length).toBeGreaterThan(0);
|
|
610
|
+
});
|
|
611
|
+
});
|
package/src/chat/SendBox.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from "react";
|
|
8
8
|
import type React from "react";
|
|
9
9
|
|
|
10
|
-
import type { Draft } from "./protocol";
|
|
10
|
+
import type { Draft, TypingVisibility } from "./protocol";
|
|
11
11
|
import {
|
|
12
12
|
clearStoredDraft,
|
|
13
13
|
defaultDraftStorage,
|
|
@@ -63,6 +63,14 @@ interface Props {
|
|
|
63
63
|
/** Storage backing `persistKey`. Defaults to localStorage; inject a fake in
|
|
64
64
|
* tests, or sessionStorage for per-tab drafts. */
|
|
65
65
|
storage?: DraftStorage | null;
|
|
66
|
+
/** This person's choice of how much of THEIR OWN in-progress message peers
|
|
67
|
+
* get to see: the words live (`live`), just the fact of typing (`typing`),
|
|
68
|
+
* or nothing until send (`hidden`). Both this and `onTypingVisibilityChange`
|
|
69
|
+
* are optional, and the control renders only when BOTH are given — a host
|
|
70
|
+
* that passes neither (a contact's socket, which has no draft sync) gets no
|
|
71
|
+
* control at all rather than a dead one. */
|
|
72
|
+
typingVisibility?: TypingVisibility;
|
|
73
|
+
onTypingVisibilityChange?: (visibility: TypingVisibility) => void;
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
export function SendBox({
|
|
@@ -81,6 +89,8 @@ export function SendBox({
|
|
|
81
89
|
onRemoveAttachment,
|
|
82
90
|
persistKey,
|
|
83
91
|
storage,
|
|
92
|
+
typingVisibility,
|
|
93
|
+
onTypingVisibilityChange,
|
|
84
94
|
}: Props) {
|
|
85
95
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
86
96
|
|
|
@@ -343,6 +353,33 @@ export function SendBox({
|
|
|
343
353
|
{stopState === "requested" ? "stopping…" : "stop"}
|
|
344
354
|
</Button>
|
|
345
355
|
) : null}
|
|
356
|
+
{typingVisibility != null && onTypingVisibilityChange != null && (
|
|
357
|
+
<label className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
358
|
+
<span className="hidden sm:inline">Others see:</span>
|
|
359
|
+
<select
|
|
360
|
+
data-testid="typing-visibility"
|
|
361
|
+
value={typingVisibility}
|
|
362
|
+
title="Choose what others see of your message while you're still typing it."
|
|
363
|
+
onChange={(e) =>
|
|
364
|
+
onTypingVisibilityChange(e.target.value as TypingVisibility)
|
|
365
|
+
}
|
|
366
|
+
className={[
|
|
367
|
+
// bg-background, not bg-transparent: a native <select>'s
|
|
368
|
+
// OPTION list paints on the OS's own background, and a
|
|
369
|
+
// transparent trigger left the closed control's text
|
|
370
|
+
// sitting on whatever was behind it — unreadable in dark
|
|
371
|
+
// mode, where that was the page's own dark-on-dark text.
|
|
372
|
+
"rounded-md border border-input bg-background px-1.5 py-1 text-xs",
|
|
373
|
+
"text-foreground focus-visible:outline-none focus-visible:ring-1",
|
|
374
|
+
"focus-visible:ring-ring",
|
|
375
|
+
].join(" ")}
|
|
376
|
+
>
|
|
377
|
+
<option value="live">My text</option>
|
|
378
|
+
<option value="typing">Typing…</option>
|
|
379
|
+
<option value="hidden">Nothing</option>
|
|
380
|
+
</select>
|
|
381
|
+
</label>
|
|
382
|
+
)}
|
|
346
383
|
<Button
|
|
347
384
|
type="button"
|
|
348
385
|
size="sm"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { TypingRows } from "./TypingRows";
|
|
6
|
+
import type { PeerDraft } from "./protocol";
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
|
|
10
|
+
describe("TypingRows", () => {
|
|
11
|
+
it("renders nothing when nobody is typing", () => {
|
|
12
|
+
const { container } = render(<TypingRows peers={[]} />);
|
|
13
|
+
expect(container.firstChild).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("shows a peer's live words when a body is present", () => {
|
|
17
|
+
const peers: PeerDraft[] = [{ author: { id: 2, name: "Bo B" }, body: "hello there", at: null }];
|
|
18
|
+
render(<TypingRows peers={peers} />);
|
|
19
|
+
const row = screen.getByTestId("typing-row");
|
|
20
|
+
expect(row.textContent).toContain("Bo B");
|
|
21
|
+
expect(row.textContent).toContain("hello there");
|
|
22
|
+
expect(row.textContent).toContain("is typing:");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("renders 'is typing…' with no words when the body is withheld", () => {
|
|
26
|
+
const peers: PeerDraft[] = [{ author: { id: 2, name: "Bo B" }, body: "", at: null, typing: true }];
|
|
27
|
+
render(<TypingRows peers={peers} />);
|
|
28
|
+
const row = screen.getByTestId("typing-row");
|
|
29
|
+
expect(row.textContent).toContain("Bo B");
|
|
30
|
+
expect(row.textContent).toContain("is typing…");
|
|
31
|
+
expect(row.textContent).not.toContain("is typing:");
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/chat/TypingRows.tsx
CHANGED
|
@@ -6,13 +6,20 @@ export function TypingRows({ peers }: { peers: PeerDraft[] }) {
|
|
|
6
6
|
if (peers.length === 0) return null;
|
|
7
7
|
return (
|
|
8
8
|
<ul className="space-y-1 border-t border-border bg-background px-3 py-2" aria-live="polite">
|
|
9
|
-
{peers.map((p) =>
|
|
10
|
-
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
{peers.map((p) =>
|
|
10
|
+
p.body === "" ? (
|
|
11
|
+
<li key={p.author.id} data-testid="typing-row" className="flex min-w-0 gap-2 text-xs">
|
|
12
|
+
<span className="shrink-0 font-medium text-foreground">{p.author.name}</span>
|
|
13
|
+
<span className="shrink-0 text-muted-foreground">is typing…</span>
|
|
14
|
+
</li>
|
|
15
|
+
) : (
|
|
16
|
+
<li key={p.author.id} data-testid="typing-row" className="flex min-w-0 gap-2 text-xs">
|
|
17
|
+
<span className="shrink-0 font-medium text-foreground">{p.author.name}</span>
|
|
18
|
+
<span className="shrink-0 text-muted-foreground">is typing:</span>
|
|
19
|
+
<span className="min-w-0 truncate italic text-foreground-secondary">{p.body}</span>
|
|
20
|
+
</li>
|
|
21
|
+
),
|
|
22
|
+
)}
|
|
16
23
|
</ul>
|
|
17
24
|
);
|
|
18
25
|
}
|
package/src/chat/drafts.test.ts
CHANGED
|
@@ -5,8 +5,10 @@ import {
|
|
|
5
5
|
clearStoredDraft,
|
|
6
6
|
draftStorageKey,
|
|
7
7
|
readStoredDraft,
|
|
8
|
+
readStoredTypingVisibility,
|
|
8
9
|
shouldSyncDraftLive,
|
|
9
10
|
writeStoredDraft,
|
|
11
|
+
writeStoredTypingVisibility,
|
|
10
12
|
} from "./drafts"
|
|
11
13
|
|
|
12
14
|
const NOW = 1_700_000_000_000
|
|
@@ -144,3 +146,43 @@ describe("clearStoredDraft", () => {
|
|
|
144
146
|
expect(readStoredDraft(s, KEY, NOW)).toBeNull()
|
|
145
147
|
})
|
|
146
148
|
})
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Typing-visibility persistence — per browser, not per session.
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
describe("readStoredTypingVisibility / writeStoredTypingVisibility", () => {
|
|
155
|
+
it("defaults to null when nothing is stored", () => {
|
|
156
|
+
expect(readStoredTypingVisibility(fakeStorage())).toBeNull()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("round-trips a written mode", () => {
|
|
160
|
+
const s = fakeStorage()
|
|
161
|
+
writeStoredTypingVisibility(s, "typing")
|
|
162
|
+
expect(readStoredTypingVisibility(s)).toBe("typing")
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("rejects a value that isn't one of the three modes", () => {
|
|
166
|
+
const s = fakeStorage({ "canopy.chat.typingVisibility": "loud" })
|
|
167
|
+
expect(readStoredTypingVisibility(s)).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("is inert without a storage", () => {
|
|
171
|
+
expect(readStoredTypingVisibility(null)).toBeNull()
|
|
172
|
+
expect(() => writeStoredTypingVisibility(null, "hidden")).not.toThrow()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("survives a storage that throws", () => {
|
|
176
|
+
const hostile = {
|
|
177
|
+
getItem: () => {
|
|
178
|
+
throw new Error("SecurityError")
|
|
179
|
+
},
|
|
180
|
+
setItem: () => {
|
|
181
|
+
throw new Error("SecurityError")
|
|
182
|
+
},
|
|
183
|
+
removeItem: () => {},
|
|
184
|
+
}
|
|
185
|
+
expect(readStoredTypingVisibility(hostile)).toBeNull()
|
|
186
|
+
expect(() => writeStoredTypingVisibility(hostile, "live")).not.toThrow()
|
|
187
|
+
})
|
|
188
|
+
})
|
package/src/chat/drafts.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { TypingVisibility } from "./protocol";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Whether keystrokes need to be mirrored to the server AS YOU TYPE.
|
|
3
5
|
*
|
|
@@ -149,3 +151,43 @@ function remove(storage: DraftStorage, key: string): void {
|
|
|
149
151
|
// same as above — best effort
|
|
150
152
|
}
|
|
151
153
|
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Typing-visibility persistence — per BROWSER, not per session: it is a
|
|
157
|
+
// standing choice about how you type, not something that resets when you
|
|
158
|
+
// switch chats. One key, not namespaced by `persistKey` like a draft body.
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
// Exported so a listener can recognise a `storage` event for this key coming
|
|
162
|
+
// from another tab (see useSessionSocket's cross-tab sync).
|
|
163
|
+
export const TYPING_VISIBILITY_STORAGE_KEY = "canopy.chat.typingVisibility";
|
|
164
|
+
|
|
165
|
+
const VALID_VISIBILITIES: readonly TypingVisibility[] = ["live", "typing", "hidden"];
|
|
166
|
+
|
|
167
|
+
/** The persisted mode, or null when there is nothing stored or it is
|
|
168
|
+
* unreadable/invalid — same try/catch-safe shape as `readStoredDraft`. */
|
|
169
|
+
export function readStoredTypingVisibility(storage: DraftStorage | null): TypingVisibility | null {
|
|
170
|
+
if (!storage) return null;
|
|
171
|
+
let raw: string | null = null;
|
|
172
|
+
try {
|
|
173
|
+
raw = storage.getItem(TYPING_VISIBILITY_STORAGE_KEY);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
return (VALID_VISIBILITIES as readonly string[]).includes(raw ?? "")
|
|
178
|
+
? (raw as TypingVisibility)
|
|
179
|
+
: null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function writeStoredTypingVisibility(
|
|
183
|
+
storage: DraftStorage | null,
|
|
184
|
+
visibility: TypingVisibility,
|
|
185
|
+
): void {
|
|
186
|
+
if (!storage) return;
|
|
187
|
+
try {
|
|
188
|
+
storage.setItem(TYPING_VISIBILITY_STORAGE_KEY, visibility);
|
|
189
|
+
} catch {
|
|
190
|
+
// Quota exceeded, or storage disabled mid-session — best effort, same as
|
|
191
|
+
// `writeStoredDraft`.
|
|
192
|
+
}
|
|
193
|
+
}
|
package/src/chat/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type {
|
|
|
17
17
|
SessionMenu,
|
|
18
18
|
SessionState,
|
|
19
19
|
TurnStatus,
|
|
20
|
+
TypingVisibility,
|
|
20
21
|
WsAction,
|
|
21
22
|
WsEvent,
|
|
22
23
|
} from "./protocol";
|
|
@@ -57,6 +58,8 @@ export {
|
|
|
57
58
|
draftStorageKey,
|
|
58
59
|
readStoredDraft,
|
|
59
60
|
writeStoredDraft,
|
|
61
|
+
readStoredTypingVisibility,
|
|
62
|
+
writeStoredTypingVisibility,
|
|
60
63
|
type DraftStorage,
|
|
61
64
|
} from "./drafts";
|
|
62
65
|
|
package/src/chat/protocol.ts
CHANGED
|
@@ -51,6 +51,12 @@ export interface Message {
|
|
|
51
51
|
author?: MessageAuthor | null;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** How much of an in-progress message its author lets OTHERS see: the words
|
|
55
|
+
* live as typed (`live`, today's behaviour), just the fact of typing
|
|
56
|
+
* (`typing`), or nothing at all until it's sent (`hidden`). Chosen per
|
|
57
|
+
* person, per browser (persisted in localStorage) — see useSessionSocket. */
|
|
58
|
+
export type TypingVisibility = "live" | "typing" | "hidden";
|
|
59
|
+
|
|
54
60
|
export interface Draft {
|
|
55
61
|
/** String PK (canopy sends `str(draft.pk)`). */
|
|
56
62
|
id: string;
|
|
@@ -66,16 +72,24 @@ export interface Draft {
|
|
|
66
72
|
* against `current_user_id` for echo-suppression; `last_editor` is kept
|
|
67
73
|
* for servers that don't send it yet. */
|
|
68
74
|
author_id?: number;
|
|
75
|
+
/** This author's chosen mode. Absent on an older server. */
|
|
76
|
+
visibility?: TypingVisibility;
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
/** A peer's live, uncommitted draft — everyone else's box, broadcast as they
|
|
72
80
|
* type. Keyed by author, never by draft id: a client only ever needs "what
|
|
73
81
|
* is this person typing right now", and an empty `body` means they sent,
|
|
74
|
-
* discarded, or cleared it
|
|
82
|
+
* discarded, or cleared it — OR its author is in `typing`/`hidden` mode,
|
|
83
|
+
* where the server withholds the words but still says whether they're
|
|
84
|
+
* typing via `typing`. */
|
|
75
85
|
export interface PeerDraft {
|
|
76
86
|
author: { id: number; name: string };
|
|
77
87
|
body: string;
|
|
78
88
|
at: string | null;
|
|
89
|
+
/** Whether this person is actively typing right now — the one fact the
|
|
90
|
+
* server still tells peers when `body` is withheld. Absent from an older
|
|
91
|
+
* server, where a non-empty `body` was the only signal. */
|
|
92
|
+
typing?: boolean;
|
|
79
93
|
}
|
|
80
94
|
|
|
81
95
|
/** A send that landed on the server but hasn't reached the agent's turn yet
|
|
@@ -263,6 +277,12 @@ export type WsAction =
|
|
|
263
277
|
| { action: "chat.send"; data: Record<string, never> }
|
|
264
278
|
| { action: "chat.stop"; data: { message_id: string } }
|
|
265
279
|
| { action: "draft.update"; data: { version: number; body: string } }
|
|
280
|
+
// The mode is its OWN idempotent frame, not a field on the version-guarded
|
|
281
|
+
// `draft.update` above — applied unconditionally server-side (no version
|
|
282
|
+
// check), so it can never race a keystroke and never itself causes a
|
|
283
|
+
// `draft_version_mismatch`. A `draft.update` carrying a stray `visibility`
|
|
284
|
+
// (an in-flight older 0.14 client) is simply ignored server-side.
|
|
285
|
+
| { action: "draft.set_visibility"; data: { visibility: TypingVisibility } }
|
|
266
286
|
/** @deprecated accepted and ignored by the server since 0.13 — every editor
|
|
267
287
|
* gets their own draft now, so there is no lock left to take over. */
|
|
268
288
|
| { action: "draft.take_over"; data: Record<string, never> }
|
|
@@ -227,6 +227,29 @@ describe("sessionReducer — drafts", () => {
|
|
|
227
227
|
expect(next.active_draft?.version).toBe(3)
|
|
228
228
|
})
|
|
229
229
|
|
|
230
|
+
it("draft.updated carries the server's visibility into active_draft even while keeping the local body", () => {
|
|
231
|
+
// The drift check in useSessionSocket (`current.visibility !==
|
|
232
|
+
// typingVisibilityRef.current`) compares against THIS field — if the
|
|
233
|
+
// echo-suppression branch never updated it, that check would compare
|
|
234
|
+
// against a permanently stale value.
|
|
235
|
+
const prev = makeState({
|
|
236
|
+
current_user_id: 5,
|
|
237
|
+
active_draft: { ...baseDraft, body: "local typing", version: 3, visibility: "live" },
|
|
238
|
+
})
|
|
239
|
+
const next = sessionReducer(prev, {
|
|
240
|
+
event: "draft.updated",
|
|
241
|
+
data: {
|
|
242
|
+
...baseDraft,
|
|
243
|
+
body: "stale server echo",
|
|
244
|
+
last_editor: 5,
|
|
245
|
+
version: 4,
|
|
246
|
+
visibility: "hidden",
|
|
247
|
+
} as Draft,
|
|
248
|
+
} as WsEvent)
|
|
249
|
+
expect(next.active_draft?.body).toBe("local typing")
|
|
250
|
+
expect(next.active_draft?.visibility).toBe("hidden")
|
|
251
|
+
})
|
|
252
|
+
|
|
230
253
|
it("draft.updated accepts the body when another user is editing", () => {
|
|
231
254
|
const prev = makeState({
|
|
232
255
|
current_user_id: 5,
|
|
@@ -746,6 +769,30 @@ describe("per-person drafts", () => {
|
|
|
746
769
|
expect(s.peer_drafts).toEqual([]);
|
|
747
770
|
});
|
|
748
771
|
|
|
772
|
+
it("keeps an empty-body row when the server says the person is still typing", () => {
|
|
773
|
+
// `typing`/`hidden` visibility withholds the words but not the fact of
|
|
774
|
+
// typing — the row must survive rather than being read as "sent/cleared".
|
|
775
|
+
const s = sessionReducer(makeState(), {
|
|
776
|
+
event: "draft.typing",
|
|
777
|
+
data: { author: { id: 2, name: "Bo" }, body: "", at: null, typing: true },
|
|
778
|
+
} as WsEvent);
|
|
779
|
+
expect(s.peer_drafts).toEqual([
|
|
780
|
+
{ author: { id: 2, name: "Bo" }, body: "", at: null, typing: true },
|
|
781
|
+
]);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("removes the row when the body is empty and typing is not true", () => {
|
|
785
|
+
let s = sessionReducer(makeState(), {
|
|
786
|
+
event: "draft.typing",
|
|
787
|
+
data: { author: { id: 2, name: "Bo" }, body: "", at: null, typing: true },
|
|
788
|
+
} as WsEvent);
|
|
789
|
+
s = sessionReducer(s, {
|
|
790
|
+
event: "draft.typing",
|
|
791
|
+
data: { author: { id: 2, name: "Bo" }, body: "", at: null, typing: false },
|
|
792
|
+
} as WsEvent);
|
|
793
|
+
expect(s.peer_drafts).toEqual([]);
|
|
794
|
+
});
|
|
795
|
+
|
|
749
796
|
it("presence.left drops that person's typing row", () => {
|
|
750
797
|
let s = sessionReducer(makeState(), {
|
|
751
798
|
event: "draft.typing",
|
|
@@ -374,6 +374,13 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
374
374
|
version: incoming.version,
|
|
375
375
|
last_editor: incoming.last_editor,
|
|
376
376
|
last_edit_at: incoming.last_edit_at,
|
|
377
|
+
// The server's CURRENT visibility, even while the body itself is
|
|
378
|
+
// kept local — `useSessionSocket`'s drift check compares against
|
|
379
|
+
// this field, and it must reflect what the server actually has,
|
|
380
|
+
// not a value frozen at whatever it was when this draft first
|
|
381
|
+
// loaded. `?? prev.active_draft.visibility` only for an older
|
|
382
|
+
// server that omits the field.
|
|
383
|
+
visibility: incoming.visibility ?? prev.active_draft.visibility,
|
|
377
384
|
},
|
|
378
385
|
};
|
|
379
386
|
}
|
|
@@ -382,9 +389,12 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
382
389
|
|
|
383
390
|
case "draft.typing": {
|
|
384
391
|
// Someone else's box, live. Keyed by author; an empty body means they
|
|
385
|
-
// sent, discarded or cleared it
|
|
392
|
+
// sent, discarded or cleared it — UNLESS `typing` is true, which means
|
|
393
|
+
// the words are simply withheld (typing/hidden visibility) while the
|
|
394
|
+
// person is still mid-thought, so the row is kept rather than removed.
|
|
386
395
|
const rest = (prev.peer_drafts ?? []).filter((d) => d.author.id !== frame.data.author.id);
|
|
387
|
-
|
|
396
|
+
const keep = frame.data.body !== "" || frame.data.typing === true;
|
|
397
|
+
return { ...prev, peer_drafts: keep ? [...rest, frame.data] : rest };
|
|
388
398
|
}
|
|
389
399
|
|
|
390
400
|
case "session.queued":
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import { fromAgui, resetAguiState } from "./agui";
|
|
4
|
-
import type { Draft, Message, SessionState, WsEvent } from "./protocol";
|
|
5
|
-
import {
|
|
4
|
+
import type { Draft, Message, SessionState, TypingVisibility, WsEvent } from "./protocol";
|
|
5
|
+
import {
|
|
6
|
+
TYPING_VISIBILITY_STORAGE_KEY,
|
|
7
|
+
defaultDraftStorage,
|
|
8
|
+
readStoredTypingVisibility,
|
|
9
|
+
shouldSyncDraftLive,
|
|
10
|
+
writeStoredTypingVisibility,
|
|
11
|
+
} from "./drafts";
|
|
6
12
|
import { prependHistory } from "./history";
|
|
7
13
|
import {
|
|
8
14
|
REDUCER_EVENTS,
|
|
@@ -185,6 +191,14 @@ export interface UseSessionSocketResult {
|
|
|
185
191
|
* they're the same send rather than rendering it twice. */
|
|
186
192
|
noteLocalSend: (text: string, clientId?: string) => void;
|
|
187
193
|
lastError: string | null;
|
|
194
|
+
/** This person's chosen mode for how their OWN in-progress message appears
|
|
195
|
+
* to others — persisted per browser (`canopy.chat.typingVisibility`).
|
|
196
|
+
* Defaults to `"live"`, today's behaviour. */
|
|
197
|
+
typingVisibility: TypingVisibility;
|
|
198
|
+
/** Change the mode. When there is a non-empty draft body and live sync is
|
|
199
|
+
* on (somebody else is present), sends one `draft.update` immediately so
|
|
200
|
+
* peers switch right away rather than waiting for the next keystroke. */
|
|
201
|
+
setTypingVisibility: (visibility: TypingVisibility) => void;
|
|
188
202
|
}
|
|
189
203
|
|
|
190
204
|
// Frames the reducer handles: its own list, so the two cannot drift (see
|
|
@@ -211,6 +225,11 @@ export function useSessionSocket({
|
|
|
211
225
|
// this window, so nothing else in the state can express it, and without it
|
|
212
226
|
// the Stop control is unreachable exactly when the turn is stuck.
|
|
213
227
|
const [awaitingReply, setAwaitingReply] = useState(false);
|
|
228
|
+
// Per-browser, not per-session — a standing choice about how you type, read
|
|
229
|
+
// once at mount from the same try/catch-safe storage `SendBox` uses.
|
|
230
|
+
const [typingVisibility, setTypingVisibilityState] = useState<TypingVisibility>(
|
|
231
|
+
() => readStoredTypingVisibility(defaultDraftStorage()) ?? "live",
|
|
232
|
+
);
|
|
214
233
|
|
|
215
234
|
const socketRef = useRef<WebSocket | null>(null);
|
|
216
235
|
const stateRef = useRef<SessionState>(INITIAL_STATE);
|
|
@@ -227,10 +246,21 @@ export function useSessionSocket({
|
|
|
227
246
|
// vocabulary mid-session.
|
|
228
247
|
const protocolRef = useRef(protocol);
|
|
229
248
|
protocolRef.current = protocol;
|
|
249
|
+
// Read wherever the mode needs to be sent or compared, closing over
|
|
250
|
+
// whatever is CURRENT rather than whatever a callback was created with.
|
|
251
|
+
const typingVisibilityRef = useRef(typingVisibility);
|
|
252
|
+
typingVisibilityRef.current = typingVisibility;
|
|
230
253
|
const warnedNativeRef = useRef(false);
|
|
231
254
|
// Control frames that must not be lost across a reconnect (currently
|
|
232
255
|
// only chat.stop). The WS-world analogue of an abortable chat transport.
|
|
233
256
|
const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
|
|
257
|
+
// A mode the person CHOSE while this socket could not carry it (closed, or
|
|
258
|
+
// open but before the first snapshot told us what the server holds). Sent
|
|
259
|
+
// once on the next snapshot, then cleared. Nothing else is ever pushed on
|
|
260
|
+
// connect: a device with nothing stored has only a DEFAULT, and pushing a
|
|
261
|
+
// default would expose words its author hid from another device.
|
|
262
|
+
const pendingVisibilityRef = useRef<TypingVisibility | null>(null);
|
|
263
|
+
const snapshotSeenRef = useRef(false);
|
|
234
264
|
// Sends awaiting the server's receipt: client_id -> {text, timer}.
|
|
235
265
|
const unconfirmedRef = useRef(new Map<string, { text: string; timer: number | null }>());
|
|
236
266
|
const resendRef = useRef(resendOverHttp);
|
|
@@ -248,6 +278,24 @@ export function useSessionSocket({
|
|
|
248
278
|
onUnknownEventRef.current = onUnknownEvent;
|
|
249
279
|
}, [onUnknownEvent]);
|
|
250
280
|
|
|
281
|
+
// Belt-and-braces for the SAME cross-tab sync `draft.updated` adoption
|
|
282
|
+
// covers: a native `storage` event fires in every OTHER tab the instant one
|
|
283
|
+
// tab writes the key (never the writer's own tab), so it catches the case
|
|
284
|
+
// no socket frame can — a tab with no live socket right now.
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (typeof window === "undefined") return;
|
|
287
|
+
const onStorage = (e: StorageEvent) => {
|
|
288
|
+
if (e.key !== TYPING_VISIBILITY_STORAGE_KEY) return;
|
|
289
|
+
const next = readStoredTypingVisibility(defaultDraftStorage());
|
|
290
|
+
if (next != null && next !== typingVisibilityRef.current) {
|
|
291
|
+
typingVisibilityRef.current = next;
|
|
292
|
+
setTypingVisibilityState(next);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
window.addEventListener("storage", onStorage);
|
|
296
|
+
return () => window.removeEventListener("storage", onStorage);
|
|
297
|
+
}, []);
|
|
298
|
+
|
|
251
299
|
const send = useCallback((frame: { action: string; data: unknown }) => {
|
|
252
300
|
const ws = socketRef.current;
|
|
253
301
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
@@ -298,12 +346,50 @@ export function useSessionSocket({
|
|
|
298
346
|
) {
|
|
299
347
|
setAwaitingReply(false);
|
|
300
348
|
}
|
|
349
|
+
// `draft.updated` only ever reaches the AUTHOR's own sockets (see
|
|
350
|
+
// `_broadcast_draft`), so this is always MY draft, echoed — including
|
|
351
|
+
// from a mode change in a DIFFERENT tab of my own, or from THIS tab's own
|
|
352
|
+
// `draft.set_visibility` round-tripping back. Adopted UNCONDITIONALLY:
|
|
353
|
+
// since the mode is now its own idempotent, unversioned frame
|
|
354
|
+
// (`draft.set_visibility`) rather than a field racing keystrokes on
|
|
355
|
+
// `draft.update`, the only thing that ever changes it server-side is an
|
|
356
|
+
// explicit choice — made here, in another tab, or on another device — so
|
|
357
|
+
// last-choice-wins is simply correct, with no downgrade race to guard
|
|
358
|
+
// against.
|
|
359
|
+
if (frame.event === "draft.updated" && frame.data.visibility) {
|
|
360
|
+
if (frame.data.visibility !== typingVisibilityRef.current) {
|
|
361
|
+
typingVisibilityRef.current = frame.data.visibility;
|
|
362
|
+
setTypingVisibilityState(frame.data.visibility);
|
|
363
|
+
writeStoredTypingVisibility(defaultDraftStorage(), frame.data.visibility);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
301
366
|
// The server's receipt for one of OUR sends: stop waiting on it.
|
|
302
367
|
if (frame.event === "draft.committed" && frame.data.client_id) {
|
|
303
368
|
const entry = unconfirmedRef.current.get(frame.data.client_id);
|
|
304
369
|
if (entry?.timer != null) window.clearTimeout(entry.timer);
|
|
305
370
|
unconfirmedRef.current.delete(frame.data.client_id);
|
|
306
371
|
}
|
|
372
|
+
// On a snapshot (connect or reconnect) the SERVER's stored mode is the
|
|
373
|
+
// truth — it is the last explicit choice from any tab or device. The one
|
|
374
|
+
// exception is a choice made HERE while the socket could not carry it
|
|
375
|
+
// (`pendingVisibilityRef`): that is newer, so it is sent now, before any
|
|
376
|
+
// later joiner's snapshot can read the older mode. A local value that was
|
|
377
|
+
// never chosen (nothing stored, a private window, the widget's partitioned
|
|
378
|
+
// storage) is only a default, and is never pushed. No own draft (a viewer
|
|
379
|
+
// or a contact) means there is nothing of ours to set.
|
|
380
|
+
if (frame.event === "session.state") {
|
|
381
|
+
snapshotSeenRef.current = true;
|
|
382
|
+
const ad = frame.data.active_draft;
|
|
383
|
+
const pending = pendingVisibilityRef.current;
|
|
384
|
+
pendingVisibilityRef.current = null;
|
|
385
|
+
if (ad != null && pending != null) {
|
|
386
|
+
send({ action: "draft.set_visibility", data: { visibility: pending } });
|
|
387
|
+
} else if (ad?.visibility && ad.visibility !== typingVisibilityRef.current) {
|
|
388
|
+
typingVisibilityRef.current = ad.visibility;
|
|
389
|
+
setTypingVisibilityState(ad.visibility);
|
|
390
|
+
writeStoredTypingVisibility(defaultDraftStorage(), ad.visibility);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
307
393
|
// Side-effect events: handle BEFORE setState so React strict-mode's
|
|
308
394
|
// double-invocation of the updater doesn't double-fire the effect.
|
|
309
395
|
if (frame.event === "session.title_updated") {
|
|
@@ -318,7 +404,9 @@ export function useSessionSocket({
|
|
|
318
404
|
typeof frame.data.detail === "object"
|
|
319
405
|
) {
|
|
320
406
|
// Clear any pending optimistic body so the user's stale local
|
|
321
|
-
// text doesn't auto-re-send with the new version.
|
|
407
|
+
// text doesn't auto-re-send with the new version. The mode is
|
|
408
|
+
// unaffected by this — it never rides `draft.update`, so a body
|
|
409
|
+
// conflict here has nothing to do with it and needs no recovery.
|
|
322
410
|
pendingDraftBodyRef.current = null;
|
|
323
411
|
if (draftDebounceRef.current != null) {
|
|
324
412
|
window.clearTimeout(draftDebounceRef.current);
|
|
@@ -334,7 +422,7 @@ export function useSessionSocket({
|
|
|
334
422
|
return;
|
|
335
423
|
}
|
|
336
424
|
setState((prev) => sessionReducer(prev, frame));
|
|
337
|
-
}, []);
|
|
425
|
+
}, [send]);
|
|
338
426
|
|
|
339
427
|
const connect = useCallback(() => {
|
|
340
428
|
if (closedByUserRef.current) return;
|
|
@@ -349,6 +437,7 @@ export function useSessionSocket({
|
|
|
349
437
|
socketRef.current = ws;
|
|
350
438
|
|
|
351
439
|
ws.onopen = () => {
|
|
440
|
+
snapshotSeenRef.current = false;
|
|
352
441
|
setConnected(true);
|
|
353
442
|
reconnectAttemptRef.current = 0;
|
|
354
443
|
// Flush any control frames that were queued while the socket was
|
|
@@ -398,6 +487,7 @@ export function useSessionSocket({
|
|
|
398
487
|
};
|
|
399
488
|
|
|
400
489
|
ws.onclose = () => {
|
|
490
|
+
snapshotSeenRef.current = false;
|
|
401
491
|
setConnected(false);
|
|
402
492
|
// A send still waiting for its receipt will not get one on this socket.
|
|
403
493
|
for (const clientId of [...unconfirmedRef.current.keys()]) rescueSend(clientId);
|
|
@@ -515,6 +605,31 @@ export function useSessionSocket({
|
|
|
515
605
|
[send],
|
|
516
606
|
);
|
|
517
607
|
|
|
608
|
+
const setTypingVisibility = useCallback(
|
|
609
|
+
(visibility: TypingVisibility) => {
|
|
610
|
+
setTypingVisibilityState(visibility);
|
|
611
|
+
typingVisibilityRef.current = visibility;
|
|
612
|
+
writeStoredTypingVisibility(defaultDraftStorage(), visibility);
|
|
613
|
+
// The mode is its OWN idempotent frame — applied unconditionally
|
|
614
|
+
// server-side, no version check — so there is no presence gate: a mode
|
|
615
|
+
// change must reach the server even while ALONE, or a peer who joins
|
|
616
|
+
// later reads the old mode straight off their connect snapshot. Until
|
|
617
|
+
// the first snapshot has told us what the server holds (or while the
|
|
618
|
+
// socket is closed) the choice waits in `pendingVisibilityRef` and the
|
|
619
|
+
// snapshot sends it. With no own draft (a viewer or a contact) there is
|
|
620
|
+
// nothing to set, and the server would refuse it anyway.
|
|
621
|
+
const ws = socketRef.current;
|
|
622
|
+
const open = ws != null && ws.readyState === WebSocket.OPEN;
|
|
623
|
+
if (!open || !snapshotSeenRef.current) {
|
|
624
|
+
pendingVisibilityRef.current = visibility;
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (stateRef.current.active_draft == null) return;
|
|
628
|
+
send({ action: "draft.set_visibility", data: { visibility } });
|
|
629
|
+
},
|
|
630
|
+
[send],
|
|
631
|
+
);
|
|
632
|
+
|
|
518
633
|
// @deprecated no-op since 0.13 — the server accepts and ignores
|
|
519
634
|
// `draft.take_over` too (see WsAction), so this is kept only so a host
|
|
520
635
|
// wired to the old prop doesn't need an upgrade-day edit.
|
|
@@ -645,5 +760,7 @@ export function useSessionSocket({
|
|
|
645
760
|
prependMessages,
|
|
646
761
|
noteLocalSend,
|
|
647
762
|
lastError,
|
|
763
|
+
typingVisibility,
|
|
764
|
+
setTypingVisibility,
|
|
648
765
|
};
|
|
649
766
|
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, renderHook } from "@testing-library/react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { useSessionSocket } from "./useSessionSocket";
|
|
6
|
+
|
|
7
|
+
/** Per-person typing visibility: a person chooses how their own in-progress
|
|
8
|
+
* message appears to others (live / typing / hidden). The mode is its OWN
|
|
9
|
+
* idempotent, unversioned frame (`draft.set_visibility`) — never a field on
|
|
10
|
+
* the version-guarded `draft.update` keystroke frame, which is exactly what
|
|
11
|
+
* let a stale keystroke echo race a mode change and downgrade it
|
|
12
|
+
* server-side (canopy-ui#… "hidden->live->hidden" regression). The hook
|
|
13
|
+
* holds the choice, persists it per browser, and applies it unconditionally
|
|
14
|
+
* both ways: every explicit choice reaches the server regardless of
|
|
15
|
+
* presence or body, and a `draft.updated` echo's visibility is adopted
|
|
16
|
+
* unconditionally (only an explicit choice — here, another tab, or another
|
|
17
|
+
* device — ever changes it server-side, so last-choice-wins is simply
|
|
18
|
+
* correct). */
|
|
19
|
+
|
|
20
|
+
class FakeSocket {
|
|
21
|
+
static OPEN = 1;
|
|
22
|
+
static last: FakeSocket | null = null;
|
|
23
|
+
sent: string[] = [];
|
|
24
|
+
onopen: (() => void) | null = null;
|
|
25
|
+
onmessage: ((e: { data: string }) => void) | null = null;
|
|
26
|
+
onclose: (() => void) | null = null;
|
|
27
|
+
onerror: (() => void) | null = null;
|
|
28
|
+
readyState = 1;
|
|
29
|
+
constructor(public url: string) {
|
|
30
|
+
FakeSocket.last = this;
|
|
31
|
+
}
|
|
32
|
+
send(data: string) {
|
|
33
|
+
this.sent.push(data);
|
|
34
|
+
}
|
|
35
|
+
close() {}
|
|
36
|
+
receive(frame: unknown) {
|
|
37
|
+
this.onmessage?.({ data: JSON.stringify(frame) });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DRAFT = { id: "d1", slot: "next", status: "open", body: "", version: 1, last_editor: 0, last_edit_at: "" };
|
|
42
|
+
|
|
43
|
+
function snapshot(presenceIds: number[]) {
|
|
44
|
+
return {
|
|
45
|
+
event: "session.state",
|
|
46
|
+
data: { messages: [], active_draft: DRAFT, participants: [], presence_user_ids: presenceIds, current_user_id: 1 },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A minimal, real Storage-shaped fake — the hook reads/writes it through
|
|
51
|
+
// `defaultDraftStorage()` (which returns `window.localStorage`), so replacing
|
|
52
|
+
// the global is how a remount is proven to persist across.
|
|
53
|
+
function fakeLocalStorage() {
|
|
54
|
+
const map = new Map<string, string>();
|
|
55
|
+
return {
|
|
56
|
+
getItem: (k: string) => map.get(k) ?? null,
|
|
57
|
+
setItem: (k: string, v: string) => void map.set(k, v),
|
|
58
|
+
removeItem: (k: string) => void map.delete(k),
|
|
59
|
+
clear: () => map.clear(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let storage: ReturnType<typeof fakeLocalStorage>;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
vi.useFakeTimers();
|
|
67
|
+
vi.stubGlobal("WebSocket", FakeSocket);
|
|
68
|
+
storage = fakeLocalStorage();
|
|
69
|
+
vi.stubGlobal("localStorage", storage);
|
|
70
|
+
});
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
vi.useRealTimers();
|
|
73
|
+
vi.unstubAllGlobals();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const wsUrl = () => "wss://h/x";
|
|
77
|
+
|
|
78
|
+
function connectedWith(presenceIds: number[]) {
|
|
79
|
+
const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
|
|
80
|
+
act(() => {
|
|
81
|
+
FakeSocket.last!.onopen?.();
|
|
82
|
+
FakeSocket.last!.receive(snapshot(presenceIds));
|
|
83
|
+
});
|
|
84
|
+
return hook;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sentFrames(action: string) {
|
|
88
|
+
return FakeSocket.last!.sent.map((f) => JSON.parse(f)).filter((f) => f.action === action);
|
|
89
|
+
}
|
|
90
|
+
const draftUpdateFrames = () => sentFrames("draft.update");
|
|
91
|
+
const setVisibilityFrames = () => sentFrames("draft.set_visibility");
|
|
92
|
+
|
|
93
|
+
describe("typingVisibility", () => {
|
|
94
|
+
it("defaults to live", () => {
|
|
95
|
+
const hook = connectedWith([1, 2]);
|
|
96
|
+
expect(hook.result.current.typingVisibility).toBe("live");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("persists the mode across a remount", () => {
|
|
100
|
+
const first = connectedWith([1, 2]);
|
|
101
|
+
act(() => first.result.current.setTypingVisibility("hidden"));
|
|
102
|
+
first.unmount();
|
|
103
|
+
|
|
104
|
+
const second = connectedWith([1, 2]);
|
|
105
|
+
expect(second.result.current.typingVisibility).toBe("hidden");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("choosing a mode sends draft.set_visibility, presence or not, body or not", () => {
|
|
109
|
+
// No gate at all: it's one tiny idempotent frame, unconditional
|
|
110
|
+
// server-side too — there is nothing left to gate on.
|
|
111
|
+
const alone = connectedWith([1]);
|
|
112
|
+
act(() => alone.result.current.setTypingVisibility("hidden"));
|
|
113
|
+
expect(setVisibilityFrames()).toEqual([{ action: "draft.set_visibility", data: { visibility: "hidden" } }]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("a keystroke's draft.update never carries a visibility field", () => {
|
|
117
|
+
const hook = connectedWith([1, 2]);
|
|
118
|
+
act(() => hook.result.current.setTypingVisibility("typing"));
|
|
119
|
+
act(() => hook.result.current.updateDraft("hello"));
|
|
120
|
+
act(() => vi.advanceTimersByTime(200));
|
|
121
|
+
const last = draftUpdateFrames().at(-1)!;
|
|
122
|
+
expect(last.data).toEqual({ version: 1, body: "hello" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("adopts a mode carried on my own draft.updated frame (other-tab sync)", () => {
|
|
126
|
+
const hook = connectedWith([1, 2]);
|
|
127
|
+
expect(hook.result.current.typingVisibility).toBe("live");
|
|
128
|
+
act(() =>
|
|
129
|
+
FakeSocket.last!.receive({
|
|
130
|
+
event: "draft.updated",
|
|
131
|
+
data: { ...DRAFT, visibility: "hidden" },
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
expect(hook.result.current.typingVisibility).toBe("hidden");
|
|
135
|
+
expect(storage.getItem("canopy.chat.typingVisibility")).toBe("hidden");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("adopts a LOOSENING carried on my own draft.updated frame too — last choice wins", () => {
|
|
139
|
+
// The mode can only ever change server-side via an explicit choice now
|
|
140
|
+
// (this tab, another tab, or another device), so a looser echo is a real
|
|
141
|
+
// choice made elsewhere, not a stale race — it must be adopted exactly
|
|
142
|
+
// like a tightening one.
|
|
143
|
+
const hook = connectedWith([1, 2]);
|
|
144
|
+
act(() => hook.result.current.setTypingVisibility("hidden"));
|
|
145
|
+
act(() =>
|
|
146
|
+
FakeSocket.last!.receive({
|
|
147
|
+
event: "draft.updated",
|
|
148
|
+
data: { ...DRAFT, visibility: "live" },
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
expect(hook.result.current.typingVisibility).toBe("live");
|
|
152
|
+
expect(storage.getItem("canopy.chat.typingVisibility")).toBe("live");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// -- the connect snapshot: the SERVER's stored mode is the truth --
|
|
156
|
+
//
|
|
157
|
+
// A device with nothing stored (fresh phone, private window, blocked or
|
|
158
|
+
// partitioned storage — the embedded widget in a third-party iframe) only
|
|
159
|
+
// has a DEFAULT, and a default is not a choice: pushing it would expose
|
|
160
|
+
// words its author hid from another device. Only a choice made while this
|
|
161
|
+
// socket could not carry it (pending) is sent on connect.
|
|
162
|
+
|
|
163
|
+
function ownSnapshot(activeDraft: unknown) {
|
|
164
|
+
return {
|
|
165
|
+
event: "session.state",
|
|
166
|
+
data: {
|
|
167
|
+
messages: [],
|
|
168
|
+
active_draft: activeDraft,
|
|
169
|
+
participants: [],
|
|
170
|
+
presence_user_ids: [1, 2],
|
|
171
|
+
current_user_id: 1,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
it("a fresh device adopts the server's mode off the snapshot and sends nothing", () => {
|
|
177
|
+
// Nothing stored: the local value is only the "live" default.
|
|
178
|
+
const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
|
|
179
|
+
act(() => {
|
|
180
|
+
FakeSocket.last!.onopen?.();
|
|
181
|
+
FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, body: "x", visibility: "hidden" }));
|
|
182
|
+
});
|
|
183
|
+
expect(setVisibilityFrames()).toEqual([]);
|
|
184
|
+
expect(hook.result.current.typingVisibility).toBe("hidden");
|
|
185
|
+
expect(storage.getItem("canopy.chat.typingVisibility")).toBe("hidden");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("a stale stored mode yields to the server's on connect, sending nothing", () => {
|
|
189
|
+
storage.setItem("canopy.chat.typingVisibility", "live");
|
|
190
|
+
const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
|
|
191
|
+
act(() => {
|
|
192
|
+
FakeSocket.last!.onopen?.();
|
|
193
|
+
FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, visibility: "hidden" }));
|
|
194
|
+
});
|
|
195
|
+
expect(setVisibilityFrames()).toEqual([]);
|
|
196
|
+
expect(hook.result.current.typingVisibility).toBe("hidden");
|
|
197
|
+
expect(storage.getItem("canopy.chat.typingVisibility")).toBe("hidden");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("a choice made while the socket was closed is sent once on the snapshot, then cleared", () => {
|
|
201
|
+
const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
|
|
202
|
+
FakeSocket.last!.readyState = 0; // CONNECTING
|
|
203
|
+
act(() => hook.result.current.setTypingVisibility("hidden"));
|
|
204
|
+
expect(setVisibilityFrames()).toEqual([]);
|
|
205
|
+
act(() => {
|
|
206
|
+
FakeSocket.last!.readyState = 1;
|
|
207
|
+
FakeSocket.last!.onopen?.();
|
|
208
|
+
FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, visibility: "live" }));
|
|
209
|
+
});
|
|
210
|
+
expect(setVisibilityFrames()).toEqual([
|
|
211
|
+
{ action: "draft.set_visibility", data: { visibility: "hidden" } },
|
|
212
|
+
]);
|
|
213
|
+
expect(hook.result.current.typingVisibility).toBe("hidden");
|
|
214
|
+
// Pending is spent: a later snapshot (a reconnect) sends nothing more,
|
|
215
|
+
// and a server mode that disagrees then is simply adopted.
|
|
216
|
+
act(() => FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, visibility: "hidden" })));
|
|
217
|
+
expect(setVisibilityFrames()).toHaveLength(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("a choice made after open but before the first snapshot is pending too", () => {
|
|
221
|
+
const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
|
|
222
|
+
act(() => FakeSocket.last!.onopen?.());
|
|
223
|
+
act(() => hook.result.current.setTypingVisibility("typing"));
|
|
224
|
+
expect(setVisibilityFrames()).toEqual([]);
|
|
225
|
+
act(() => FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, visibility: "hidden" })));
|
|
226
|
+
expect(setVisibilityFrames()).toEqual([
|
|
227
|
+
{ action: "draft.set_visibility", data: { visibility: "typing" } },
|
|
228
|
+
]);
|
|
229
|
+
expect(hook.result.current.typingVisibility).toBe("typing");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("a choice made while connected is sent immediately and leaves nothing pending", () => {
|
|
233
|
+
const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
|
|
234
|
+
act(() => {
|
|
235
|
+
FakeSocket.last!.onopen?.();
|
|
236
|
+
FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, visibility: "live" }));
|
|
237
|
+
});
|
|
238
|
+
act(() => hook.result.current.setTypingVisibility("hidden"));
|
|
239
|
+
expect(setVisibilityFrames()).toHaveLength(1);
|
|
240
|
+
act(() => FakeSocket.last!.receive(ownSnapshot({ ...DRAFT, visibility: "hidden" })));
|
|
241
|
+
expect(setVisibilityFrames()).toHaveLength(1);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("a viewer or contact (no own draft) never sends set_visibility", () => {
|
|
245
|
+
const hook = renderHook(() => useSessionSocket({ sessionId: "s1", wsUrl }));
|
|
246
|
+
FakeSocket.last!.readyState = 0;
|
|
247
|
+
act(() => hook.result.current.setTypingVisibility("hidden")); // pending
|
|
248
|
+
act(() => {
|
|
249
|
+
FakeSocket.last!.readyState = 1;
|
|
250
|
+
FakeSocket.last!.onopen?.();
|
|
251
|
+
FakeSocket.last!.receive(ownSnapshot(null));
|
|
252
|
+
});
|
|
253
|
+
act(() => hook.result.current.setTypingVisibility("typing")); // while open
|
|
254
|
+
expect(setVisibilityFrames()).toEqual([]);
|
|
255
|
+
expect(hook.result.current.typingVisibility).toBe("typing");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("a draft_version_mismatch never touches the mode", () => {
|
|
259
|
+
const hook = connectedWith([1, 2]);
|
|
260
|
+
act(() => hook.result.current.setTypingVisibility("hidden"));
|
|
261
|
+
act(() => hook.result.current.updateDraft("secret"));
|
|
262
|
+
act(() => vi.advanceTimersByTime(200));
|
|
263
|
+
|
|
264
|
+
act(() =>
|
|
265
|
+
FakeSocket.last!.receive({
|
|
266
|
+
event: "session.error",
|
|
267
|
+
data: {
|
|
268
|
+
code: "draft_version_mismatch",
|
|
269
|
+
message: "Draft changed since your last edit.",
|
|
270
|
+
detail: { current_version: 2, current_body: "secret" },
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
// No automatic resend of anything mode-related — a body conflict has
|
|
276
|
+
// nothing to do with the mode any more.
|
|
277
|
+
expect(hook.result.current.typingVisibility).toBe("hidden");
|
|
278
|
+
expect(setVisibilityFrames()).toHaveLength(1); // only the explicit choice above
|
|
279
|
+
});
|
|
280
|
+
});
|