@automerge/automerge-repo-react-hooks 0.2.1 → 1.0.0-alpha.2

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,5 +1,28 @@
1
1
  # React Hooks for Automerge Repo
2
2
 
3
+ These hooks are provided as helpers for using Automerge in your React project.
4
+
5
+ #### [useBootstrap](./src/useBootstrap.ts)
6
+ This hook is used to load a document based on the URL hash, for example `//myapp/#documentId=[document ID]`.
7
+ It can also load the document ID from localStorage, or create a new document if none is specified.
8
+
9
+ #### [useLocalAwareness](./src/useLocalAwareness.ts) & [useRemoteAwareness](./src/useRemoteAwareness.ts)
10
+ These hooks implement ephemeral awareness/presence, similar to [Yjs Awareness](https://docs.yjs.dev/getting-started/adding-awareness).
11
+ They allow temporary state to be shared, such as cursor positions or peer online/offline status.
12
+
13
+ Ephemeral messages are replicated between peers, but not saved to the Automerge doc, and are used for temporary updates that will be discarded.
14
+
15
+ #### [useRepo/RepoContext](./src/useRepo.ts)
16
+ Use RepoContext to set up react context for an Automerge repo.
17
+ Use useRepo to lookup the repo from context.
18
+ Most hooks depend on RepoContext being available.
19
+
20
+ #### [useDocument](./src/useDocument.ts)
21
+ Return a document & updater fn, by ID.
22
+
23
+ #### [useHandle](./src/useHandle.ts)
24
+ Return a handle, by ID.
25
+
3
26
  ## Example usage
4
27
 
5
28
  ### App Setup
package/dist/index.d.ts CHANGED
@@ -2,4 +2,6 @@ export { useDocument } from "./useDocument.js";
2
2
  export { useBootstrap } from './useBootstrap.js';
3
3
  export { useHandle } from "./useHandle.js";
4
4
  export { RepoContext, useRepo } from "./useRepo.js";
5
+ export { useLocalAwareness } from './useLocalAwareness.js';
6
+ export { useRemoteAwareness } from './useRemoteAwareness.js';
5
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACnD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAC1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA"}
package/dist/index.js CHANGED
@@ -2,3 +2,5 @@ export { useDocument } from "./useDocument.js";
2
2
  export { useBootstrap } from './useBootstrap.js';
3
3
  export { useHandle } from "./useHandle.js";
4
4
  export { RepoContext, useRepo } from "./useRepo.js";
5
+ export { useLocalAwareness } from './useLocalAwareness.js';
6
+ export { useRemoteAwareness } from './useRemoteAwareness.js';
@@ -1 +1 @@
1
- {"version":3,"file":"useBootstrap.d.ts","sourceRoot":"","sources":["../src/useBootstrap.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAc,IAAI,EAAE,MAAM,2BAA2B,CAAA;AAKvE,eAAO,MAAM,OAAO,SAAU,MAAM,8BAUnC,CAAA;AAGD,eAAO,MAAM,OAAO,cAQnB,CAAA;AAwBD;;;;;;;;;;;;;;;;GAgBG;AACH,UAAU,mBAAmB,CAAC,CAAC;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,CAAA;IAC3C,mBAAmB,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC,CAAA;CAC7D;AAED,eAAO,MAAM,YAAY,2FAgCxB,CAAA"}
1
+ {"version":3,"file":"useBootstrap.d.ts","sourceRoot":"","sources":["../src/useBootstrap.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,IAAI,EAGL,MAAM,2BAA2B,CAAA;AAKlC,eAAO,MAAM,OAAO,SAAU,MAAM,8BAUnC,CAAA;AAGD,eAAO,MAAM,OAAO,cAQnB,CAAA;AAwBD;;;;;;;;;;;;;;;;GAgBG;AACH,UAAU,mBAAmB,CAAC,CAAC;IAC7B,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,CAAA;IAC3C,mBAAmB,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC,CAAA;CAC7D;AAED,eAAO,MAAM,YAAY,2FAgCxB,CAAA"}
@@ -1,3 +1,4 @@
1
+ import { generateAutomergeUrl, } from "@automerge/automerge-repo";
1
2
  import { useEffect, useState, useMemo } from "react";
2
3
  import { useRepo } from "./useRepo.js";
3
4
  // Set URL hash
@@ -42,15 +43,15 @@ export const useBootstrap = ({ key = "documentId", onNoDocument = repo => repo.c
42
43
  const hash = useHash();
43
44
  // Try to get existing document; else create a new one
44
45
  const handle = useMemo(() => {
45
- const existingDocumentId = getDocumentId(key, hash);
46
+ const documentId = getDocumentId(key, hash);
46
47
  try {
47
- return existingDocumentId
48
- ? repo.find(existingDocumentId)
48
+ return documentId
49
+ ? repo.find(generateAutomergeUrl({ documentId }))
49
50
  : onNoDocument(repo);
50
51
  }
51
52
  catch (error) {
52
53
  // Presumably the documentId was invalid
53
- if (existingDocumentId && onInvalidDocumentId)
54
+ if (documentId && onInvalidDocumentId)
54
55
  return onInvalidDocumentId(repo, error);
55
56
  // Forward other errors
56
57
  throw error;
@@ -1,4 +1,4 @@
1
- import { Doc, ChangeFn } from "@automerge/automerge";
2
- import { DocumentId } from "@automerge/automerge-repo";
3
- export declare function useDocument<T>(documentId?: DocumentId): [Doc<T>, (changeFn: ChangeFn<T>) => void];
1
+ import { ChangeFn, Doc } from "@automerge/automerge";
2
+ import { AutomergeUrl } from "@automerge/automerge-repo";
3
+ export declare function useDocument<T>(documentUrl?: AutomergeUrl): [Doc<T>, (changeFn: ChangeFn<T>) => void];
4
4
  //# sourceMappingURL=useDocument.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useDocument.d.ts","sourceRoot":"","sources":["../src/useDocument.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAiB,MAAM,sBAAsB,CAAA;AACnE,OAAO,EAAE,UAAU,EAAyB,MAAM,2BAA2B,CAAA;AAI7E,wBAAgB,WAAW,CAAC,CAAC,EAAE,UAAU,CAAC,EAAE,UAAU,uBAyBL,SAAS,CAAC,CAAC,KAAK,IAAI,EACpE"}
1
+ {"version":3,"file":"useDocument.d.ts","sourceRoot":"","sources":["../src/useDocument.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAiB,GAAG,EAAE,MAAM,sBAAsB,CAAA;AACnE,OAAO,EAAE,YAAY,EAA0B,MAAM,2BAA2B,CAAA;AAIhF,wBAAgB,WAAW,CAAC,CAAC,EAAE,WAAW,CAAC,EAAE,YAAY,uBA4BR,SAAS,CAAC,CAAC,KAAK,IAAI,EACpE"}
@@ -1,17 +1,17 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { useRepo } from "./useRepo";
3
- export function useDocument(documentId) {
3
+ export function useDocument(documentUrl) {
4
4
  const [doc, setDoc] = useState();
5
5
  const repo = useRepo();
6
- const handle = documentId ? repo.find(documentId) : null;
6
+ const handle = documentUrl ? repo.find(documentUrl) : null;
7
7
  useEffect(() => {
8
8
  if (!handle)
9
9
  return;
10
- handle.value().then(v => setDoc(v));
11
- const onPatch = (h) => setDoc(h.patchInfo.after);
12
- handle.on("patch", onPatch);
10
+ handle.doc().then(v => setDoc(v));
11
+ const onChange = (h) => setDoc(h.doc);
12
+ handle.on("change", onChange);
13
13
  const cleanup = () => {
14
- handle.removeListener("patch", onPatch);
14
+ handle.removeListener("change", onChange);
15
15
  };
16
16
  return cleanup;
17
17
  }, [handle]);
@@ -1,3 +1,3 @@
1
- import { DocHandle, DocumentId } from "@automerge/automerge-repo";
2
- export declare function useHandle<T>(documentId: DocumentId): DocHandle<T>;
1
+ import { AutomergeUrl, DocHandle } from "@automerge/automerge-repo";
2
+ export declare function useHandle<T>(automergeUrl: AutomergeUrl): DocHandle<T>;
3
3
  //# sourceMappingURL=useHandle.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useHandle.d.ts","sourceRoot":"","sources":["../src/useHandle.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAA;AAIjE,wBAAgB,SAAS,CAAC,CAAC,EAAE,UAAU,EAAE,UAAU,GAAG,SAAS,CAAC,CAAC,CAAC,CAIjE"}
1
+ {"version":3,"file":"useHandle.d.ts","sourceRoot":"","sources":["../src/useHandle.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAA;AAInE,wBAAgB,SAAS,CAAC,CAAC,EAAE,YAAY,EAAE,YAAY,GAAG,SAAS,CAAC,CAAC,CAAC,CAIrE"}
package/dist/useHandle.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useState } from "react";
2
2
  import { useRepo } from "./useRepo.js";
3
- export function useHandle(documentId) {
3
+ export function useHandle(automergeUrl) {
4
4
  const repo = useRepo();
5
- const [handle] = useState(repo.find(documentId));
5
+ const [handle] = useState(repo.find(automergeUrl));
6
6
  return handle;
7
7
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * This hook maintains state for the local client.
3
+ * Like React.useState, it returns a [state, setState] array.
4
+ * It is intended to be used alongside useRemoteAwareness.
5
+ *
6
+ * When state is changed it is broadcast to all clients.
7
+ * It also broadcasts a heartbeat to let other clients know it is online.
8
+ *
9
+ * Note that userIds aren't secure (yet). Any client can lie about theirs.
10
+ * ChannelID is usually just your documentID with some extra characters.
11
+ *
12
+ * @param {string} props.userId Unique user ID. Clients can lie about this.
13
+ * @param {string} props.channelId Which channel to send messages on. This *must* be unique.
14
+ * @param {any} props.initialState Initial state object/primitive
15
+ * @param {number?1500} props.heartbeatTime How often to send a heartbeat (in ms)
16
+ * @returns [state, setState]
17
+ */
18
+ export declare const useLocalAwareness: ({ userId, channelId: channelIdUnprefixed, initialState, heartbeatTime }?: {
19
+ userId: any;
20
+ channelId: any;
21
+ initialState: any;
22
+ heartbeatTime?: number;
23
+ }) => any[];
24
+ //# sourceMappingURL=useLocalAwareness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useLocalAwareness.d.ts","sourceRoot":"","sources":["../src/useLocalAwareness.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,iBAAiB;;;;;WAyD7B,CAAC"}
@@ -0,0 +1,66 @@
1
+ // @ts-nocheck
2
+ import { useRepo } from "./useRepo";
3
+ import { useEffect } from "react";
4
+ import useStateRef from "react-usestateref";
5
+ import { peerEvents, CHANNEL_ID_PREFIX } from "./useRemoteAwareness";
6
+ /**
7
+ * This hook maintains state for the local client.
8
+ * Like React.useState, it returns a [state, setState] array.
9
+ * It is intended to be used alongside useRemoteAwareness.
10
+ *
11
+ * When state is changed it is broadcast to all clients.
12
+ * It also broadcasts a heartbeat to let other clients know it is online.
13
+ *
14
+ * Note that userIds aren't secure (yet). Any client can lie about theirs.
15
+ * ChannelID is usually just your documentID with some extra characters.
16
+ *
17
+ * @param {string} props.userId Unique user ID. Clients can lie about this.
18
+ * @param {string} props.channelId Which channel to send messages on. This *must* be unique.
19
+ * @param {any} props.initialState Initial state object/primitive
20
+ * @param {number?1500} props.heartbeatTime How often to send a heartbeat (in ms)
21
+ * @returns [state, setState]
22
+ */
23
+ export const useLocalAwareness = ({ userId, channelId: channelIdUnprefixed, initialState, heartbeatTime = 15000 } = {}) => {
24
+ const channelId = CHANNEL_ID_PREFIX + channelIdUnprefixed;
25
+ const [localState, setLocalState, localStateRef] = useStateRef(initialState);
26
+ const { ephemeralData } = useRepo();
27
+ const setState = (stateOrUpdater) => {
28
+ const state = typeof stateOrUpdater === "function"
29
+ ? stateOrUpdater(localStateRef.current)
30
+ : stateOrUpdater;
31
+ setLocalState(state);
32
+ // TODO: Send deltas instead of entire state
33
+ ephemeralData.broadcast(channelId, [userId, state]);
34
+ };
35
+ useEffect(() => {
36
+ // Send periodic heartbeats
37
+ const heartbeat = () => void ephemeralData.broadcast(channelId, [userId, localStateRef.current]);
38
+ heartbeat(); // Initial heartbeat
39
+ // TODO: we don't need to send a heartbeat if we've changed state recently; use recursive setTimeout instead of setInterval
40
+ const heartbeatIntervalId = setInterval(heartbeat, heartbeatTime);
41
+ return () => void clearInterval(heartbeatIntervalId);
42
+ }, [userId, channelId, heartbeatTime, ephemeralData]);
43
+ useEffect(() => {
44
+ // Send entire state to new peers
45
+ let broadcastTimeoutId;
46
+ const newPeerEvents = peerEvents.on("new_peer", (e) => {
47
+ if (e.channelId !== channelId)
48
+ return;
49
+ broadcastTimeoutId = setTimeout(() => void ephemeralData.broadcast(channelId, [
50
+ userId,
51
+ localStateRef.current,
52
+ ]), 500 // Wait for the peer to be ready
53
+ );
54
+ });
55
+ return () => {
56
+ newPeerEvents.off();
57
+ broadcastTimeoutId && clearTimeout(broadcastTimeoutId);
58
+ };
59
+ }, [userId, channelId, peerEvents]);
60
+ // TODO: Send an "offline" message on unmount
61
+ // useEffect(
62
+ // () => () => void ephemeralData.broadcast(channelId, null), // Same as Yjs awareness
63
+ // []
64
+ // );
65
+ return [localState, setState];
66
+ };
@@ -0,0 +1,22 @@
1
+ import EventEmitter from "eventemitter3";
2
+ export declare const CHANNEL_ID_PREFIX = "aw\n";
3
+ export declare const peerEvents: EventEmitter<string | symbol, any>;
4
+ /**
5
+ *
6
+ * This hook returns read-only state for remote clients.
7
+ * It also returns their heartbeat status.
8
+ * It is intended to be used alongside useLocalAwareness.
9
+ *
10
+ * @param {string} props.channelId Which channel to send messages on. This *must* be unique.
11
+ * @param {string?} props.localUserId Automerge BroadcastChannel sometimes sends us our own messages; optionally filters them
12
+ * @param {number?30000} props.offlineTimeout How long to wait (in ms) before marking a peer as offline
13
+ * @param {function?} props.getTime Function to provide current epoch time (used for testing)
14
+ * @returns [ peerStates: { [userId]: state, ... }, { [userId]: heartbeatEpochTime, ...} ]
15
+ */
16
+ export declare const useRemoteAwareness: ({ channelId: channelIdUnprefixed, localUserId, offlineTimeout, getTime, }?: {
17
+ channelId: any;
18
+ localUserId: any;
19
+ offlineTimeout?: number;
20
+ getTime?: () => number;
21
+ }) => {}[];
22
+ //# sourceMappingURL=useRemoteAwareness.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useRemoteAwareness.d.ts","sourceRoot":"","sources":["../src/useRemoteAwareness.ts"],"names":[],"mappings":"AAIA,OAAO,YAAY,MAAM,eAAe,CAAC;AAGzC,eAAO,MAAM,iBAAiB,SAAS,CAAC;AAGxC,eAAO,MAAM,UAAU,oCAAqB,CAAC;AAE7C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB;;;;;UA0D9B,CAAC"}
@@ -0,0 +1,76 @@
1
+ // @ts-nocheck
2
+ import { useRepo } from "./useRepo";
3
+ import { useEffect } from "react";
4
+ import useStateRef from "react-usestateref";
5
+ import EventEmitter from "eventemitter3";
6
+ // Add this to the beginning of every channel ID to help prevent collisions
7
+ export const CHANNEL_ID_PREFIX = "aw\n";
8
+ // Emits new_peer event when a new peer is seen
9
+ export const peerEvents = new EventEmitter();
10
+ /**
11
+ *
12
+ * This hook returns read-only state for remote clients.
13
+ * It also returns their heartbeat status.
14
+ * It is intended to be used alongside useLocalAwareness.
15
+ *
16
+ * @param {string} props.channelId Which channel to send messages on. This *must* be unique.
17
+ * @param {string?} props.localUserId Automerge BroadcastChannel sometimes sends us our own messages; optionally filters them
18
+ * @param {number?30000} props.offlineTimeout How long to wait (in ms) before marking a peer as offline
19
+ * @param {function?} props.getTime Function to provide current epoch time (used for testing)
20
+ * @returns [ peerStates: { [userId]: state, ... }, { [userId]: heartbeatEpochTime, ...} ]
21
+ */
22
+ export const useRemoteAwareness = ({ channelId: channelIdUnprefixed, localUserId, offlineTimeout = 30000, getTime = () => new Date().getTime(), } = {}) => {
23
+ // TODO: You should be able to use multiple instances of this hook on the same channelID (write test)
24
+ // TODO: This should support some kind of caching or memoization when switching between channelIDs
25
+ const channelId = CHANNEL_ID_PREFIX + channelIdUnprefixed;
26
+ const [peerStates, setPeerStates, peerStatesRef] = useStateRef({});
27
+ const [heartbeats, setHeartbeats, heartbeatsRef] = useStateRef({});
28
+ const { ephemeralData } = useRepo(); // EphemeralData API lets us send messages to peers on the automerge document
29
+ useEffect(() => {
30
+ // Receive incoming message
31
+ const handleIncomingUpdate = (event) => {
32
+ try {
33
+ if (event.channelId !== channelId)
34
+ return;
35
+ const [userId, state] = event.data;
36
+ if (userId === localUserId)
37
+ return;
38
+ if (!heartbeatsRef.current[userId])
39
+ peerEvents.emit("new_peer", event); // Let useLocalAwareness know we've seen a new peer
40
+ setPeerStates({
41
+ ...peerStatesRef.current,
42
+ [userId]: state,
43
+ });
44
+ setHeartbeats({
45
+ ...heartbeatsRef.current,
46
+ [userId]: getTime(),
47
+ });
48
+ }
49
+ catch (e) {
50
+ return;
51
+ }
52
+ };
53
+ // Remove peers we haven't seen recently
54
+ const pruneOfflinePeers = () => {
55
+ const peerStates = peerStatesRef.current;
56
+ const heartbeats = heartbeatsRef.current;
57
+ const time = getTime();
58
+ for (const key in heartbeats) {
59
+ if (time - heartbeats[key] > offlineTimeout) {
60
+ delete peerStates[key];
61
+ delete heartbeats[key];
62
+ }
63
+ }
64
+ setPeerStates(peerStates);
65
+ setHeartbeats(heartbeats);
66
+ };
67
+ ephemeralData.on("data", handleIncomingUpdate);
68
+ // Check for offline peers every `offlineTimeout` ms
69
+ const pruneOfflinePeersIntervalId = setInterval(pruneOfflinePeers, offlineTimeout);
70
+ return () => {
71
+ ephemeralData.removeListener("data", handleIncomingUpdate);
72
+ clearInterval(pruneOfflinePeersIntervalId);
73
+ };
74
+ }, [channelId, localUserId, offlineTimeout, getTime, ephemeralData]);
75
+ return [peerStates, heartbeats];
76
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automerge/automerge-repo-react-hooks",
3
- "version": "0.2.1",
3
+ "version": "1.0.0-alpha.2",
4
4
  "description": "Hooks to access an Automerge Repo from your react app.",
5
5
  "repository": "https://github.com/automerge/automerge-repo",
6
6
  "author": "Peter van Hardenberg <pvh@pvh.ca>",
@@ -10,10 +10,27 @@
10
10
  "main": "dist/index.js",
11
11
  "scripts": {
12
12
  "build": "tsc",
13
+ "test": "vitest run",
13
14
  "watch": "npm-watch"
14
15
  },
16
+ "peerDependencies": {
17
+ "@automerge/automerge": "^2.1.0-alpha.10"
18
+ },
15
19
  "dependencies": {
16
- "@automerge/automerge-repo": "^0.2.1"
20
+ "@automerge/automerge-repo": "^1.0.0-alpha.2",
21
+ "eventemitter3": "^5.0.0",
22
+ "react": "^18.2.0",
23
+ "react-usestateref": "^1.0.8"
24
+ },
25
+ "devDependencies": {
26
+ "@testing-library/react": "^14.0.0",
27
+ "@vitejs/plugin-react": "^4.0.2",
28
+ "jsdom": "^22.1.0",
29
+ "react": "^18.2.0",
30
+ "react-dom": "^18.2.0",
31
+ "vite-plugin-top-level-await": "^1.3.1",
32
+ "vite-plugin-wasm": "^3.2.2",
33
+ "vitest": "^0.33.0"
17
34
  },
18
35
  "watch": {
19
36
  "build": {
@@ -26,5 +43,5 @@
26
43
  "publishConfig": {
27
44
  "access": "public"
28
45
  },
29
- "gitHead": "7f048ecaa62eb1246f54773c6b10bada0767497b"
46
+ "gitHead": "b5830dde8f135b694809698aaad2a9fdc79a9898"
30
47
  }
package/src/index.ts CHANGED
@@ -2,3 +2,5 @@ export { useDocument } from "./useDocument.js"
2
2
  export { useBootstrap } from './useBootstrap.js'
3
3
  export { useHandle } from "./useHandle.js"
4
4
  export { RepoContext, useRepo } from "./useRepo.js"
5
+ export { useLocalAwareness } from './useLocalAwareness.js'
6
+ export { useRemoteAwareness } from './useRemoteAwareness.js'
@@ -1,4 +1,9 @@
1
- import { DocHandle, DocumentId, Repo } from "@automerge/automerge-repo"
1
+ import {
2
+ DocHandle,
3
+ Repo,
4
+ type DocumentId,
5
+ generateAutomergeUrl,
6
+ } from "@automerge/automerge-repo"
2
7
  import { useEffect, useState, useMemo } from "react"
3
8
  import { useRepo } from "./useRepo.js"
4
9
 
@@ -27,7 +32,7 @@ export const useHash = () => {
27
32
  }
28
33
 
29
34
  // Get a key from a query-param-style URL hash
30
- const getQueryParamValue = (key: string, hash) =>
35
+ const getQueryParamValue = (key: string, hash: string) =>
31
36
  new URLSearchParams(hash.substr(1)).get(key)
32
37
 
33
38
  const setQueryParamValue = (key: string, value, hash): string => {
@@ -36,10 +41,10 @@ const setQueryParamValue = (key: string, value, hash): string => {
36
41
  return u.toString()
37
42
  }
38
43
 
39
- const getDocumentId = (key, hash) =>
44
+ const getDocumentId = (key: string, hash: string) =>
40
45
  key && (getQueryParamValue(key, hash) || localStorage.getItem(key))
41
46
 
42
- const setDocumentId = (key, documentId) => {
47
+ const setDocumentId = (key: string, documentId: DocumentId) => {
43
48
  if (key) {
44
49
  // Only set URL hash if document ID changed
45
50
  if (documentId !== getQueryParamValue(key, window.location.hash))
@@ -81,14 +86,14 @@ export const useBootstrap = <T>({
81
86
 
82
87
  // Try to get existing document; else create a new one
83
88
  const handle = useMemo((): DocHandle<T> => {
84
- const existingDocumentId = getDocumentId(key, hash)
89
+ const documentId = getDocumentId(key, hash) as DocumentId | undefined
85
90
  try {
86
- return existingDocumentId
87
- ? repo.find(existingDocumentId as DocumentId)
91
+ return documentId
92
+ ? repo.find(generateAutomergeUrl({ documentId }))
88
93
  : onNoDocument(repo)
89
94
  } catch (error) {
90
95
  // Presumably the documentId was invalid
91
- if (existingDocumentId && onInvalidDocumentId)
96
+ if (documentId && onInvalidDocumentId)
92
97
  return onInvalidDocumentId(repo, error)
93
98
  // Forward other errors
94
99
  throw error
@@ -1,29 +1,32 @@
1
- import { Doc, ChangeFn, ChangeOptions } from "@automerge/automerge"
2
- import { DocumentId, DocHandlePatchPayload } from "@automerge/automerge-repo"
1
+ import { ChangeFn, ChangeOptions, Doc } from "@automerge/automerge"
2
+ import { AutomergeUrl, DocHandleChangePayload } from "@automerge/automerge-repo"
3
3
  import { useEffect, useState } from "react"
4
4
  import { useRepo } from "./useRepo"
5
5
 
6
- export function useDocument<T>(documentId?: DocumentId) {
6
+ export function useDocument<T>(documentUrl?: AutomergeUrl) {
7
7
  const [doc, setDoc] = useState<Doc<T>>()
8
8
  const repo = useRepo()
9
9
 
10
- const handle = documentId ? repo.find<T>(documentId) : null
10
+ const handle = documentUrl ? repo.find<T>(documentUrl) : null
11
11
 
12
12
  useEffect(() => {
13
13
  if (!handle) return
14
14
 
15
- handle.value().then(v => setDoc(v))
15
+ handle.doc().then(v => setDoc(v))
16
16
 
17
- const onPatch = (h: DocHandlePatchPayload<T>) => setDoc(h.patchInfo.after)
18
- handle.on("patch", onPatch)
17
+ const onChange = (h: DocHandleChangePayload<T>) => setDoc(h.doc)
18
+ handle.on("change", onChange)
19
19
  const cleanup = () => {
20
- handle.removeListener("patch", onPatch)
20
+ handle.removeListener("change", onChange)
21
21
  }
22
22
 
23
23
  return cleanup
24
24
  }, [handle])
25
25
 
26
- const changeDoc = (changeFn: ChangeFn<T>, options?: ChangeOptions<T> | undefined) => {
26
+ const changeDoc = (
27
+ changeFn: ChangeFn<T>,
28
+ options?: ChangeOptions<T> | undefined
29
+ ) => {
27
30
  if (!handle) return
28
31
  handle.change(changeFn, options)
29
32
  }
package/src/useHandle.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { DocHandle, DocumentId } from "@automerge/automerge-repo"
1
+ import { AutomergeUrl, DocHandle } from "@automerge/automerge-repo"
2
2
  import { useState } from "react"
3
3
  import { useRepo } from "./useRepo.js"
4
4
 
5
- export function useHandle<T>(documentId: DocumentId): DocHandle<T> {
5
+ export function useHandle<T>(automergeUrl: AutomergeUrl): DocHandle<T> {
6
6
  const repo = useRepo()
7
- const [handle] = useState<DocHandle<T>>(repo.find(documentId))
7
+ const [handle] = useState<DocHandle<T>>(repo.find(automergeUrl))
8
8
  return handle
9
9
  }
@@ -0,0 +1,81 @@
1
+ // @ts-nocheck
2
+ import { useRepo } from "./useRepo";
3
+ import { useEffect } from "react";
4
+ import useStateRef from "react-usestateref";
5
+ import { peerEvents, CHANNEL_ID_PREFIX } from "./useRemoteAwareness";
6
+
7
+ /**
8
+ * This hook maintains state for the local client.
9
+ * Like React.useState, it returns a [state, setState] array.
10
+ * It is intended to be used alongside useRemoteAwareness.
11
+ *
12
+ * When state is changed it is broadcast to all clients.
13
+ * It also broadcasts a heartbeat to let other clients know it is online.
14
+ *
15
+ * Note that userIds aren't secure (yet). Any client can lie about theirs.
16
+ * ChannelID is usually just your documentID with some extra characters.
17
+ *
18
+ * @param {string} props.userId Unique user ID. Clients can lie about this.
19
+ * @param {string} props.channelId Which channel to send messages on. This *must* be unique.
20
+ * @param {any} props.initialState Initial state object/primitive
21
+ * @param {number?1500} props.heartbeatTime How often to send a heartbeat (in ms)
22
+ * @returns [state, setState]
23
+ */
24
+ export const useLocalAwareness = ({
25
+ userId,
26
+ channelId: channelIdUnprefixed,
27
+ initialState,
28
+ heartbeatTime = 15000
29
+ } = {}) => {
30
+ const channelId = CHANNEL_ID_PREFIX + channelIdUnprefixed;
31
+ const [localState, setLocalState, localStateRef] = useStateRef(initialState);
32
+ const { ephemeralData } = useRepo();
33
+
34
+ const setState = (stateOrUpdater) => {
35
+ const state =
36
+ typeof stateOrUpdater === "function"
37
+ ? stateOrUpdater(localStateRef.current)
38
+ : stateOrUpdater;
39
+ setLocalState(state);
40
+ // TODO: Send deltas instead of entire state
41
+ ephemeralData.broadcast(channelId, [userId, state]);
42
+ };
43
+
44
+ useEffect(() => {
45
+ // Send periodic heartbeats
46
+ const heartbeat = () =>
47
+ void ephemeralData.broadcast(channelId, [userId, localStateRef.current]);
48
+ heartbeat(); // Initial heartbeat
49
+ // TODO: we don't need to send a heartbeat if we've changed state recently; use recursive setTimeout instead of setInterval
50
+ const heartbeatIntervalId = setInterval(heartbeat, heartbeatTime);
51
+ return () => void clearInterval(heartbeatIntervalId);
52
+ }, [userId, channelId, heartbeatTime, ephemeralData]);
53
+
54
+ useEffect(() => {
55
+ // Send entire state to new peers
56
+ let broadcastTimeoutId;
57
+ const newPeerEvents = peerEvents.on("new_peer", (e) => {
58
+ if (e.channelId !== channelId) return;
59
+ broadcastTimeoutId = setTimeout(
60
+ () =>
61
+ void ephemeralData.broadcast(channelId, [
62
+ userId,
63
+ localStateRef.current,
64
+ ]),
65
+ 500 // Wait for the peer to be ready
66
+ );
67
+ });
68
+ return () => {
69
+ newPeerEvents.off();
70
+ broadcastTimeoutId && clearTimeout(broadcastTimeoutId);
71
+ };
72
+ }, [userId, channelId, peerEvents]);
73
+
74
+ // TODO: Send an "offline" message on unmount
75
+ // useEffect(
76
+ // () => () => void ephemeralData.broadcast(channelId, null), // Same as Yjs awareness
77
+ // []
78
+ // );
79
+
80
+ return [localState, setState];
81
+ };