@awesomate/sdk 0.10.0 → 0.11.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/README.md CHANGED
@@ -151,6 +151,19 @@ If the account switched on reply emails, someone looking at the app isn't emaile
151
151
  but a tab left open in the background doesn't count as looking, and closing the last one counts as
152
152
  leaving straight away.
153
153
 
154
+ A conversation can also show who else has it open and who is typing:
155
+
156
+ ```ts
157
+ const room = app.here(job.id, (people) => showWhoIsHere(people)); // [{ user, role, name, typing, assistant? }]
158
+ messageBox.oninput = () => room.typing(true); // sent at most every few seconds
159
+ messageBox.onblur = () => room.typing(false);
160
+ // later: room.leave();
161
+ ```
162
+
163
+ Everyone else with that record open and their tab in front is listed once, and the app's
164
+ assistant appears while it writes a reply. The hub checks this person may read the record, and
165
+ keeps checking; a refusal goes to the optional third argument, `onError`. Nothing is stored.
166
+
154
167
  ### Staff find a customer
155
168
 
156
169
  People in an app read no contacts but their own, so by default staff cannot put a job under a
package/dist/index.d.ts CHANGED
@@ -418,6 +418,26 @@ export interface LiveChange<R> {
418
418
  /** Ids that left the list. */
419
419
  removes: string[];
420
420
  }
