@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 CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # @faybl/react-utils
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -0,0 +1,3 @@
1
+ export { useChannelsLatest } from './useChannelsLatest.js';
2
+ export { useChannelsSeries } from './useChannelsSeries.js';
3
+ export { usePresence } from './usePresence.js';
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
+ };