@abloatai/humans 0.61.0 → 0.63.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/dist/Ablo.d.ts +4 -4
- package/dist/Ablo.js +2 -1
- package/dist/client.d.ts +7 -12
- package/dist/humans.d.ts +2 -2
- package/dist/humans.js +2 -6
- package/dist/index.d.ts +1 -0
- package/dist/local/BaseSyncedStore.d.ts +2 -4
- package/dist/local/BaseSyncedStore.js +0 -3
- package/dist/local/InstanceCache.js +1 -1
- package/dist/local/SyncClient.d.ts +3 -3
- package/dist/local/client/clientPrelude.d.ts +2 -0
- package/dist/local/client/clientPrelude.js +13 -1
- package/dist/local/client/createInternalComponents.js +2 -0
- package/dist/local/client/createModelOperations.d.ts +26 -0
- package/dist/local/client/createModelOperations.js +85 -0
- package/dist/local/client/reactiveEngine.d.ts +2 -2
- package/dist/local/client/reactiveEngine.js +25 -11
- package/dist/local/query/client.d.ts +3 -0
- package/dist/local/query/client.js +1 -1
- package/dist/local/sync/BootstrapFetcher.d.ts +2 -0
- package/dist/local/sync/BootstrapFetcher.js +4 -4
- package/dist/local/sync/OnDemandLoader.d.ts +2 -0
- package/dist/local/sync/OnDemandLoader.js +1 -0
- package/dist/local/sync/SyncWebSocket.d.ts +2 -15
- package/dist/local/sync/SyncWebSocket.js +1 -36
- package/dist/local/sync/createClaimStream.d.ts +9 -19
- package/dist/local/sync/createClaimStream.js +40 -55
- package/dist/local/sync/socketEventWiring.d.ts +1 -2
- package/dist/local/sync/socketEventWiring.js +1 -5
- package/dist/local/transactions/mutations/MutationQueue.d.ts +6 -3
- package/dist/local/transactions/mutations/MutationQueue.js +36 -2
- package/dist/local/transactions/mutations/deltaConfirmation.d.ts +1 -0
- package/dist/local/transactions/mutations/deltaConfirmation.js +4 -0
- package/dist/local/transactions/mutations/failureHandling.d.ts +1 -0
- package/dist/local/transactions/mutations/failureHandling.js +1 -1
- package/dist/local/transactions/mutations/replayValidation.d.ts +4 -4
- package/dist/presence/index.d.ts +20 -0
- package/dist/presence/index.js +67 -0
- package/dist/presence/readActivity.d.ts +19 -0
- package/dist/presence/readActivity.js +122 -0
- package/dist/react/AbloProvider.d.ts +3 -3
- package/dist/react/AbloProvider.js +4 -3
- package/dist/react/createAbloReact.d.ts +4 -0
- package/dist/react/createAbloReact.js +12 -2
- package/dist/react/useAblo.d.ts +2 -0
- package/dist/react/useAblo.js +10 -5
- package/dist/react/usePresence.d.ts +19 -0
- package/dist/react/usePresence.js +28 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +1 -0
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -0
- package/package.json +2 -2
- package/src/Ablo.ts +15 -6
- package/src/client.ts +8 -13
- package/src/humans.ts +3 -10
- package/src/index.ts +1 -0
- package/src/local/BaseSyncedStore.ts +0 -6
- package/src/local/InstanceCache.ts +1 -1
- package/src/local/client/clientPrelude.ts +20 -0
- package/src/local/client/createInternalComponents.ts +2 -0
- package/src/local/client/createModelOperations.ts +140 -0
- package/src/local/client/reactiveEngine.ts +35 -14
- package/src/local/query/client.ts +4 -0
- package/src/local/sync/BootstrapFetcher.ts +13 -4
- package/src/local/sync/OnDemandLoader.ts +3 -0
- package/src/local/sync/SyncWebSocket.ts +1 -42
- package/src/local/sync/createClaimStream.ts +51 -71
- package/src/local/sync/socketEventWiring.ts +1 -8
- package/src/local/transactions/mutations/MutationQueue.ts +26 -2
- package/src/local/transactions/mutations/deltaConfirmation.ts +3 -0
- package/src/local/transactions/mutations/failureHandling.ts +2 -1
- package/src/presence/index.ts +101 -0
- package/src/presence/readActivity.ts +149 -0
- package/src/react/AbloProvider.tsx +14 -9
- package/src/react/createAbloReact.ts +25 -1
- package/src/react/useAblo.ts +14 -6
- package/src/react/usePresence.ts +88 -0
- package/src/react.ts +4 -0
- package/src/surface.ts +2 -0
- package/dist/presenceStream.d.ts +0 -69
- package/dist/presenceStream.js +0 -200
- package/src/presenceStream.ts +0 -279
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from './AbloProvider.js';
|
|
30
30
|
import {
|
|
31
31
|
useAbloImpl,
|
|
32
|
+
useAbloClientImpl,
|
|
32
33
|
type AbloSelector,
|
|
33
34
|
type ModelClientSelector,
|
|
34
35
|
type UseAbloHydratedModelResult,
|
|
@@ -38,6 +39,11 @@ import {
|
|
|
38
39
|
import type { AbloClient as Ablo } from '../client.js';
|
|
39
40
|
import type { ModelOperations } from '../local/client/createModelOperations.js';
|
|
40
41
|
import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
42
|
+
import {
|
|
43
|
+
usePresenceImpl,
|
|
44
|
+
type PresenceModelSelector,
|
|
45
|
+
} from './usePresence.js';
|
|
46
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
41
47
|
|
|
42
48
|
/** What a binding returns: the provider and the hook, with `S` fixed. */
|
|
43
49
|
export interface AbloReactBinding<S extends SchemaRecord> {
|
|
@@ -60,6 +66,11 @@ export interface AbloReactBinding<S extends SchemaRecord> {
|
|
|
60
66
|
options?: UseAbloModelOptions<T>,
|
|
61
67
|
): UseAbloModelResult<T>;
|
|
62
68
|
};
|
|
69
|
+
/** Declare and reactively read record presence with the same model clients. */
|
|
70
|
+
usePresence: <T, C>(
|
|
71
|
+
modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>,
|
|
72
|
+
recordId: string,
|
|
73
|
+
) => readonly PresenceSession[];
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
/**
|
|
@@ -112,5 +123,18 @@ export function createAbloReact<S extends SchemaRecord>(
|
|
|
112
123
|
return useAbloImpl<S, T, C>(bound, modelOrSelect, id, options);
|
|
113
124
|
}
|
|
114
125
|
|
|
115
|
-
|
|
126
|
+
function useBoundPresence<T, C>(
|
|
127
|
+
modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>,
|
|
128
|
+
recordId: string,
|
|
129
|
+
): readonly PresenceSession[] {
|
|
130
|
+
const bound = useContext(BoundClientContext);
|
|
131
|
+
const engine = useAbloClientImpl(bound);
|
|
132
|
+
return usePresenceImpl(engine, modelOrSelect, recordId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
AbloProvider: BoundAbloProvider,
|
|
137
|
+
useAblo: useBoundAblo,
|
|
138
|
+
usePresence: useBoundPresence,
|
|
139
|
+
};
|
|
116
140
|
}
|
package/src/react/useAblo.ts
CHANGED
|
@@ -223,12 +223,7 @@ export function useAbloImpl<
|
|
|
223
223
|
id?: string,
|
|
224
224
|
options?: UseAbloModelOptions<T>,
|
|
225
225
|
): Ablo<R> | null | UseAbloModelResult<T> | T | undefined {
|
|
226
|
-
const
|
|
227
|
-
// The bound client wins — it is already `Ablo<R>`, no rebinding. The
|
|
228
|
-
// fallback is the ONE remaining schema rebind in the SDK; it retires with
|
|
229
|
-
// the last legacy provider mount (docs/plans/typed-react-binding.md).
|
|
230
|
-
const engine: Ablo<R> | null =
|
|
231
|
-
boundClient ?? (ctx?.engine ? rebindEngine<R>(ctx.engine) : null);
|
|
226
|
+
const engine = useAbloClientImpl(boundClient);
|
|
232
227
|
const initial = options?.initial;
|
|
233
228
|
const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
|
|
234
229
|
const modelClient: ModelOperations<T, C> | undefined =
|
|
@@ -278,3 +273,16 @@ export function useAbloImpl<
|
|
|
278
273
|
if (modelOrSelect) return modelResult;
|
|
279
274
|
return engine;
|
|
280
275
|
}
|
|
276
|
+
|
|
277
|
+
/** @internal Resolve the bound or legacy provider client through one rebind seam. */
|
|
278
|
+
export function useAbloClientImpl<R extends SchemaRecord>(
|
|
279
|
+
boundClient: Ablo<R> | null,
|
|
280
|
+
): Ablo<R> | null {
|
|
281
|
+
const ctx = useContext(AbloInternalContext);
|
|
282
|
+
// The bound client wins — it is already `Ablo<R>`, no rebinding. The
|
|
283
|
+
// fallback is the ONE remaining schema rebind in the SDK; it retires with
|
|
284
|
+
// the last legacy provider mount (docs/plans/typed-react-binding.md).
|
|
285
|
+
const engine: Ablo<R> | null =
|
|
286
|
+
boundClient ?? (ctx?.engine ? rebindEngine<R>(ctx.engine) : null);
|
|
287
|
+
return engine;
|
|
288
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import type { PresenceSession } from '@abloatai/transaction/presence';
|
|
5
|
+
import { AbloValidationError } from '@abloatai/transaction/errors';
|
|
6
|
+
import {
|
|
7
|
+
getModelClientMeta,
|
|
8
|
+
type ModelOperations,
|
|
9
|
+
} from '../local/client/createModelOperations.js';
|
|
10
|
+
import type { AbloClient as Ablo } from '../client.js';
|
|
11
|
+
import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
|
|
12
|
+
import type { ResolveSchema } from '@abloatai/transaction/types/global';
|
|
13
|
+
import { useAbloClientImpl } from './useAblo.js';
|
|
14
|
+
|
|
15
|
+
type DefaultModels = ResolveSchema extends { models: infer M }
|
|
16
|
+
? M extends SchemaRecord
|
|
17
|
+
? M
|
|
18
|
+
: SchemaRecord
|
|
19
|
+
: SchemaRecord;
|
|
20
|
+
|
|
21
|
+
export type PresenceModelSelector<R extends SchemaRecord, T, C> =
|
|
22
|
+
(ablo: Ablo<R>) => ModelOperations<T, C>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Declare that this component is reading one row and return every live session
|
|
26
|
+
* reading or otherwise acting on that row. Ablo owns the lease, refresh,
|
|
27
|
+
* reconnect, and cleanup mechanics for the component's lifetime.
|
|
28
|
+
*/
|
|
29
|
+
export function usePresence<T, C>(
|
|
30
|
+
modelClient: ModelOperations<T, C>,
|
|
31
|
+
recordId: string,
|
|
32
|
+
): readonly PresenceSession[];
|
|
33
|
+
export function usePresence<
|
|
34
|
+
R extends SchemaRecord = DefaultModels,
|
|
35
|
+
T = Record<string, unknown>,
|
|
36
|
+
C = unknown,
|
|
37
|
+
>(
|
|
38
|
+
select: PresenceModelSelector<R, T, C>,
|
|
39
|
+
recordId: string,
|
|
40
|
+
): readonly PresenceSession[];
|
|
41
|
+
export function usePresence<
|
|
42
|
+
R extends SchemaRecord = DefaultModels,
|
|
43
|
+
T = Record<string, unknown>,
|
|
44
|
+
C = unknown,
|
|
45
|
+
>(
|
|
46
|
+
modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<R, T, C>,
|
|
47
|
+
recordId: string,
|
|
48
|
+
): readonly PresenceSession[] {
|
|
49
|
+
const engine = useAbloClientImpl<R>(null);
|
|
50
|
+
return usePresenceImpl(engine, modelOrSelect, recordId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @internal Shared by the global hook and schema-bound React factory. */
|
|
54
|
+
export function usePresenceImpl<R extends SchemaRecord, T, C>(
|
|
55
|
+
engine: Ablo<R> | null,
|
|
56
|
+
modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<R, T, C>,
|
|
57
|
+
recordId: string,
|
|
58
|
+
): readonly PresenceSession[] {
|
|
59
|
+
if (recordId.length === 0) {
|
|
60
|
+
throw new AbloValidationError(
|
|
61
|
+
'usePresence requires a non-empty record id.',
|
|
62
|
+
{ code: 'invalid_request', param: 'recordId' },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const modelClient = typeof modelOrSelect === 'function'
|
|
66
|
+
? engine
|
|
67
|
+
? modelOrSelect(engine)
|
|
68
|
+
: null
|
|
69
|
+
: modelOrSelect;
|
|
70
|
+
const presence = modelClient ? getModelClientMeta(modelClient)?.presence : undefined;
|
|
71
|
+
if (modelClient !== null && presence === undefined) {
|
|
72
|
+
throw new AbloValidationError(
|
|
73
|
+
'usePresence requires a model from the reactive Ablo client.',
|
|
74
|
+
{ code: 'invalid_request', param: 'modelClient' },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const [, render] = useState(0);
|
|
79
|
+
|
|
80
|
+
useEffect(
|
|
81
|
+
() => presence?.subscribe(() => { render((version) => version + 1); }),
|
|
82
|
+
[presence],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
useEffect(() => presence?.read(recordId), [presence, recordId]);
|
|
86
|
+
|
|
87
|
+
return presence?.get(recordId) ?? [];
|
|
88
|
+
}
|
package/src/react.ts
CHANGED
|
@@ -3,6 +3,10 @@ export { useReactive } from './useReactive.js';
|
|
|
3
3
|
export { useCurrentUserId } from './react/useCurrentUserId.js';
|
|
4
4
|
export { useErrorListener } from './react/useErrorListener.js';
|
|
5
5
|
export { useSyncStatus, type SyncStatusSnapshot } from './react/useSyncStatus.js';
|
|
6
|
+
export {
|
|
7
|
+
usePresence,
|
|
8
|
+
type PresenceModelSelector,
|
|
9
|
+
} from './react/usePresence.js';
|
|
6
10
|
export {
|
|
7
11
|
useMutationFailureListener,
|
|
8
12
|
type MutationFailurePayload,
|
package/src/surface.ts
CHANGED
package/dist/presenceStream.d.ts
DELETED
|
@@ -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;
|
package/dist/presenceStream.js
DELETED
|
@@ -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
|
-
}
|