@faybl/react-utils 0.0.0-stage → 1.1.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 +31 -2
- package/package.json +30 -4
- package/src/commitBuffer.js +38 -0
- package/src/idList.js +10 -0
- package/src/index.js +3 -0
- package/src/point.js +13 -0
- package/src/series.js +21 -0
- package/src/useChannelsLatest.js +57 -0
- package/src/useChannelsSeries.js +79 -0
- package/src/usePresence.js +29 -0
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @faybl/react-utils
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
React hooks for Faybl web apps.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm install @faybl/react-utils
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Every hook takes the faybl-client as `client` and does nothing until it has
|
|
10
|
+
one. A list of rids or ids is compared by its content, so a new array with the
|
|
11
|
+
same ids does not resubscribe.
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { useChannelsLatest, useChannelsSeries, usePresence } from '@faybl/react-utils';
|
|
15
|
+
|
|
16
|
+
const presenceById = usePresence({ client, identityIds });
|
|
17
|
+
const latest = useChannelsLatest({ client, rids, commitMs: 1000, onError });
|
|
18
|
+
const series = useChannelsSeries({ client, rids, windowSeconds: 3600 });
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Hook | Returns |
|
|
22
|
+
| ------------------- | ---------------------------------------------------------------------- |
|
|
23
|
+
| `usePresence` | `{ [identityId]: { ts, connected } }`, live |
|
|
24
|
+
| `useChannelsLatest` | `{ [rid]: { ts, pl } }`, the last stored point, then live |
|
|
25
|
+
| `useChannelsSeries` | `{ [rid]: points }`, the last `windowSeconds`, oldest first, then live |
|
|
26
|
+
|
|
27
|
+
The two channel hooks take two options:
|
|
28
|
+
|
|
29
|
+
- `commitMs` collects live points and renders once per that many milliseconds.
|
|
30
|
+
Without it, every point renders as it arrives.
|
|
31
|
+
- `onError(error, rid)` is called when a history read fails. The hooks report
|
|
32
|
+
nothing on their own.
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faybl/react-utils",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "React hooks for Faybl web apps",
|
|
5
|
+
"license": "TBD",
|
|
6
|
+
"author": "Arcturitech",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://gitlab.com/arcturitech/faybl/utils/utils-js.git",
|
|
21
|
+
"directory": "packages/react-utils"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"react": "^19.2.0"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@faybl/utils": "^1.1.0"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hands updates to `commit`, either one at a time as they are added or, with
|
|
3
|
+
* `intervalMs`, collected and together once per interval.
|
|
4
|
+
*
|
|
5
|
+
* `commit` receives the updates in the order they were added and is not called
|
|
6
|
+
* for an interval without any. After `stop`, pending and later updates are
|
|
7
|
+
* dropped.
|
|
8
|
+
*/
|
|
9
|
+
export const createCommitBuffer = ({ commit, intervalMs }) => {
|
|
10
|
+
/* eslint-disable functional/no-let -- Updates arriving between two commits must outlive the message callbacks that deliver them, and a stopped buffer must refuse reads that resolve after its component is gone. */
|
|
11
|
+
let pending = [];
|
|
12
|
+
let stopped = false;
|
|
13
|
+
/* eslint-enable functional/no-let */
|
|
14
|
+
|
|
15
|
+
const flush = () => {
|
|
16
|
+
if (pending.length === 0) return;
|
|
17
|
+
const updates = pending;
|
|
18
|
+
pending = [];
|
|
19
|
+
commit(updates);
|
|
20
|
+
};
|
|
21
|
+
const intervalId = intervalMs ? setInterval(flush, intervalMs) : undefined;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
add: (update) => {
|
|
25
|
+
if (stopped) return;
|
|
26
|
+
if (intervalId === undefined) {
|
|
27
|
+
commit([update]);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
pending = [...pending, update];
|
|
31
|
+
},
|
|
32
|
+
stop: () => {
|
|
33
|
+
stopped = true;
|
|
34
|
+
pending = [];
|
|
35
|
+
clearInterval(intervalId);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
};
|
package/src/idList.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const SEPARATOR = '|';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A string that identifies a list of ids, for an effect's dependencies: a
|
|
5
|
+
* caller passing a new array with the same ids must not resubscribe. Empty
|
|
6
|
+
* entries and duplicates are dropped; an empty list gives ''.
|
|
7
|
+
*/
|
|
8
|
+
export const listKey = (ids = []) => [...new Set(ids.filter(Boolean))].join(SEPARATOR);
|
|
9
|
+
|
|
10
|
+
export const keyList = (key) => (key ? key.split(SEPARATOR) : []);
|
package/src/index.js
ADDED
package/src/point.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const timeOf = (point) => Date.parse(point?.ts);
|
|
2
|
+
|
|
3
|
+
// A history read can resolve after a live message has arrived.
|
|
4
|
+
export const newerPoint = (current, candidate) => {
|
|
5
|
+
if (!candidate) return current;
|
|
6
|
+
if (!current) return candidate;
|
|
7
|
+
return timeOf(candidate) >= timeOf(current) ? candidate : current;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const mergeNewerPoint = (points, { rid, point }) => {
|
|
11
|
+
const next = newerPoint(points[rid], point);
|
|
12
|
+
return next === points[rid] ? points : { ...points, [rid]: next };
|
|
13
|
+
};
|
package/src/series.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const timeOf = (point) => Date.parse(point.ts);
|
|
2
|
+
|
|
3
|
+
const byTime = (left, right) => timeOf(left) - timeOf(right);
|
|
4
|
+
|
|
5
|
+
// Points are kept oldest first. A live point normally belongs at the end.
|
|
6
|
+
export const appendPoint = (point) => (points) => {
|
|
7
|
+
if (!point) return points;
|
|
8
|
+
const last = points.at(-1);
|
|
9
|
+
if (!last || timeOf(point) > timeOf(last)) return [...points, point];
|
|
10
|
+
if (points.some(({ ts }) => ts === point.ts)) return points;
|
|
11
|
+
return [...points, point].toSorted(byTime);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const trimBefore = (fromMs) => (points) => {
|
|
15
|
+
const firstKept = points.findIndex((point) => timeOf(point) >= fromMs);
|
|
16
|
+
if (firstKept === 0) return points;
|
|
17
|
+
return firstKept === -1 ? [] : points.slice(firstKept);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const mergeSeries = (history, live) =>
|
|
21
|
+
live.reduce((merged, point) => appendPoint(point)(merged), history);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { fetchHistoryLast } from '@faybl/utils';
|
|
2
|
+
import { useEffect, useEffectEvent, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
import { createCommitBuffer } from './commitBuffer.js';
|
|
5
|
+
import { keyList, listKey } from './idList.js';
|
|
6
|
+
import { mergeNewerPoint } from './point.js';
|
|
7
|
+
|
|
8
|
+
const NO_POINTS = Object.freeze({});
|
|
9
|
+
|
|
10
|
+
const applyAll = (updates) => (points) => updates.reduce(mergeNewerPoint, points);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Latest `{ ts, pl }` per channel rid, as `{ [rid]: point }`.
|
|
14
|
+
*
|
|
15
|
+
* Reads the last stored point and then follows the channel live. The
|
|
16
|
+
* subscriptions exist only while the calling component is mounted.
|
|
17
|
+
* @param {object} params
|
|
18
|
+
* @param {object} params.client faybl-client
|
|
19
|
+
* @param {string[]} params.rids
|
|
20
|
+
* @param {number} [params.commitMs] Collect live points and render once per
|
|
21
|
+
* this many milliseconds, instead of once per point
|
|
22
|
+
* @param {(error: Error, rid: string) => void} [params.onError] Called when
|
|
23
|
+
* reading a channel's last point fails
|
|
24
|
+
*/
|
|
25
|
+
export const useChannelsLatest = ({ client, rids, commitMs, onError }) => {
|
|
26
|
+
const ridsKey = listKey(rids);
|
|
27
|
+
const [points, setPoints] = useState(NO_POINTS);
|
|
28
|
+
const reportError = useEffectEvent((error, rid) => onError?.(error, rid));
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!client || !ridsKey) return undefined;
|
|
32
|
+
|
|
33
|
+
const buffer = createCommitBuffer({
|
|
34
|
+
commit: (updates) => setPoints(applyAll(updates)),
|
|
35
|
+
intervalMs: commitMs,
|
|
36
|
+
});
|
|
37
|
+
const ridList = keyList(ridsKey);
|
|
38
|
+
|
|
39
|
+
const unsubscribers = ridList.map((rid) =>
|
|
40
|
+
client.channel.subscribe(rid, ({ data }) => buffer.add({ rid, point: data })),
|
|
41
|
+
);
|
|
42
|
+
ridList.forEach(async (rid) => {
|
|
43
|
+
try {
|
|
44
|
+
buffer.add({ rid, point: await fetchHistoryLast({ client, rid }) });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
reportError(error, rid);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return () => {
|
|
51
|
+
buffer.stop();
|
|
52
|
+
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
|
53
|
+
};
|
|
54
|
+
}, [client, ridsKey, commitMs]);
|
|
55
|
+
|
|
56
|
+
return points;
|
|
57
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { fetchHistory } from '@faybl/utils';
|
|
2
|
+
import { useEffect, useEffectEvent, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
import { createCommitBuffer } from './commitBuffer.js';
|
|
5
|
+
import { keyList, listKey } from './idList.js';
|
|
6
|
+
import { appendPoint, mergeSeries, trimBefore } from './series.js';
|
|
7
|
+
|
|
8
|
+
const NO_SERIES = Object.freeze({});
|
|
9
|
+
const NO_POINTS = Object.freeze([]);
|
|
10
|
+
|
|
11
|
+
// Each update is `{ rid, change }`, where `change` maps the points of that channel.
|
|
12
|
+
const applyAll = (updates, fromMs) => (series) =>
|
|
13
|
+
updates.reduce(
|
|
14
|
+
(changed, { rid, change }) => ({
|
|
15
|
+
...changed,
|
|
16
|
+
[rid]: trimBefore(fromMs)(change(changed[rid] ?? NO_POINTS)),
|
|
17
|
+
}),
|
|
18
|
+
series,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The last `windowSeconds` of each channel as stored, as `{ [rid]: points }`,
|
|
23
|
+
* oldest first.
|
|
24
|
+
*
|
|
25
|
+
* Reads the window from history and then follows the channels live. The
|
|
26
|
+
* subscriptions exist only while the calling component is mounted. With an
|
|
27
|
+
* empty list of rids nothing is read.
|
|
28
|
+
* @param {object} params
|
|
29
|
+
* @param {object} params.client faybl-client
|
|
30
|
+
* @param {string[]} params.rids
|
|
31
|
+
* @param {number} params.windowSeconds
|
|
32
|
+
* @param {number} [params.commitMs] Collect live points and render once per
|
|
33
|
+
* this many milliseconds, instead of once per point
|
|
34
|
+
* @param {(error: Error, rid: string) => void} [params.onError] Called when
|
|
35
|
+
* reading a channel's history fails
|
|
36
|
+
*/
|
|
37
|
+
export const useChannelsSeries = ({ client, rids, windowSeconds, commitMs, onError }) => {
|
|
38
|
+
const ridsKey = listKey(rids);
|
|
39
|
+
const requestKey = `${ridsKey}@${windowSeconds}`;
|
|
40
|
+
const [state, setState] = useState({ requestKey, series: NO_SERIES });
|
|
41
|
+
const reportError = useEffectEvent((error, rid) => onError?.(error, rid));
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!client || !ridsKey) return undefined;
|
|
45
|
+
|
|
46
|
+
const windowStartMs = () => Date.now() - windowSeconds * 1000;
|
|
47
|
+
const buffer = createCommitBuffer({
|
|
48
|
+
commit: (updates) => {
|
|
49
|
+
const change = applyAll(updates, windowStartMs());
|
|
50
|
+
setState((current) => ({
|
|
51
|
+
requestKey,
|
|
52
|
+
series: change(current.requestKey === requestKey ? current.series : NO_SERIES),
|
|
53
|
+
}));
|
|
54
|
+
},
|
|
55
|
+
intervalMs: commitMs,
|
|
56
|
+
});
|
|
57
|
+
const ridList = keyList(ridsKey);
|
|
58
|
+
|
|
59
|
+
const unsubscribers = ridList.map((rid) =>
|
|
60
|
+
client.channel.subscribe(rid, ({ data }) => buffer.add({ rid, change: appendPoint(data) })),
|
|
61
|
+
);
|
|
62
|
+
ridList.forEach(async (rid) => {
|
|
63
|
+
const from = new Date(windowStartMs()).toISOString();
|
|
64
|
+
try {
|
|
65
|
+
const { data = NO_POINTS } = await fetchHistory({ client, rid, from });
|
|
66
|
+
buffer.add({ rid, change: (live) => mergeSeries(data, live) });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
reportError(error, rid);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
buffer.stop();
|
|
74
|
+
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
|
75
|
+
};
|
|
76
|
+
}, [client, ridsKey, windowSeconds, requestKey, commitMs]);
|
|
77
|
+
|
|
78
|
+
return state.requestKey === requestKey ? state.series : NO_SERIES;
|
|
79
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { keyList, listKey } from './idList.js';
|
|
4
|
+
|
|
5
|
+
const NO_PRESENCE = Object.freeze({});
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Presence per identity, as `{ [identityId]: { ts, connected } }`.
|
|
9
|
+
*
|
|
10
|
+
* A thing logs in with its `id`, so that is its identity. The broker keeps the
|
|
11
|
+
* last presence message, which arrives right after subscribing. Nothing is
|
|
12
|
+
* subscribed without a client or ids.
|
|
13
|
+
* @param {object} params
|
|
14
|
+
* @param {object} params.client faybl-client
|
|
15
|
+
* @param {string[]} params.identityIds
|
|
16
|
+
*/
|
|
17
|
+
export const usePresence = ({ client, identityIds }) => {
|
|
18
|
+
const idsKey = listKey(identityIds);
|
|
19
|
+
const [presence, setPresence] = useState(NO_PRESENCE);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!client || !idsKey) return undefined;
|
|
23
|
+
return client.presence.subscribe(keyList(idsKey), ({ identityId, data }) =>
|
|
24
|
+
setPresence((current) => ({ ...current, [identityId]: data })),
|
|
25
|
+
);
|
|
26
|
+
}, [client, idsKey]);
|
|
27
|
+
|
|
28
|
+
return presence;
|
|
29
|
+
};
|