diff --git a/docs/API.md b/docs/API.md index 1217f3e..d7e5278 100644 --- a/docs/API.md +++ b/docs/API.md @@ -47,6 +47,11 @@ - [useSetAutostopDataSyncTimeout](#usesetautostopdatasynctimeout) - [useExportGeoJSON](#useexportgeojson) - [useExportZipFile](#useexportzipfile) +- [useManyInviteLinks](#usemanyinvitelinks) +- [useCreateInviteLink](#usecreateinvitelink) +- [useCancelInviteLink](#usecancelinvitelink) +- [useAcceptInviteLinkRequest](#useacceptinvitelinkrequest) +- [useDenyInviteLinkRequest](#usedenyinvitelinkrequest) - [useSingleDocByDocId](#usesingledocbydocid) - [useSingleDocByVersionId](#usesingledocbyversionid) - [useManyDocs](#usemanydocs) @@ -60,6 +65,10 @@ - [useRejectInvite](#userejectinvite) - [useSendInvite](#usesendinvite) - [useRequestCancelInvite](#userequestcancelinvite) +- [useManyJoinRequests](#usemanyjoinrequests) +- [useSingleJoinRequest](#usesinglejoinrequest) +- [useCreateJoinRequest](#usecreatejoinrequest) +- [useCancelJoinRequest](#usecanceljoinrequest) - [useMapStyleUrl](#usemapstyleurl) - [useImportCustomMapFile](#useimportcustommapfile) - [useRemoveCustomMapFile](#useremovecustommapfile) @@ -833,6 +842,62 @@ Parameters: * `opts.projectId`: Public ID of the project to apply changes to. +### useManyInviteLinks + +Get all active invite links. + +| Function | Type | +| ---------- | ---------- | +| `useManyInviteLinks` | `({ projectId, }: { projectId: string; }) => Pick, "data" or "error" or "isRefetching">` | + +### useCreateInviteLink + +Create an invite link for a project. + +| Function | Type | +| ---------- | ---------- | +| `useCreateInviteLink` | `() => FilteredMutationResult>>` | + +### useCancelInviteLink + +Cancel a created invite link or all invite links for a project. If `inviteId` is not specified, +all invite links are cancelled. + +| Function | Type | +| ---------- | ---------- | +| `useCancelInviteLink` | `() => Pick, { mutate: UseMutateFunction; }> and { ...; }, "error" or ... 3 more ... or "mutateAsync"> or Pick<...> or Pick<...> or Pick<...>` | + +Examples: + +ts``` +function Example() { + const cancelInviteLink = useCancelInviteLink() + + // Cancel a specific invite link + cancelInviteLink.mutate({ projectId: '...', inviteId: '...' }) + + // Cancel all invite links + cancelInviteLink.mutate({ projectId: '...' }) +} +``` + + +### useAcceptInviteLinkRequest + +Accept an invite link join request for a project. + +| Function | Type | +| ---------- | ---------- | +| `useAcceptInviteLinkRequest` | `() => FilteredMutationResult>` | + +### useDenyInviteLinkRequest + +Deny an invite link join request for a project. + +| Function | Type | +| ---------- | ---------- | +| `useDenyInviteLinkRequest` | `() => FilteredMutationResult>` | + ### useSingleDocByDocId Retrieve a single document from the database based on the document's document ID. @@ -1087,7 +1152,7 @@ Send an invite for a project. | Function | Type | | ---------- | ---------- | -| `useSendInvite` | `({ projectId }: { projectId: string; }) => Pick, { ...; }> and { ...; },...` | +| `useSendInvite` | `({ projectId, }: { projectId: string; }) => FilteredMutationResult>` | Parameters: @@ -1107,6 +1172,65 @@ Parameters: * `opts.projectId`: Public ID of project to request the invite cancellation for. +### useManyJoinRequests + +Get all active join requests. + +| Function | Type | +| ---------- | ---------- | +| `useManyJoinRequests` | `() => Pick, "data" or "error" or "isRefetching">` | + +### useSingleJoinRequest + +Get a specific join request. + +If the join request is not available when the consuming component mounts, it will trigger the closest error boundary. +If the join request is cancelled while this hook is active, the `error` field will be populated with the relevant error from core. +Updates from the '`join-request-update`' event are reflected in the `data` field. Updates that indicate failure are represented by +`data.status` and `data.error`, not the top-level error field from the hook's return value. + +| Function | Type | +| ---------- | ---------- | +| `useSingleJoinRequest` | `({ inviteId, }: { inviteId: string; }) => Pick, "data" or "error" or "isRefetching">` | + +Parameters: + +* `opts.inviteId`: ID of relevant invite. + + +Examples: + +```ts +function ErrorExample({ inviteId }) { + const joinRequestQuery = useSingleJoinRequest({ inviteId }) + + if (joinRequestQuery.error) { + // Handle error from refetch due to write hook call (e.g. cancelling the join request) + } + + if (joinRequestQuery.data.status === 'failed') { + // Handle failure state based on updates happening in the background. + } +} +``` + + +### useCreateJoinRequest + +Create a join request for a received invite. + +| Function | Type | +| ---------- | ---------- | +| `useCreateJoinRequest` | `() => FilteredMutationResult>` | + +### useCancelJoinRequest + +Cancel a join request that has been created for a received invite. + +| Function | Type | +| ---------- | ---------- | +| `useCancelJoinRequest` | `() => FilteredMutationResult>` | + ### useMapStyleUrl Get a URL that points to a StyleJSON resource served by the embedded HTTP server. diff --git a/package-lock.json b/package-lock.json index f65d7e9..3aaa3cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "type-fest": "^5.9.0" }, "devDependencies": { - "@comapeo/core": "7.5.2", + "@comapeo/core": "7.7.0", "@comapeo/ipc": "11.0.0", "@eslint/js": "10.0.1", "@ianvs/prettier-plugin-sort-imports": "4.7.1", @@ -37,6 +37,7 @@ "fastify": "4.29.1", "globals": "17.14.0", "husky": "9.1.7", + "hyperdht": "6.34.1", "jsdom": "31.0.0", "ky": "2.2.0", "lint-staged": "17.6.0", @@ -54,9 +55,9 @@ "vitest": "5.1.0" }, "peerDependencies": { - "@comapeo/core": "^7.2.0", + "@comapeo/core": "^7.7.0", "@comapeo/ipc": "^11.0.0", - "@tanstack/react-query": "^5", + "@tanstack/react-query": "^5.102.8", "react": "^18 || ^19" } }, @@ -404,9 +405,9 @@ } }, "node_modules/@comapeo/core": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/@comapeo/core/-/core-7.5.2.tgz", - "integrity": "sha512-wNQUZvfRehvOjw0auCwp6+NPdnmYvya+RiMYw0zDN8UvF1WLAtA2To7fsPTgQnqkOjSyjXV/YIQD8wzKKgxjZA==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@comapeo/core/-/core-7.7.0.tgz", + "integrity": "sha512-4UqmfRJdHUAg+wR88yc687iSf9gqNA1NaYtzjubVMVNB9YlUh6Czv3l+8g0XOdfgXT8jZTucAPuEGf8P1VsQMw==", "dev": true, "license": "MIT", "dependencies": { @@ -417,6 +418,7 @@ "@fastify/error": "^3.4.1", "@fastify/type-provider-typebox": "^4.1.0", "@hyperswarm/secret-stream": "^6.6.3", + "@mapeo/crypto": "^1.1.0", "@mapeo/sqlite-indexer": "^1.0.2", "@sinclair/typebox": "^0.33.17", "@sindresorhus/merge-streams": "^4.0.0", @@ -425,10 +427,11 @@ "bcp-47-normalize": "^2.3.0", "better-sqlite3": "^11.10.0", "big-sparse-array": "^1.0.3", - "bogon": "^1.1.0", + "bogon": "^1.2.0", "comapeocat": "^1.1.0", "compact-encoding": "^2.12.0", "corestore": "6.8.4", + "crockford-base32": "^2.1.0", "custom-error-creator": "^1.1.1", "debug": "^4.3.4", "dot-prop": "^9.0.0", @@ -441,6 +444,7 @@ "hypercore": "10.19.0", "hypercore-crypto": "3.4.2", "hyperdrive": "11.5.3", + "hyperswarm": "^4.17.0", "iso-3166": "^4.3.0", "json-stable-stringify": "^1.1.1", "magic-bytes.js": "^1.10.0", @@ -3421,6 +3425,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adaptive-timeout": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/adaptive-timeout/-/adaptive-timeout-1.0.1.tgz", + "integrity": "sha512-+seGiBtHqbnyZ0Quq/ZGxxwP66153WBABawa07/QeyuPFzbduPBjiGQAyCiriiLDzt3dkbMBskSlfqtRwblK8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "xache": "^1.2.1" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -4015,6 +4029,16 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bits-to-bytes": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bits-to-bytes/-/bits-to-bytes-1.3.0.tgz", + "integrity": "sha512-OJoHTpFXS9bXHBCekGTByf3MqM8CGblBDIduKQeeVVeiU9dDWywSSirXIBYGgg3d1zbVuvnMa1vD4r6PA0kOKg==", + "dev": true, + "license": "ISC", + "dependencies": { + "b4a": "^1.5.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -4067,6 +4091,66 @@ "node": ">= 6" } }, + "node_modules/blind-relay": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/blind-relay/-/blind-relay-1.6.1.tgz", + "integrity": "sha512-38YmKCl/m9NcTkwueBbcGIt9Zx3IT/Q9Nsluu6C5Gur6PqfE0zWPhPwCzia1hri/g0ICYxrqkgLtEaoWD5WeSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4", + "bare-events": "^2.2.0", + "bits-to-bytes": "^1.3.0", + "compact-encoding": "^3.0.0", + "compact-encoding-bitfield": "^1.0.0", + "protomux": "^3.5.1", + "sodium-universal": "^5.0.0", + "streamx": "^2.15.1" + } + }, + "node_modules/blind-relay/node_modules/compact-encoding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.5.2.tgz", + "integrity": "sha512-rgGcWdfxyuLwJHMUXI0OrYGpJe3VN4RZyovbxWVlxPYIsUZutOTfP3shmDyDi/iaybwcxMsTJDDuxQa4xRG0RQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.3.0" + } + }, + "node_modules/blind-relay/node_modules/sodium-native": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", + "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bare-assert": "^1.2.0", + "require-addon": "^1.1.0", + "which-runtime": "^1.2.1" + }, + "engines": { + "bare": ">=1.16.0" + } + }, + "node_modules/blind-relay/node_modules/sodium-universal": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-5.0.1.tgz", + "integrity": "sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "sodium-native": "^5.0.1" + }, + "peerDependencies": { + "sodium-javascript": "~0.8.0" + }, + "peerDependenciesMeta": { + "sodium-javascript": { + "optional": true + } + } + }, "node_modules/bogon": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/bogon/-/bogon-1.3.0.tgz", @@ -4505,6 +4589,26 @@ "b4a": "^1.3.0" } }, + "node_modules/compact-encoding-bitfield": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/compact-encoding-bitfield/-/compact-encoding-bitfield-1.1.0.tgz", + "integrity": "sha512-F6wliSKHi50BsBStmnsRJAzJgYSIYzdfSe3M0oHj4uQ1RcctJK/NQVD7wF51bj/DBMne2i5gUxrGUFkNZQns5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "compact-encoding": "^3.0.0" + } + }, + "node_modules/compact-encoding-bitfield/node_modules/compact-encoding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.5.2.tgz", + "integrity": "sha512-rgGcWdfxyuLwJHMUXI0OrYGpJe3VN4RZyovbxWVlxPYIsUZutOTfP3shmDyDi/iaybwcxMsTJDDuxQa4xRG0RQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.3.0" + } + }, "node_modules/compact-encoding-net": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/compact-encoding-net/-/compact-encoding-net-1.3.0.tgz", @@ -4669,6 +4773,23 @@ "node": ">= 14" } }, + "node_modules/crockford-base32": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/crockford-base32/-/crockford-base32-2.1.0.tgz", + "integrity": "sha512-vadAEK4CkOKWaIkf0oWr3XMaYpXCyAwwE8N6rCUye6/QsVXn7cuJQXp2Yuy9BKgsCoXlkixTu+o3PXUoW1s5lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^14.17.29" + } + }, + "node_modules/crockford-base32/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5010,6 +5131,70 @@ "node": ">=8" } }, + "node_modules/dht-rpc": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.27.0.tgz", + "integrity": "sha512-NsfgRlFDQnkA2+H+hJXMPLmBOn/TSIIc0cRMV8q42M4xc1uAXWtpvIIQsONF5tYcYC6px0bslrUGideN1h4S4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "adaptive-timeout": "^1.0.1", + "b4a": "^1.6.1", + "bare-events": "^2.2.0", + "compact-encoding": "^3.0.0", + "compact-encoding-net": "^1.2.0", + "fast-fifo": "^1.1.0", + "kademlia-routing-table": "^1.0.1", + "nat-sampler": "^1.0.1", + "sodium-universal": "^5.0.0", + "streamx": "^2.13.2", + "time-ordered-set": "^2.0.0", + "udx-native": "^1.5.3" + } + }, + "node_modules/dht-rpc/node_modules/compact-encoding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.5.2.tgz", + "integrity": "sha512-rgGcWdfxyuLwJHMUXI0OrYGpJe3VN4RZyovbxWVlxPYIsUZutOTfP3shmDyDi/iaybwcxMsTJDDuxQa4xRG0RQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.3.0" + } + }, + "node_modules/dht-rpc/node_modules/sodium-native": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", + "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bare-assert": "^1.2.0", + "require-addon": "^1.1.0", + "which-runtime": "^1.2.1" + }, + "engines": { + "bare": ">=1.16.0" + } + }, + "node_modules/dht-rpc/node_modules/sodium-universal": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-5.0.1.tgz", + "integrity": "sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "sodium-native": "^5.0.1" + }, + "peerDependencies": { + "sodium-javascript": "~0.8.0" + }, + "peerDependenciesMeta": { + "sodium-javascript": { + "optional": true + } + } + }, "node_modules/diff": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", @@ -6115,6 +6300,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-object-property": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-2.0.0.tgz", + "integrity": "sha512-KwuURPyqn2Mz8DdV29pJwQu0Y7tcsbkULr82eeOcY/ZllFK6I9Wm8dsRByIu7CKWlFi9BdW1b3mcXMp/kQBQsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.0" + } + }, + "node_modules/generate-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/generate-string/-/generate-string-1.0.1.tgz", + "integrity": "sha512-IfTY0dKZM43ACyGvXkbG7De7WY7MxTS5VO6Juhe8oJKpCmrYYXoqp/cJMskkpi0k9H8wuXq0H+eI898/BCqvXg==", + "dev": true, + "license": "MIT" + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6522,6 +6724,101 @@ "z32": "^1.0.0" } }, + "node_modules/hyperdht": { + "version": "6.34.1", + "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.34.1.tgz", + "integrity": "sha512-Ki7utRmmxx4PmdxKmDKCJ4AQvri+2LVBdaNvZoLMjXI0eda6ZyjlmQycOkc8va1sfoFyRF4ei2nOGbZJOdR/TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hyperswarm/secret-stream": "^6.6.2", + "b4a": "^1.3.1", + "bare-events": "^2.2.0", + "blind-relay": "^1.3.0", + "bogon": "^1.0.0", + "compact-encoding": "^3.0.0", + "dht-rpc": "^6.15.1", + "hypercore-crypto": "^3.3.0", + "hypercore-id-encoding": "^1.2.0", + "hyperdht-address": "^1.0.1", + "noise-curve-ed": "^2.0.0", + "noise-handshake": "^4.0.0", + "record-cache": "^1.1.1", + "safety-catch": "^1.0.1", + "signal-promise": "^1.0.3", + "sodium-universal": "^5.0.1", + "streamx": "^2.16.1", + "unslab": "^1.3.0", + "xache": "^1.1.0" + }, + "bin": { + "hyperdht": "bin.js" + } + }, + "node_modules/hyperdht-address": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hyperdht-address/-/hyperdht-address-1.1.1.tgz", + "integrity": "sha512-Mu/+7SW2cwvHxMXswa5mOrhrS5BJyntViAuB25k8d8wNtA8eAPs4LaIq6TwN1SS/KQY02h1r+gM8cQeN/k360w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "compact-encoding": "^3.0.0", + "hyperschema": "^1.20.1" + } + }, + "node_modules/hyperdht-address/node_modules/compact-encoding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.5.2.tgz", + "integrity": "sha512-rgGcWdfxyuLwJHMUXI0OrYGpJe3VN4RZyovbxWVlxPYIsUZutOTfP3shmDyDi/iaybwcxMsTJDDuxQa4xRG0RQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.3.0" + } + }, + "node_modules/hyperdht/node_modules/compact-encoding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.5.2.tgz", + "integrity": "sha512-rgGcWdfxyuLwJHMUXI0OrYGpJe3VN4RZyovbxWVlxPYIsUZutOTfP3shmDyDi/iaybwcxMsTJDDuxQa4xRG0RQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.3.0" + } + }, + "node_modules/hyperdht/node_modules/sodium-native": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", + "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bare-assert": "^1.2.0", + "require-addon": "^1.1.0", + "which-runtime": "^1.2.1" + }, + "engines": { + "bare": ">=1.16.0" + } + }, + "node_modules/hyperdht/node_modules/sodium-universal": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-5.0.1.tgz", + "integrity": "sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "sodium-native": "^5.0.1" + }, + "peerDependencies": { + "sodium-javascript": "~0.8.0" + }, + "peerDependenciesMeta": { + "sodium-javascript": { + "optional": true + } + } + }, "node_modules/hyperdrive": { "version": "11.5.3", "resolved": "https://registry.npmjs.org/hyperdrive/-/hyperdrive-11.5.3.tgz", @@ -6541,6 +6838,45 @@ "unix-path-resolve": "^1.0.2" } }, + "node_modules/hyperschema": { + "version": "1.26.2", + "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.26.2.tgz", + "integrity": "sha512-5Y7C5zCfH28yiK7RhuMl7VkWt24Mr0E5sp1r2ntd3Ab3+yOBdkDWau0/d/F5beC799Vd5e4xqIAu5NJLM7i//w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-fs": "^4.0.1", + "compact-encoding": "^3.5.0", + "generate-object-property": "^2.0.0", + "generate-string": "^1.0.1" + } + }, + "node_modules/hyperschema/node_modules/compact-encoding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.5.2.tgz", + "integrity": "sha512-rgGcWdfxyuLwJHMUXI0OrYGpJe3VN4RZyovbxWVlxPYIsUZutOTfP3shmDyDi/iaybwcxMsTJDDuxQa4xRG0RQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.3.0" + } + }, + "node_modules/hyperswarm": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/hyperswarm/-/hyperswarm-4.17.2.tgz", + "integrity": "sha512-yKTNgHjijwrmSj5neC2Lu5JyKDvj9gZK/zkVLySP6J2M7nF+uvFMoxzRlWKKjdySfZVHZXPLfhk4xAnpqibm5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.3.1", + "bare-events": "^2.2.0", + "hyperdht": "^6.21.0", + "safety-catch": "^1.0.2", + "shuffled-priority-queue": "^2.1.0", + "streamx": "^2.22.1", + "unslab": "^1.3.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -6805,6 +7141,13 @@ "dev": true, "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -7133,6 +7476,16 @@ "node": ">=18.0.0" } }, + "node_modules/kademlia-routing-table": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/kademlia-routing-table/-/kademlia-routing-table-1.0.6.tgz", + "integrity": "sha512-Ve6jwIlUCYvUzBnXnzVRHDZCFgXURW9gmF3r7n05kZs/2rNbLHXwGdcq0qIaSwdmJCvtosgR4JensnVU65hzNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bare-events": "^2.2.0" + } + }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -7902,6 +8255,13 @@ "dev": true, "license": "MIT" }, + "node_modules/nat-sampler": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/nat-sampler/-/nat-sampler-1.0.1.tgz", + "integrity": "sha512-yQvyNN7xbqR8crTKk3U8gRgpcV1Az+vfCEijiHu9oHHsnIl8n3x+yXNHl42M6L3czGynAVoOT9TqBfS87gDdcw==", + "dev": true, + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -9237,6 +9597,16 @@ "node": ">= 12.13.0" } }, + "node_modules/record-cache": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/record-cache/-/record-cache-1.2.0.tgz", + "integrity": "sha512-kyy3HWCez2WrotaL3O4fTn0rsIdfRKOdQQcEJ9KpvmKmbffKVvwsloX063EgRUlpJIXHiDQFhJcTbZequ2uTZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.3.1" + } + }, "node_modules/reghex": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/reghex/-/reghex-3.0.2.tgz", @@ -9807,6 +10177,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shuffled-priority-queue": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/shuffled-priority-queue/-/shuffled-priority-queue-2.1.0.tgz", + "integrity": "sha512-xhdh7fHyMsr0m/w2kDfRJuBFRS96b9l8ZPNWGaQ+PMvnUnZ/Eh+gJJ9NsHBd7P9k0399WYlCLzsy18EaMfyadA==", + "dev": true, + "license": "MIT", + "dependencies": { + "unordered-set": "^2.0.1" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -9820,6 +10200,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/signal-promise": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/signal-promise/-/signal-promise-1.0.3.tgz", + "integrity": "sha512-WBgv0UnIq2C+Aeh0/n+IRpP6967eIx9WpynTUoiW3isPpfe1zu2LJzyfXdo9Tgef8yR/sGjcMvoUXD7EYdiz+g==", + "dev": true, + "license": "MIT" + }, "node_modules/signed-varint": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/signed-varint/-/signed-varint-2.0.1.tgz", @@ -10731,6 +11118,13 @@ "node": ">=12.22" } }, + "node_modules/time-ordered-set": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/time-ordered-set/-/time-ordered-set-2.0.1.tgz", + "integrity": "sha512-VJEKmgSN2UiOLB8BpN8Sh2b9LGMHTP5OPrQRpnKjvOheOyzk0mufbjzjKTIG2gO4A+Y+vDJ+0TcLbpUmMLsg8A==", + "dev": true, + "license": "MIT" + }, "node_modules/timeout-refresh": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/timeout-refresh/-/timeout-refresh-2.0.1.tgz", @@ -11103,6 +11497,22 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/udx-native": { + "version": "1.21.3", + "resolved": "https://registry.npmjs.org/udx-native/-/udx-native-1.21.3.tgz", + "integrity": "sha512-2rUrocAjI2/0ijFqb0E3R6PH3AoH+o5ZhaL3lGm/bHCRGaqPnGhywTOMPM2ByYEXscsZsj5svhmSbY1ogxGGbg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.5.0", + "bare-events": "^2.2.0", + "require-addon": "^1.1.0", + "streamx": "^2.22.0" + }, + "engines": { + "bare": ">=1.17.4" + } + }, "node_modules/uint8array-extras": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz", @@ -11151,6 +11561,13 @@ "dev": true, "license": "MIT" }, + "node_modules/unordered-set": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unordered-set/-/unordered-set-2.0.1.tgz", + "integrity": "sha512-eUmNTPzdx+q/WvOHW0bgGYLWvWHNT3PTKEQLg0MAQhc0AHASHVHoP/9YytYd4RBVariqno/mEUhVZN98CmD7bg==", + "dev": true, + "license": "MIT" + }, "node_modules/unslab": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/unslab/-/unslab-1.3.0.tgz", diff --git a/package.json b/package.json index 48d289b..b92fc39 100644 --- a/package.json +++ b/package.json @@ -70,13 +70,13 @@ "type-fest": "^5.9.0" }, "peerDependencies": { - "@comapeo/core": "^7.2.0", + "@comapeo/core": "^7.7.0", "@comapeo/ipc": "^11.0.0", - "@tanstack/react-query": "^5", + "@tanstack/react-query": "^5.102.8", "react": "^18 || ^19" }, "devDependencies": { - "@comapeo/core": "7.5.2", + "@comapeo/core": "7.7.0", "@comapeo/ipc": "11.0.0", "@eslint/js": "10.0.1", "@ianvs/prettier-plugin-sort-imports": "4.7.1", @@ -98,6 +98,7 @@ "fastify": "4.29.1", "globals": "17.14.0", "husky": "9.1.7", + "hyperdht": "6.34.1", "jsdom": "31.0.0", "ky": "2.2.0", "lint-staged": "17.6.0", diff --git a/src/contexts/ClientApi.ts b/src/contexts/ClientApi.ts index 8fcdffb..e657fbd 100644 --- a/src/contexts/ClientApi.ts +++ b/src/contexts/ClientApi.ts @@ -1,3 +1,4 @@ +import type { InviteLinkJoiner } from '@comapeo/core' import type { ComapeoCoreClientApi } from '@comapeo/ipc' import { useQueryClient } from '@tanstack/react-query' import { @@ -9,7 +10,11 @@ import { type PropsWithChildren, } from 'react' -import { getInvitesQueryKey } from '../lib/react-query.js' +import { + getInvitesQueryKey, + getJoinRequestsByIdQueryKey, + getJoinRequestsQueryKey, +} from '../lib/react-query.js' export const ClientApiContext: Context = createContext(null) @@ -32,17 +37,48 @@ export function ClientApiProvider({ const queryClient = useQueryClient() useEffect(() => { - function invalidateInviteCache() { + function handleInviteEvent() { queryClient.invalidateQueries({ queryKey: getInvitesQueryKey() }) } - // Invite listeners - clientApi.invite.addListener('invite-received', invalidateInviteCache) - clientApi.invite.addListener('invite-updated', invalidateInviteCache) + clientApi.invite.addListener('invite-received', handleInviteEvent) + clientApi.invite.addListener('invite-updated', handleInviteEvent) + + function handleJoinRequestUpdateEvent( + update: InviteLinkJoiner.JoinRequestUpdate, + ) { + queryClient.invalidateQueries({ + queryKey: getJoinRequestsQueryKey(), + // NOTE: Only want to invalidate the query for getting all join requests (no cascade) + exact: true, + }) + + // NOTE: Directly update query for getting specific join request + queryClient.setQueryData( + getJoinRequestsByIdQueryKey({ inviteId: update.inviteId }), + (prev) => { + if (!prev) { + return prev + } + + return { ...prev, ...update } + }, + ) + } + + clientApi.inviteLinks.addListener( + 'join-request-update', + handleJoinRequestUpdateEvent, + ) return () => { - clientApi.invite.removeListener('invite-received', invalidateInviteCache) - clientApi.invite.removeListener('invite-updated', invalidateInviteCache) + clientApi.invite.removeListener('invite-received', handleInviteEvent) + clientApi.invite.removeListener('invite-updated', handleInviteEvent) + + clientApi.inviteLinks.removeListener( + 'join-request-update', + handleJoinRequestUpdateEvent, + ) } }, [clientApi, queryClient]) diff --git a/src/hooks/invites.ts b/src/hooks/invites.ts index 925aeaa..91906b3 100644 --- a/src/hooks/invites.ts +++ b/src/hooks/invites.ts @@ -1,8 +1,9 @@ -import type { InviteApi, MemberApi } from '@comapeo/core' +import type { InviteApi, InviteLinkJoiner, MemberApi } from '@comapeo/core' import { useMutation, useQueryClient, useSuspenseQuery, + type UseMutationResult, type UseSuspenseQueryResult, } from '@tanstack/react-query' @@ -12,8 +13,11 @@ import { filterMutationResult, getInvitesByIdQueryKey, getInvitesQueryKey, + getJoinRequestsByIdQueryKey, + getJoinRequestsQueryKey, getMembersQueryKey, getProjectsQueryKey, + type FilteredMutationResult, } from '../lib/react-query.js' import { useClientApi } from './client.js' import { useSingleProject } from './projects.js' @@ -132,7 +136,23 @@ export function useRejectInvite() { * * @param opts.projectId Public ID of project to send the invite on behalf of. */ -export function useSendInvite({ projectId }: { projectId: string }) { +export function useSendInvite({ + projectId, +}: { + projectId: string +}): // NOTE: Needs explicit return type due to TS struggles with inference (TS 2883) +FilteredMutationResult< + UseMutationResult< + MemberApi.InviteDecision, + Error, + { + deviceId: string + roleDescription?: string + roleId: MemberApi.RoleIdForNewInvite + roleName?: string + } + > +> { const queryClient = useQueryClient() const { data: projectApi } = useSingleProject({ projectId }) @@ -185,3 +205,150 @@ export function useRequestCancelInvite({ projectId }: { projectId: string }) { }), ) } + +/** + * Get all active join requests. + */ +export function useManyJoinRequests(): // NOTE: Needs explicit return type due to TS2742 +Pick< + UseSuspenseQueryResult>, + 'data' | 'error' | 'isRefetching' +> { + const clientApi = useClientApi() + + const { data, error, isRefetching } = useSuspenseQuery({ + ...baseQueryOptions(), + queryKey: getJoinRequestsQueryKey(), + queryFn: async () => { + return clientApi.inviteLinks.getJoinRequests() + }, + }) + + return { data, error, isRefetching } +} + +/** + * Get a specific join request. + * + * If the join request is not available when the consuming component mounts, it will trigger the closest error boundary. + * If the join request is cancelled while this hook is active, the `error` field will be populated with the relevant error from core. + * Updates from the '`join-request-update`' event are reflected in the `data` field. Updates that indicate failure are represented by + * `data.status` and `data.error`, not the top-level error field from the hook's return value. + * + * @param opts.inviteId ID of relevant invite. + * + * @example + * ```ts + * function ErrorExample({ inviteId }) { + * const joinRequestQuery = useSingleJoinRequest({ inviteId }) + * + * if (joinRequestQuery.error) { + * // Handle error from refetch due to write hook call (e.g. cancelling the join request) + * } + * + * if (joinRequestQuery.data.status === 'failed') { + * // Handle failure state based on updates happening in the background. + * } + * } + * ``` + */ +export function useSingleJoinRequest({ + inviteId, +}: { + inviteId: string +}): // NOTE: Needs explicit return type due to TS2742 +Pick< + UseSuspenseQueryResult, + 'data' | 'error' | 'isRefetching' +> { + const clientApi = useClientApi() + + const { data, error, isRefetching } = useSuspenseQuery({ + ...baseQueryOptions(), + // NOTE: Ensure garbage collection occurs immediately when all observers are unmounted + // so that newly active instances only use data from refetch. + // https://tanstack.com/query/latest/docs/framework/react/guides/caching + gcTime: 0, + queryKey: getJoinRequestsByIdQueryKey({ inviteId }), + queryFn: async () => { + return clientApi.inviteLinks.getJoinRequestById(inviteId) + }, + }) + + return { data, error, isRefetching } +} + +/** + * Create a join request for a received invite. + */ +export function useCreateJoinRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) +FilteredMutationResult< + UseMutationResult< + InviteLinkJoiner.JoinRequest, + Error, + { + /** URL for the received invite link. */ + url: string + /** Connection timeout in ms. */ + timeout?: number + } + > +> { + const clientApi = useClientApi() + + return filterMutationResult( + useMutation({ + ...baseMutationOptions(), + mutationFn: async ({ + url, + timeout, + }: { + url: string + timeout?: number + }) => { + // Have to avoid passing `undefined` explicitly + // See https://github.com/digidem/rpc-reflector/issues/21 + return timeout === undefined + ? clientApi.inviteLinks.createJoinRequest(url) + : clientApi.inviteLinks.createJoinRequest(url, { timeout }) + }, + onSuccess: async (_data, _variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: getJoinRequestsQueryKey(), + }) + }, + }), + ) +} + +/** + * Cancel a join request that has been created for a received invite. + */ +export function useCancelJoinRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) +FilteredMutationResult< + UseMutationResult< + void, + Error, + // TODO: Expose reason? + { + /** ID of invite to cancel request for. */ + inviteId: string + } + > +> { + const clientApi = useClientApi() + + return filterMutationResult( + useMutation({ + ...baseMutationOptions(), + mutationFn: async ({ inviteId }) => { + return clientApi.inviteLinks.cancelJoinRequest(inviteId) + }, + onSuccess: async (_data, _variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: getJoinRequestsQueryKey(), + }) + }, + }), + ) +} diff --git a/src/hooks/projects.ts b/src/hooks/projects.ts index 1a45ed4..5475ca0 100644 --- a/src/hooks/projects.ts +++ b/src/hooks/projects.ts @@ -10,6 +10,7 @@ import type { } from '@comapeo/ipc' import { matchQuery, + queryOptions, useMutation, UseMutationResult, useQueryClient, @@ -23,6 +24,7 @@ import { baseQueryOptions, filterMutationResult, getDocumentCreatedByQueryKey, + getInviteLinksQueryKey, getMediaServerOriginQueryKey, getMemberByIdQueryKey, getMembersQueryKey, @@ -36,6 +38,28 @@ import { SyncStore, type SyncState } from '../lib/sync.js' import { getBlobUrl, getIconUrl } from '../lib/urls.js' import { useClientApi } from './client.js' +function getProjectByIdQueryOptions({ + clientApi, + projectId, +}: { + clientApi: ComapeoCoreClientApi + projectId: string +}) { + return queryOptions({ + ...baseQueryOptions(), + queryKey: getProjectByIdQueryKey({ projectId }), + queryFn: async () => { + return clientApi.getProject(projectId) + }, + // Keep project instances around indefinitely - shouldn't be a memory + // problem because these are only lightweight proxy objects, and project + // references are kept indefinitely on the backend anyway once they are + // accessed + staleTime: Infinity, + gcTime: Infinity, + }) +} + /** * Retrieve the project settings for a project. * @@ -99,19 +123,9 @@ Pick< > { const clientApi = useClientApi() - const { data, error, isRefetching } = useSuspenseQuery({ - ...baseQueryOptions(), - queryKey: getProjectByIdQueryKey({ projectId }), - queryFn: async () => { - return clientApi.getProject(projectId) - }, - // Keep project instances around indefinitely - shouldn't be a memory - // problem because these are only lightweight proxy objects, and project - // references are kept indefinitely on the backend anyway once they are - // accessed - staleTime: Infinity, - gcTime: Infinity, - }) + const { data, error, isRefetching } = useSuspenseQuery( + getProjectByIdQueryOptions({ clientApi, projectId }), + ) return { data, error, isRefetching } } @@ -1044,3 +1058,201 @@ export function useExportZipFile({ projectId }: { projectId: string }) { }), ) } + +/** + * Get all active invite links. + */ +export function useManyInviteLinks({ + projectId, +}: { + projectId: string +}): // NOTE: Needs explicit return type due to TS2742 +Pick< + UseSuspenseQueryResult>, + 'data' | 'error' | 'isRefetching' +> { + const { data: projectApi } = useSingleProject({ projectId }) + + const { data, error, isRefetching } = useSuspenseQuery({ + ...baseQueryOptions(), + queryKey: getInviteLinksQueryKey({ projectId }), + queryFn: async () => { + return projectApi.$member.listInviteLinks() + }, + }) + + return { data, error, isRefetching } +} + +/** + * Create an invite link for a project. + */ +export function useCreateInviteLink(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) +FilteredMutationResult< + UseMutationResult< + string, + Error, + { + /** Public ID of relevant project. */ + projectId: string + } & Omit + > +> { + const clientApi = useClientApi() + + return filterMutationResult( + useMutation({ + ...baseMutationOptions(), + mutationFn: async ({ projectId, ...inviteOptions }, context) => { + const projectApi = await context.client.query({ + ...getProjectByIdQueryOptions({ clientApi, projectId }), + staleTime: 'static', + }) + + return projectApi.$member.createInviteLink(inviteOptions) + }, + onSuccess: async (_data, variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: getInviteLinksQueryKey({ projectId: variables.projectId }), + }) + }, + }), + ) +} + +/** + * Cancel a created invite link or all invite links for a project. If `inviteId` is not specified, + * all invite links are cancelled. + * + * @example + * ts``` + * function Example() { + * const cancelInviteLink = useCancelInviteLink() + * + * // Cancel a specific invite link + * cancelInviteLink.mutate({ projectId: '...', inviteId: '...' }) + * + * // Cancel all invite links + * cancelInviteLink.mutate({ projectId: '...' }) + * } + * ``` + */ +export function useCancelInviteLink() { + const clientApi = useClientApi() + + return filterMutationResult( + useMutation< + void, + Error, + { + /** Public ID of relevant project. */ + projectId: string + /** ID of relevant invite. */ + inviteId?: string + } + >({ + ...baseMutationOptions(), + mutationFn: async ({ projectId, inviteId }, context) => { + const projectApi = await context.client.query({ + ...getProjectByIdQueryOptions({ clientApi, projectId }), + staleTime: 'static', + }) + + // Have to avoid passing `undefined` explicitly + // See https://github.com/digidem/rpc-reflector/issues/21 + return inviteId + ? projectApi.$member.cancelInviteLink(inviteId) + : projectApi.$member.cancelInviteLink() + }, + onSuccess: async (_data, variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: getInviteLinksQueryKey({ projectId: variables.projectId }), + }) + }, + }), + ) +} + +/** + * Accept an invite link join request for a project. + */ +export function useAcceptInviteLinkRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) +FilteredMutationResult< + UseMutationResult< + MemberApi.InviteDecision, + Error, + { + /** Public ID of relevant project. */ + projectId: string + /** ID of relevant invite. */ + inviteId: string + /** ID of device who sent join request. */ + deviceId: string + } + > +> { + const clientApi = useClientApi() + + return filterMutationResult( + useMutation({ + ...baseMutationOptions(), + mutationFn: async ({ projectId, inviteId, deviceId }, context) => { + const projectApi = await context.client.query({ + ...getProjectByIdQueryOptions({ clientApi, projectId }), + staleTime: 'static', + }) + + return projectApi.$member.acceptInviteLinkRequest(inviteId, deviceId) + }, + onSuccess: async (_data, variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: getInviteLinksQueryKey({ projectId: variables.projectId }), + }) + context.client.invalidateQueries({ + queryKey: getMembersQueryKey({ projectId: variables.projectId }), + }) + }, + }), + ) +} + +/** + * Deny an invite link join request for a project. + */ +export function useDenyInviteLinkRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) +FilteredMutationResult< + UseMutationResult< + void, + Error, + // TODO: Expose reason? + { + /** Public ID of relevant project. */ + projectId: string + /** ID of relevant invite. */ + inviteId: string + /** ID of device who sent join request. */ + deviceId: string + } + > +> { + const clientApi = useClientApi() + + return filterMutationResult( + useMutation({ + ...baseMutationOptions(), + mutationFn: async ({ projectId, inviteId, deviceId }, context) => { + const projectApi = await context.client.query({ + ...getProjectByIdQueryOptions({ clientApi, projectId }), + staleTime: 'static', + }) + + return projectApi.$member.denyInviteLinkRequest(inviteId, deviceId) + }, + onSuccess: async (_data, variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: getInviteLinksQueryKey({ projectId: variables.projectId }), + }) + }, + }), + ) +} diff --git a/src/index.ts b/src/index.ts index d1325fa..c4ae127 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,11 +17,15 @@ export { } from './hooks/documents.js' export { useAcceptInvite, + useCancelJoinRequest, + useCreateJoinRequest, useManyInvites, + useManyJoinRequests, useRejectInvite, useRequestCancelInvite, useSendInvite, useSingleInvite, + useSingleJoinRequest, } from './hooks/invites.js' export { useMapStyleUrl, @@ -54,25 +58,33 @@ export { InvalidStatusTransitionError, } from './lib/map-shares-stores.js' export { + useAcceptInviteLinkRequest, useAddServerPeer, useAttachmentUrl, + useCancelInviteLink, + useChangeMemberRole, useConnectSyncServers, useCreateBlob, + useCreateInviteLink, useCreateProject, useDataSyncProgress, + useDenyInviteLinkRequest, useDisconnectSyncServers, useDocumentCreatedBy, + useExportGeoJSON, + useExportZipFile, useIconUrl, useImportProjectCategories, useImportProjectConfig, useLeaveProject, + useManyInviteLinks, useManyMembers, useManyProjects, useOwnRoleInProject, useProjectOwnRoleChangeListener, useProjectSettings, - useRemoveServerPeer, useRemoveMember, + useRemoveServerPeer, useSetAutostopDataSyncTimeout, useSingleMember, useSingleProject, @@ -80,9 +92,6 @@ export { useStopSync, useSyncState, useUpdateProjectSettings, - useChangeMemberRole, - useExportGeoJSON, - useExportZipFile, } from './hooks/projects.js' export type { SyncState } from './lib/sync.js' export type { diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts index beefed2..4108092 100644 --- a/src/lib/react-query.ts +++ b/src/lib/react-query.ts @@ -113,6 +113,18 @@ export function getInvitesByIdQueryKey({ inviteId }: { inviteId: string }) { return [ROOT_QUERY_KEY, 'invites', { inviteId }] as const } +export function getJoinRequestsQueryKey() { + return [ROOT_QUERY_KEY, 'join-requests'] as const +} + +export function getJoinRequestsByIdQueryKey({ + inviteId, +}: { + inviteId: string +}) { + return [ROOT_QUERY_KEY, 'join-requests', { inviteId }] as const +} + // #endregion // #region Maps @@ -201,6 +213,10 @@ export function getMemberByIdQueryKey({ return [ROOT_QUERY_KEY, 'projects', projectId, 'members', deviceId] as const } +export function getInviteLinksQueryKey({ projectId }: { projectId: string }) { + return [ROOT_QUERY_KEY, 'projects', projectId, 'invite-links'] as const +} + export function getDocumentCreatedByQueryKey({ projectId, originalVersionId, diff --git a/test/helpers/client-api-mock.ts b/test/helpers/client-api-mock.ts index 887d548..d359895 100644 --- a/test/helpers/client-api-mock.ts +++ b/test/helpers/client-api-mock.ts @@ -10,6 +10,10 @@ export type MockClientApi = { addListener: ReturnType removeListener: ReturnType } + inviteLinks: { + addListener: ReturnType + removeListener: ReturnType + } } export function createMockClientApi(): MockClientApi { @@ -48,5 +52,10 @@ export function createMockClientApi(): MockClientApi { removeListener: vi.fn(), } - return { on, off, emit, listeners, sendMapShare, invite } + const inviteLinks = { + addListener: vi.fn(), + removeListener: vi.fn(), + } + + return { on, off, emit, listeners, sendMapShare, invite, inviteLinks } } diff --git a/test/helpers/ipc.ts b/test/helpers/ipc.ts index 761b69c..741903a 100644 --- a/test/helpers/ipc.ts +++ b/test/helpers/ipc.ts @@ -24,7 +24,11 @@ const clientMigrationsFolder = path.join( 'drizzle/client', ) -export function setupCoreIpc() { +export function setupCoreIpc({ + managerOverrides, +}: { + managerOverrides?: Partial[0]> +} = {}) { const { port1, port2 } = new MessageChannel() const fastify = Fastify() @@ -36,6 +40,7 @@ export function setupCoreIpc() { projectMigrationsFolder, clientMigrationsFolder, fastify, + ...managerOverrides, }) const server = createComapeoCoreServer(manager, port1) diff --git a/test/hooks/hyperdht.d.ts b/test/hooks/hyperdht.d.ts new file mode 100644 index 0000000..623ee00 --- /dev/null +++ b/test/hooks/hyperdht.d.ts @@ -0,0 +1,28 @@ +declare module 'hyperdht' { + export default class HyperDHT {} +} + +declare module 'hyperdht/testnet.js' { + interface TestnetOpts { + teardown?: TestnetTearDownFn + host?: string + port?: number + } + + type TestnetTearDownFn = ( + opts: TestnetOpts, + onFinishTeardown: () => Promise, + ) => void | Promise + + class TestNet { + nodes: Array + bootstrap: Array + createNode(opts?: object): HyperDHT + destroy(): Promise + } + + export default function createTestnet( + size?: number, + opts?: TestnetOpts | TestnetTearDownFn, + ): Promise +} diff --git a/test/hooks/invite-rejoin.test.ts b/test/hooks/invite-rejoin.test.ts deleted file mode 100644 index e1436ea..0000000 --- a/test/hooks/invite-rejoin.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -// @vitest-environment node -import '../helpers/jsdom-setup.js' - -import type { InviteApi } from '@comapeo/core' -import { QueryClient } from '@tanstack/react-query' -import { act, renderHook, waitFor } from '@testing-library/react' -import { assert, test } from 'vitest' - -import { - useAcceptInvite, - useLeaveProject, - useProjectSettings, - useSingleProject, -} from '../../src/index.js' -import { setupCoreIpc } from '../helpers/ipc.js' -import { createWrapper } from '../helpers/react.js' - -const MEMBER_ROLE_ID = '012fd2d431c0bf60' -const BLOCKED_ROLE_ID = '9e6d29263cba36c9' - -type Managers = Array['manager']> - -function connectPeers(managers: Managers) { - let requestedDisconnect = false - for (const manager of managers) { - manager.startLocalPeerDiscoveryServer().then(({ name, port }) => { - if (requestedDisconnect) return - for (const otherManager of managers) { - if (otherManager === manager) continue - otherManager.connectLocalPeer({ address: '127.0.0.1', name, port }) - } - }) - } - return async () => { - requestedDisconnect = true - await Promise.all( - managers.map((manager) => - manager.stopLocalPeerDiscoveryServer({ force: true }), - ), - ) - } -} - -async function waitForPeers(managers: Managers) { - const deviceIds = new Set(managers.map((m) => m.deviceId)) - const isDone = async () => { - for (const manager of managers) { - const unconnected = new Set(deviceIds) - unconnected.delete(manager.deviceId) - for (const peer of await manager.listLocalPeers()) { - if (peer.status === 'connected') unconnected.delete(peer.deviceId) - } - if (unconnected.size > 0) return false - } - return true - } - while (!(await isDone())) { - await new Promise((res) => setTimeout(res, 50)) - } -} - -// Regression test for digidem/comapeo-mobile#2042 and #2041: a member is -// removed from a project, leaves it, and is re-invited. Accepting the new -// invite closes the old project instance on the manager -// (`MapeoManager.addProject`) and opens a fresh one. The project client -// wrapper is cached with `staleTime: Infinity`, so without invalidation the -// hooks keep using the closed instance and every project call rejects with -// ProjectClosed until app restart. -test( - 're-joining a project after leaving yields a working project instance', - { timeout: 60_000 }, - async (t) => { - const invitor = setupCoreIpc() - const invitee = setupCoreIpc() - - t.onTestFinished(async () => { - await Promise.all([invitor.cleanup(), invitee.cleanup()]) - }) - - await invitor.manager.setDeviceInfo({ - name: 'invitor', - deviceType: 'desktop', - }) - await invitee.manager.setDeviceInfo({ - name: 'invitee', - deviceType: 'mobile', - }) - - const disconnect = connectPeers([invitor.manager, invitee.manager]) - t.onTestFinished(disconnect) - await waitForPeers([invitor.manager, invitee.manager]) - - const projectId = await invitor.manager.createProject({ name: 'mapeo' }) - const invitorProject = await invitor.manager.getProject(projectId) - - const queryClient = new QueryClient() - const wrapper = createWrapper({ clientApi: invitee.client, queryClient }) - - async function inviteAndAccept() { - const deferredInvitePromise = Promise.withResolvers() - - invitee.manager.invite.once('invite-received', (invite) => { - deferredInvitePromise.resolve(invite) - }) - - const inviteSettled = invitorProject.$member.invite( - invitee.manager.deviceId, - { roleId: MEMBER_ROLE_ID }, - ) - const { inviteId } = await deferredInvitePromise.promise - const acceptHook = renderHook(() => useAcceptInvite(), { wrapper }) - act(() => { - acceptHook.result.current.mutate({ inviteId }) - }) - await waitFor( - () => { - assert.strictEqual( - acceptHook.result.current.status, - 'success', - `accept failed: ${acceptHook.result.current.error?.stack}`, - ) - }, - { timeout: 10_000 }, - ) - await inviteSettled - acceptHook.unmount() - } - - await inviteAndAccept() - - // Simulates app screens using the project after joining - const projectHook = renderHook( - ({ projectId }) => useSingleProject({ projectId }), - { wrapper, initialProps: { projectId } }, - ) - await waitFor(() => { - assert.isNotNull(projectHook.result.current) - assert.ok(projectHook.result.current.data) - }) - const originalWrapper = projectHook.result.current.data - - // Invitor removes the member - await invitorProject.$member.assignRole( - invitee.manager.deviceId, - BLOCKED_ROLE_ID, - ) - - // Wait for the role change to sync to the invitee (the app listens for - // this via `own-role-change` and shows the "removed from project" sheet) - await waitFor( - async () => { - const role = await originalWrapper.$getOwnRole() - assert.strictEqual(role.roleId, BLOCKED_ROLE_ID) - }, - { timeout: 10_000 }, - ) - - // The app unmounts the removed project's screens before leaving - projectHook.unmount() - - const leaveHook = renderHook(() => useLeaveProject(), { wrapper }) - act(() => { - leaveHook.result.current.mutate({ projectId }) - }) - await waitFor(() => { - assert.strictEqual(leaveHook.result.current.status, 'success') - }) - leaveHook.unmount() - - // Invitor re-invites, invitee accepts. Accepting re-adds the project: - // the manager closes the stale project instance and opens a fresh one. - await inviteAndAccept() - - // Simulates the app navigating (back) into the project after re-joining: - // the project provider and its dependent screens mount together, so a - // stale cached project client would be handed to the dependent queries - // synchronously (digidem/comapeo-mobile#2041's fatal ProjectClosed). - const rejoinedProjectHook = renderHook( - ({ projectId }) => useSingleProject({ projectId }), - { wrapper, initialProps: { projectId } }, - ) - const settingsHook = renderHook( - ({ projectId }) => useProjectSettings({ projectId }), - { wrapper, initialProps: { projectId } }, - ) - await waitFor(() => { - assert.isNotNull(rejoinedProjectHook.result.current) - assert.ok(rejoinedProjectHook.result.current.data) - }) - await waitFor( - () => { - assert.isNotNull(settingsHook.result.current) - assert.isNull(settingsHook.result.current.error) - assert.ok(settingsHook.result.current.data) - }, - { timeout: 10_000 }, - ) - assert.strictEqual(settingsHook.result.current.data.name, 'mapeo') - - assert.strictEqual( - rejoinedProjectHook.result.current.data, - originalWrapper, - 'Rejoined project uses original project client instance', - ) - }, -) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts new file mode 100644 index 0000000..37d98c1 --- /dev/null +++ b/test/hooks/invites.test.ts @@ -0,0 +1,1009 @@ +// @vitest-environment node +import '../helpers/jsdom-setup.js' + +import type { InviteApi, InviteLinkJoiner } from '@comapeo/core' +import { + getErrorCode, + InviteDeniedByInviterError, + JoinRequestNotFoundError, +} from '@comapeo/core/errors.js' +import { parseInviteURL } from '@comapeo/core/invite-urls.js' +import { QueryClient } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import createTestnet from 'hyperdht/testnet.js' +import { assert, test } from 'vitest' + +import { + useAcceptInvite, + useAcceptInviteLinkRequest, + useCancelInviteLink, + useCancelJoinRequest, + useCreateInviteLink, + useCreateJoinRequest, + useDenyInviteLinkRequest, + useLeaveProject, + useManyInviteLinks, + useManyJoinRequests, + useManyMembers, + useProjectSettings, + useSingleJoinRequest, + useSingleProject, +} from '../../src/index.js' +import { setupCoreIpc } from '../helpers/ipc.js' +import { createWrapper } from '../helpers/react.js' + +const MEMBER_ROLE_ID = '012fd2d431c0bf60' +const BLOCKED_ROLE_ID = '9e6d29263cba36c9' + +type Managers = Array['manager']> + +function connectPeers(managers: Managers) { + let requestedDisconnect = false + for (const manager of managers) { + manager.startLocalPeerDiscoveryServer().then(({ name, port }) => { + if (requestedDisconnect) return + for (const otherManager of managers) { + if (otherManager === manager) continue + otherManager.connectLocalPeer({ address: '127.0.0.1', name, port }) + } + }) + } + return async () => { + requestedDisconnect = true + await Promise.all( + managers.map((manager) => + manager.stopLocalPeerDiscoveryServer({ force: true }), + ), + ) + } +} + +async function waitForPeers(managers: Managers) { + const deviceIds = new Set(managers.map((m) => m.deviceId)) + const isDone = async () => { + for (const manager of managers) { + const unconnected = new Set(deviceIds) + unconnected.delete(manager.deviceId) + for (const peer of await manager.listLocalPeers()) { + if (peer.status === 'connected') unconnected.delete(peer.deviceId) + } + if (unconnected.size > 0) return false + } + return true + } + while (!(await isDone())) { + await new Promise((res) => setTimeout(res, 50)) + } +} + +// Regression test for digidem/comapeo-mobile#2042 and #2041: a member is +// removed from a project, leaves it, and is re-invited. Accepting the new +// invite closes the old project instance on the manager +// (`MapeoManager.addProject`) and opens a fresh one. The project client +// wrapper is cached with `staleTime: Infinity`, so without invalidation the +// hooks keep using the closed instance and every project call rejects with +// ProjectClosed until app restart. +test( + 're-joining a project after leaving yields a working project instance', + { timeout: 60_000 }, + async (t) => { + const invitor = setupCoreIpc() + const invitee = setupCoreIpc() + + t.onTestFinished(async () => { + await Promise.all([invitor.cleanup(), invitee.cleanup()]) + }) + + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) + await invitee.manager.setDeviceInfo({ + name: 'invitee', + deviceType: 'mobile', + }) + + const disconnect = connectPeers([invitor.manager, invitee.manager]) + t.onTestFinished(disconnect) + await waitForPeers([invitor.manager, invitee.manager]) + + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + const invitorProject = await invitor.manager.getProject(projectId) + + const queryClient = new QueryClient() + const wrapper = createWrapper({ clientApi: invitee.client, queryClient }) + + async function inviteAndAccept() { + const deferredInvitePromise = Promise.withResolvers() + + invitee.manager.invite.once('invite-received', (invite) => { + deferredInvitePromise.resolve(invite) + }) + + const inviteSettled = invitorProject.$member.invite( + invitee.manager.deviceId, + { roleId: MEMBER_ROLE_ID }, + ) + const { inviteId } = await deferredInvitePromise.promise + const acceptHook = renderHook(() => useAcceptInvite(), { wrapper }) + act(() => { + acceptHook.result.current.mutate({ inviteId }) + }) + await waitFor( + () => { + assert.strictEqual( + acceptHook.result.current.status, + 'success', + `accept failed: ${acceptHook.result.current.error?.stack}`, + ) + }, + { timeout: 10_000 }, + ) + await inviteSettled + acceptHook.unmount() + } + + await inviteAndAccept() + + // Simulates app screens using the project after joining + const projectHook = renderHook( + ({ projectId }) => useSingleProject({ projectId }), + { wrapper, initialProps: { projectId } }, + ) + await waitFor(() => { + assert.isNotNull(projectHook.result.current) + assert.ok(projectHook.result.current.data) + }) + const originalWrapper = projectHook.result.current.data + + // Invitor removes the member + await invitorProject.$member.assignRole( + invitee.manager.deviceId, + BLOCKED_ROLE_ID, + ) + + // Wait for the role change to sync to the invitee (the app listens for + // this via `own-role-change` and shows the "removed from project" sheet) + await waitFor( + async () => { + const role = await originalWrapper.$getOwnRole() + assert.strictEqual(role.roleId, BLOCKED_ROLE_ID) + }, + { timeout: 10_000 }, + ) + + // The app unmounts the removed project's screens before leaving + projectHook.unmount() + + const leaveHook = renderHook(() => useLeaveProject(), { wrapper }) + act(() => { + leaveHook.result.current.mutate({ projectId }) + }) + await waitFor(() => { + assert.strictEqual(leaveHook.result.current.status, 'success') + }) + leaveHook.unmount() + + // Invitor re-invites, invitee accepts. Accepting re-adds the project: + // the manager closes the stale project instance and opens a fresh one. + await inviteAndAccept() + + // Simulates the app navigating (back) into the project after re-joining: + // the project provider and its dependent screens mount together, so a + // stale cached project client would be handed to the dependent queries + // synchronously (digidem/comapeo-mobile#2041's fatal ProjectClosed). + const rejoinedProjectHook = renderHook( + ({ projectId }) => useSingleProject({ projectId }), + { wrapper, initialProps: { projectId } }, + ) + const settingsHook = renderHook( + ({ projectId }) => useProjectSettings({ projectId }), + { wrapper, initialProps: { projectId } }, + ) + await waitFor(() => { + assert.isNotNull(rejoinedProjectHook.result.current) + assert.ok(rejoinedProjectHook.result.current.data) + }) + await waitFor( + () => { + assert.isNotNull(settingsHook.result.current) + assert.isNull(settingsHook.result.current.error) + assert.ok(settingsHook.result.current.data) + }, + { timeout: 10_000 }, + ) + assert.strictEqual(settingsHook.result.current.data.name, 'mapeo') + + assert.strictEqual( + rejoinedProjectHook.result.current.data, + originalWrapper, + 'Rejoined project uses original project client instance', + ) + }, +) + +test.describe('invite over internet', () => { + test('invitee joins from URL', async (t) => { + // 1. Setup + const testnet = await createTestnet(2) + + t.onTestFinished(() => { + return testnet.destroy() + }) + + const invitor = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + const invitee = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + t.onTestFinished(async () => { + await Promise.all([invitor.cleanup(), invitee.cleanup()]) + }) + + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) + + await invitee.manager.setDeviceInfo({ + name: 'invitee', + deviceType: 'mobile', + }) + + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + + const invitorWrapper = createWrapper({ + clientApi: invitor.client, + }) + + const inviteeWrapper = createWrapper({ + clientApi: invitee.client, + }) + + const manyMembersHook = renderHook( + ({ projectId }) => useManyMembers({ projectId, includeLeft: false }), + { wrapper: invitorWrapper, initialProps: { projectId } }, + ) + + await waitFor( + () => { + assert.isNotNull(manyMembersHook.result.current) + assert.isNull(manyMembersHook.result.current.error) + assert.ok(manyMembersHook.result.current.data) + }, + { timeout: 10_000 }, + ) + + assert.strictEqual(manyMembersHook.result.current.data.length, 1) + + // 2. Invitor: create invite link and wait for Invitee to send join request + const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { + wrapper: invitorWrapper, + }) + + const inviteUrl = await act(() => { + return createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }) + }) + + const inviteIdFromUrl = parseInviteURL(inviteUrl).inviteIdString + + const deferredInviteLinkJoinRequest = Promise.withResolvers<{ + projectId: string + deviceId: string + inviteId: string + name: string + }>() + + invitor.manager.on( + 'invite-link-join-request', + (projectId, deviceId, inviteId, name) => { + if (inviteId !== inviteIdFromUrl) { + return + } + + deferredInviteLinkJoinRequest.resolve({ + projectId, + deviceId, + inviteId, + name, + }) + }, + ) + + invitor.manager.on( + 'invite-link-join-request-error', + (error, deviceId, inviteId) => { + if (inviteId !== inviteIdFromUrl) { + return + } + + deferredInviteLinkJoinRequest.reject( + new Error( + `Invite link join error for device ${deviceId} and invite ${inviteId}`, + { cause: error }, + ), + ) + }, + ) + + // 3. Invitee: create join request and wait for it to be accepted and completed + const createJoinRequestHook = renderHook(() => useCreateJoinRequest(), { + wrapper: inviteeWrapper, + }) + const manyJoinRequestsHook = renderHook(() => useManyJoinRequests(), { + wrapper: inviteeWrapper, + }) + + const createdJoinRequest = await act(() => { + return createJoinRequestHook.result.current.mutateAsync({ + url: inviteUrl, + }) + }) + + const singleJoinRequestHook = renderHook( + ({ inviteId }) => useSingleJoinRequest({ inviteId }), + { + wrapper: inviteeWrapper, + initialProps: { inviteId: createdJoinRequest.inviteId }, + }, + ) + + await waitFor(() => { + assert.strictEqual( + manyJoinRequestsHook.result.current.isRefetching, + false, + ) + assert.isNull(manyJoinRequestsHook.result.current.error) + assert.ok(manyJoinRequestsHook.result.current.data) + + assert.strictEqual( + singleJoinRequestHook.result.current.isRefetching, + false, + ) + assert.isNull(singleJoinRequestHook.result.current.error) + assert.ok(singleJoinRequestHook.result.current.data) + }) + + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 1) + + // Changes to the `status` field are managed by internal implementation details. + { + const { status: _, ...joinRequestFromManyJoinRequestsHook } = + manyJoinRequestsHook.result.current.data[0]! + + const { status: __, ...joinRequestFromSingleJoinRequestHook } = + singleJoinRequestHook.result.current.data + + const { status: ___, ...createdJoinRequestWithoutStatus } = + createdJoinRequest + + assert.deepStrictEqual( + joinRequestFromManyJoinRequestsHook, + createdJoinRequestWithoutStatus, + ) + assert.deepStrictEqual( + joinRequestFromSingleJoinRequestHook, + createdJoinRequestWithoutStatus, + ) + } + + const deferredJoinRequestCompleted = + Promise.withResolvers() + + invitee.manager.inviteLinks.on('join-request-update', (update) => { + if (update.inviteId !== createdJoinRequest.inviteId) { + return + } + + if (update.status === 'failed') { + deferredJoinRequestCompleted.reject(update.error) + return + } + + if (update.status === 'completed') { + deferredJoinRequestCompleted.resolve(update) + } + }) + + const linkJoinRequestPayload = await deferredInviteLinkJoinRequest.promise + + const acceptInviteLinkRequestHook = renderHook( + () => useAcceptInviteLinkRequest(), + { wrapper: invitorWrapper }, + ) + + act(() => { + acceptInviteLinkRequestHook.result.current.mutate(linkJoinRequestPayload) + }) + + await waitFor( + () => { + assert.strictEqual( + acceptInviteLinkRequestHook.result.current.status, + 'success', + `accept invite link request failed: ${acceptInviteLinkRequestHook.result.current.error?.stack}`, + ) + }, + { timeout: 10_000 }, + ) + + await deferredJoinRequestCompleted.promise + + // 4. Updates to relevant read hooks + await waitFor(() => { + assert.strictEqual(manyMembersHook.result.current.data.length, 2) + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) + + assert.strictEqual( + singleJoinRequestHook.result.current.data.status, + 'completed', + ) + assert.strictEqual( + singleJoinRequestHook.result.current.data.projectId, + projectId, + ) + }) + + // TODO: Assert error behavior when read hook is reinitialized + }) + + test('invitor denies invitee join request', async (t) => { + // 1. Setup + const testnet = await createTestnet(2) + + t.onTestFinished(() => { + return testnet.destroy() + }) + + const invitor = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + const invitee = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + t.onTestFinished(async () => { + await Promise.all([invitor.cleanup(), invitee.cleanup()]) + }) + + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) + + await invitee.manager.setDeviceInfo({ + name: 'invitee', + deviceType: 'mobile', + }) + + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + + const invitorWrapper = createWrapper({ + clientApi: invitor.client, + }) + + const inviteeWrapper = createWrapper({ + clientApi: invitee.client, + }) + + const manyMembersHook = renderHook( + ({ projectId }) => useManyMembers({ projectId, includeLeft: false }), + { wrapper: invitorWrapper, initialProps: { projectId } }, + ) + + await waitFor( + () => { + assert.isNotNull(manyMembersHook.result.current) + assert.isNull(manyMembersHook.result.current.error) + assert.ok(manyMembersHook.result.current.data) + }, + { timeout: 10_000 }, + ) + + assert.strictEqual(manyMembersHook.result.current.data.length, 1) + + // 2. Invitor: create invite link and wait for Invitee to send join request + const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { + wrapper: invitorWrapper, + }) + + const inviteUrl = await act(() => { + return createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }) + }) + + const inviteIdFromUrl = parseInviteURL(inviteUrl).inviteIdString + + const deferredInviteLinkJoinRequest = Promise.withResolvers<{ + projectId: string + deviceId: string + inviteId: string + name: string + }>() + + invitor.manager.on( + 'invite-link-join-request', + (projectId, deviceId, inviteId, name) => { + if (inviteId !== inviteIdFromUrl) { + return + } + + deferredInviteLinkJoinRequest.resolve({ + projectId, + deviceId, + inviteId, + name, + }) + }, + ) + + invitor.manager.on( + 'invite-link-join-request-error', + (error, deviceId, inviteId) => { + if (inviteId !== inviteIdFromUrl) { + return + } + + deferredInviteLinkJoinRequest.reject( + new Error( + `Invite link join error for device ${deviceId} and invite ${inviteId}`, + { cause: error }, + ), + ) + }, + ) + + // 3. Invitee: create join request and wait for it to be denied + const createJoinRequestHook = renderHook(() => useCreateJoinRequest(), { + wrapper: inviteeWrapper, + }) + const manyJoinRequestsHook = renderHook(() => useManyJoinRequests(), { + wrapper: inviteeWrapper, + }) + + const createdJoinRequest = await act(() => { + return createJoinRequestHook.result.current.mutateAsync({ + url: inviteUrl, + }) + }) + + const singleJoinRequestHook = renderHook( + ({ inviteId }) => useSingleJoinRequest({ inviteId }), + { + wrapper: inviteeWrapper, + initialProps: { inviteId: createdJoinRequest.inviteId }, + }, + ) + + await waitFor(() => { + assert.strictEqual( + manyJoinRequestsHook.result.current.isRefetching, + false, + ) + assert.isNull(manyJoinRequestsHook.result.current.error) + assert.ok(manyJoinRequestsHook.result.current.data) + + assert.strictEqual( + singleJoinRequestHook.result.current.isRefetching, + false, + ) + assert.isNull(singleJoinRequestHook.result.current.error) + assert.ok(singleJoinRequestHook.result.current.data) + }) + + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 1) + + // Changes to the `status` field are managed by internal implementation details. + { + const { status: _, ...joinRequestFromManyJoinRequestsHook } = + manyJoinRequestsHook.result.current.data[0]! + + const { status: __, ...joinRequestFromSingleJoinRequestHook } = + singleJoinRequestHook.result.current.data + + const { status: ___, ...createdJoinRequestWithoutStatus } = + createdJoinRequest + + assert.deepStrictEqual( + joinRequestFromManyJoinRequestsHook, + createdJoinRequestWithoutStatus, + ) + assert.deepStrictEqual( + joinRequestFromSingleJoinRequestHook, + createdJoinRequestWithoutStatus, + ) + } + + const deferredJoinRequestDenied = + Promise.withResolvers() + + invitee.manager.inviteLinks.on('join-request-update', (update) => { + if (update.inviteId !== createdJoinRequest.inviteId) { + return + } + + if (update.status === 'failed') { + deferredJoinRequestDenied.resolve(update) + } else { + deferredJoinRequestDenied.reject( + new Error('Unexpected join request update', { cause: update }), + ) + } + }) + + const linkJoinRequestPayload = await deferredInviteLinkJoinRequest.promise + + const denyInviteLinkRequestHook = renderHook( + () => useDenyInviteLinkRequest(), + { wrapper: invitorWrapper }, + ) + + act(() => { + denyInviteLinkRequestHook.result.current.mutate(linkJoinRequestPayload) + }) + + await waitFor( + () => { + assert.strictEqual( + denyInviteLinkRequestHook.result.current.status, + 'success', + `deny invite link request failed: ${denyInviteLinkRequestHook.result.current.error?.stack}`, + ) + }, + { timeout: 10_000 }, + ) + + const joinRequestUpdatePayload = await deferredJoinRequestDenied.promise + + assert.strictEqual( + getErrorCode(joinRequestUpdatePayload.error), + InviteDeniedByInviterError.code, + ) + + // 4. Updates to relevant read hooks + await waitFor(() => { + assert.strictEqual(manyMembersHook.result.current.data.length, 1) + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) + + assert.strictEqual( + singleJoinRequestHook.result.current.data.status, + 'failed', + ) + + // TODO: Should be enabled. Custom error fields seem to be missing... + // assert.strictEqual( + // getErrorCode(singleJoinRequestHook.result.current.data.error), + // InviteDeniedByInviterError.code, + // ) + }) + + // TODO: Assert error behavior when read hook is reinitialized + }) + + test('invitor cancels specific invite link', async (t) => { + const testnet = await createTestnet(1) + + t.onTestFinished(() => { + return testnet.destroy() + }) + + const invitor = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + t.onTestFinished(() => { + return invitor.cleanup() + }) + + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) + + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + + const invitorWrapper = createWrapper({ + clientApi: invitor.client, + }) + + const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { + wrapper: invitorWrapper, + }) + const cancelInviteLinkHook = renderHook(() => useCancelInviteLink(), { + wrapper: invitorWrapper, + }) + + // 1. Invitor creates invite links + const [inviteUrl1, inviteUrl2] = await act(() => { + return Promise.all([ + createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }), + createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }), + ]) + }) + + const manyInviteLinksHook = renderHook( + ({ projectId }) => useManyInviteLinks({ projectId }), + { wrapper: invitorWrapper, initialProps: { projectId } }, + ) + + await waitFor(() => { + assert.strictEqual(manyInviteLinksHook.result.current.isRefetching, false) + assert.isNull(manyInviteLinksHook.result.current.error) + assert.ok(manyInviteLinksHook.result.current.data) + }) + + assert.strictEqual(manyInviteLinksHook.result.current.data.length, 2) + + // 2. Invitor cancels first invite link + act(() => { + cancelInviteLinkHook.result.current.mutate({ + projectId, + inviteId: parseInviteURL(inviteUrl1).inviteIdString, + }) + }) + + await waitFor(() => { + assert.strictEqual(cancelInviteLinkHook.result.current.status, 'success') + }) + + // 3. Updates to relevant read hooks + await waitFor(() => { + assert.strictEqual(manyInviteLinksHook.result.current.data.length, 1) + + assert.strictEqual( + manyInviteLinksHook.result.current.data[0]!.inviteId, + parseInviteURL(inviteUrl2).inviteIdString, + ) + }) + }) + + test('invitor cancels all invite links', async (t) => { + const testnet = await createTestnet(1) + + t.onTestFinished(() => { + return testnet.destroy() + }) + + const invitor = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + t.onTestFinished(() => { + return invitor.cleanup() + }) + + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) + + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + + const invitorWrapper = createWrapper({ + clientApi: invitor.client, + }) + + const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { + wrapper: invitorWrapper, + }) + const cancelInviteLinkHook = renderHook(() => useCancelInviteLink(), { + wrapper: invitorWrapper, + }) + + // 1. Invitor creates invite links + await act(() => { + return Promise.all([ + createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }), + createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }), + ]) + }) + + const manyInviteLinksHook = renderHook( + ({ projectId }) => useManyInviteLinks({ projectId }), + { wrapper: invitorWrapper, initialProps: { projectId } }, + ) + + await waitFor(() => { + assert.strictEqual(manyInviteLinksHook.result.current.isRefetching, false) + assert.isNull(manyInviteLinksHook.result.current.error) + assert.ok(manyInviteLinksHook.result.current.data) + }) + + assert.strictEqual(manyInviteLinksHook.result.current.data.length, 2) + + // 2. Invitor cancels all invite links + act(() => { + cancelInviteLinkHook.result.current.mutate({ projectId }) + }) + + await waitFor(() => { + assert.strictEqual(cancelInviteLinkHook.result.current.status, 'success') + }) + + // 3. Updates to relevant read hooks + await waitFor(() => { + assert.strictEqual(manyInviteLinksHook.result.current.data.length, 0) + }) + + // TODO: Assert error behavior when read hook is reinitialized + }) + + test('invitee cancels join request', { timeout: 30_000 }, async (t) => { + // 1. Setup + const testnet = await createTestnet(2) + + t.onTestFinished(() => { + return testnet.destroy() + }) + + const invitor = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + const invitee = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + t.onTestFinished(async () => { + await Promise.all([invitor.cleanup(), invitee.cleanup()]) + }) + + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) + + await invitee.manager.setDeviceInfo({ + name: 'invitee', + deviceType: 'mobile', + }) + + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + + const invitorWrapper = createWrapper({ + clientApi: invitor.client, + }) + + const inviteeWrapper = createWrapper({ + clientApi: invitee.client, + }) + + const manyMembersHook = renderHook( + ({ projectId }) => useManyMembers({ projectId, includeLeft: false }), + { wrapper: invitorWrapper, initialProps: { projectId } }, + ) + + await waitFor( + () => { + assert.isNotNull(manyMembersHook.result.current) + assert.isNull(manyMembersHook.result.current.error) + assert.ok(manyMembersHook.result.current.data) + }, + { timeout: 10_000 }, + ) + + assert.strictEqual(manyMembersHook.result.current.data.length, 1) + + // 2. Invitor: create invite link + const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { + wrapper: invitorWrapper, + }) + + const inviteUrl = await act(() => { + return createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }) + }) + + // 3. Invitee: create join request + const createJoinRequestHook = renderHook(() => useCreateJoinRequest(), { + wrapper: inviteeWrapper, + }) + const manyJoinRequestsHook = renderHook(() => useManyJoinRequests(), { + wrapper: inviteeWrapper, + }) + + const createdJoinRequest = await act(() => { + return createJoinRequestHook.result.current.mutateAsync({ + url: inviteUrl, + }) + }) + + const singleJoinRequestHook = renderHook( + ({ inviteId }) => useSingleJoinRequest({ inviteId }), + { + wrapper: inviteeWrapper, + initialProps: { inviteId: createdJoinRequest.inviteId }, + }, + ) + + await waitFor(() => { + assert.strictEqual( + manyJoinRequestsHook.result.current.isRefetching, + false, + ) + assert.isNull(manyJoinRequestsHook.result.current.error) + assert.ok(manyJoinRequestsHook.result.current.data) + + assert.strictEqual( + singleJoinRequestHook.result.current.isRefetching, + false, + ) + assert.isNull(singleJoinRequestHook.result.current.error) + assert.ok(singleJoinRequestHook.result.current.data) + }) + + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 1) + + // Changes to the `status` field are managed by internal implementation details. + { + const { status: _, ...joinRequestFromManyJoinRequestsHook } = + manyJoinRequestsHook.result.current.data[0]! + + const { status: __, ...joinRequestFromSingleJoinRequestHook } = + singleJoinRequestHook.result.current.data + + const { status: ___, ...createdJoinRequestWithoutStatus } = + createdJoinRequest + + assert.deepStrictEqual( + joinRequestFromManyJoinRequestsHook, + createdJoinRequestWithoutStatus, + ) + assert.deepStrictEqual( + joinRequestFromSingleJoinRequestHook, + createdJoinRequestWithoutStatus, + ) + } + + // 4. Invitee: cancel join request + const cancelJoinRequestHook = renderHook(() => useCancelJoinRequest(), { + wrapper: inviteeWrapper, + }) + + act(() => { + cancelJoinRequestHook.result.current.mutate({ + inviteId: createdJoinRequest.inviteId, + }) + }) + + await waitFor(() => { + assert.strictEqual( + cancelJoinRequestHook.result.current.status, + 'success', + `cancel join request failed: ${cancelJoinRequestHook.result.current.error?.stack}`, + ) + }) + + // 4. Updates to relevant read hooks + await waitFor(() => { + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) + assert.strictEqual( + getErrorCode(singleJoinRequestHook.result.current.error), + JoinRequestNotFoundError.code, + ) + }) + }) +})