@jupyter/chat 0.24.1 → 0.25.0-alpha.1
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/lib/__tests__/writers.spec.d.ts +1 -0
- package/lib/__tests__/writers.spec.js +61 -0
- package/lib/__tests__/writing-indicator.spec.d.ts +1 -0
- package/lib/__tests__/writing-indicator.spec.js +58 -0
- package/lib/components/writing-indicator.js +29 -3
- package/lib/model.d.ts +56 -0
- package/lib/model.js +96 -10
- package/package.json +1 -1
- package/src/__tests__/writers.spec.ts +75 -0
- package/src/__tests__/writing-indicator.spec.tsx +76 -0
- package/src/components/writing-indicator.tsx +35 -7
- package/src/model.ts +163 -12
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
import { MockChatModel } from './mocks';
|
|
6
|
+
const userA = { username: 'a', name: 'Alice', display_name: 'Alice' };
|
|
7
|
+
const userB = { username: 'b', name: 'Bob', display_name: 'Bob' };
|
|
8
|
+
describe('writers state', () => {
|
|
9
|
+
let model;
|
|
10
|
+
let changes;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
var _a;
|
|
13
|
+
model = new MockChatModel();
|
|
14
|
+
changes = [];
|
|
15
|
+
(_a = model.writersChanged) === null || _a === void 0 ? void 0 : _a.connect((_, writers) => changes.push(writers));
|
|
16
|
+
});
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
model.dispose();
|
|
19
|
+
});
|
|
20
|
+
it('adds and removes a writer per user', () => {
|
|
21
|
+
model.setWritingStatus(userA);
|
|
22
|
+
expect(model.writers.map(w => w.user.username)).toEqual(['a']);
|
|
23
|
+
model.setWritingStatus(userB);
|
|
24
|
+
expect(model.writers.map(w => w.user.username).sort()).toEqual(['a', 'b']);
|
|
25
|
+
model.clearWritingStatus(userA);
|
|
26
|
+
expect(model.writers.map(w => w.user.username)).toEqual(['b']);
|
|
27
|
+
});
|
|
28
|
+
it('carries a custom typingIndicator', () => {
|
|
29
|
+
model.setWritingStatus(userA, { typingIndicator: 'is running ripgrep' });
|
|
30
|
+
expect(model.writers[0].typingIndicator).toBe('is running ripgrep');
|
|
31
|
+
});
|
|
32
|
+
it('does not re-emit when the status is unchanged', () => {
|
|
33
|
+
model.setWritingStatus(userA, { typingIndicator: 'x' });
|
|
34
|
+
const count = changes.length;
|
|
35
|
+
model.setWritingStatus(userA, { typingIndicator: 'x' });
|
|
36
|
+
expect(changes.length).toBe(count);
|
|
37
|
+
});
|
|
38
|
+
it('auto-clears a writer after the timeout unless refreshed', () => {
|
|
39
|
+
jest.useFakeTimers();
|
|
40
|
+
try {
|
|
41
|
+
model.setWritingStatus(userA, undefined, 2000);
|
|
42
|
+
expect(model.writers).toHaveLength(1);
|
|
43
|
+
// Refresh before expiry keeps it alive.
|
|
44
|
+
jest.advanceTimersByTime(1500);
|
|
45
|
+
model.setWritingStatus(userA, undefined, 2000);
|
|
46
|
+
jest.advanceTimersByTime(1500);
|
|
47
|
+
expect(model.writers).toHaveLength(1);
|
|
48
|
+
// No refresh -> auto-cleared after the timeout (the WS anti-stuck path).
|
|
49
|
+
jest.advanceTimersByTime(2000);
|
|
50
|
+
expect(model.writers).toHaveLength(0);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
jest.useRealTimers();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
it('updateWriters replaces the whole list', () => {
|
|
57
|
+
model.setWritingStatus(userA);
|
|
58
|
+
model.updateWriters([{ user: userB }]);
|
|
59
|
+
expect(model.writers.map(w => w.user.username)).toEqual(['b']);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
import React, { act } from 'react';
|
|
6
|
+
import { createRoot } from 'react-dom/client';
|
|
7
|
+
// React 18 asks test environments to declare themselves, otherwise every
|
|
8
|
+
// `act` call warns.
|
|
9
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
import { WritingIndicator } from '../components/writing-indicator';
|
|
11
|
+
const alice = { username: 'a', name: 'Alice', display_name: 'Alice' };
|
|
12
|
+
const writer = (user, typingIndicator) => ({ user, typingIndicator });
|
|
13
|
+
describe('WritingIndicator accessibility', () => {
|
|
14
|
+
let container;
|
|
15
|
+
let root;
|
|
16
|
+
const render = (writers) => {
|
|
17
|
+
act(() => {
|
|
18
|
+
root.render(React.createElement(WritingIndicator, { writers: writers }));
|
|
19
|
+
});
|
|
20
|
+
return container.querySelector('.jp-chat-writers');
|
|
21
|
+
};
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
container = document.createElement('div');
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
root = createRoot(container);
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
act(() => root.unmount());
|
|
29
|
+
container.remove();
|
|
30
|
+
});
|
|
31
|
+
it('is a polite live region, so a reply on its way is announced', () => {
|
|
32
|
+
// The indicator is the only signal that someone is replying. Without a
|
|
33
|
+
// live region it is visible text that assistive technology never speaks.
|
|
34
|
+
const el = render([]);
|
|
35
|
+
expect(el.getAttribute('role')).toBe('status');
|
|
36
|
+
expect(el.getAttribute('aria-live')).toBe('polite');
|
|
37
|
+
});
|
|
38
|
+
it('reads the whole phrase rather than a fragment of it', () => {
|
|
39
|
+
// Without aria-atomic, a change to part of the text can be announced on
|
|
40
|
+
// its own, so a reader hears a bare name with no context.
|
|
41
|
+
expect(render([]).getAttribute('aria-atomic')).toBe('true');
|
|
42
|
+
});
|
|
43
|
+
it('announces who is writing', () => {
|
|
44
|
+
expect(render([writer(alice)]).textContent).toContain('Alice is typing');
|
|
45
|
+
});
|
|
46
|
+
it('announces a custom indicator, not just a generic one', () => {
|
|
47
|
+
const el = render([writer(alice, 'is running `ripgrep`')]);
|
|
48
|
+
expect(el.textContent).toContain('Alice is running `ripgrep`');
|
|
49
|
+
});
|
|
50
|
+
it('says nothing when nobody is writing', () => {
|
|
51
|
+
var _a;
|
|
52
|
+
// The container is always rendered to reserve space, and holds a
|
|
53
|
+
// non-breaking space as a placeholder. That placeholder must not be
|
|
54
|
+
// announced as if it were a message.
|
|
55
|
+
const text = ((_a = render([]).textContent) !== null && _a !== void 0 ? _a : '').replace(/ /g, '').trim();
|
|
56
|
+
expect(text).toBe('');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -17,10 +17,26 @@ function formatWritersText(writers, trans) {
|
|
|
17
17
|
if (writers.length === 0) {
|
|
18
18
|
return '';
|
|
19
19
|
}
|
|
20
|
-
const
|
|
20
|
+
const nameOf = (w) => {
|
|
21
21
|
var _a, _b, _c;
|
|
22
22
|
return (_c = (_b = (_a = w.user.display_name) !== null && _a !== void 0 ? _a : w.user.name) !== null && _b !== void 0 ? _b : w.user.username) !== null && _c !== void 0 ? _c : trans.__('Unknown');
|
|
23
|
-
}
|
|
23
|
+
};
|
|
24
|
+
// When any writer supplies a custom typing indicator, render per-writer
|
|
25
|
+
// phrases ("<name> <indicator>"), so e.g. "Jupyternaut is running `ripgrep`".
|
|
26
|
+
if (writers.some(w => w.typingIndicator)) {
|
|
27
|
+
const phrases = writers.map(w => { var _a; return `${nameOf(w)} ${(_a = w.typingIndicator) !== null && _a !== void 0 ? _a : trans.__('is typing...')}`; });
|
|
28
|
+
if (phrases.length === 1) {
|
|
29
|
+
return phrases[0];
|
|
30
|
+
}
|
|
31
|
+
else if (phrases.length === 2) {
|
|
32
|
+
return trans.__('%1 and %2', phrases[0], phrases[1]);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const allButLast = phrases.slice(0, -1).join(', ');
|
|
36
|
+
return trans.__('%1, and %2', allButLast, phrases[phrases.length - 1]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const names = writers.map(nameOf);
|
|
24
40
|
if (names.length === 1) {
|
|
25
41
|
return trans.__('%1 is typing...', names[0]);
|
|
26
42
|
}
|
|
@@ -41,7 +57,17 @@ export function WritingIndicator(props) {
|
|
|
41
57
|
const trans = useTranslator();
|
|
42
58
|
// Always render the container to reserve space, even if no writers
|
|
43
59
|
const writersText = writers.length > 0 ? formatWritersText(writers, trans) : '';
|
|
44
|
-
return (React.createElement(Box, { className: WRITERS_ELEMENT_CLASSNAME,
|
|
60
|
+
return (React.createElement(Box, { className: WRITERS_ELEMENT_CLASSNAME,
|
|
61
|
+
// The indicator already says something useful, "Alice is typing..." or
|
|
62
|
+
// "Jupyternaut is running `ripgrep`", but only on screen. Announcing it
|
|
63
|
+
// politely means a screen reader user learns a reply is coming without
|
|
64
|
+
// being interrupted mid-sentence.
|
|
65
|
+
//
|
|
66
|
+
// The region is the container rather than the text, so it is present in
|
|
67
|
+
// the accessibility tree before a writer appears and the change is
|
|
68
|
+
// announced. `aria-atomic` keeps the phrase together: without it a name
|
|
69
|
+
// change alone can be read out on its own, stripped of its context.
|
|
70
|
+
role: "status", "aria-live": "polite", "aria-atomic": "true", sx: {
|
|
45
71
|
...props.sx,
|
|
46
72
|
minHeight: '16px'
|
|
47
73
|
} },
|
package/lib/model.d.ts
CHANGED
|
@@ -160,6 +160,27 @@ export interface IChatModel extends IDisposable {
|
|
|
160
160
|
* Update the current writers list.
|
|
161
161
|
*/
|
|
162
162
|
updateWriters(writers: IChatModel.IWriter[]): void;
|
|
163
|
+
/**
|
|
164
|
+
* Mark a user as currently writing (add or update their writer entry).
|
|
165
|
+
*
|
|
166
|
+
* @param user - the writing user.
|
|
167
|
+
* @param status - optional writing status (messageID, custom typingIndicator).
|
|
168
|
+
* @param timeout - optional auto-clear delay in ms. When set, the user is
|
|
169
|
+
* automatically cleared after `timeout` unless refreshed by another call.
|
|
170
|
+
* Used by transports without their own liveness signal (e.g. WebSocket) to
|
|
171
|
+
* avoid a stuck "is typing" if a clear message is never received.
|
|
172
|
+
*/
|
|
173
|
+
setWritingStatus(user: IUser, status?: IChatModel.IWritingStatus, timeout?: number): void;
|
|
174
|
+
/**
|
|
175
|
+
* Remove a user from the writers list.
|
|
176
|
+
*/
|
|
177
|
+
clearWritingStatus(user: IUser): void;
|
|
178
|
+
/**
|
|
179
|
+
* Broadcast the *current user's* writing status to other clients, or clear it
|
|
180
|
+
* with `null`. Implemented per transport (RTC sets awareness; WebSocket sends
|
|
181
|
+
* a periodic writing frame). Default implementation is a no-op.
|
|
182
|
+
*/
|
|
183
|
+
broadcastWritingStatus(status: IChatModel.IWritingStatus | null): void;
|
|
163
184
|
/**
|
|
164
185
|
* Create the chat context that will be passed to the input model.
|
|
165
186
|
*/
|
|
@@ -332,6 +353,22 @@ export declare abstract class AbstractChatModel implements IChatModel {
|
|
|
332
353
|
* This implementation only propagate the list via a signal.
|
|
333
354
|
*/
|
|
334
355
|
updateWriters(writers: IChatModel.IWriter[]): void;
|
|
356
|
+
/**
|
|
357
|
+
* Mark a user as currently writing. See IChatModel.setWritingStatus.
|
|
358
|
+
*/
|
|
359
|
+
setWritingStatus(user: IUser, status?: IChatModel.IWritingStatus, timeout?: number): void;
|
|
360
|
+
/**
|
|
361
|
+
* Remove a user from the writers list. See IChatModel.clearWritingStatus.
|
|
362
|
+
*/
|
|
363
|
+
clearWritingStatus(user: IUser): void;
|
|
364
|
+
/**
|
|
365
|
+
* Broadcast the current user's writing status. Default no-op; transports
|
|
366
|
+
* override this (RTC awareness, WebSocket frame).
|
|
367
|
+
*/
|
|
368
|
+
broadcastWritingStatus(_status: IChatModel.IWritingStatus | null): void;
|
|
369
|
+
private _removeWriter;
|
|
370
|
+
private _clearWriterTimer;
|
|
371
|
+
private _clearAllWriterTimers;
|
|
335
372
|
/**
|
|
336
373
|
* Create the chat context that will be passed to the input model.
|
|
337
374
|
*/
|
|
@@ -382,6 +419,7 @@ export declare abstract class AbstractChatModel implements IChatModel {
|
|
|
382
419
|
private _documentManager;
|
|
383
420
|
private _notificationId;
|
|
384
421
|
private _writers;
|
|
422
|
+
private _writerTimers;
|
|
385
423
|
private _messageEditions;
|
|
386
424
|
private _messagesUpdated;
|
|
387
425
|
private _messageChanged;
|
|
@@ -453,6 +491,24 @@ export declare namespace IChatModel {
|
|
|
453
491
|
* The message ID (optional)
|
|
454
492
|
*/
|
|
455
493
|
messageID?: string;
|
|
494
|
+
/**
|
|
495
|
+
* Custom status text to display instead of the default "is typing…",
|
|
496
|
+
* e.g. "is running `ripgrep …`". Falls back to the default when unset.
|
|
497
|
+
*/
|
|
498
|
+
typingIndicator?: string;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* The writing status broadcast by (or on behalf of) a user.
|
|
502
|
+
*/
|
|
503
|
+
interface IWritingStatus {
|
|
504
|
+
/**
|
|
505
|
+
* The ID of the message being edited, if any.
|
|
506
|
+
*/
|
|
507
|
+
messageID?: string;
|
|
508
|
+
/**
|
|
509
|
+
* Optional custom typing-indicator text (see IWriter.typingIndicator).
|
|
510
|
+
*/
|
|
511
|
+
typingIndicator?: string;
|
|
456
512
|
}
|
|
457
513
|
}
|
|
458
514
|
/**
|
package/lib/model.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
5
|
import { nullTranslator } from '@jupyterlab/translation';
|
|
6
|
-
import { ArrayExt } from '@lumino/algorithm';
|
|
7
6
|
import { PromiseDelegate } from '@lumino/coreutils';
|
|
8
7
|
import { Signal } from '@lumino/signaling';
|
|
9
8
|
import { TRANSLATION_DOMAIN } from './context';
|
|
@@ -30,7 +29,8 @@ export class AbstractChatModel {
|
|
|
30
29
|
this._disposed = new Signal(this);
|
|
31
30
|
this._isDisposed = false;
|
|
32
31
|
this._notificationId = null;
|
|
33
|
-
this._writers =
|
|
32
|
+
this._writers = new Map();
|
|
33
|
+
this._writerTimers = new Map();
|
|
34
34
|
this._messageEditions = new Map();
|
|
35
35
|
this._messagesUpdated = new Signal(this);
|
|
36
36
|
this._messageChanged = new Signal(this);
|
|
@@ -114,7 +114,7 @@ export class AbstractChatModel {
|
|
|
114
114
|
* The current writer list.
|
|
115
115
|
*/
|
|
116
116
|
get writers() {
|
|
117
|
-
return this._writers;
|
|
117
|
+
return [...this._writers.values()];
|
|
118
118
|
}
|
|
119
119
|
/**
|
|
120
120
|
* Get the active cell manager.
|
|
@@ -381,16 +381,70 @@ export class AbstractChatModel {
|
|
|
381
381
|
* This implementation only propagate the list via a signal.
|
|
382
382
|
*/
|
|
383
383
|
updateWriters(writers) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
384
|
+
// Reconcile the writers map with the provided snapshot (used by
|
|
385
|
+
// snapshot-style transports such as RTC awareness). Any auto-clear timers
|
|
386
|
+
// are dropped, since the snapshot is authoritative.
|
|
387
|
+
this._clearAllWriterTimers();
|
|
388
|
+
const next = new Map();
|
|
389
|
+
for (const writer of writers) {
|
|
390
|
+
next.set(writer.user.username, writer);
|
|
391
|
+
}
|
|
392
|
+
if (!Private.writersEqual(this._writers, next)) {
|
|
393
|
+
this._writers = next;
|
|
394
|
+
this._writersChanged.emit(this.writers);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Mark a user as currently writing. See IChatModel.setWritingStatus.
|
|
399
|
+
*/
|
|
400
|
+
setWritingStatus(user, status, timeout) {
|
|
401
|
+
const writer = {
|
|
402
|
+
user,
|
|
403
|
+
messageID: status === null || status === void 0 ? void 0 : status.messageID,
|
|
404
|
+
typingIndicator: status === null || status === void 0 ? void 0 : status.typingIndicator
|
|
388
405
|
};
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
this.
|
|
406
|
+
this._clearWriterTimer(user.username);
|
|
407
|
+
if (timeout !== undefined) {
|
|
408
|
+
this._writerTimers.set(user.username, window.setTimeout(() => this._removeWriter(user.username), timeout));
|
|
409
|
+
}
|
|
410
|
+
const previous = this._writers.get(user.username);
|
|
411
|
+
this._writers.set(user.username, writer);
|
|
412
|
+
if (!previous || !Private.writerEqual(previous, writer)) {
|
|
413
|
+
this._writersChanged.emit(this.writers);
|
|
392
414
|
}
|
|
393
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Remove a user from the writers list. See IChatModel.clearWritingStatus.
|
|
418
|
+
*/
|
|
419
|
+
clearWritingStatus(user) {
|
|
420
|
+
this._removeWriter(user.username);
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Broadcast the current user's writing status. Default no-op; transports
|
|
424
|
+
* override this (RTC awareness, WebSocket frame).
|
|
425
|
+
*/
|
|
426
|
+
broadcastWritingStatus(_status) {
|
|
427
|
+
// no-op by default
|
|
428
|
+
}
|
|
429
|
+
_removeWriter(username) {
|
|
430
|
+
this._clearWriterTimer(username);
|
|
431
|
+
if (this._writers.delete(username)) {
|
|
432
|
+
this._writersChanged.emit(this.writers);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
_clearWriterTimer(username) {
|
|
436
|
+
const timer = this._writerTimers.get(username);
|
|
437
|
+
if (timer !== undefined) {
|
|
438
|
+
window.clearTimeout(timer);
|
|
439
|
+
this._writerTimers.delete(username);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
_clearAllWriterTimers() {
|
|
443
|
+
for (const timer of this._writerTimers.values()) {
|
|
444
|
+
window.clearTimeout(timer);
|
|
445
|
+
}
|
|
446
|
+
this._writerTimers.clear();
|
|
447
|
+
}
|
|
394
448
|
/**
|
|
395
449
|
* Get the input model of the edited message, given its id.
|
|
396
450
|
*/
|
|
@@ -492,3 +546,35 @@ export class AbstractChatContext {
|
|
|
492
546
|
return this._model.awareness;
|
|
493
547
|
}
|
|
494
548
|
}
|
|
549
|
+
/**
|
|
550
|
+
* A namespace for private functionality.
|
|
551
|
+
*/
|
|
552
|
+
var Private;
|
|
553
|
+
(function (Private) {
|
|
554
|
+
/**
|
|
555
|
+
* Whether two writers are equivalent for change-detection purposes.
|
|
556
|
+
*/
|
|
557
|
+
function writerEqual(a, b) {
|
|
558
|
+
return (a.user.username === b.user.username &&
|
|
559
|
+
a.user.display_name === b.user.display_name &&
|
|
560
|
+
a.messageID === b.messageID &&
|
|
561
|
+
a.typingIndicator === b.typingIndicator);
|
|
562
|
+
}
|
|
563
|
+
Private.writerEqual = writerEqual;
|
|
564
|
+
/**
|
|
565
|
+
* Whether two writer maps are equivalent.
|
|
566
|
+
*/
|
|
567
|
+
function writersEqual(a, b) {
|
|
568
|
+
if (a.size !== b.size) {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
for (const [username, writer] of a) {
|
|
572
|
+
const other = b.get(username);
|
|
573
|
+
if (!other || !writerEqual(writer, other)) {
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
Private.writersEqual = writersEqual;
|
|
580
|
+
})(Private || (Private = {}));
|
package/package.json
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { IChatModel } from '../model';
|
|
7
|
+
import { IUser } from '../types';
|
|
8
|
+
import { MockChatModel } from './mocks';
|
|
9
|
+
|
|
10
|
+
const userA: IUser = { username: 'a', name: 'Alice', display_name: 'Alice' };
|
|
11
|
+
const userB: IUser = { username: 'b', name: 'Bob', display_name: 'Bob' };
|
|
12
|
+
|
|
13
|
+
describe('writers state', () => {
|
|
14
|
+
let model: MockChatModel;
|
|
15
|
+
let changes: IChatModel.IWriter[][];
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
model = new MockChatModel();
|
|
19
|
+
changes = [];
|
|
20
|
+
model.writersChanged?.connect((_, writers) => changes.push(writers));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
model.dispose();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('adds and removes a writer per user', () => {
|
|
28
|
+
model.setWritingStatus(userA);
|
|
29
|
+
expect(model.writers.map(w => w.user.username)).toEqual(['a']);
|
|
30
|
+
|
|
31
|
+
model.setWritingStatus(userB);
|
|
32
|
+
expect(model.writers.map(w => w.user.username).sort()).toEqual(['a', 'b']);
|
|
33
|
+
|
|
34
|
+
model.clearWritingStatus(userA);
|
|
35
|
+
expect(model.writers.map(w => w.user.username)).toEqual(['b']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('carries a custom typingIndicator', () => {
|
|
39
|
+
model.setWritingStatus(userA, { typingIndicator: 'is running ripgrep' });
|
|
40
|
+
expect(model.writers[0].typingIndicator).toBe('is running ripgrep');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not re-emit when the status is unchanged', () => {
|
|
44
|
+
model.setWritingStatus(userA, { typingIndicator: 'x' });
|
|
45
|
+
const count = changes.length;
|
|
46
|
+
model.setWritingStatus(userA, { typingIndicator: 'x' });
|
|
47
|
+
expect(changes.length).toBe(count);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('auto-clears a writer after the timeout unless refreshed', () => {
|
|
51
|
+
jest.useFakeTimers();
|
|
52
|
+
try {
|
|
53
|
+
model.setWritingStatus(userA, undefined, 2000);
|
|
54
|
+
expect(model.writers).toHaveLength(1);
|
|
55
|
+
|
|
56
|
+
// Refresh before expiry keeps it alive.
|
|
57
|
+
jest.advanceTimersByTime(1500);
|
|
58
|
+
model.setWritingStatus(userA, undefined, 2000);
|
|
59
|
+
jest.advanceTimersByTime(1500);
|
|
60
|
+
expect(model.writers).toHaveLength(1);
|
|
61
|
+
|
|
62
|
+
// No refresh -> auto-cleared after the timeout (the WS anti-stuck path).
|
|
63
|
+
jest.advanceTimersByTime(2000);
|
|
64
|
+
expect(model.writers).toHaveLength(0);
|
|
65
|
+
} finally {
|
|
66
|
+
jest.useRealTimers();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('updateWriters replaces the whole list', () => {
|
|
71
|
+
model.setWritingStatus(userA);
|
|
72
|
+
model.updateWriters([{ user: userB }]);
|
|
73
|
+
expect(model.writers.map(w => w.user.username)).toEqual(['b']);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { act } from 'react';
|
|
7
|
+
import { createRoot, Root } from 'react-dom/client';
|
|
8
|
+
|
|
9
|
+
// React 18 asks test environments to declare themselves, otherwise every
|
|
10
|
+
// `act` call warns.
|
|
11
|
+
(
|
|
12
|
+
globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
|
|
13
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
import { WritingIndicator } from '../components/writing-indicator';
|
|
16
|
+
import { IChatModel } from '../model';
|
|
17
|
+
import { IUser } from '../types';
|
|
18
|
+
|
|
19
|
+
const alice: IUser = { username: 'a', name: 'Alice', display_name: 'Alice' };
|
|
20
|
+
|
|
21
|
+
const writer = (user: IUser, typingIndicator?: string): IChatModel.IWriter =>
|
|
22
|
+
({ user, typingIndicator }) as IChatModel.IWriter;
|
|
23
|
+
|
|
24
|
+
describe('WritingIndicator accessibility', () => {
|
|
25
|
+
let container: HTMLDivElement;
|
|
26
|
+
let root: Root;
|
|
27
|
+
|
|
28
|
+
const render = (writers: IChatModel.IWriter[]) => {
|
|
29
|
+
act(() => {
|
|
30
|
+
root.render(<WritingIndicator writers={writers} />);
|
|
31
|
+
});
|
|
32
|
+
return container.querySelector('.jp-chat-writers') as HTMLElement;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
container = document.createElement('div');
|
|
37
|
+
document.body.appendChild(container);
|
|
38
|
+
root = createRoot(container);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
act(() => root.unmount());
|
|
43
|
+
container.remove();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('is a polite live region, so a reply on its way is announced', () => {
|
|
47
|
+
// The indicator is the only signal that someone is replying. Without a
|
|
48
|
+
// live region it is visible text that assistive technology never speaks.
|
|
49
|
+
const el = render([]);
|
|
50
|
+
expect(el.getAttribute('role')).toBe('status');
|
|
51
|
+
expect(el.getAttribute('aria-live')).toBe('polite');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('reads the whole phrase rather than a fragment of it', () => {
|
|
55
|
+
// Without aria-atomic, a change to part of the text can be announced on
|
|
56
|
+
// its own, so a reader hears a bare name with no context.
|
|
57
|
+
expect(render([]).getAttribute('aria-atomic')).toBe('true');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('announces who is writing', () => {
|
|
61
|
+
expect(render([writer(alice)]).textContent).toContain('Alice is typing');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('announces a custom indicator, not just a generic one', () => {
|
|
65
|
+
const el = render([writer(alice, 'is running `ripgrep`')]);
|
|
66
|
+
expect(el.textContent).toContain('Alice is running `ripgrep`');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('says nothing when nobody is writing', () => {
|
|
70
|
+
// The container is always rendered to reserve space, and holds a
|
|
71
|
+
// non-breaking space as a placeholder. That placeholder must not be
|
|
72
|
+
// announced as if it were a message.
|
|
73
|
+
const text = (render([]).textContent ?? '').replace(/ /g, '').trim();
|
|
74
|
+
expect(text).toBe('');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -41,13 +41,29 @@ function formatWritersText(
|
|
|
41
41
|
return '';
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
const
|
|
45
|
-
w
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
const nameOf = (w: IChatModel.IWriter) =>
|
|
45
|
+
w.user.display_name ??
|
|
46
|
+
w.user.name ??
|
|
47
|
+
w.user.username ??
|
|
48
|
+
trans.__('Unknown');
|
|
49
|
+
|
|
50
|
+
// When any writer supplies a custom typing indicator, render per-writer
|
|
51
|
+
// phrases ("<name> <indicator>"), so e.g. "Jupyternaut is running `ripgrep`".
|
|
52
|
+
if (writers.some(w => w.typingIndicator)) {
|
|
53
|
+
const phrases = writers.map(
|
|
54
|
+
w => `${nameOf(w)} ${w.typingIndicator ?? trans.__('is typing...')}`
|
|
55
|
+
);
|
|
56
|
+
if (phrases.length === 1) {
|
|
57
|
+
return phrases[0];
|
|
58
|
+
} else if (phrases.length === 2) {
|
|
59
|
+
return trans.__('%1 and %2', phrases[0], phrases[1]);
|
|
60
|
+
} else {
|
|
61
|
+
const allButLast = phrases.slice(0, -1).join(', ');
|
|
62
|
+
return trans.__('%1, and %2', allButLast, phrases[phrases.length - 1]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const names = writers.map(nameOf);
|
|
51
67
|
|
|
52
68
|
if (names.length === 1) {
|
|
53
69
|
return trans.__('%1 is typing...', names[0]);
|
|
@@ -76,6 +92,18 @@ export function WritingIndicator(
|
|
|
76
92
|
return (
|
|
77
93
|
<Box
|
|
78
94
|
className={WRITERS_ELEMENT_CLASSNAME}
|
|
95
|
+
// The indicator already says something useful, "Alice is typing..." or
|
|
96
|
+
// "Jupyternaut is running `ripgrep`", but only on screen. Announcing it
|
|
97
|
+
// politely means a screen reader user learns a reply is coming without
|
|
98
|
+
// being interrupted mid-sentence.
|
|
99
|
+
//
|
|
100
|
+
// The region is the container rather than the text, so it is present in
|
|
101
|
+
// the accessibility tree before a writer appears and the change is
|
|
102
|
+
// announced. `aria-atomic` keeps the phrase together: without it a name
|
|
103
|
+
// change alone can be read out on its own, stripped of its context.
|
|
104
|
+
role="status"
|
|
105
|
+
aria-live="polite"
|
|
106
|
+
aria-atomic="true"
|
|
79
107
|
sx={{
|
|
80
108
|
...props.sx,
|
|
81
109
|
minHeight: '16px'
|
package/src/model.ts
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
TranslationBundle
|
|
11
11
|
} from '@jupyterlab/translation';
|
|
12
12
|
import type { IAwareness } from '@jupyter/ydoc';
|
|
13
|
-
import { ArrayExt } from '@lumino/algorithm';
|
|
14
13
|
import { CommandRegistry } from '@lumino/commands';
|
|
15
14
|
import { PromiseDelegate } from '@lumino/coreutils';
|
|
16
15
|
import { IDisposable } from '@lumino/disposable';
|
|
@@ -217,6 +216,34 @@ export interface IChatModel extends IDisposable {
|
|
|
217
216
|
*/
|
|
218
217
|
updateWriters(writers: IChatModel.IWriter[]): void;
|
|
219
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Mark a user as currently writing (add or update their writer entry).
|
|
221
|
+
*
|
|
222
|
+
* @param user - the writing user.
|
|
223
|
+
* @param status - optional writing status (messageID, custom typingIndicator).
|
|
224
|
+
* @param timeout - optional auto-clear delay in ms. When set, the user is
|
|
225
|
+
* automatically cleared after `timeout` unless refreshed by another call.
|
|
226
|
+
* Used by transports without their own liveness signal (e.g. WebSocket) to
|
|
227
|
+
* avoid a stuck "is typing" if a clear message is never received.
|
|
228
|
+
*/
|
|
229
|
+
setWritingStatus(
|
|
230
|
+
user: IUser,
|
|
231
|
+
status?: IChatModel.IWritingStatus,
|
|
232
|
+
timeout?: number
|
|
233
|
+
): void;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Remove a user from the writers list.
|
|
237
|
+
*/
|
|
238
|
+
clearWritingStatus(user: IUser): void;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Broadcast the *current user's* writing status to other clients, or clear it
|
|
242
|
+
* with `null`. Implemented per transport (RTC sets awareness; WebSocket sends
|
|
243
|
+
* a periodic writing frame). Default implementation is a no-op.
|
|
244
|
+
*/
|
|
245
|
+
broadcastWritingStatus(status: IChatModel.IWritingStatus | null): void;
|
|
246
|
+
|
|
220
247
|
/**
|
|
221
248
|
* Create the chat context that will be passed to the input model.
|
|
222
249
|
*/
|
|
@@ -345,7 +372,7 @@ export abstract class AbstractChatModel implements IChatModel {
|
|
|
345
372
|
* The current writer list.
|
|
346
373
|
*/
|
|
347
374
|
get writers(): IChatModel.IWriter[] {
|
|
348
|
-
return this._writers;
|
|
375
|
+
return [...this._writers.values()];
|
|
349
376
|
}
|
|
350
377
|
|
|
351
378
|
/**
|
|
@@ -666,19 +693,84 @@ export abstract class AbstractChatModel implements IChatModel {
|
|
|
666
693
|
* This implementation only propagate the list via a signal.
|
|
667
694
|
*/
|
|
668
695
|
updateWriters(writers: IChatModel.IWriter[]): void {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
696
|
+
// Reconcile the writers map with the provided snapshot (used by
|
|
697
|
+
// snapshot-style transports such as RTC awareness). Any auto-clear timers
|
|
698
|
+
// are dropped, since the snapshot is authoritative.
|
|
699
|
+
this._clearAllWriterTimers();
|
|
700
|
+
const next = new Map<string, IChatModel.IWriter>();
|
|
701
|
+
for (const writer of writers) {
|
|
702
|
+
next.set(writer.user.username, writer);
|
|
703
|
+
}
|
|
704
|
+
if (!Private.writersEqual(this._writers, next)) {
|
|
705
|
+
this._writers = next;
|
|
706
|
+
this._writersChanged.emit(this.writers);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Mark a user as currently writing. See IChatModel.setWritingStatus.
|
|
712
|
+
*/
|
|
713
|
+
setWritingStatus(
|
|
714
|
+
user: IUser,
|
|
715
|
+
status?: IChatModel.IWritingStatus,
|
|
716
|
+
timeout?: number
|
|
717
|
+
): void {
|
|
718
|
+
const writer: IChatModel.IWriter = {
|
|
719
|
+
user,
|
|
720
|
+
messageID: status?.messageID,
|
|
721
|
+
typingIndicator: status?.typingIndicator
|
|
675
722
|
};
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
this.
|
|
723
|
+
this._clearWriterTimer(user.username);
|
|
724
|
+
if (timeout !== undefined) {
|
|
725
|
+
this._writerTimers.set(
|
|
726
|
+
user.username,
|
|
727
|
+
window.setTimeout(() => this._removeWriter(user.username), timeout)
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
const previous = this._writers.get(user.username);
|
|
731
|
+
this._writers.set(user.username, writer);
|
|
732
|
+
if (!previous || !Private.writerEqual(previous, writer)) {
|
|
733
|
+
this._writersChanged.emit(this.writers);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Remove a user from the writers list. See IChatModel.clearWritingStatus.
|
|
739
|
+
*/
|
|
740
|
+
clearWritingStatus(user: IUser): void {
|
|
741
|
+
this._removeWriter(user.username);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Broadcast the current user's writing status. Default no-op; transports
|
|
746
|
+
* override this (RTC awareness, WebSocket frame).
|
|
747
|
+
*/
|
|
748
|
+
broadcastWritingStatus(_status: IChatModel.IWritingStatus | null): void {
|
|
749
|
+
// no-op by default
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private _removeWriter(username: string): void {
|
|
753
|
+
this._clearWriterTimer(username);
|
|
754
|
+
if (this._writers.delete(username)) {
|
|
755
|
+
this._writersChanged.emit(this.writers);
|
|
679
756
|
}
|
|
680
757
|
}
|
|
681
758
|
|
|
759
|
+
private _clearWriterTimer(username: string): void {
|
|
760
|
+
const timer = this._writerTimers.get(username);
|
|
761
|
+
if (timer !== undefined) {
|
|
762
|
+
window.clearTimeout(timer);
|
|
763
|
+
this._writerTimers.delete(username);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private _clearAllWriterTimers(): void {
|
|
768
|
+
for (const timer of this._writerTimers.values()) {
|
|
769
|
+
window.clearTimeout(timer);
|
|
770
|
+
}
|
|
771
|
+
this._writerTimers.clear();
|
|
772
|
+
}
|
|
773
|
+
|
|
682
774
|
/**
|
|
683
775
|
* Create the chat context that will be passed to the input model.
|
|
684
776
|
*/
|
|
@@ -793,7 +885,8 @@ export abstract class AbstractChatModel implements IChatModel {
|
|
|
793
885
|
private _selectionWatcher: ISelectionWatcher | null;
|
|
794
886
|
private _documentManager: IDocumentManager | null;
|
|
795
887
|
private _notificationId: string | null = null;
|
|
796
|
-
private _writers
|
|
888
|
+
private _writers = new Map<string, IChatModel.IWriter>();
|
|
889
|
+
private _writerTimers = new Map<string, number>();
|
|
797
890
|
private _messageEditions = new Map<string, IInputModel>();
|
|
798
891
|
private _messagesUpdated = new Signal<IChatModel, void>(this);
|
|
799
892
|
private _messageChanged = new Signal<IChatModel, IMessage>(this);
|
|
@@ -877,6 +970,25 @@ export namespace IChatModel {
|
|
|
877
970
|
* The message ID (optional)
|
|
878
971
|
*/
|
|
879
972
|
messageID?: string;
|
|
973
|
+
/**
|
|
974
|
+
* Custom status text to display instead of the default "is typing…",
|
|
975
|
+
* e.g. "is running `ripgrep …`". Falls back to the default when unset.
|
|
976
|
+
*/
|
|
977
|
+
typingIndicator?: string;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* The writing status broadcast by (or on behalf of) a user.
|
|
982
|
+
*/
|
|
983
|
+
export interface IWritingStatus {
|
|
984
|
+
/**
|
|
985
|
+
* The ID of the message being edited, if any.
|
|
986
|
+
*/
|
|
987
|
+
messageID?: string;
|
|
988
|
+
/**
|
|
989
|
+
* Optional custom typing-indicator text (see IWriter.typingIndicator).
|
|
990
|
+
*/
|
|
991
|
+
typingIndicator?: string;
|
|
880
992
|
}
|
|
881
993
|
}
|
|
882
994
|
|
|
@@ -942,3 +1054,42 @@ export abstract class AbstractChatContext implements IChatContext {
|
|
|
942
1054
|
|
|
943
1055
|
protected _model: IChatModel;
|
|
944
1056
|
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* A namespace for private functionality.
|
|
1060
|
+
*/
|
|
1061
|
+
namespace Private {
|
|
1062
|
+
/**
|
|
1063
|
+
* Whether two writers are equivalent for change-detection purposes.
|
|
1064
|
+
*/
|
|
1065
|
+
export function writerEqual(
|
|
1066
|
+
a: IChatModel.IWriter,
|
|
1067
|
+
b: IChatModel.IWriter
|
|
1068
|
+
): boolean {
|
|
1069
|
+
return (
|
|
1070
|
+
a.user.username === b.user.username &&
|
|
1071
|
+
a.user.display_name === b.user.display_name &&
|
|
1072
|
+
a.messageID === b.messageID &&
|
|
1073
|
+
a.typingIndicator === b.typingIndicator
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Whether two writer maps are equivalent.
|
|
1079
|
+
*/
|
|
1080
|
+
export function writersEqual(
|
|
1081
|
+
a: Map<string, IChatModel.IWriter>,
|
|
1082
|
+
b: Map<string, IChatModel.IWriter>
|
|
1083
|
+
): boolean {
|
|
1084
|
+
if (a.size !== b.size) {
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
for (const [username, writer] of a) {
|
|
1088
|
+
const other = b.get(username);
|
|
1089
|
+
if (!other || !writerEqual(writer, other)) {
|
|
1090
|
+
return false;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
1095
|
+
}
|