diff --git a/demo/package-lock.json b/demo/package-lock.json index dd755286..e7fa68e1 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -8,8 +8,9 @@ "name": "demo", "version": "1.0.0", "dependencies": { - "@ably-labs/spaces": "^0.0.12-alpha", - "ably": "^1.2.41", + "@ably-labs/react-hooks": "file:../../react-hooks", + "@ably-labs/spaces": "file:../", + "ably": "^1.2.43", "classnames": "^2.3.2", "dayjs": "^1.11.9", "lodash.assign": "^4.2.0", @@ -18,7 +19,9 @@ "nanoid": "^4.0.2", "random-words": "^2.0.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-contenteditable": "^3.3.7", + "react-dom": "^18.2.0", + "sanitize-html": "^2.11.0" }, "devDependencies": { "@types/lodash.assign": "^4.2.7", @@ -27,6 +30,7 @@ "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", "@types/react-helmet": "^6.1.6", + "@types/sanitize-html": "^2.9.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react": "^4.0.3", @@ -42,6 +46,56 @@ "vite": "^4.4.5" } }, + "..": { + "name": "@ably-labs/spaces", + "version": "0.0.12", + "license": "ISC", + "dependencies": { + "ably": "^1.2.43", + "nanoid": "^4.0.2" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^5.51.0", + "@typescript-eslint/parser": "^5.51.0", + "@vitest/coverage-c8": "^0.28.4", + "eslint": "^8.33.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jsdoc": "^39.8.0", + "eslint-plugin-security": "^1.7.1", + "husky": "^8.0.0", + "mock-socket": "^9.1.5", + "prettier": "^2.8.3", + "rollup": "^3.28.0", + "ts-node": "^10.9.1", + "typescript": "^4.9.5", + "vitest": "^0.29.8" + } + }, + "../../react-hooks": { + "version": "2.1.1", + "license": "ISC", + "devDependencies": { + "@testing-library/react": "^13.3.0", + "@typescript-eslint/eslint-plugin": "^6.1.0", + "@typescript-eslint/parser": "^6.4.0", + "@vitejs/plugin-react": "^1.3.2", + "eslint": "^8.45.0", + "eslint-plugin-import": "^2.28.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "jsdom": "^20.0.0", + "prettier": "^3.0.0", + "react": ">=18.1.0", + "react-dom": ">=18.1.0", + "typescript": ">=4.4.4", + "vitest": "^0.18.0" + }, + "peerDependencies": { + "ably": "^1.2.27", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -51,13 +105,13 @@ "node": ">=0.10.0" } }, + "node_modules/@ably-labs/react-hooks": { + "resolved": "../../react-hooks", + "link": true + }, "node_modules/@ably-labs/spaces": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@ably-labs/spaces/-/spaces-0.0.12.tgz", - "integrity": "sha512-mNPtsltJPVT5sz/TYfEgIOlZUarWMD4TH4JnPtd2mHqS+9v+gnfKQ5tdEvTLMFvv0jEc1ZonUgNkU/EUsk4yYA==", - "dependencies": { - "ably": "^1.2.39" - } + "resolved": "..", + "link": true }, "node_modules/@ably/msgpack-js": { "version": "0.4.0", @@ -1117,6 +1171,15 @@ "@types/node": "*" } }, + "node_modules/@types/sanitize-html": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.9.0.tgz", + "integrity": "sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==", + "dev": true, + "dependencies": { + "htmlparser2": "^8.0.0" + } + }, "node_modules/@types/scheduler": { "version": "0.16.3", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", @@ -1338,9 +1401,9 @@ } }, "node_modules/ably": { - "version": "1.2.42", - "resolved": "https://registry.npmjs.org/ably/-/ably-1.2.42.tgz", - "integrity": "sha512-dUnza7cERLWaDa/2pLVXtU2PJoU5k/t6g9sQZI1dgWC5Vok39nE6tf/xH2Rat7PJs7pXl9hLpkg1AhS4Xwd2/w==", + "version": "1.2.43", + "resolved": "https://registry.npmjs.org/ably/-/ably-1.2.43.tgz", + "integrity": "sha512-HZ99Nd98KzYToNUD4+ysHp4+vMp1NmYTi59yqGpejHo/VffTgg0pereoib0nRRAHYUhGUGys5HGwR5yHYESWDA==", "dependencies": { "@ably/msgpack-js": "^0.4.0", "got": "^11.8.5", @@ -1825,6 +1888,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -1869,6 +1940,57 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.469", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.469.tgz", @@ -1883,6 +2005,17 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.19.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.0.tgz", @@ -2200,8 +2333,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.1", @@ -2485,6 +2617,24 @@ "node": ">=4" } }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -2615,6 +2765,14 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -18859,7 +19017,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -18948,6 +19105,11 @@ "node": ">=6" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -18993,8 +19155,7 @@ "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -19030,7 +19191,6 @@ "version": "8.4.27", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz", "integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -19161,7 +19321,6 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", - "dev": true, "funding": [ { "type": "github", @@ -19184,6 +19343,16 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -19252,6 +19421,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-contenteditable": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz", + "integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "prop-types": "^15.7.1" + }, + "peerDependencies": { + "react": ">=16.3" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -19264,6 +19445,11 @@ "react": "^18.2.0" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/react-refresh": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", @@ -19400,6 +19586,30 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/sanitize-html": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.11.0.tgz", + "integrity": "sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, + "node_modules/sanitize-html/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -19480,7 +19690,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -20272,12 +20481,44 @@ "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", "dev": true }, + "@ably-labs/react-hooks": { + "version": "file:../../react-hooks", + "requires": { + "@testing-library/react": "^13.3.0", + "@typescript-eslint/eslint-plugin": "^6.1.0", + "@typescript-eslint/parser": "^6.4.0", + "@vitejs/plugin-react": "^1.3.2", + "eslint": "^8.45.0", + "eslint-plugin-import": "^2.28.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "jsdom": "^20.0.0", + "prettier": "^3.0.0", + "react": ">=18.1.0", + "react-dom": ">=18.1.0", + "typescript": ">=4.4.4", + "vitest": "^0.18.0" + } + }, "@ably-labs/spaces": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@ably-labs/spaces/-/spaces-0.0.12.tgz", - "integrity": "sha512-mNPtsltJPVT5sz/TYfEgIOlZUarWMD4TH4JnPtd2mHqS+9v+gnfKQ5tdEvTLMFvv0jEc1ZonUgNkU/EUsk4yYA==", + "version": "file:..", "requires": { - "ably": "^1.2.39" + "@typescript-eslint/eslint-plugin": "^5.51.0", + "@typescript-eslint/parser": "^5.51.0", + "@vitest/coverage-c8": "^0.28.4", + "ably": "^1.2.43", + "eslint": "^8.33.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jsdoc": "^39.8.0", + "eslint-plugin-security": "^1.7.1", + "husky": "^8.0.0", + "mock-socket": "^9.1.5", + "nanoid": "^4.0.2", + "prettier": "^2.8.3", + "rollup": "^3.28.0", + "ts-node": "^10.9.1", + "typescript": "^4.9.5", + "vitest": "^0.29.8" } }, "@ably/msgpack-js": { @@ -20981,6 +21222,15 @@ "@types/node": "*" } }, + "@types/sanitize-html": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.9.0.tgz", + "integrity": "sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==", + "dev": true, + "requires": { + "htmlparser2": "^8.0.0" + } + }, "@types/scheduler": { "version": "0.16.3", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz", @@ -21107,9 +21357,9 @@ } }, "ably": { - "version": "1.2.42", - "resolved": "https://registry.npmjs.org/ably/-/ably-1.2.42.tgz", - "integrity": "sha512-dUnza7cERLWaDa/2pLVXtU2PJoU5k/t6g9sQZI1dgWC5Vok39nE6tf/xH2Rat7PJs7pXl9hLpkg1AhS4Xwd2/w==", + "version": "1.2.43", + "resolved": "https://registry.npmjs.org/ably/-/ably-1.2.43.tgz", + "integrity": "sha512-HZ99Nd98KzYToNUD4+ysHp4+vMp1NmYTi59yqGpejHo/VffTgg0pereoib0nRRAHYUhGUGys5HGwR5yHYESWDA==", "requires": { "@ably/msgpack-js": "^0.4.0", "got": "^11.8.5", @@ -21445,6 +21695,11 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" + }, "defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -21480,6 +21735,39 @@ "esutils": "^2.0.2" } }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + } + }, "electron-to-chromium": { "version": "1.4.469", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.469.tgz", @@ -21494,6 +21782,11 @@ "once": "^1.4.0" } }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, "esbuild": { "version": "0.19.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.0.tgz", @@ -21721,8 +22014,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "3.3.1", @@ -21934,6 +22226,17 @@ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true }, + "htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -22031,6 +22334,11 @@ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true }, + "is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -34129,8 +34437,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, "object-hash": { "version": "3.0.0", @@ -34192,6 +34499,11 @@ "callsites": "^3.0.0" } }, + "parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -34225,8 +34537,7 @@ "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" }, "picomatch": { "version": "2.3.1", @@ -34250,7 +34561,6 @@ "version": "8.4.27", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz", "integrity": "sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==", - "dev": true, "requires": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -34260,8 +34570,7 @@ "nanoid": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", - "dev": true + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==" } } }, @@ -34326,6 +34635,16 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -34368,6 +34687,15 @@ "loose-envify": "^1.1.0" } }, + "react-contenteditable": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/react-contenteditable/-/react-contenteditable-3.3.7.tgz", + "integrity": "sha512-GA9NbC0DkDdpN3iGvib/OMHWTJzDX2cfkgy5Tt98JJAbA3kLnyrNbBIpsSpPpq7T8d3scD39DHP+j8mAM7BIfQ==", + "requires": { + "fast-deep-equal": "^3.1.3", + "prop-types": "^15.7.1" + } + }, "react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -34377,6 +34705,11 @@ "scheduler": "^0.23.0" } }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "react-refresh": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", @@ -34464,6 +34797,26 @@ "queue-microtask": "^1.2.2" } }, + "sanitize-html": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.11.0.tgz", + "integrity": "sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==", + "requires": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + }, + "dependencies": { + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + } + } + }, "scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -34527,8 +34880,7 @@ "source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" }, "strip-ansi": { "version": "6.0.1", diff --git a/demo/package.json b/demo/package.json index 880cf545..4ca6863b 100644 --- a/demo/package.json +++ b/demo/package.json @@ -12,8 +12,9 @@ "deploy:production": "npm run build && netlify deploy --prod" }, "dependencies": { - "@ably-labs/spaces": "^0.0.12-alpha", - "ably": "^1.2.41", + "@ably-labs/react-hooks": "file:../../react-hooks", + "@ably-labs/spaces": "file:../", + "ably": "^1.2.43", "classnames": "^2.3.2", "dayjs": "^1.11.9", "lodash.assign": "^4.2.0", @@ -22,7 +23,9 @@ "nanoid": "^4.0.2", "random-words": "^2.0.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-contenteditable": "^3.3.7", + "react-dom": "^18.2.0", + "sanitize-html": "^2.11.0" }, "devDependencies": { "@types/lodash.assign": "^4.2.7", @@ -31,6 +34,7 @@ "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", "@types/react-helmet": "^6.1.6", + "@types/sanitize-html": "^2.9.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react": "^4.0.3", diff --git a/demo/src/App.tsx b/demo/src/App.tsx index a58874d6..d824ad99 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -3,6 +3,7 @@ import { useContext, useEffect } from 'react'; import { Header, SlideMenu, SpacesContext, CurrentSlide, AblySvg, slides } from './components'; import { getRandomName, getRandomColor } from './utils'; import { useMembers } from './hooks'; +import { PreviewProvider } from './components/PreviewContext.tsx'; const App = () => { const space = useContext(SpacesContext); @@ -32,7 +33,9 @@ const App = () => { id="feature-display" className="absolute gap-12 bg-[#F7F6F9] w-full h-[calc(100%-80px)] -z-10 overflow-y-hidden overflow-x-hidden flex justify-between min-w-[375px] xs:flex-col md:flex-row" > - + + + diff --git a/demo/src/components/Avatar.tsx b/demo/src/components/Avatar.tsx index 820c80b1..c9d84ab0 100644 --- a/demo/src/components/Avatar.tsx +++ b/demo/src/components/Avatar.tsx @@ -1,5 +1,5 @@ import cn from 'classnames'; -import { type SpaceMember } from '../../../src/types'; +import { type SpaceMember } from '@ably-labs/spaces'; import { AvatarInfo } from './AvatarInfo'; import { LightningSvg } from './svg'; diff --git a/demo/src/components/AvatarInfo.tsx b/demo/src/components/AvatarInfo.tsx index ec6dd6fa..ae8180ef 100644 --- a/demo/src/components/AvatarInfo.tsx +++ b/demo/src/components/AvatarInfo.tsx @@ -4,7 +4,7 @@ import cn from 'classnames'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; -import { type SpaceMember } from '../../../src/types'; +import { type SpaceMember } from '@ably-labs/spaces'; import { type ProfileData } from '../utils/types'; type Props = Omit & { diff --git a/demo/src/components/EditableText.tsx b/demo/src/components/EditableText.tsx new file mode 100644 index 00000000..238990ac --- /dev/null +++ b/demo/src/components/EditableText.tsx @@ -0,0 +1,65 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import ContentEditable, { ContentEditableEvent } from 'react-contenteditable'; +import sanitize from 'sanitize-html'; + +interface EditableTextProps extends Omit, 'onChange' | 'children'> { + as?: string; + disabled: boolean; + value: string; + onChange(nextValue: string): void; + maxlength?: number; + className?: string; +} + +export const EditableText: React.FC = ({ + as, + disabled, + maxlength = 300, + value, + onChange, + ...restProps +}) => { + const elementRef = useRef(null); + const handleTextChange = useCallback( + (evt: ContentEditableEvent) => { + const nextValue = sanitize(evt.target.value, { + allowedTags: [], + }); + + if (nextValue.length > maxlength) { + onChange(value); + } else { + onChange(nextValue); + } + }, + [onChange, value, maxlength], + ); + + useEffect(() => { + const element = elementRef.current; + if (!disabled && element) { + moveCursorToEnd(element); + } + }, [disabled]); + + return ( + + ); +}; + +const moveCursorToEnd = (el: HTMLElement) => { + el.focus(); + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +}; diff --git a/demo/src/components/Image.tsx b/demo/src/components/Image.tsx index 06fa9149..3a91253b 100644 --- a/demo/src/components/Image.tsx +++ b/demo/src/components/Image.tsx @@ -8,21 +8,23 @@ interface Props extends React.HTMLAttributes { className?: string; id: string; slide: string; + locatable?: boolean; } -export const Image = ({ src, children, className, id, slide }: Props) => { - const { members } = useMembers(); - const { handleSelect } = useElementSelect(id); +export const Image = ({ src, children, className, id, slide, locatable = true }: Props) => { + const { members, self } = useMembers(); + const { handleSelect } = useElementSelect(id, false); const activeMember = findActiveMember(id, slide, members); - const name = getMemberFirstName(activeMember); - const outlineClasses = getOutlineClasses(activeMember); + const { outlineClasses, stickyLabelClasses } = getOutlineClasses(activeMember); + const memberName = getMemberFirstName(activeMember); + const label = self?.connectionId === activeMember?.connectionId ? 'You' : memberName; return (
{ data-id="slide-image-placeholder" className="cursor-pointer block" src={src} - onClick={handleSelect} + onClick={locatable ? handleSelect : undefined} /> {children ? children : null}
diff --git a/demo/src/components/Paragraph.tsx b/demo/src/components/Paragraph.tsx index 5c559269..02512f06 100644 --- a/demo/src/components/Paragraph.tsx +++ b/demo/src/components/Paragraph.tsx @@ -1,36 +1,73 @@ +import React, { useRef } from 'react'; import cn from 'classnames'; -import { useElementSelect, useMembers } from '../hooks'; -import { findActiveMember, getMemberFirstName, getOutlineClasses } from '../utils'; +import { getMemberFirstName, getOutlineClasses } from '../utils'; +import { StickyLabel } from './StickyLabel'; +import { LockFilledSvg } from './svg/LockedFilled.tsx'; +import { EditableText } from './EditableText.tsx'; +import { useTextComponentLock } from '../hooks/useTextComponentLock.ts'; interface Props extends React.HTMLAttributes { id: string; slide: string; variant?: 'regular' | 'aside'; + children: string; + maxlength?: number; } -export const Paragraph = ({ variant = 'regular', id, slide, className, ...props }: Props) => { - const { members } = useMembers(); - const { handleSelect } = useElementSelect(id); - const activeMember = findActiveMember(id, slide, members); - const name = getMemberFirstName(activeMember); - const outlineClasses = getOutlineClasses(activeMember); +export const Paragraph = ({ + variant = 'regular', + id, + slide, + className, + children, + maxlength = 300, + ...props +}: Props) => { + const containerRef = useRef(null); + const { content, activeMember, locked, lockedByYou, editIsNotAllowed, handleSelect, handleContentUpdate } = + useTextComponentLock({ + id, + slide, + defaultText: children, + containerRef, + }); + const memberName = getMemberFirstName(activeMember); + const { outlineClasses, stickyLabelClasses } = getOutlineClasses(activeMember); return ( -

+ className="relative" + onClick={locked ? undefined : handleSelect} + > + + {lockedByYou ? 'You' : memberName} + {editIsNotAllowed && } + + + ); }; diff --git a/demo/src/components/PreviewContext.tsx b/demo/src/components/PreviewContext.tsx new file mode 100644 index 00000000..81066d95 --- /dev/null +++ b/demo/src/components/PreviewContext.tsx @@ -0,0 +1,13 @@ +import React, { useContext } from 'react'; + +interface PreviewContextProviderProps { + preview: boolean; + children: React.ReactNode; +} +const PreviewContext = React.createContext(false); + +export const PreviewProvider: React.FC = ({ preview, children }) => ( + {children} +); + +export const usePreview = () => useContext(PreviewContext); diff --git a/demo/src/components/SlidePreview.tsx b/demo/src/components/SlidePreview.tsx index 46ef3307..b1af292e 100644 --- a/demo/src/components/SlidePreview.tsx +++ b/demo/src/components/SlidePreview.tsx @@ -15,7 +15,7 @@ export const SlidePreview = ({ children, index }: SlidePreviewProps) => { const membersOnASlide = (members || []).filter(({ location }) => location?.slide === `${index}`); const isActive = self?.location?.slide === `${index}`; - const handleSlideClick = () => { + const handleSlideClick = async () => { if (!space || !self) return; space.locations.set({ slide: `${index}`, element: null }); }; @@ -43,7 +43,7 @@ export const SlidePreview = ({ children, index }: SlidePreviewProps) => {

{children}
diff --git a/demo/src/components/SlidesStateContext.tsx b/demo/src/components/SlidesStateContext.tsx new file mode 100644 index 00000000..1680223a --- /dev/null +++ b/demo/src/components/SlidesStateContext.tsx @@ -0,0 +1,24 @@ +import React, { useMemo, useState } from 'react'; + +interface SlidesStateContextProps { + slidesState: Record; + setContent(id: string, nextContent: string): void; +} +export const SlidesStateContext = React.createContext({ + slidesState: {}, + setContent: () => {}, +}); + +export const SlidesStateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [slidesState, setSlidesState] = useState>({}); + const value = useMemo( + () => ({ + slidesState, + setContent: (id: string, nextContent: string) => { + setSlidesState((prevState) => ({ ...prevState, [id]: nextContent })); + }, + }), + [slidesState, setSlidesState], + ); + return {children}; +}; diff --git a/demo/src/components/SpacesContext.tsx b/demo/src/components/SpacesContext.tsx index 1c777450..a4d7074f 100644 --- a/demo/src/components/SpacesContext.tsx +++ b/demo/src/components/SpacesContext.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import Spaces, { type Space } from '../../../src/index'; +import Spaces, { type Space } from '@ably-labs/spaces'; import { Realtime } from 'ably'; import { nanoid } from 'nanoid'; @@ -7,7 +7,7 @@ import { getSpaceNameFromUrl } from '../utils'; const clientId = nanoid(); -const ably = new Realtime.Promise({ +export const ably = new Realtime.Promise({ authUrl: `/api/ably-token-request?clientId=${clientId}`, clientId, }); diff --git a/demo/src/components/StickyLabel.tsx b/demo/src/components/StickyLabel.tsx new file mode 100644 index 00000000..7511c826 --- /dev/null +++ b/demo/src/components/StickyLabel.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +interface StickyLabelProps { + visible: boolean; + className?: string; + children: React.ReactNode; +} +export const StickyLabel: React.FC = ({ visible, className, children }) => { + if (!visible) return null; + + return ( +
+ {children} +
+ ); +}; diff --git a/demo/src/components/Title.tsx b/demo/src/components/Title.tsx index 21c79b9c..2c24d714 100644 --- a/demo/src/components/Title.tsx +++ b/demo/src/components/Title.tsx @@ -1,39 +1,68 @@ +import React, { useRef } from 'react'; import cn from 'classnames'; -import { useElementSelect, useMembers } from '../hooks'; -import { findActiveMember, getMemberFirstName, getOutlineClasses } from '../utils'; + +import { getMemberFirstName, getOutlineClasses } from '../utils'; +import { LockFilledSvg } from './svg/LockedFilled.tsx'; +import { StickyLabel } from './StickyLabel.tsx'; +import { EditableText } from './EditableText.tsx'; +import { useTextComponentLock } from '../hooks/useTextComponentLock.ts'; interface Props extends React.HTMLAttributes { id: string; slide: string; variant?: 'h1' | 'h2' | 'h3'; + children: string; + maxlength?: number; } -export const Title = ({ variant = 'h1', className, id, slide, ...props }: Props) => { - const Component = variant; - const { members } = useMembers(); - const { handleSelect } = useElementSelect(id); - const activeMember = findActiveMember(id, slide, members); - const name = getMemberFirstName(activeMember); - const outlineClasses = getOutlineClasses(activeMember); +export const Title = ({ variant = 'h1', className, id, slide, children, maxlength = 70, ...props }: Props) => { + const containerRef = useRef(null); + const { content, activeMember, locked, lockedByYou, editIsNotAllowed, handleSelect, handleContentUpdate } = + useTextComponentLock({ + id, + slide, + defaultText: children, + containerRef, + }); + const memberName = getMemberFirstName(activeMember); + const { outlineClasses, stickyLabelClasses } = getOutlineClasses(activeMember); return ( - + className="relative" + onClick={locked ? undefined : handleSelect} + > + + {lockedByYou ? 'You' : memberName} + {editIsNotAllowed && } + + + ); }; diff --git a/demo/src/components/slides.tsx b/demo/src/components/slides.tsx index a96a0778..d5b2f781 100644 --- a/demo/src/components/slides.tsx +++ b/demo/src/components/slides.tsx @@ -5,7 +5,10 @@ import { Image } from './Image'; export const slides = [ { children: ( -
+
<div className="absolute w-[176px] left-[20px] top-[86px] md:top-20 md:left-6 md:right-6 md:mx-auto"> <Title variant="h2" id="4" slide="0" + maxlength={15} > Contrast @@ -40,6 +45,7 @@ export const slides = [ variant="aside" id="5" slide="0" + maxlength={105} > When a design uses several elements, the goal is to make each one distinct. @@ -49,12 +55,14 @@ export const slides = [ src="/repetition.svg" id="6" slide="0" + locatable={false} >
Repetition @@ -62,6 +70,7 @@ export const slides = [ variant="aside" id="8" slide="0" + maxlength={105} > Repetition helps designers establish relationships, develop organization and strengthen unity. @@ -71,6 +80,7 @@ export const slides = [ src="/alignment.svg" id="9" slide="0" + locatable={false} >
Alignment @@ -87,6 +98,7 @@ export const slides = [ variant="aside" id="11" slide="0" + maxlength={105} > Alignment creates a clean, sophisticated look. All elements should relate to all others in some way. @@ -96,6 +108,7 @@ export const slides = [ src="/proximity.svg" id="12" slide="0" + locatable={false} >
Proximity @@ -112,6 +126,7 @@ export const slides = [ variant="aside" id="14" slide="0" + maxlength={105} > When items are grouped, they become a single visual unit, rather than several separate entities. @@ -123,7 +138,10 @@ export const slides = [ }, { children: ( -
+
- No one likes boring text blocks on a website. And{' '} - <span className="text-ably-avatar-stack-demo-slide-title-highlight font-semibold">images and icons</span>{' '} - are the fastest way to get information. + No one likes boring text blocks on a website. And images and icons are the fastest way to get information. </Paragraph> <Paragraph id="4" slide="1" > - But <span className="text-ably-avatar-stack-demo-slide-title-highlight font-semibold">don't overdo it</span> - . If you can't explain for what purpose you put this line or icon, it's better to abandon it. + But don't overdo it. If you can't explain for what purpose you put this line or icon, it's better to abandon + it. </Paragraph> </div> <Image @@ -168,7 +184,10 @@ export const slides = [ }, { children: ( - <div className="grid grid-cols-1 md:grid-cols-2 gap-8 h-full items-center p-8"> + <div + key={2} + className="grid grid-cols-1 md:grid-cols-2 gap-8 h-full items-center p-8" + > <div> <Title variant="h1" diff --git a/demo/src/components/svg/LockedFilled.tsx b/demo/src/components/svg/LockedFilled.tsx new file mode 100644 index 00000000..68762802 --- /dev/null +++ b/demo/src/components/svg/LockedFilled.tsx @@ -0,0 +1,19 @@ +import { SVGProps } from 'react'; + +export const LockFilledSvg = (props: SVGProps<SVGSVGElement>) => { + return ( + <svg + width="1em" + height="1em" + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + {...props} + > + <path + d="M12.0003 5.8334H11.3337V4.50007C11.3337 2.66008 9.84032 1.16675 8.00033 1.16675C6.16033 1.16675 4.66699 2.66008 4.66699 4.50007V5.8334H4.00033C3.26699 5.8334 2.66699 6.43339 2.66699 7.16672V13.8334C2.66699 14.5667 3.26699 15.1667 4.00033 15.1667H12.0003C12.7337 15.1667 13.3337 14.5667 13.3337 13.8334V7.16672C13.3337 6.43339 12.7337 5.8334 12.0003 5.8334ZM8.00033 11.8334C7.26699 11.8334 6.66699 11.2334 6.66699 10.5C6.66699 9.76671 7.26699 9.16671 8.00033 9.16671C8.73366 9.16671 9.33366 9.76671 9.33366 10.5C9.33366 11.2334 8.73366 11.8334 8.00033 11.8334ZM6.00033 5.8334V4.50007C6.00033 3.39341 6.89366 2.50008 8.00033 2.50008C9.10699 2.50008 10.0003 3.39341 10.0003 4.50007V5.8334H6.00033Z" + fill="currentColor" + /> + </svg> + ); +}; diff --git a/demo/src/hooks/index.ts b/demo/src/hooks/index.ts index e39c8f68..b2ab5dab 100644 --- a/demo/src/hooks/index.ts +++ b/demo/src/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useMembers'; export * from './useElementSelect'; export * from './useTrackCursor'; +export * from './useLock'; diff --git a/demo/src/hooks/useElementSelect.ts b/demo/src/hooks/useElementSelect.ts index ff6c7ac4..be873b66 100644 --- a/demo/src/hooks/useElementSelect.ts +++ b/demo/src/hooks/useElementSelect.ts @@ -1,15 +1,67 @@ -import { useContext } from 'react'; +import { MutableRefObject, useContext, useEffect } from 'react'; import { SpacesContext } from '../components'; import { useMembers } from './useMembers'; -export const useElementSelect = (element?: string) => { +import { buildLockId, releaseMyLocks } from '../utils/locking'; +import { Member } from '../utils/types'; + +export const useElementSelect = (element?: string, lockable: boolean = true) => { const space = useContext(SpacesContext); const { self } = useMembers(); - const handleSelect = () => { + const handleSelect = async () => { if (!space || !self) return; - space.locations.set({ slide: self.location?.slide, element }); + + if (lockable) { + const lockId = buildLockId(self.location?.slide, element); + const lock = space.locks.get(lockId); + + if (!lock) { + // The lock is not set but we enter the location optimistically + await space.locations.set({ slide: self.location?.slide, element }); + // TODO delete this workaround when spaces API is ready + await delay(60); + await space.locks.acquire(lockId); + } + } else { + space.locations.set({ slide: self.location?.slide, element }); + } }; return { handleSelect }; }; + +export const useClickOutside = (ref: MutableRefObject<HTMLElement | null>, self?: Member, enabled?: boolean) => { + const space = useContext(SpacesContext); + + useEffect(() => { + if (!enabled) return; + const handleClick = async (e: DocumentEventMap['click']) => { + const clickedOutside = !ref.current?.contains(e.target as Node); + if (clickedOutside && space && self) { + await space.locations.set({ slide: self.location?.slide, element: undefined }); + // TODO delete this workaround when spaces API is ready + await delay(60); + await releaseMyLocks(space, self); + } + }; + + document.addEventListener('click', handleClick, true); + + return () => { + document.removeEventListener('click', handleClick, true); + }; + }, [space, self, enabled]); +}; + +export const useClearOnFailedLock = (lockConflict: boolean, self?: Member) => { + const space = useContext(SpacesContext); + + useEffect(() => { + if (lockConflict) { + space?.locations.set({ slide: self?.location?.slide, element: undefined }); + } + }, [lockConflict]); +}; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/demo/src/hooks/useLock.ts b/demo/src/hooks/useLock.ts new file mode 100644 index 00000000..356ca349 --- /dev/null +++ b/demo/src/hooks/useLock.ts @@ -0,0 +1,56 @@ +import { useContext, useEffect, useState } from 'react'; + +import { type Lock, LockStatus } from '@ably-labs/spaces'; + +import { SpacesContext } from '../components'; +import { buildLockId } from '../utils/locking'; +import { isMember } from '../hooks'; + +import { type Member } from '../utils/types'; + +export const useLock = (slide: string, id: string): { status?: string; member?: Member } => { + const space = useContext(SpacesContext); + const locationLockId = buildLockId(slide, id); + const [status, setStatus] = useState<LockStatus | undefined>(undefined); + const [member, setMember] = useState<Member | undefined>(undefined); + + useEffect(() => { + if (!space) return; + + const handler = (lock: Lock) => { + if (lock.request.id !== locationLockId) return; + + setStatus(lock.request.status); + + if (isMember(lock.member)) { + setMember(lock.member); + } + }; + + space.locks.subscribe('update', handler); + + return () => { + space?.locks.unsubscribe('update', handler); + }; + }, [space, slide, id]); + + useEffect(() => { + if (status !== undefined) return; + const lock = space?.locks.get(locationLockId); + if (lock) { + setMember(lock.member as any); + setStatus(lock.request.status); + } + }, [status]); + + return { status, member }; +}; + +export const useLockStatus = (slide: string, id: string, selfConnectionId?: string) => { + const { member, status } = useLock(slide, id); + + const locked = status === 'locked'; + const lockedByYou = locked && member?.connectionId === selfConnectionId; + + return { locked, lockedByYou }; +}; diff --git a/demo/src/hooks/useMembers.ts b/demo/src/hooks/useMembers.ts index 31442505..f7d5f717 100644 --- a/demo/src/hooks/useMembers.ts +++ b/demo/src/hooks/useMembers.ts @@ -1,10 +1,10 @@ import { useEffect, useState, useContext } from 'react'; -import { type SpaceMember } from '../../../src/types'; +import { type SpaceMember } from '@ably-labs/spaces'; import { SpacesContext } from '../components'; import { type Member } from '../utils/types'; -const isMember = (obj: unknown): obj is Member => { +export const isMember = (obj: unknown): obj is Member => { return !!(obj as Member)?.profileData?.name && !!(obj as Member)?.profileData?.color; }; @@ -24,6 +24,20 @@ export const useMembers: () => Partial<{ self?: Member; others: Member[]; member useEffect(() => { if (!space) return; + const handler = ({ members }: { members: SpaceMember[] }) => + (async () => { + const self = await space.members.getSelf(); + + if (isMember(self)) { + setSelf(self); + } + + if (areMembers(members)) { + setMembers([...members]); + setOthers(membersToOthers([...members], self)); + } + })(); + const init = async () => { const initSelf = await space.members.getSelf(); const initMembers = await space.members.getAll(); @@ -37,19 +51,6 @@ export const useMembers: () => Partial<{ self?: Member; others: Member[]; member setOthers(membersToOthers(initMembers, initSelf)); } - const handler = async ({ members }: { members: SpaceMember[] }) => { - const self = await space.members.getSelf(); - - if (isMember(self)) { - setSelf(self); - } - - if (areMembers(members)) { - setMembers([...members]); - setOthers(membersToOthers([...members], self)); - } - }; - space.subscribe('update', handler); }; diff --git a/demo/src/hooks/useSlideElementContent.ts b/demo/src/hooks/useSlideElementContent.ts new file mode 100644 index 00000000..5ef97f84 --- /dev/null +++ b/demo/src/hooks/useSlideElementContent.ts @@ -0,0 +1,13 @@ +import { useCallback, useContext } from 'react'; +import { SlidesStateContext } from '../components/SlidesStateContext.tsx'; + +export const useSlideElementContent = (id: string, defaultContent: string) => { + const { slidesState, setContent } = useContext(SlidesStateContext); + const updateContent = useCallback( + (nextContent: string) => { + setContent(id, nextContent); + }, + [id], + ); + return [slidesState[id] ?? defaultContent, updateContent] as const; +}; diff --git a/demo/src/hooks/useTextComponentLock.ts b/demo/src/hooks/useTextComponentLock.ts new file mode 100644 index 00000000..e67c5490 --- /dev/null +++ b/demo/src/hooks/useTextComponentLock.ts @@ -0,0 +1,55 @@ +import { MutableRefObject, useCallback } from 'react'; +import { useChannel } from '@ably-labs/react-hooks'; +import { findActiveMember, getSpaceNameFromUrl } from '../utils'; +import { buildLockId } from '../utils/locking.ts'; +import { usePreview } from '../components/PreviewContext.tsx'; +import { useMembers } from './useMembers.ts'; +import { useClearOnFailedLock, useClickOutside, useElementSelect } from './useElementSelect.ts'; +import { useLockStatus } from './useLock.ts'; +import { useSlideElementContent } from './useSlideElementContent.ts'; + +interface UseTextComponentLockArgs { + id: string; + slide: string; + defaultText: string; + containerRef: MutableRefObject<HTMLElement | null>; +} +export const useTextComponentLock = ({ id, slide, defaultText, containerRef }: UseTextComponentLockArgs) => { + const spaceName = getSpaceNameFromUrl(); + const { members, self } = useMembers(); + const activeMember = findActiveMember(id, slide, members); + const { locked, lockedByYou } = useLockStatus(slide, id, self?.connectionId); + const lockId = buildLockId(slide, id); + const channelName = `[?rewind=1]${spaceName}${lockId}`; + const [content, updateContent] = useSlideElementContent(lockId, defaultText); + const preview = usePreview(); + + const { handleSelect } = useElementSelect(id); + const handleContentUpdate = useCallback((content: string) => { + updateContent(content); + channel.publish('update', content); + }, []); + + const { channel } = useChannel(channelName, (message) => { + if (message.connectionId === self?.connectionId) return; + updateContent(message.data); + }); + + const optimisticallyLocked = !!activeMember; + const optimisticallyLockedByYou = optimisticallyLocked && activeMember?.connectionId === self?.connectionId; + const editIsNotAllowed = !optimisticallyLockedByYou && optimisticallyLocked; + const lockConflict = optimisticallyLockedByYou && locked && !lockedByYou && !preview; + + useClickOutside(containerRef, self, optimisticallyLockedByYou && !preview); + useClearOnFailedLock(lockConflict, self); + + return { + content, + activeMember, + locked: optimisticallyLocked, + lockedByYou: optimisticallyLockedByYou, + editIsNotAllowed, + handleSelect, + handleContentUpdate, + }; +}; diff --git a/demo/src/main.tsx b/demo/src/main.tsx index c3187cb1..cf155827 100644 --- a/demo/src/main.tsx +++ b/demo/src/main.tsx @@ -1,16 +1,22 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { AblyProvider } from '@ably-labs/react-hooks'; import App from './App'; import './index.css'; -import { SpaceContextProvider } from './components'; +import { ably, SpaceContextProvider } from './components'; +import { SlidesStateContextProvider } from './components/SlidesStateContext.tsx'; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( <React.StrictMode> <SpaceContextProvider> - <App /> + <AblyProvider client={ably}> + <SlidesStateContextProvider> + <App /> + </SlidesStateContextProvider> + </AblyProvider> </SpaceContextProvider> </React.StrictMode>, ); diff --git a/demo/src/utils/active-member.ts b/demo/src/utils/active-member.ts index d4ea5670..e636d4b8 100644 --- a/demo/src/utils/active-member.ts +++ b/demo/src/utils/active-member.ts @@ -11,9 +11,16 @@ export const getMemberFirstName = (member?: Member) => { }; export const getOutlineClasses = (member?: Member) => { - if (!member) return ''; + if (!member) + return { + outlineClasses: '', + stickyLabelClasses: '', + }; const { color } = member.profileData; const { name } = color; const { intensity } = color.gradientStart; - return `outline-${name}-${intensity} before:bg-${name}-${intensity}`; + return { + outlineClasses: `outline-${name}-${intensity}`, + stickyLabelClasses: `bg-${name}-${intensity}`, + }; }; diff --git a/demo/src/utils/locking.ts b/demo/src/utils/locking.ts new file mode 100644 index 00000000..35a56b25 --- /dev/null +++ b/demo/src/utils/locking.ts @@ -0,0 +1,9 @@ +import { Space } from '@ably-labs/spaces'; +import { Member } from './types'; + +export const releaseMyLocks = async (space: Space, self: Member) => { + await Promise.all([...space.locks.getLockRequests(self.connectionId).map((lock) => space.locks.release(lock.id))]); +}; + +export const buildLockId = (slide: string | undefined, element: string | undefined) => + `/slide/${slide}/element/${element}`; diff --git a/demo/src/utils/types.d.ts b/demo/src/utils/types.d.ts index a986fc55..f9b9d106 100644 --- a/demo/src/utils/types.d.ts +++ b/demo/src/utils/types.d.ts @@ -1,4 +1,4 @@ -import { type SpaceMember } from '../../../src'; +import { type SpaceMember } from '@ably-labs/spaces'; interface ProfileData { name: string;