@abloatai/humans 0.61.0 → 0.62.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.
Files changed (56) hide show
  1. package/dist/Ablo.d.ts +4 -4
  2. package/dist/Ablo.js +2 -1
  3. package/dist/client.d.ts +7 -12
  4. package/dist/humans.d.ts +2 -2
  5. package/dist/humans.js +2 -6
  6. package/dist/local/BaseSyncedStore.d.ts +2 -4
  7. package/dist/local/BaseSyncedStore.js +0 -3
  8. package/dist/local/SyncClient.d.ts +3 -3
  9. package/dist/local/client/clientPrelude.d.ts +2 -0
  10. package/dist/local/client/clientPrelude.js +13 -1
  11. package/dist/local/client/createInternalComponents.js +2 -0
  12. package/dist/local/client/createModelOperations.d.ts +5 -0
  13. package/dist/local/client/createModelOperations.js +1 -0
  14. package/dist/local/client/reactiveEngine.d.ts +2 -2
  15. package/dist/local/client/reactiveEngine.js +9 -11
  16. package/dist/local/query/client.d.ts +3 -0
  17. package/dist/local/query/client.js +1 -1
  18. package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
  19. package/dist/local/sync/BootstrapFetcher.js +4 -4
  20. package/dist/local/sync/OnDemandLoader.d.ts +2 -0
  21. package/dist/local/sync/OnDemandLoader.js +1 -0
  22. package/dist/local/sync/SyncWebSocket.d.ts +2 -15
  23. package/dist/local/sync/SyncWebSocket.js +1 -36
  24. package/dist/local/sync/createClaimStream.d.ts +9 -19
  25. package/dist/local/sync/createClaimStream.js +40 -55
  26. package/dist/local/sync/socketEventWiring.d.ts +1 -2
  27. package/dist/local/sync/socketEventWiring.js +1 -5
  28. package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -3
  29. package/dist/local/transactions/mutations/replayValidation.d.ts +4 -4
  30. package/dist/presence/index.d.ts +15 -0
  31. package/dist/presence/index.js +49 -0
  32. package/dist/react/AbloProvider.d.ts +3 -3
  33. package/dist/react/AbloProvider.js +4 -3
  34. package/dist/surface.d.ts +1 -1
  35. package/dist/surface.js +1 -0
  36. package/package.json +2 -2
  37. package/src/Ablo.ts +15 -6
  38. package/src/client.ts +8 -13
  39. package/src/humans.ts +3 -10
  40. package/src/local/BaseSyncedStore.ts +0 -6
  41. package/src/local/client/clientPrelude.ts +20 -0
  42. package/src/local/client/createInternalComponents.ts +2 -0
  43. package/src/local/client/createModelOperations.ts +8 -0
  44. package/src/local/client/reactiveEngine.ts +19 -14
  45. package/src/local/query/client.ts +4 -0
  46. package/src/local/sync/BootstrapFetcher.ts +13 -4
  47. package/src/local/sync/OnDemandLoader.ts +3 -0
  48. package/src/local/sync/SyncWebSocket.ts +1 -42
  49. package/src/local/sync/createClaimStream.ts +51 -71
  50. package/src/local/sync/socketEventWiring.ts +1 -8
  51. package/src/presence/index.ts +72 -0
  52. package/src/react/AbloProvider.tsx +14 -9
  53. package/src/surface.ts +1 -0
  54. package/dist/presenceStream.d.ts +0 -69
  55. package/dist/presenceStream.js +0 -200
  56. package/src/presenceStream.ts +0 -279
@@ -12,7 +12,7 @@ import {
12
12
  } from 'react';
13
13
  import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
14
14
  import type { AbloClient as Ablo } from '../client.js';
15
- import type { Peer } from '@abloatai/transaction/types/streams';
15
+ import type { PresenceSession } from '@abloatai/transaction/presence';
16
16
  import type { GroupScope } from '../local/sync/scopeGroups.js';
17
17
  import { resolveScopeGroups } from '../local/sync/scopeGroups.js';
18
18
  import { SyncContext, type SyncStoreContract } from './context.js';
@@ -20,6 +20,7 @@ import { AbloInternalContext, type AbloInternalContextValue } from './internalCo
20
20
  import { AbloValidationError } from '@abloatai/transaction/errors';