421
+ /** Someone else with the same record open (app.here), as the hub sends them. */
422
+ export interface HerePerson {
423
+ /** Their app user id, or 'assistant' for the app's AI assistant. */
424
+ user: string;
425
+ role: string;
426
+ /** Their first name from the account's Contacts, or null. */
427
+ name: string | null;
428
+ typing: boolean;
429
+ /** The app's AI assistant, writing a reply. */
430
+ assistant?: true;
431
+ }
432
+ export interface HereHandle {
433
+ /**
434
+ * Say this person is typing (call it on each keystroke: it sends at most every few seconds) or has
435
+ * stopped (on send or blur). Without another call, typing ends on its own after a few seconds.
436
+ */
437
+ typing(on: boolean): void;
438
+ /** Close the record: the others stop seeing this person on it. */
439
+ leave(): void;
440
+ }
421
441
  export interface LiveHandlers<R> {
422
442
  /** Called with the whole list, in the query's order, at first and after every change. */
423
443
  onRows: (rows: R[], change: LiveChange<R> | null) => void;
@@ -516,6 +536,15 @@ export declare class AwesomateAppClient {
516
536
  * a fresh snapshot when it does. Returns stop().
517
537
  */
518
538
  live<K extends KindName, S extends keyof RowOf<K> = keyof RowOf<K>>(kind: K, options: Omit<QueryOptions<RowOf<K>, S>, 'after'>, handlers: LiveHandlers<Pick<RowOf<K>, S>> | ((rows: Array<Pick<RowOf<K>, S>>, change: LiveChange<Pick<RowOf<K>, S>> | null) => void)): () => void;
539
+ /** The one live connection behind live() and here(), made on first use. */
540
+ private liveSocket;
541
+ /**
542
+ * Open a record (a job's conversation) for who-is-here: onPeople gets everyone else who has it
543
+ * open with their tab in front, and who is typing, at first and on every change; the app's
544
+ * assistant appears while it writes a reply. The hub checks this person may read the record, and
545
+ * keeps checking; onError hears a refusal. Shares the one live connection. Returns typing() and leave().
546
+ */
547
+ here(recordId: string, onPeople: (people: HerePerson[]) => void, onError?: (err: AwesomateError) => void): HereHandle;
519
548
  }
520
549
  export declare function createAppClient(options: AppClientOptions): AwesomateAppClient;
521
550
  export declare function createClient(options: ClientOptions): AwesomateClient;
package/dist/index.js CHANGED
@@ -539,15 +539,29 @@ export class AwesomateAppClient {
539
539
  */
540
540
  live(kind, options, handlers) {
541
541
  const h = typeof handlers === 'function' ? { onRows: handlers } : handlers;
542
- this.socket ??= new LiveSocket({
542
+ return this.liveSocket().add({ kind, ...options }, h);
543
+ }
544
+ /** The one live connection behind live() and here(), made on first use. */
545
+ liveSocket() {
546
+ return this.socket ??= new LiveSocket({
543
547
  url: `${this.base.replace(/^http/, 'ws')}/api/sdk/v1/socket`,
544
548
  WebSocket: this.opts.WebSocket ?? globalThis.WebSocket,
545
549
  session: () => this.current(),
546
550
  refresh: async () => { const s = await this.load(); return s ? this.refresh(s) : null; },
547
551
  });
548
- return this.socket.add({ kind, ...options }, h);
552
+ }
553
+ /**
554
+ * Open a record (a job's conversation) for who-is-here: onPeople gets everyone else who has it
555
+ * open with their tab in front, and who is typing, at first and on every change; the app's
556
+ * assistant appears while it writes a reply. The hub checks this person may read the record, and
557
+ * keeps checking; onError hears a refusal. Shares the one live connection. Returns typing() and leave().
558
+ */
559
+ here(recordId, onPeople, onError) {
560
+ return this.liveSocket().join(recordId, onPeople, onError);
549
561
  }
550
562
  }
563
+ /** A page saying "still typing" more often than this is not sent: the hub keeps it for six seconds. */
564
+ const TYPING_RESEND_MS = 2_500;
551
565
  /** Timers must never keep a Node process (a test, a server-side render) alive; browsers have no unref. */
552
566
  function unref(t) {
553
567
  t.unref?.();
@@ -583,6 +597,41 @@ class LiveSocket {
583
597
  if (this.ready && typeof d?.visibilityState === 'string')
584
598
  this.send({ type: 'presence', visible: d.visibilityState === 'visible' });
585
599
  }
600
+ /** Records open with here(): who else is on them goes to onPeople. */
601
+ rooms = new Map();
602
+ get wanted() {
603
+ return this.entries.size > 0 || this.rooms.size > 0;
604
+ }
605
+ join(id, onPeople, onError) {
606
+ const room = { onPeople, onError, typingSentAt: 0 };
607
+ this.rooms.set(id, room);
608
+ if (this.ready)
609
+ this.send({ type: 'here', id, on: true });
610
+ else if (!this.ws && !this.connecting)
611
+ void this.connect();
612
+ return {
613
+ typing: (on) => {
614
+ if (this.rooms.get(id) !== room || !this.ready)
615
+ return;
616
+ const now = Date.now();
617
+ if (on && now - room.typingSentAt < TYPING_RESEND_MS)
618
+ return;
619
+ if (!on && !room.typingSentAt)
620
+ return;
621
+ room.typingSentAt = on ? now : 0;
622
+ this.send({ type: 'typing', id, on });
623
+ },
624
+ leave: () => {
625
+ if (this.rooms.get(id) !== room)
626
+ return;
627
+ this.rooms.delete(id);
628
+ if (this.ready)
629
+ this.send({ type: 'here', id, on: false });
630
+ if (!this.wanted)
631
+ this.shut();
632
+ },
633
+ };
634
+ }
586
635
  add(spec, handlers) {
587
636
  const id = `l${++this.seq}`;
588
637
  this.entries.set(id, { spec, handlers, rows: new Map(), order: [] });
@@ -595,7 +644,7 @@ class LiveSocket {
595
644
  return;
596
645
  if (this.ready)
597
646
  this.send({ type: 'unsub', id });
598
- if (!this.entries.size)
647
+ if (!this.wanted)
599
648
  this.shut();
600
649
  };
601
650
  }
@@ -604,7 +653,7 @@ class LiveSocket {
604
653
  this.ws.send(JSON.stringify(f));
605
654
  }
606
655
  async connect() {
607
- if (this.stopped || this.ws || this.connecting || !this.entries.size)
656
+ if (this.stopped || this.ws || this.connecting || !this.wanted)
608
657
  return;
609
658
  if (!this.o.WebSocket) {
610
659
  this.failAll(new AwesomateError('unavailable', 'No WebSocket here: pass createAppClient({ WebSocket }).', 0));
@@ -622,7 +671,7 @@ class LiveSocket {
622
671
  this.failAll(new AwesomateError('unauthenticated', 'Sign in first.', 401));
623
672
  return;
624
673
  }
625
- if (this.stopped || this.ws || !this.entries.size)
674
+ if (this.stopped || this.ws || !this.wanted)
626
675
  return;
627
676
  this.scheduleRefresh(session);
628
677
  const ws = new this.o.WebSocket(this.o.url);
@@ -646,9 +695,29 @@ class LiveSocket {
646
695
  for (const [id, e] of this.entries)
647
696
  this.send({ type: 'sub', id, spec: e.spec });
648
697
  this.sendPresence();
698
+ for (const [id, room] of this.rooms) {
699
+ room.typingSentAt = 0;
700
+ this.send({ type: 'here', id, on: true });
701
+ }
649
702
  this.pinger = unref(setInterval(() => this.send({ type: 'ping' }), 25_000));
650
703
  return;
651
704
  }
705
+ if (f.type === 'people' && typeof f.id === 'string') {
706
+ this.rooms.get(f.id)?.onPeople(Array.isArray(f.people) ? f.people : []);
707
+ return;
708
+ }
709
+ if (f.type === 'error' && typeof f.id === 'string' && f.id.startsWith('here:')) {
710
+ const id = f.id.slice(5);
711
+ const room = this.rooms.get(id);
712
+ if (!room)
713
+ return;
714
+ this.rooms.delete(id);
715
+ const code = ERROR_CODES.includes(String(f.code)) ? f.code : 'unavailable';
716
+ room.onError?.(new AwesomateError(code, String(f.message ?? 'Refused.'), 0, undefined, String(f.code)));
717
+ if (!this.wanted)
718
+ this.shut();
719
+ return;
720
+ }
652
721
  const e = typeof f.id === 'string' ? this.entries.get(f.id) : undefined;
653
722
  if (f.type === 'error') {
654
723
  const code = ERROR_CODES.includes(String(f.code)) ? f.code : 'unavailable';
@@ -713,7 +782,8 @@ class LiveSocket {
713
782
  if (this.refresher)
714
783
  clearTimeout(this.refresher);
715
784
  const ms = Math.max(5_000, s.expires_at - Date.now() - 60_000);
716
- this.refresher = unref(setTimeout(() => { if (this.entries.size)
785
+ // A socket kept open only by here() needs a fresh token too, or the hub closes it at expiry.
786
+ this.refresher = unref(setTimeout(() => { if (this.wanted)
717
787
  void this.o.refresh(); }, ms));
718
788
  }
719
789
  async closed(ws, code) {
@@ -724,7 +794,7 @@ class LiveSocket {
724
794
  if (this.pinger)
725
795
  clearInterval(this.pinger);
726
796
  this.pinger = null;
727
- if (this.stopped || !this.entries.size)
797
+ if (this.stopped || !this.wanted)
728
798
  return;
729
799
  if (code === 4401) {
730
800
  // Refused as unauthenticated: one refresh; if that is refused too, the person is signed out.
@@ -739,9 +809,13 @@ class LiveSocket {
739
809
  }
740
810
  failAll(err) {
741
811
  const all = [...this.entries.values()];
812
+ const rooms = [...this.rooms.values()];
742
813
  this.entries.clear();
814
+ this.rooms.clear();
743
815
  for (const e of all)
744
816
  e.handlers.onError?.(err);
817
+ for (const r of rooms)
818
+ r.onError?.(err);
745
819
  this.shut();
746
820
  }
747
821
  shut() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awesomate/sdk",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Your own Awesomate data from Node and the browser: query contacts and app data with generated types, and sign your app's own users in",
5
5
  "license": "MIT",
6
6
  "type": "module",