21
21
  import { useSyncStatus } from './useSyncStatus.js';
22
22
  import { DefaultFallback } from './DefaultFallback.js';
23
+ import { presenceOfClient } from '../presence/index.js';
23
24
 
24
25
  /**
25
26
  * Ablo umbrella provider — owns the sync engine, multiplayer, and
@@ -380,12 +381,12 @@ function BootstrapGate({
380
381
  }
381
382
 
382
383
 
383
- const EMPTY_PRESENCE: readonly Peer[] = Object.freeze([]);
384
+ const EMPTY_PRESENCE: readonly PresenceSession[] = Object.freeze([]);
384
385
 
385
386
  export type { GroupScope };
386
387
 
387
388
  /**
388
- * Read-only presence: the OTHER participants currently visible to this
389
+ * Read-only presence: the other sessions currently visible to this
389
390
  * connection, bridged to React. This is a pure reader of the engine's
390
391
  * already-flowing presence stream; it does not mutate connection groups.
391
392
  *
@@ -403,7 +404,7 @@ export type { GroupScope };
403
404
  * const alone = !peers.some((p) => p.participantKind === 'user');
404
405
  * ```
405
406
  */
406
- export function usePeers(scope?: GroupScope): readonly Peer[] {
407
+ export function usePeers(scope?: GroupScope): readonly PresenceSession[] {
407
408
  const ctx = useContext(AbloInternalContext);
408
409
  const engine = ctx?.engine ?? null;
409
410
 
@@ -414,19 +415,23 @@ export function usePeers(scope?: GroupScope): readonly Peer[] {
414
415
  );
415
416
  const groups = useMemo(() => JSON.parse(scopeKey) as string[], [scopeKey]);
416
417
 
417
- const [peers, setPeers] = useState<readonly Peer[]>(EMPTY_PRESENCE);
418
+ const [peers, setPeers] = useState<readonly PresenceSession[]>(EMPTY_PRESENCE);
418
419
 
419
420
  useEffect(() => {
420
421
  if (!engine) {
421
422
  setPeers(EMPTY_PRESENCE);
422
423
  return;
423
424
  }
424
- const presence = engine.presence;
425
- const compute = (): readonly Peer[] =>
425
+ const presence = presenceOfClient(engine);
426
+ const compute = (): readonly PresenceSession[] =>
426
427
  groups.length === 0
427
428
  ? presence.others
428
- : presence.others.filter((p) =>
429
- p.syncGroups.some((g) => groups.includes(g)),
429
+ : presence.others.filter((session) =>
430
+ session.activities.some(({ target }) =>
431
+ target.id !== undefined && groups.includes(
432
+ `${target.model.toLowerCase()}:${target.id}`,
433
+ ),
434
+ ),
430
435
  );
431
436
  // Plain useState + onChange — presence changes on connect/disconnect/activity
432
437
  // only (never on cursor traffic, a separate channel), so this fires
package/src/surface.ts CHANGED
@@ -37,6 +37,7 @@ export const PUBLIC_MODEL_VERBS = [
37
37
  'list',
38
38
  'listAll',
39
39
  'local',
40
+ 'presence',
40
41
  'create',
41
42
  'update',
42
43
  'delete',
@@ -1,69 +0,0 @@
1
- /**
2
- * Creates a {@link PresenceStream} over a live sync connection. Presence is the
3
- * lightweight, ephemeral "who's here and what are they doing" view: each
4
- * participant broadcasts a status and an activity, and sees everyone else's.
5
- * The stream is built directly on the sync WebSocket and adds no second
6
- * connection. It is the sibling of {@link createClaimStream}, which reuses the
7
- * same presence frames.
8
- *
9
- * There are two ways to construct it:
10
- *
11
- * 1. Direct — pass an already-open `transport`, for example an agent worker
12
- * or a test.
13
- * 2. Deferred — construct without a transport and call `attach(transport)`
14
- * once the connection is ready. The returned stream object is stable from
15
- * construction, so callers can hold the reference and let attachment
16
- * happen later.
17
- *
18
- * Wire frames:
19
- * • Outbound `presence_update` — `{ status, activity? }`. The server stamps
20
- * `userId`, `kind`, `timestamp`, and `isAgent`, then broadcasts to the
21
- * other participants on the same sync groups.
22
- * • Inbound — the same frame, with `kind` one of `enter`, `update`, or
23
- * `leave`.
24
- */
25
- import type { WsTransport } from '@abloatai/transaction/transport/websocket';
26
- import type { PresenceStream } from '@abloatai/transaction/types/streams';
27
- import type { ParticipantKind } from '@abloatai/transaction/types/participant';
28
- /**
29
- * The wire capability the presence stream actually uses: subscribe to typed
30
- * inbound frames, check liveness, and send outbound frames. The duplex
31
- * `WsTransport` satisfies it — the same port shape the claim stream depends
32
- * on — so the stream can attach to whatever connection the host built,
33
- * without naming the engine's subclass.
34
- */
35
- export type PresenceTransport = Pick<WsTransport, 'subscribe' | 'isConnected' | 'send'>;
36
- export interface PresenceStreamConfig {
37
- /** Identity used to filter our own echoed frames out of `others`. */
38
- participantId: string;
39
- /** Optional human label for the self entry. */
40
- label?: string;
41
- /** Sync groups the participant is broadcasting on. Used for the
42
- * initial `self` entry and for `othersIn(...)` filtering. */
43
- syncGroups: readonly string[];
44
- /** Marks `self` as an agent. Server is the source of truth for
45
- * peers' `isAgent`, but `self` is local — caller decides. */
46
- isAgent?: boolean;
47
- }
48
- /** PresenceStream extended with engine-lifecycle hooks. */
49
- export interface AttachablePresenceStream extends PresenceStream {
50
- /** Wire the stream to a now-ready transport. Calls before this are
51
- * buffered (self mutations only — no wire send). Idempotent. */
52
- attach(transport: PresenceTransport): void;
53
- /**
54
- * Seeds the participant identity once the host resolves it. The stream can
55
- * be built before identity is known — a hosted client learns who it is
56
- * from its credential's scope during connect — and until then the
57
- * construction-time values (possibly empty) would leave the `self` entry
58
- * blank and let the participant's own echoed frames into `others`. Updates
59
- * the `self` entry in place, so held references see the resolved identity.
60
- */
61
- setParticipant(participant: {
62
- id: string;
63
- kind?: ParticipantKind;
64
- syncGroups?: readonly string[];
65
- }): void;
66
- /** Tear down listeners. Stream object stays usable as a no-op. */
67
- dispose(): void;
68
- }
69
- export declare function createPresenceStream(config: PresenceStreamConfig, transport?: PresenceTransport | null): AttachablePresenceStream;
@@ -1,200 +0,0 @@
1
- /**
2
- * Creates a {@link PresenceStream} over a live sync connection. Presence is the
3
- * lightweight, ephemeral "who's here and what are they doing" view: each
4
- * participant broadcasts a status and an activity, and sees everyone else's.
5
- * The stream is built directly on the sync WebSocket and adds no second
6
- * connection. It is the sibling of {@link createClaimStream}, which reuses the
7
- * same presence frames.
8
- *
9
- * There are two ways to construct it:
10
- *
11
- * 1. Direct — pass an already-open `transport`, for example an agent worker
12
- * or a test.
13
- * 2. Deferred — construct without a transport and call `attach(transport)`
14
- * once the connection is ready. The returned stream object is stable from
15
- * construction, so callers can hold the reference and let attachment
16
- * happen later.
17
- *
18
- * Wire frames:
19
- * • Outbound `presence_update` — `{ status, activity? }`. The server stamps
20
- * `userId`, `kind`, `timestamp`, and `isAgent`, then broadcasts to the
21
- * other participants on the same sync groups.
22
- * • Inbound — the same frame, with `kind` one of `enter`, `update`, or
23
- * `leave`.
24
- */
25
- import { asyncIteratorFrom } from '@abloatai/transaction/utils/asyncIterator';
26
- import { participantKindFromWire } from '@abloatai/transaction/coordination/schema';
27
- import { isTargetTuple, subTarget, wireTarget } from '@abloatai/transaction/coordination';
28
- export function createPresenceStream(config, transport = null) {
29
- const { label, syncGroups, isAgent = false } = config;
30
- // Mutable: the host seeds the resolved identity via `setParticipant` once
31
- // it is known; the own-echo filter always reads the current value.
32
- let participantId = config.participantId;
33
- // ── Self ─────────────────────────────────────────────────────────
34
- const self = {
35
- participantKind: isAgent ? 'agent' : 'user',
36
- participantId,
37
- label,
38
- syncGroups: [...syncGroups],
39
- activity: { entityType: 'Unknown', entityId: '', action: 'idle' },
40
- lastActive: new Date().toISOString(),
41
- };
42
- // ── Others ───────────────────────────────────────────────────────
43
- const othersById = new Map();
44
- let othersSnapshot = Object.freeze([]);
45
- const listeners = new Set();
46
- const notifyListeners = () => {
47
- othersSnapshot = Object.freeze(Array.from(othersById.values()));
48
- for (const l of listeners) {
49
- try {
50
- l();
51
- }
52
- catch {
53
- /* one bad listener doesn't break the others */
54
- }
55
- }
56
- };
57
- // ── Wire wiring ──────────────────────────────────────────────────
58
- let attached = null;
59
- const unsubs = [];
60
- function attach(t) {
61
- if (attached)
62
- return; // idempotent
63
- attached = t;
64
- // Reconnect: clear roster (Hub sends fresh snapshot), re-announce
65
- // own activity (peers don't auto-learn about us across reconnects).
66
- unsubs.push(t.subscribe('connected', () => {
67
- if (othersById.size > 0) {
68
- othersById.clear();
69
- othersSnapshot = Object.freeze([]);
70
- notifyListeners();
71
- }
72
- if (self.activity.entityId)
73
- sendUpdate(self.activity);
74
- }));
75
- // Inbound presence frames arrive in the wire vocabulary
76
- // (userId / isAgent / timestamp); translate them into the shape this
77
- // stream exposes (participantId / participantKind / lastActive).
78
- unsubs.push(t.subscribe('presence_update', (event) => {
79
- if (event.userId === participantId)
80
- return; // own echo
81
- if (!event.userId)
82
- return;
83
- switch (event.kind) {
84
- case 'leave':
85
- if (othersById.delete(event.userId))
86
- notifyListeners();
87
- return;
88
- // No `undefined` arm: every site that builds a presence frame stamps
89
- // `kind`, and the schema now says so, so an unlabelled frame is not a
90
- // shape the transport can hand us.
91
- case 'enter':
92
- case 'update': {
93
- const entry = {
94
- participantKind: participantKindFromWire(event.participantKind, event.isAgent),
95
- participantId: event.userId,
96
- syncGroups: event.syncGroups ?? [],
97
- activity: event.activity
98
- ? {
99
- ...wireTarget(event.activity),
100
- ...subTarget(event.activity),
101
- action: event.activity.action,
102
- ...(event.activity.detail !== undefined
103
- ? { detail: event.activity.detail }
104
- : {}),
105
- }
106
- : { entityType: 'Unknown', entityId: '', action: event.status },
107
- lastActive: event.timestamp
108
- ? new Date(event.timestamp).toISOString()
109
- : new Date().toISOString(),
110
- };
111
- othersById.set(event.userId, entry);
112
- notifyListeners();
113
- return;
114
- }
115
- }
116
- }));
117
- // If self was already mutated before attach, broadcast it now.
118
- if (self.activity.entityId)
119
- sendUpdate(self.activity);
120
- }
121
- if (transport)
122
- attach(transport);
123
- // ── Outbound ────────────────────────────────────────────────────
124
- // Do not include `isAgent` in the payload. The server derives it
125
- // authoritatively from the connection's identity, and letting a client
126
- // self-declare it once caused human sessions to broadcast as agents to peers.
127
- function sendUpdate(activity) {
128
- if (!attached?.isConnected())
129
- return; // no-op until connected
130
- attached.send({
131
- type: 'presence_update',
132
- payload: { status: 'online', activity },
133
- });
134
- }
135
- function doUpdate(activity) {
136
- self.activity = activity;
137
- self.lastActive = new Date().toISOString();
138
- sendUpdate(activity);
139
- }
140
- function resolveTarget(target) {
141
- if (isTargetTuple(target)) {
142
- return { entityType: target[0], entityId: target[1], action: 'unknown' };
143
- }
144
- return {
145
- ...wireTarget(target),
146
- ...subTarget(target),
147
- action: 'unknown',
148
- };
149
- }
150
- const withVerb = (action) => (target, detail) => {
151
- doUpdate({ ...resolveTarget(target), action, detail });
152
- };
153
- return {
154
- self,
155
- update: doUpdate,
156
- editing: withVerb('editing'),
157
- reading: withVerb('reading'),
158
- viewing: withVerb('viewing'),
159
- idle: () => {
160
- doUpdate({ entityType: 'Unknown', entityId: '', action: 'idle' });
161
- },
162
- get others() {
163
- return othersSnapshot;
164
- },
165
- othersIn: (syncGroup) => othersSnapshot.filter((e) => e.syncGroups.includes(syncGroup)),
166
- onChange: (listener) => {
167
- listeners.add(listener);
168
- return () => {
169
- listeners.delete(listener);
170
- };
171
- },
172
- [Symbol.asyncIterator]() {
173
- return asyncIteratorFrom((onChange) => {
174
- listeners.add(onChange);
175
- return () => {
176
- listeners.delete(onChange);
177
- };
178
- }, () => othersSnapshot);
179
- },
180
- attach,
181
- setParticipant(participant) {
182
- participantId = participant.id;
183
- const writable = self;
184
- writable.participantId = participant.id;
185
- if (participant.kind)
186
- writable.participantKind = participant.kind;
187
- if (participant.syncGroups)
188
- writable.syncGroups = [...participant.syncGroups];
189
- },
190
- dispose() {
191
- for (const off of unsubs)
192
- off();
193
- unsubs.length = 0;
194
- listeners.clear();
195
- othersById.clear();
196
- othersSnapshot = Object.freeze([]);
197
- attached = null;
198
- },
199
- };
200
- }
@@ -1,279 +0,0 @@
1
- /**
2
- * Creates a {@link PresenceStream} over a live sync connection. Presence is the
3
- * lightweight, ephemeral "who's here and what are they doing" view: each
4
- * participant broadcasts a status and an activity, and sees everyone else's.
5
- * The stream is built directly on the sync WebSocket and adds no second
6
- * connection. It is the sibling of {@link createClaimStream}, which reuses the
7
- * same presence frames.
8
- *
9
- * There are two ways to construct it:
10
- *
11
- * 1. Direct — pass an already-open `transport`, for example an agent worker
12
- * or a test.
13
- * 2. Deferred — construct without a transport and call `attach(transport)`
14
- * once the connection is ready. The returned stream object is stable from
15
- * construction, so callers can hold the reference and let attachment
16
- * happen later.
17
- *
18
- * Wire frames:
19
- * • Outbound `presence_update` — `{ status, activity? }`. The server stamps
20
- * `userId`, `kind`, `timestamp`, and `isAgent`, then broadcasts to the
21
- * other participants on the same sync groups.
22
- * • Inbound — the same frame, with `kind` one of `enter`, `update`, or
23
- * `leave`.
24
- */
25
-
26
- import type { WsTransport } from '@abloatai/transaction/transport/websocket';
27
- import type { PresenceUpdate } from '@abloatai/transaction/transport/websocket';
28
- import type {
29
- Activity,
30
- Peer,
31
- PresenceStream,
32
- PresenceTarget,
33
- } from '@abloatai/transaction/types/streams';
34
-
35
- import { asyncIteratorFrom } from '@abloatai/transaction/utils/asyncIterator';
36
- import { participantKindFromWire } from '@abloatai/transaction/coordination/schema';
37
- import { isTargetTuple, subTarget, wireTarget } from '@abloatai/transaction/coordination';
38
- import type { ParticipantKind } from '@abloatai/transaction/types/participant';
39
-
40
- /**
41
- * The wire capability the presence stream actually uses: subscribe to typed
42
- * inbound frames, check liveness, and send outbound frames. The duplex
43
- * `WsTransport` satisfies it — the same port shape the claim stream depends
44
- * on — so the stream can attach to whatever connection the host built,
45
- * without naming the engine's subclass.
46
- */
47
- export type PresenceTransport = Pick<WsTransport, 'subscribe' | 'isConnected' | 'send'>;
48
-
49
- export interface PresenceStreamConfig {
50
- /** Identity used to filter our own echoed frames out of `others`. */
51
- participantId: string;
52
- /** Optional human label for the self entry. */
53
- label?: string;
54
- /** Sync groups the participant is broadcasting on. Used for the
55
- * initial `self` entry and for `othersIn(...)` filtering. */
56
- syncGroups: readonly string[];
57
- /** Marks `self` as an agent. Server is the source of truth for
58
- * peers' `isAgent`, but `self` is local — caller decides. */
59
- isAgent?: boolean;
60
- }
61
-
62
- /** PresenceStream extended with engine-lifecycle hooks. */
63
- export interface AttachablePresenceStream extends PresenceStream {
64
- /** Wire the stream to a now-ready transport. Calls before this are
65
- * buffered (self mutations only — no wire send). Idempotent. */
66
- attach(transport: PresenceTransport): void;
67
- /**
68
- * Seeds the participant identity once the host resolves it. The stream can
69
- * be built before identity is known — a hosted client learns who it is
70
- * from its credential's scope during connect — and until then the
71
- * construction-time values (possibly empty) would leave the `self` entry
72
- * blank and let the participant's own echoed frames into `others`. Updates
73
- * the `self` entry in place, so held references see the resolved identity.
74
- */
75
- setParticipant(participant: {
76
- id: string;
77
- kind?: ParticipantKind;
78
- syncGroups?: readonly string[];
79
- }): void;
80
- /** Tear down listeners. Stream object stays usable as a no-op. */
81
- dispose(): void;
82
- }
83
-
84
- export function createPresenceStream(
85
- config: PresenceStreamConfig,
86
- transport: PresenceTransport | null = null,
87
- ): AttachablePresenceStream {
88
- const { label, syncGroups, isAgent = false } = config;
89
- // Mutable: the host seeds the resolved identity via `setParticipant` once
90
- // it is known; the own-echo filter always reads the current value.
91
- let participantId = config.participantId;
92
-
93
- // ── Self ─────────────────────────────────────────────────────────
94
- const self: Peer = {
95
- participantKind: isAgent ? 'agent' : 'user',
96
- participantId,
97
- label,
98
- syncGroups: [...syncGroups],
99
- activity: { entityType: 'Unknown', entityId: '', action: 'idle' },
100
- lastActive: new Date().toISOString(),
101
- };
102
-
103
- // ── Others ───────────────────────────────────────────────────────
104
- const othersById = new Map<string, Peer>();
105
- let othersSnapshot: readonly Peer[] = Object.freeze([]);
106
- const listeners = new Set<() => void>();
107
-
108
- const notifyListeners = () => {
109
- othersSnapshot = Object.freeze(Array.from(othersById.values()));
110
- for (const l of listeners) {
111
- try {
112
- l();
113
- } catch {
114
- /* one bad listener doesn't break the others */
115
- }
116
- }
117
- };
118
-
119
- // ── Wire wiring ──────────────────────────────────────────────────
120
- let attached: PresenceTransport | null = null;
121
- const unsubs: (() => void)[] = [];
122
-
123
- function attach(t: PresenceTransport): void {
124
- if (attached) return; // idempotent
125
- attached = t;
126
-
127
- // Reconnect: clear roster (Hub sends fresh snapshot), re-announce
128
- // own activity (peers don't auto-learn about us across reconnects).
129
- unsubs.push(
130
- t.subscribe('connected', () => {
131
- if (othersById.size > 0) {
132
- othersById.clear();
133
- othersSnapshot = Object.freeze([]);
134
- notifyListeners();
135
- }
136
- if (self.activity.entityId) sendUpdate(self.activity);
137
- }),
138
- );
139
-
140
- // Inbound presence frames arrive in the wire vocabulary
141
- // (userId / isAgent / timestamp); translate them into the shape this
142
- // stream exposes (participantId / participantKind / lastActive).
143
- unsubs.push(
144
- t.subscribe('presence_update', (event: PresenceUpdate) => {
145
- if (event.userId === participantId) return; // own echo
146
- if (!event.userId) return;
147
-
148
- switch (event.kind) {
149
- case 'leave':
150
- if (othersById.delete(event.userId)) notifyListeners();
151
- return;
152
- // No `undefined` arm: every site that builds a presence frame stamps
153
- // `kind`, and the schema now says so, so an unlabelled frame is not a
154
- // shape the transport can hand us.
155
- case 'enter':
156
- case 'update': {
157
- const entry: Peer = {
158
- participantKind: participantKindFromWire(
159
- event.participantKind,
160
- event.isAgent,
161
- ),
162
- participantId: event.userId,
163
- syncGroups: event.syncGroups ?? [],
164
- activity: event.activity
165
- ? {
166
- ...wireTarget(event.activity),
167
- ...subTarget(event.activity),
168
- action: event.activity.action,
169
- ...(event.activity.detail !== undefined
170
- ? { detail: event.activity.detail }
171
- : {}),
172
- }
173
- : { entityType: 'Unknown', entityId: '', action: event.status },
174
- lastActive: event.timestamp
175
- ? new Date(event.timestamp).toISOString()
176
- : new Date().toISOString(),
177
- };
178
- othersById.set(event.userId, entry);
179
- notifyListeners();
180
- return;
181
- }
182
- }
183
- }),
184
- );
185
-
186
- // If self was already mutated before attach, broadcast it now.
187
- if (self.activity.entityId) sendUpdate(self.activity);
188
- }
189
-
190
- if (transport) attach(transport);
191
-
192
- // ── Outbound ────────────────────────────────────────────────────
193
- // Do not include `isAgent` in the payload. The server derives it
194
- // authoritatively from the connection's identity, and letting a client
195
- // self-declare it once caused human sessions to broadcast as agents to peers.
196
- function sendUpdate(activity: Activity): void {
197
- if (!attached?.isConnected()) return; // no-op until connected
198
- attached.send({
199
- type: 'presence_update',
200
- payload: { status: 'online', activity },
201
- });
202
- }
203
-
204
- function doUpdate(activity: Activity): void {
205
- (self as { activity: Activity }).activity = activity;
206
- (self as { lastActive: string }).lastActive = new Date().toISOString();
207
- sendUpdate(activity);
208
- }
209
-
210
- function resolveTarget(target: PresenceTarget): Activity {
211
- if (isTargetTuple(target)) {
212
- return { entityType: target[0], entityId: target[1], action: 'unknown' };
213
- }
214
- return {
215
- ...wireTarget(target),
216
- ...subTarget(target),
217
- action: 'unknown',
218
- };
219
- }
220
-
221
- const withVerb =
222
- (action: string) =>
223
- (target: PresenceTarget, detail?: string): void => {
224
- doUpdate({ ...resolveTarget(target), action, detail });
225
- };
226
-
227
- return {
228
- self,
229
- update: doUpdate,
230
- editing: withVerb('editing'),
231
- reading: withVerb('reading'),
232
- viewing: withVerb('viewing'),
233
- idle: () => {
234
- doUpdate({ entityType: 'Unknown', entityId: '', action: 'idle' });
235
- },
236
- get others() {
237
- return othersSnapshot;
238
- },
239
- othersIn: (syncGroup: string) =>
240
- othersSnapshot.filter((e) => e.syncGroups.includes(syncGroup)),
241
- onChange: (listener: () => void) => {
242
- listeners.add(listener);
243
- return () => {
244
- listeners.delete(listener);
245
- };
246
- },
247
- [Symbol.asyncIterator]() {
248
- return asyncIteratorFrom<readonly Peer[]>(
249
- (onChange) => {
250
- listeners.add(onChange);
251
- return () => {
252
- listeners.delete(onChange);
253
- };
254
- },
255
- () => othersSnapshot,
256
- );
257
- },
258
- attach,
259
- setParticipant(participant): void {
260
- participantId = participant.id;
261
- const writable = self as {
262
- participantId: string;
263
- participantKind: Peer['participantKind'];
264
- syncGroups: readonly string[];
265
- };
266
- writable.participantId = participant.id;
267
- if (participant.kind) writable.participantKind = participant.kind;
268
- if (participant.syncGroups) writable.syncGroups = [...participant.syncGroups];
269
- },
270
- dispose(): void {
271
- for (const off of unsubs) off();
272
- unsubs.length = 0;
273
- listeners.clear();
274
- othersById.clear();
275
- othersSnapshot = Object.freeze([]);
276
- attached = null;
277
- },
278
- };
279
- }