diff --git a/.eslintrc.js b/.eslintrc.js index e8f9224..7043741 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,6 +27,7 @@ module.exports = { rules: { 'linebreak-style': 0, 'no-underscore-dangle': 0, + "no-shadow": "off", "import/prefer-default-export": "off", "import/extensions": "off", @@ -55,5 +56,6 @@ module.exports = { "react-hooks/exhaustive-deps": "error", "@typescript-eslint/no-unused-vars": "error", + "@typescript-eslint/no-shadow": "error" }, }; diff --git a/index.html b/index.html index af1a626..36c5740 100644 --- a/index.html +++ b/index.html @@ -27,7 +27,7 @@ - + diff --git a/package-lock.json b/package-lock.json index 2cca1d0..6267524 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,14 +14,25 @@ "@khanacademy/simple-markdown": "0.8.6", "@matrix-org/olm": "3.2.14", "@tippyjs/react": "4.2.6", + "@vanilla-extract/css": "1.9.3", + "@vanilla-extract/recipes": "0.3.0", + "@vanilla-extract/vite-plugin": "3.7.1", + "await-to-js": "3.0.0", "blurhash": "2.0.4", "browser-encrypt-attachment": "0.3.0", + "classnames": "2.3.2", "dateformat": "5.0.3", + "emojibase": "6.1.0", "emojibase-data": "7.0.1", "file-saver": "2.0.5", "flux": "4.0.3", + "focus-trap-react": "10.0.2", + "folds": "1.2.1", "formik": "2.2.9", "html-react-parser": "3.0.4", + "immer": "9.0.16", + "is-hotkey": "0.2.0", + "jotai": "1.12.0", "katex": "0.16.4", "linkify-html": "4.0.2", "linkifyjs": "4.0.2", @@ -36,8 +47,11 @@ "react-google-recaptcha": "2.1.0", "react-modal": "3.16.1", "sanitize-html": "2.8.0", + "slate": "0.90.0", + "slate-react": "0.90.0", "tippy.js": "6.3.7", - "twemoji": "14.0.2" + "twemoji": "14.0.2", + "ua-parser-js": "1.0.35" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "0.2.3", @@ -46,6 +60,7 @@ "@types/node": "18.11.18", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", + "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", "@vitejs/plugin-react": "3.0.0", @@ -61,7 +76,7 @@ "prettier": "2.8.1", "sass": "1.56.2", "typescript": "4.9.4", - "vite": "4.0.1", + "vite": "4.0.4", "vite-plugin-static-copy": "0.13.0" }, "engines": { @@ -72,7 +87,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", - "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.1.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -85,7 +99,6 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", - "dev": true, "dependencies": { "@babel/highlight": "^7.18.6" }, @@ -94,34 +107,32 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.5.tgz", - "integrity": "sha512-KZXo2t10+/jxmkhNXc7pZTqRvSOIvVv/+lJwHS+B2rErwOyjuVRh60yVpb7liQ1U5t7lLJ1bz+t8tSypUZdm0g==", - "dev": true, + "version": "7.20.10", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.10.tgz", + "integrity": "sha512-sEnuDPpOJR/fcafHMjpcpGN5M2jbUGUHwmuWKM/YdPzeEDJg8bgmbcWQFUfE32MQjti1koACvoPVsDe8Uq+idg==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.5.tgz", - "integrity": "sha512-UdOWmk4pNWTm/4DlPUl/Pt4Gz4rcEMb7CY0Y3eJl5Yz1vI8ZJGmHWaVE55LoxRjdpx0z259GE9U5STA9atUinQ==", - "dev": true, + "version": "7.20.12", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz", + "integrity": "sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==", "dependencies": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.18.6", - "@babel/generator": "^7.20.5", - "@babel/helper-compilation-targets": "^7.20.0", - "@babel/helper-module-transforms": "^7.20.2", - "@babel/helpers": "^7.20.5", - "@babel/parser": "^7.20.5", - "@babel/template": "^7.18.10", - "@babel/traverse": "^7.20.5", - "@babel/types": "^7.20.5", + "@babel/generator": "^7.20.7", + "@babel/helper-compilation-targets": "^7.20.7", + "@babel/helper-module-transforms": "^7.20.11", + "@babel/helpers": "^7.20.7", + "@babel/parser": "^7.20.7", + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.20.12", + "@babel/types": "^7.20.7", "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", - "json5": "^2.2.1", + "json5": "^2.2.2", "semver": "^6.3.0" }, "engines": { @@ -133,12 +144,11 @@ } }, "node_modules/@babel/generator": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.5.tgz", - "integrity": "sha512-jl7JY2Ykn9S0yj4DQP82sYvPU+T3g0HFcWTqDLqiuA9tGRNIj9VfbtXGAYTTkyNEnQk1jkMGOdYka8aG/lulCA==", - "dev": true, + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.7.tgz", + "integrity": "sha512-7wqMOJq8doJMZmP4ApXTzLxSr7+oO2jroJURrVEp6XShrQUObV8Tq/D0NCcoYg2uHqUrjzO0zwBjoYzelxK+sw==", "dependencies": { - "@babel/types": "^7.20.5", + "@babel/types": "^7.20.7", "@jridgewell/gen-mapping": "^0.3.2", "jsesc": "^2.5.1" }, @@ -150,7 +160,6 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", - "dev": true, "dependencies": { "@jridgewell/set-array": "^1.0.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -161,14 +170,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.0.tgz", - "integrity": "sha512-0jp//vDGp9e8hZzBc6N/KwA5ZK3Wsm/pfm4CrY7vzegkVxc65SgSn6wYOnwHe9Js9HRQ1YTCKLGPzDtaS3RoLQ==", - "dev": true, + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.7.tgz", + "integrity": "sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==", "dependencies": { - "@babel/compat-data": "^7.20.0", + "@babel/compat-data": "^7.20.5", "@babel/helper-validator-option": "^7.18.6", "browserslist": "^4.21.3", + "lru-cache": "^5.1.1", "semver": "^6.3.0" }, "engines": { @@ -178,11 +187,23 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, "node_modules/@babel/helper-environment-visitor": { "version": "7.18.9", "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz", "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -191,7 +212,6 @@ "version": "7.19.0", "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz", "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==", - "dev": true, "dependencies": { "@babel/template": "^7.18.10", "@babel/types": "^7.19.0" @@ -204,7 +224,6 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", - "dev": true, "dependencies": { "@babel/types": "^7.18.6" }, @@ -216,7 +235,6 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", - "dev": true, "dependencies": { "@babel/types": "^7.18.6" }, @@ -225,19 +243,18 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.20.2", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.2.tgz", - "integrity": "sha512-zvBKyJXRbmK07XhMuujYoJ48B5yvvmM6+wcpv6Ivj4Yg6qO7NOZOSnvZN9CRl1zz1Z4cKf8YejmCMh8clOoOeA==", - "dev": true, + "version": "7.20.11", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.11.tgz", + "integrity": "sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg==", "dependencies": { "@babel/helper-environment-visitor": "^7.18.9", "@babel/helper-module-imports": "^7.18.6", "@babel/helper-simple-access": "^7.20.2", "@babel/helper-split-export-declaration": "^7.18.6", "@babel/helper-validator-identifier": "^7.19.1", - "@babel/template": "^7.18.10", - "@babel/traverse": "^7.20.1", - "@babel/types": "^7.20.2" + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.20.10", + "@babel/types": "^7.20.7" }, "engines": { "node": ">=6.9.0" @@ -247,7 +264,6 @@ "version": "7.20.2", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.20.2.tgz", "integrity": "sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -256,7 +272,6 @@ "version": "7.20.2", "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz", "integrity": "sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==", - "dev": true, "dependencies": { "@babel/types": "^7.20.2" }, @@ -268,7 +283,6 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", - "dev": true, "dependencies": { "@babel/types": "^7.18.6" }, @@ -280,7 +294,6 @@ "version": "7.19.4", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz", "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -289,7 +302,6 @@ "version": "7.19.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -298,20 +310,18 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz", "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==", - "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.20.6.tgz", - "integrity": "sha512-Pf/OjgfgFRW5bApskEz5pvidpim7tEDPlFtKcNRXWmfHGn9IEI2W2flqRQXTFb7gIPTyK++N6rVHuwKut4XK6w==", - "dev": true, + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.20.13.tgz", + "integrity": "sha512-nzJ0DWCL3gB5RCXbUO3KIMMsBY2Eqbx8mBpKGE/02PgyRQFcPQLbkQ1vyy596mZLaP+dAfD+R4ckASzNVmW3jg==", "dependencies": { - "@babel/template": "^7.18.10", - "@babel/traverse": "^7.20.5", - "@babel/types": "^7.20.5" + "@babel/template": "^7.20.7", + "@babel/traverse": "^7.20.13", + "@babel/types": "^7.20.7" }, "engines": { "node": ">=6.9.0" @@ -321,7 +331,6 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", - "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.18.6", "chalk": "^2.0.0", @@ -332,10 +341,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.5.tgz", - "integrity": "sha512-r27t/cy/m9uKLXQNWWebeCUHgnAZq0CpG1OwKRxzJMP1vpSU4bSIK2hq+/cp0bQxetkXx38n09rNu8jVkcK/zA==", - "dev": true, + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.13.tgz", + "integrity": "sha512-gFDLKMfpiXCsjt4za2JA9oTMn70CeseCehb11kRZgvd7+F67Hih3OHOK24cRrWECJ/ljfPGac6ygXAs/C8kIvw==", "bin": { "parser": "bin/babel-parser.js" }, @@ -343,6 +351,20 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.20.0.tgz", + "integrity": "sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.19.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.18.6.tgz", @@ -409,33 +431,31 @@ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "node_modules/@babel/template": { - "version": "7.18.10", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz", - "integrity": "sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==", - "dev": true, + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz", + "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==", "dependencies": { "@babel/code-frame": "^7.18.6", - "@babel/parser": "^7.18.10", - "@babel/types": "^7.18.10" + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.5.tgz", - "integrity": "sha512-WM5ZNN3JITQIq9tFZaw1ojLU3WgWdtkxnhM1AegMS+PvHjkM5IXjmYEGY7yukz5XS4sJyEf2VzWjI8uAavhxBQ==", - "dev": true, + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.13.tgz", + "integrity": "sha512-kMJXfF0T6DIS9E8cgdLCSAL+cuCK+YEZHWiLK0SXpTo8YRj5lpJu3CDNKiIBCne4m9hhTIqUg6SYTAI39tAiVQ==", "dependencies": { "@babel/code-frame": "^7.18.6", - "@babel/generator": "^7.20.5", + "@babel/generator": "^7.20.7", "@babel/helper-environment-visitor": "^7.18.9", "@babel/helper-function-name": "^7.19.0", "@babel/helper-hoist-variables": "^7.18.6", "@babel/helper-split-export-declaration": "^7.18.6", - "@babel/parser": "^7.20.5", - "@babel/types": "^7.20.5", + "@babel/parser": "^7.20.13", + "@babel/types": "^7.20.7", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -444,10 +464,9 @@ } }, "node_modules/@babel/types": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.5.tgz", - "integrity": "sha512-c9fst/h2/dcF7H+MJKZ2T0KjEQ8hY/BNnDk/H3XY8C4Aw/eWQXWn/lWntHF9ooUBnGmEvbfGrTgLWc+um0YDUg==", - "dev": true, + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.7.tgz", + "integrity": "sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==", "dependencies": { "@babel/helper-string-parser": "^7.19.4", "@babel/helper-validator-identifier": "^7.19.1", @@ -457,6 +476,11 @@ "node": ">=6.9.0" } }, + "node_modules/@emotion/hash": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.0.tgz", + "integrity": "sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ==" + }, "node_modules/@esbuild-plugins/node-globals-polyfill": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", @@ -473,7 +497,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "android" @@ -489,7 +512,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -505,7 +527,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "android" @@ -521,7 +542,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -537,7 +557,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -553,7 +572,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -569,7 +587,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -585,7 +602,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -601,7 +617,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -617,7 +632,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "linux" @@ -633,7 +647,6 @@ "cpu": [ "loong64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -649,7 +662,6 @@ "cpu": [ "mips64el" ], - "dev": true, "optional": true, "os": [ "linux" @@ -665,7 +677,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -681,7 +692,6 @@ "cpu": [ "riscv64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -697,7 +707,6 @@ "cpu": [ "s390x" ], - "dev": true, "optional": true, "os": [ "linux" @@ -713,7 +722,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -729,7 +737,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "netbsd" @@ -745,7 +752,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "openbsd" @@ -761,7 +767,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "sunos" @@ -777,7 +782,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -793,7 +797,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -809,7 +812,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -903,7 +905,6 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", - "dev": true, "dependencies": { "@jridgewell/set-array": "^1.0.0", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -916,7 +917,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -925,7 +925,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -933,19 +932,22 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.14", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.17", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz", "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==", - "dev": true, "dependencies": { "@jridgewell/resolve-uri": "3.1.0", "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" + }, "node_modules/@khanacademy/simple-markdown": { "version": "0.8.6", "resolved": "https://registry.npmjs.org/@khanacademy/simple-markdown/-/simple-markdown-0.8.6.tgz", @@ -1126,6 +1128,11 @@ "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==" }, + "node_modules/@types/is-hotkey": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@types/is-hotkey/-/is-hotkey-0.1.7.tgz", + "integrity": "sha512-yB5C7zcOM7idwYZZ1wKQ3pTfjA9BbvFqRWvKB46GFddxnJtHwi/b9y84ykQtxQPg5qhdpg4Q/kWU3EGoCTmLzQ==" + }, "node_modules/@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -1138,6 +1145,11 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.14.191", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", + "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==" + }, "node_modules/@types/node": { "version": "18.11.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", @@ -1184,6 +1196,12 @@ "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", "dev": true }, + "node_modules/@types/ua-parser-js": { + "version": "0.7.36", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz", + "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.46.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.46.1.tgz", @@ -1438,6 +1456,148 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vanilla-extract/babel-plugin-debug-ids": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vanilla-extract/babel-plugin-debug-ids/-/babel-plugin-debug-ids-1.0.1.tgz", + "integrity": "sha512-ynyKqsJiMzM1/yiIJ6QdqpWKlK4IMJJWREpPtaemZrE1xG1B4E/Nfa6YazuDWjDkCJC1tRIpEGnVs+pMIjUxyw==", + "dependencies": { + "@babel/core": "^7.20.7" + } + }, + "node_modules/@vanilla-extract/css": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@vanilla-extract/css/-/css-1.9.3.tgz", + "integrity": "sha512-vitcD8usEOTWDLAnbtnZ46YbHADAp3Es+3xyHsMDMZOEWk03FhD+PbR58kdwtGpr258+hMryCYtQPeFh5lWFbA==", + "dependencies": { + "@emotion/hash": "^0.9.0", + "@vanilla-extract/private": "^1.0.3", + "ahocorasick": "1.0.2", + "chalk": "^4.1.1", + "css-what": "^5.0.1", + "cssesc": "^3.0.0", + "csstype": "^3.0.7", + "deep-object-diff": "^1.1.0", + "deepmerge": "^4.2.2", + "media-query-parser": "^2.0.2", + "outdent": "^0.8.0" + } + }, + "node_modules/@vanilla-extract/css/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@vanilla-extract/css/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@vanilla-extract/css/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@vanilla-extract/css/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/@vanilla-extract/css/node_modules/deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@vanilla-extract/css/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@vanilla-extract/css/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@vanilla-extract/integration": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.0.2.tgz", + "integrity": "sha512-LwfXlh0THeNvVXdA3iWFYvJs1mvEP1PkfQD/7S6Purry7L8iDizDV/87FgWBJ79FnTmYIvMrc7BOQsUajNj9VQ==", + "dependencies": { + "@babel/core": "^7.20.7", + "@babel/plugin-syntax-typescript": "^7.20.0", + "@vanilla-extract/babel-plugin-debug-ids": "^1.0.1", + "@vanilla-extract/css": "^1.9.3", + "esbuild": "^0.16.3", + "eval": "0.1.6", + "find-up": "^5.0.0", + "javascript-stringify": "^2.0.1", + "lodash": "^4.17.21", + "outdent": "^0.8.0" + } + }, + "node_modules/@vanilla-extract/private": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.3.tgz", + "integrity": "sha512-17kVyLq3ePTKOkveHxXuIJZtGYs+cSoev7BlP+Lf4916qfDhk/HBjvlYDe8egrea7LNPHKwSZJK/bzZC+Q6AwQ==" + }, + "node_modules/@vanilla-extract/recipes": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.3.0.tgz", + "integrity": "sha512-7wXrgfq1oldKdBfCKen4XmSlDmQR+4o0CQ3WnnLfhQaEtI65xJ774yyQF6dD2CC+hHdW2LFKVXgH5NZRbMQ8Sg==", + "peerDependencies": { + "@vanilla-extract/css": "^1.0.0" + } + }, + "node_modules/@vanilla-extract/vite-plugin": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@vanilla-extract/vite-plugin/-/vite-plugin-3.7.1.tgz", + "integrity": "sha512-KFeTSEJKtJDfQhUJh4jGmrJDLCU59DSA3YKZSdys4jTOLZ1ZFsKzDP2pnFwH/24Oc2ebK+EV5x3OPlWxvRYthg==", + "dependencies": { + "@vanilla-extract/integration": "^6.0.2", + "outdent": "^0.8.0", + "postcss": "^8.3.6", + "postcss-load-config": "^3.1.0" + }, + "peerDependencies": { + "vite": "^2.2.3 || ^3.0.0 || ^4.0.3" + } + }, "node_modules/@vitejs/plugin-react": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-3.0.0.tgz", @@ -1478,6 +1638,11 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ahocorasick": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ahocorasick/-/ahocorasick-1.0.2.tgz", + "integrity": "sha512-hCOfMzbFx5IDutmWLAt6MZwOUjIfSM9G9FyVxytmE4Rs/5YDPWQrD/+IR1w+FweD9H2oOZEnv36TmkjhNURBVA==" + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1512,7 +1677,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -1645,6 +1809,14 @@ "resolved": "https://registry.npmjs.org/autosize/-/autosize-4.0.4.tgz", "integrity": "sha512-5yxLQ22O0fCRGoxGfeLSNt3J8LB1v+umtpMnPW6XjkTWXKoN0AmXAIhelJcDtFT/Y/wYWmfE+oqU10Q0b8FhaQ==" }, + "node_modules/await-to-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz", + "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/axe-core": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.6.0.tgz", @@ -1736,7 +1908,6 @@ "version": "4.21.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz", "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==", - "dev": true, "funding": [ { "type": "opencollective", @@ -1815,10 +1986,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001439", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001439.tgz", - "integrity": "sha512-1MgUzEkoMO6gKfXflStpYgZDlFM7M/ck/bgfVCACO5vnAf0fXoNVHdWtqGU+MYca+4bL9Z5bpOVmR33cWW9G2A==", - "dev": true, + "version": "1.0.30001446", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001446.tgz", + "integrity": "sha512-fEoga4PrImGcwUUGEol/PoFCSBnSkA9drgdkxXkJLsUBOnJ8rs3zDv6ApqYXGQFOyMPsjh79naWhF4DAxbF8rw==", "funding": [ { "type": "opencollective", @@ -1834,7 +2004,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -1883,11 +2052,15 @@ "node": ">= 6" } }, + "node_modules/classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -1895,8 +2068,12 @@ "node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/compute-scroll-into-view": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", + "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==" }, "node_modules/computed-style": { "version": "0.1.4", @@ -1926,8 +2103,7 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/core-js-pure": { "version": "3.26.1", @@ -1962,6 +2138,28 @@ "node": ">= 8" } }, + "node_modules/css-what": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-5.1.0.tgz", + "integrity": "sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", @@ -1985,7 +2183,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -2004,6 +2201,11 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deep-object-diff": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.9.tgz", + "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==" + }, "node_modules/deepmerge": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", @@ -2040,6 +2242,18 @@ "node": ">=8" } }, + "node_modules/direction": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", + "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dnd-core": { "version": "15.1.2", "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-15.1.2.tgz", @@ -2116,8 +2330,7 @@ "node_modules/electron-to-chromium": { "version": "1.4.284", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz", - "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==", - "dev": true + "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==" }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -2125,6 +2338,15 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/emojibase": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/emojibase/-/emojibase-6.1.0.tgz", + "integrity": "sha512-1GkKJPXP6tVkYJHOBSJHoGOr/6uaDxZ9xJ6H7m6PfdGXTmQgbALHLWaVRY4Gi/qf5x/gT/NUXLPuSHYLqtLtrQ==", + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/milesjohnson" + } + }, "node_modules/emojibase-data": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-7.0.1.tgz", @@ -2217,7 +2439,6 @@ "version": "0.16.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.16.9.tgz", "integrity": "sha512-gkH83yHyijMSZcZFs1IWew342eMdFuWXmQo3zkDPTre25LIPBJsXryg02M3u8OpTwCJdBkdaQwqKkDLnAsAeLQ==", - "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -2254,7 +2475,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, "engines": { "node": ">=6" } @@ -2263,7 +2483,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, "engines": { "node": ">=0.8.0" } @@ -2777,6 +2996,17 @@ "node": ">=0.10.0" } }, + "node_modules/eval": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/eval/-/eval-0.1.6.tgz", + "integrity": "sha512-o0XUw+5OGkXw4pJZzQoXUk+H87DHuC+7ZE//oSrRGtatTmr12oTnLfg6QOq9DyTt0c/p4TwzgmkKrBzWTSizyQ==", + "dependencies": { + "require-like": ">= 0.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -2871,6 +3101,24 @@ "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==" }, + "node_modules/fbjs/node_modules/ua-parser-js": { + "version": "0.7.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.35.tgz", + "integrity": "sha512-veRf7dawaj9xaWEu9HoTVn5Pggtc/qj+kqTOFvNiN1l0YdxwC1kvel57UCjThjGa3BHBihE8/UJAHI+uQHmd/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "engines": { + "node": "*" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2904,7 +3152,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -2962,6 +3209,40 @@ "react": "^15.0.2 || ^16.0.0 || ^17.0.0" } }, + "node_modules/focus-trap": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.2.0.tgz", + "integrity": "sha512-v4wY6HDDYvzkBy4735kW5BUEuw6Yz9ABqMYLuTNbzAFPcBOGiGHwwcNVMvUz4G0kgSYh13wa/7TG3XwTeT4O/A==", + "dependencies": { + "tabbable": "^6.0.1" + } + }, + "node_modules/focus-trap-react": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.0.2.tgz", + "integrity": "sha512-MnN2cmdgpY7NY74ePOio4kbO5A3ILhrg1g5OGbgIQjcWEv1hhcbh6e98K0a+df88hNbE+4i9r8ji9aQnHou6GA==", + "dependencies": { + "focus-trap": "^7.2.0", + "tabbable": "^6.0.1" + }, + "peerDependencies": { + "prop-types": "^15.8.1", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, + "node_modules/folds": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/folds/-/folds-1.2.1.tgz", + "integrity": "sha512-BCV5oFCndiGFp1HyeSnbDKmTSbu1yfAtAIF6znPvLthuI/QG4516bBUr6+MyNUQWx/IAkj1bdQL/cdD+jEZWCw==", + "peerDependencies": { + "@vanilla-extract/css": "^1.9.2", + "@vanilla-extract/recipes": "^0.3.0", + "classnames": "^2.3.2", + "react": "^17.0.0", + "react-dom": "^17.0.0" + } + }, "node_modules/formik": { "version": "2.2.9", "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz", @@ -3063,7 +3344,6 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -3134,7 +3414,6 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, "engines": { "node": ">=4" } @@ -3207,7 +3486,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, "engines": { "node": ">=4" } @@ -3329,6 +3607,15 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "9.0.16", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.16.tgz", + "integrity": "sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz", @@ -3495,6 +3782,11 @@ "node": ">=0.10.0" } }, + "node_modules/is-hotkey": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz", + "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==" + }, "node_modules/is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", @@ -3624,6 +3916,64 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/javascript-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz", + "integrity": "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==" + }, + "node_modules/jotai": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-1.12.0.tgz", + "integrity": "sha512-IhyBmjxU1sE2Ni/MUK7gQAb8QvCM6yd1/K5jtQzgQBmmjCjgfXZkkk1rYlQAIRp2KoQk0Y+yzhm1f5cZ7kegnw==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@babel/core": "*", + "@babel/template": "*", + "jotai-immer": "*", + "jotai-optics": "*", + "jotai-redux": "*", + "jotai-tanstack-query": "*", + "jotai-urql": "*", + "jotai-valtio": "*", + "jotai-xstate": "*", + "jotai-zustand": "*", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@babel/template": { + "optional": true + }, + "jotai-immer": { + "optional": true + }, + "jotai-optics": { + "optional": true + }, + "jotai-redux": { + "optional": true + }, + "jotai-tanstack-query": { + "optional": true + }, + "jotai-urql": { + "optional": true + }, + "jotai-valtio": { + "optional": true + }, + "jotai-xstate": { + "optional": true + }, + "jotai-zustand": { + "optional": true + } + } + }, "node_modules/js-sdsl": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz", @@ -3655,7 +4005,6 @@ "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true, "bin": { "jsesc": "bin/jsesc" }, @@ -3676,10 +4025,9 @@ "dev": true }, "node_modules/json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", - "dev": true, + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": { "json5": "lib/cli.js" }, @@ -3762,6 +4110,14 @@ "node": ">= 0.8.0" } }, + "node_modules/lilconfig": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz", + "integrity": "sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==", + "engines": { + "node": ">=10" + } + }, "node_modules/line-height": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/line-height/-/line-height-0.3.1.tgz", @@ -3790,7 +4146,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -3900,6 +4255,14 @@ "events": "^3.2.0" } }, + "node_modules/media-query-parser": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/media-query-parser/-/media-query-parser-2.0.2.tgz", + "integrity": "sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==", + "dependencies": { + "@babel/runtime": "^7.12.5" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3955,8 +4318,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nanoid": { "version": "3.3.4", @@ -4001,10 +4363,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.7.tgz", - "integrity": "sha512-EJ3rzxL9pTWPjk5arA0s0dgXpnyiAbJDE6wHT62g7VsgrgQgmmZ+Ru++M1BFofncWja+Pnn3rEr3fieRySAdKQ==", - "dev": true + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.8.tgz", + "integrity": "sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==" }, "node_modules/normalize-path": { "version": "3.0.0", @@ -4146,11 +4507,15 @@ "node": ">= 0.8.0" } }, + "node_modules/outdent": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz", + "integrity": "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -4165,7 +4530,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -4209,7 +4573,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -4287,6 +4650,34 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4563,6 +4954,14 @@ "url": "https://github.com/sponsors/mysticatea" } }, + "node_modules/require-like": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz", + "integrity": "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==", + "engines": { + "node": "*" + } + }, "node_modules/resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -4718,6 +5117,14 @@ "object-assign": "^4.1.1" } }, + "node_modules/scroll-into-view-if-needed": { + "version": "2.2.31", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz", + "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==", + "dependencies": { + "compute-scroll-into-view": "^1.0.20" + } + }, "node_modules/sdp-transform": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.14.1.tgz", @@ -4730,7 +5137,6 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true, "bin": { "semver": "bin/semver.js" } @@ -4784,6 +5190,42 @@ "node": ">=8" } }, + "node_modules/slate": { + "version": "0.90.0", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.90.0.tgz", + "integrity": "sha512-dv8idv0JjYyHiAJcVKf5yWKPDMTDi+PSZyfjsnquEI8VB5nmTVGjeJab06lc3o69O7aN05ROwO9/OY8mU1IUPA==", + "dependencies": { + "immer": "^9.0.6", + "is-plain-object": "^5.0.0", + "tiny-warning": "^1.0.3" + } + }, + "node_modules/slate-react": { + "version": "0.90.0", + "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.90.0.tgz", + "integrity": "sha512-z6pGd6jjU5VazLxlDi6zL3a6yaPBPJ+A2VyIlE/h/rvDywaLYGvk0xcrA9NrK71Dr47HK5ZN2zFEZNleh6wlPA==", + "dependencies": { + "@juggle/resize-observer": "^3.4.0", + "@types/is-hotkey": "^0.1.1", + "@types/lodash": "^4.14.149", + "direction": "^1.0.3", + "is-hotkey": "^0.1.6", + "is-plain-object": "^5.0.0", + "lodash": "^4.17.4", + "scroll-into-view-if-needed": "^2.2.20", + "tiny-invariant": "1.0.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "slate": ">=0.65.3" + } + }, + "node_modules/slate-react/node_modules/is-hotkey": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.8.tgz", + "integrity": "sha512-qs3NZ1INIS+H+yeo7cD9pDfwYV/jqRh1JG9S9zYrNudkoUQg7OL7ziXqRKu+InFjUIDoP2o6HIkLYMh1pcWgyQ==" + }, "node_modules/source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", @@ -4892,7 +5334,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -4912,12 +5353,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tabbable": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.0.1.tgz", + "integrity": "sha512-SYJSIgeyXW7EuX1ytdneO5e8jip42oHWg9xl/o3oTYhmXusZVgiA+VlPvjIN+kHii9v90AmzTZEBcsEvuAY+TA==" + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "node_modules/tiny-invariant": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.0.6.tgz", + "integrity": "sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA==" + }, "node_modules/tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", @@ -4935,7 +5386,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, "engines": { "node": ">=4" } @@ -5055,9 +5505,9 @@ } }, "node_modules/ua-parser-js": { - "version": "0.7.32", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.32.tgz", - "integrity": "sha512-f9BESNVhzlhEFf2CHMSj40NWOjYPl1YKYbrvIr/hFTDEmLq7SRbWvm7FcdcpCYT95zrOhC7gZSxjdnnTpBcwVw==", + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", + "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==", "funding": [ { "type": "opencollective", @@ -5104,7 +5554,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5144,9 +5593,9 @@ } }, "node_modules/vite": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.0.1.tgz", - "integrity": "sha512-kZQPzbDau35iWOhy3CpkrRC7It+HIHtulAzBhMqzGHKRf/4+vmh8rPDDdv98SWQrFWo6//3ozwsRmwQIPZsK9g==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.0.4.tgz", + "integrity": "sha512-xevPU7M8FU0i/80DMR+YhgrzR5KS2ORy1B4xcX/cXLsvnUWvfHuqMmVU6N0YiJ4JWGRJJsLCgjEzKjG9/GKoSw==", "dev": true, "dependencies": { "esbuild": "^0.16.3", @@ -5319,11 +5768,18 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index a37b624..38be28d 100644 --- a/package.json +++ b/package.json @@ -24,14 +24,25 @@ "@khanacademy/simple-markdown": "0.8.6", "@matrix-org/olm": "3.2.14", "@tippyjs/react": "4.2.6", + "@vanilla-extract/css": "1.9.3", + "@vanilla-extract/recipes": "0.3.0", + "@vanilla-extract/vite-plugin": "3.7.1", + "await-to-js": "3.0.0", "blurhash": "2.0.4", "browser-encrypt-attachment": "0.3.0", + "classnames": "2.3.2", "dateformat": "5.0.3", + "emojibase": "6.1.0", "emojibase-data": "7.0.1", "file-saver": "2.0.5", "flux": "4.0.3", + "focus-trap-react": "10.0.2", + "folds": "1.2.1", "formik": "2.2.9", "html-react-parser": "3.0.4", + "immer": "9.0.16", + "is-hotkey": "0.2.0", + "jotai": "1.12.0", "katex": "0.16.4", "linkify-html": "4.0.2", "linkifyjs": "4.0.2", @@ -46,8 +57,11 @@ "react-google-recaptcha": "2.1.0", "react-modal": "3.16.1", "sanitize-html": "2.8.0", + "slate": "0.90.0", + "slate-react": "0.90.0", "tippy.js": "6.3.7", - "twemoji": "14.0.2" + "twemoji": "14.0.2", + "ua-parser-js": "1.0.35" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "0.2.3", @@ -56,6 +70,7 @@ "@types/node": "18.11.18", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", + "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", "@vitejs/plugin-react": "3.0.0", @@ -71,7 +86,7 @@ "prettier": "2.8.1", "sass": "1.56.2", "typescript": "4.9.4", - "vite": "4.0.1", + "vite": "4.0.4", "vite-plugin-static-copy": "0.13.0" } } diff --git a/public/font/Twemoji.Mozilla.v.7.0.woff2 b/public/font/Twemoji.Mozilla.v.7.0.woff2 new file mode 100644 index 0000000..b3b20e9 Binary files /dev/null and b/public/font/Twemoji.Mozilla.v.7.0.woff2 differ diff --git a/public/font/Twemoji.Mozilla.v0.7.0.ttf b/public/font/Twemoji.Mozilla.v0.7.0.ttf new file mode 100644 index 0000000..9f45178 Binary files /dev/null and b/public/font/Twemoji.Mozilla.v0.7.0.ttf differ diff --git a/src/app/components/UseStateProvider.tsx b/src/app/components/UseStateProvider.tsx new file mode 100644 index 0000000..21e5b3c --- /dev/null +++ b/src/app/components/UseStateProvider.tsx @@ -0,0 +1,9 @@ +import { Dispatch, ReactElement, SetStateAction, useState } from 'react'; + +type UseStateProviderProps = { + initial: T | (() => T); + children: (value: T, setter: Dispatch>) => ReactElement; +}; +export function UseStateProvider({ initial, children }: UseStateProviderProps) { + return children(...useState(initial)); +} diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts new file mode 100644 index 0000000..034ded7 --- /dev/null +++ b/src/app/components/editor/Editor.css.ts @@ -0,0 +1,63 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, DefaultReset, toRem } from 'folds'; + +export const Editor = style([ + DefaultReset, + { + backgroundColor: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, + boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`, + borderRadius: config.radii.R400, + overflow: 'hidden', + }, +]); + +export const EditorOptions = style([ + DefaultReset, + { + padding: config.space.S200, + }, +]); + +export const EditorTextareaScroll = style({}); + +export const EditorTextarea = style([ + DefaultReset, + { + flexGrow: 1, + height: '100%', + padding: `${toRem(13)} 0`, + selectors: { + [`${EditorTextareaScroll}:first-child &`]: { + paddingLeft: toRem(13), + }, + [`${EditorTextareaScroll}:last-child &`]: { + paddingRight: toRem(13), + }, + }, + }, +]); + +export const EditorPlaceholder = style([ + DefaultReset, + { + position: 'absolute', + zIndex: 1, + opacity: config.opacity.Placeholder, + pointerEvents: 'none', + userSelect: 'none', + + selectors: { + '&:not(:first-child)': { + display: 'none', + }, + }, + }, +]); + +export const EditorToolbar = style([ + DefaultReset, + { + padding: config.space.S100, + }, +]); diff --git a/src/app/components/editor/Editor.preview.tsx b/src/app/components/editor/Editor.preview.tsx new file mode 100644 index 0000000..ad67dc1 --- /dev/null +++ b/src/app/components/editor/Editor.preview.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + config, + Icon, + IconButton, + Icons, + Line, + Modal, + Overlay, + OverlayBackdrop, + OverlayCenter, +} from 'folds'; + +import { CustomEditor, useEditor } from './Editor'; +import { Toolbar } from './Toolbar'; + +export function EditorPreview() { + const [open, setOpen] = useState(false); + const editor = useEditor(); + const [toolbar, setToolbar] = useState(false); + + return ( + <> + setOpen(!open)}> + + + }> + + setOpen(false), + clickOutsideDeactivates: true, + }} + > + +
+ + + + } + after={ + <> + setToolbar(!toolbar)} + aria-pressed={toolbar} + > + + + + + + + + + + } + bottom={ + toolbar && ( +
+ + +
+ ) + } + /> +
+
+
+
+
+ + ); +} diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx new file mode 100644 index 0000000..edf1ac6 --- /dev/null +++ b/src/app/components/editor/Editor.tsx @@ -0,0 +1,151 @@ +/* eslint-disable no-param-reassign */ +import React, { + ClipboardEventHandler, + KeyboardEventHandler, + ReactNode, + forwardRef, + useCallback, + useState, +} from 'react'; + +import { Box, Scroll, Text } from 'folds'; +import { Descendant, Editor, createEditor } from 'slate'; +import { + Slate, + Editable, + withReact, + RenderLeafProps, + RenderElementProps, + RenderPlaceholderProps, +} from 'slate-react'; +import { BlockType, RenderElement, RenderLeaf } from './Elements'; +import { CustomElement } from './slate'; +import * as css from './Editor.css'; +import { toggleKeyboardShortcut } from './keyboard'; + +const initialValue: CustomElement[] = [ + { + type: BlockType.Paragraph, + children: [{ text: '' }], + }, +]; + +const withInline = (editor: Editor): Editor => { + const { isInline } = editor; + + editor.isInline = (element) => + [BlockType.Mention, BlockType.Emoticon, BlockType.Link].includes(element.type) || + isInline(element); + + return editor; +}; + +const withVoid = (editor: Editor): Editor => { + const { isVoid } = editor; + + editor.isVoid = (element) => + [BlockType.Mention, BlockType.Emoticon].includes(element.type) || isVoid(element); + + return editor; +}; + +export const useEditor = (): Editor => { + const [editor] = useState(withInline(withVoid(withReact(createEditor())))); + return editor; +}; + +export type EditorChangeHandler = ((value: Descendant[]) => void) | undefined; +type CustomEditorProps = { + top?: ReactNode; + bottom?: ReactNode; + before?: ReactNode; + after?: ReactNode; + maxHeight?: string; + editor: Editor; + placeholder?: string; + onKeyDown?: KeyboardEventHandler; + onChange?: EditorChangeHandler; + onPaste?: ClipboardEventHandler; +}; +export const CustomEditor = forwardRef( + ( + { + top, + bottom, + before, + after, + maxHeight = '50vh', + editor, + placeholder, + onKeyDown, + onChange, + onPaste, + }, + ref + ) => { + const renderElement = useCallback( + (props: RenderElementProps) => , + [] + ); + + const renderLeaf = useCallback((props: RenderLeafProps) => , []); + + const handleKeydown: KeyboardEventHandler = useCallback( + (evt) => { + onKeyDown?.(evt); + toggleKeyboardShortcut(editor, evt); + }, + [editor, onKeyDown] + ); + + const renderPlaceholder = useCallback(({ attributes, children }: RenderPlaceholderProps) => { + // drop style attribute as we use our custom placeholder css. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { style, ...props } = attributes; + return ( + + {children} + + ); + }, []); + + return ( +
+ + {top} + + {before && ( + + {before} + + )} + + + + {after && ( + + {after} + + )} + + {bottom} + +
+ ); + } +); diff --git a/src/app/components/editor/Elements.css.ts b/src/app/components/editor/Elements.css.ts new file mode 100644 index 0000000..99d037d --- /dev/null +++ b/src/app/components/editor/Elements.css.ts @@ -0,0 +1,142 @@ +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; +import { color, config, DefaultReset, toRem } from 'folds'; + +const MarginBottom = style({ + marginBottom: config.space.S200, + selectors: { + '&:last-child': { + marginBottom: 0, + }, + }, +}); + +export const Paragraph = style([MarginBottom]); + +export const Heading = style([MarginBottom]); + +export const BlockQuote = style([ + DefaultReset, + MarginBottom, + { + paddingLeft: config.space.S200, + borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`, + fontStyle: 'italic', + }, +]); + +const BaseCode = style({ + fontFamily: 'monospace', + color: color.Warning.OnContainer, + background: color.Warning.Container, + border: `${config.borderWidth.B300} solid ${color.Warning.ContainerLine}`, + borderRadius: config.radii.R300, +}); + +export const Code = style([ + DefaultReset, + BaseCode, + { + padding: `0 ${config.space.S100}`, + }, +]); +export const Spoiler = style([ + DefaultReset, + { + padding: `0 ${config.space.S100}`, + backgroundColor: color.SurfaceVariant.ContainerActive, + borderRadius: config.radii.R300, + }, +]); + +export const CodeBlock = style([DefaultReset, BaseCode, MarginBottom]); +export const CodeBlockInternal = style({ + padding: `${config.space.S200} ${config.space.S200} 0`, +}); + +export const List = style([ + DefaultReset, + MarginBottom, + { + padding: `0 ${config.space.S100}`, + paddingLeft: config.space.S600, + }, +]); + +export const InlineChromiumBugfix = style({ + fontSize: 0, + lineHeight: 0, +}); + +export const Mention = recipe({ + base: [ + DefaultReset, + { + backgroundColor: color.Secondary.Container, + color: color.Secondary.OnContainer, + boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Secondary.ContainerLine}`, + padding: `0 ${toRem(2)}`, + borderRadius: config.radii.R300, + fontWeight: config.fontWeight.W500, + }, + ], + variants: { + highlight: { + true: { + backgroundColor: color.Primary.Container, + color: color.Primary.OnContainer, + boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Primary.ContainerLine}`, + }, + }, + focus: { + true: { + boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.OnContainer}`, + }, + }, + }, +}); + +export const EmoticonBase = style([ + DefaultReset, + { + display: 'inline-block', + padding: '0.05rem', + height: '1em', + verticalAlign: 'middle', + }, +]); + +export const Emoticon = recipe({ + base: [ + DefaultReset, + { + display: 'inline-flex', + justifyContent: 'center', + alignItems: 'center', + + height: '1em', + minWidth: '1em', + fontSize: '1.47em', + lineHeight: '1em', + verticalAlign: 'middle', + position: 'relative', + top: '-0.25em', + borderRadius: config.radii.R300, + }, + ], + variants: { + focus: { + true: { + boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.OnContainer}`, + }, + }, + }, +}); + +export const EmoticonImg = style([ + DefaultReset, + { + height: '1em', + cursor: 'default', + }, +]); diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx new file mode 100644 index 0000000..59893e5 --- /dev/null +++ b/src/app/components/editor/Elements.tsx @@ -0,0 +1,254 @@ +import { Scroll, Text } from 'folds'; +import React from 'react'; +import { RenderElementProps, RenderLeafProps, useFocused, useSelected } from 'slate-react'; + +import * as css from './Elements.css'; +import { EmoticonElement, LinkElement, MentionElement } from './slate'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; + +export enum MarkType { + Bold = 'bold', + Italic = 'italic', + Underline = 'underline', + StrikeThrough = 'strikeThrough', + Code = 'code', + Spoiler = 'spoiler', +} + +export enum BlockType { + Paragraph = 'paragraph', + Heading = 'heading', + CodeLine = 'code-line', + CodeBlock = 'code-block', + QuoteLine = 'quote-line', + BlockQuote = 'block-quote', + ListItem = 'list-item', + OrderedList = 'ordered-list', + UnorderedList = 'unordered-list', + Mention = 'mention', + Emoticon = 'emoticon', + Link = 'link', +} + +// Put this at the start and end of an inline component to work around this Chromium bug: +// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 +function InlineChromiumBugfix() { + return ( + + {String.fromCodePoint(160) /* Non-breaking space */} + + ); +} + +function RenderMentionElement({ + attributes, + element, + children, +}: { element: MentionElement } & RenderElementProps) { + const selected = useSelected(); + const focused = useFocused(); + + return ( + + {element.name} + {children} + + ); +} + +function RenderEmoticonElement({ + attributes, + element, + children, +}: { element: EmoticonElement } & RenderElementProps) { + const mx = useMatrixClient(); + const selected = useSelected(); + const focused = useFocused(); + + return ( + + + {element.key.startsWith('mxc://') ? ( + {element.shortcode} + ) : ( + element.key + )} + {children} + + + ); +} + +function RenderLinkElement({ + attributes, + element, + children, +}: { element: LinkElement } & RenderElementProps) { + return ( + + + {children} + + ); +} + +export function RenderElement({ attributes, element, children }: RenderElementProps) { + switch (element.type) { + case BlockType.Paragraph: + return ( + + {children} + + ); + case BlockType.Heading: + if (element.level === 1) + return ( + + {children} + + ); + if (element.level === 2) + return ( + + {children} + + ); + if (element.level === 3) + return ( + + {children} + + ); + return ( + + {children} + + ); + case BlockType.CodeLine: + return
{children}
; + case BlockType.CodeBlock: + return ( + + +
{children}
+
+
+ ); + case BlockType.QuoteLine: + return
{children}
; + case BlockType.BlockQuote: + return ( + + {children} + + ); + case BlockType.ListItem: + return ( + + {children} + + ); + case BlockType.OrderedList: + return ( +
    + {children} +
+ ); + case BlockType.UnorderedList: + return ( +
    + {children} +
+ ); + case BlockType.Mention: + return ( + + {children} + + ); + case BlockType.Emoticon: + return ( + + {children} + + ); + case BlockType.Link: + return ( + + {children} + + ); + default: + return ( + + {children} + + ); + } +} + +export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) { + let child = children; + if (leaf.bold) + child = ( + + + {child} + + ); + if (leaf.italic) + child = ( + + + {child} + + ); + if (leaf.underline) + child = ( + + + {child} + + ); + if (leaf.strikeThrough) + child = ( + + + {child} + + ); + if (leaf.code) + child = ( + + + {child} + + ); + if (leaf.spoiler) + child = ( + + + {child} + + ); + + if (child !== children) return child; + + return {child}; +} diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx new file mode 100644 index 0000000..a84fca2 --- /dev/null +++ b/src/app/components/editor/Toolbar.tsx @@ -0,0 +1,247 @@ +import FocusTrap from 'focus-trap-react'; +import { + Badge, + Box, + config, + Icon, + IconButton, + Icons, + IconSrc, + Line, + Menu, + PopOut, + Text, + Tooltip, + TooltipProvider, + toRem, +} from 'folds'; +import React, { ReactNode, useState } from 'react'; +import { ReactEditor, useSlate } from 'slate-react'; +import { isBlockActive, isMarkActive, toggleBlock, toggleMark } from './common'; +import * as css from './Editor.css'; +import { BlockType, MarkType } from './Elements'; +import { HeadingLevel } from './slate'; +import { isMacOS } from '../../utils/user-agent'; +import { KeySymbol } from '../../utils/key-symbol'; + +function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) { + return ( + + + {text} + {shortCode && ( + + + {shortCode} + + + )} + + + ); +} + +type MarkButtonProps = { format: MarkType; icon: IconSrc; tooltip: ReactNode }; +export function MarkButton({ format, icon, tooltip }: MarkButtonProps) { + const editor = useSlate(); + + const handleClick = () => { + toggleMark(editor, format); + ReactEditor.focus(editor); + }; + + return ( + + {(triggerRef) => ( + + + + )} + + ); +} + +type BlockButtonProps = { + format: BlockType; + icon: IconSrc; + tooltip: ReactNode; +}; +export function BlockButton({ format, icon, tooltip }: BlockButtonProps) { + const editor = useSlate(); + + const handleClick = () => { + toggleBlock(editor, format, { level: 1 }); + ReactEditor.focus(editor); + }; + + return ( + + {(triggerRef) => ( + + + + )} + + ); +} + +export function HeadingBlockButton() { + const editor = useSlate(); + const [level, setLevel] = useState(1); + const [open, setOpen] = useState(false); + const isActive = isBlockActive(editor, BlockType.Heading); + + const handleMenuSelect = (selectedLevel: HeadingLevel) => { + setOpen(false); + setLevel(selectedLevel); + toggleBlock(editor, BlockType.Heading, { level: selectedLevel }); + ReactEditor.focus(editor); + }; + + return ( + setOpen(false), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + }} + > + + + handleMenuSelect(1)} size="300" radii="300"> + + + handleMenuSelect(2)} size="300" radii="300"> + + + handleMenuSelect(3)} size="300" radii="300"> + + + + + + } + > + {(ref) => ( + (isActive ? toggleBlock(editor, BlockType.Heading) : setOpen(!open))} + aria-pressed={isActive} + size="300" + radii="300" + > + + + + )} + + ); +} + +export function Toolbar() { + const editor = useSlate(); + const allowInline = !isBlockActive(editor, BlockType.CodeBlock); + const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; + + return ( + + + + + } + /> + + } + /> + + } + /> + + } + /> + + {allowInline && ( + <> + + + } + /> + } + /> + } + /> + + } + /> + } + /> + } + /> + + + )} + + ); +} diff --git a/src/app/components/editor/autocomplete/AutocompleteMenu.css.tsx b/src/app/components/editor/autocomplete/AutocompleteMenu.css.tsx new file mode 100644 index 0000000..98f653e --- /dev/null +++ b/src/app/components/editor/autocomplete/AutocompleteMenu.css.tsx @@ -0,0 +1,35 @@ +import { style } from '@vanilla-extract/css'; +import { DefaultReset, config } from 'folds'; + +export const AutocompleteMenuBase = style([ + DefaultReset, + { + position: 'relative', + }, +]); + +export const AutocompleteMenuContainer = style([ + DefaultReset, + { + position: 'absolute', + bottom: config.space.S200, + left: 0, + right: 0, + zIndex: config.zIndex.Max, + }, +]); + +export const AutocompleteMenu = style([ + DefaultReset, + { + maxHeight: '30vh', + height: '100%', + display: 'flex', + flexDirection: 'column', + }, +]); + +export const AutocompleteMenuHeader = style([ + DefaultReset, + { padding: `0 ${config.space.S300}`, flexShrink: 0 }, +]); diff --git a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx new file mode 100644 index 0000000..d89cda0 --- /dev/null +++ b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx @@ -0,0 +1,40 @@ +import React, { ReactNode } from 'react'; +import FocusTrap from 'focus-trap-react'; +import isHotkey from 'is-hotkey'; +import { Header, Menu, Scroll, config } from 'folds'; + +import * as css from './AutocompleteMenu.css'; +import { preventScrollWithArrowKey } from '../../../utils/keyboard'; + +type AutocompleteMenuProps = { + requestClose: () => void; + headerContent: ReactNode; + children: ReactNode; +}; +export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) { + return ( +
+
+ requestClose(), + clickOutsideDeactivates: true, + allowOutsideClick: true, + isKeyForward: (evt: KeyboardEvent) => isHotkey('arrowdown', evt), + isKeyBackward: (evt: KeyboardEvent) => isHotkey('arrowup', evt), + }} + > + +
+ {headerContent} +
+ +
{children}
+
+
+
+
+
+ ); +} diff --git a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx new file mode 100644 index 0000000..e5af3fa --- /dev/null +++ b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx @@ -0,0 +1,129 @@ +import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react'; +import { Editor } from 'slate'; +import { Box, MenuItem, Text, toRem } from 'folds'; +import { Room } from 'matrix-js-sdk'; + +import { AutocompleteQuery } from './autocompleteQuery'; +import { AutocompleteMenu } from './AutocompleteMenu'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { + SearchItemStrGetter, + UseAsyncSearchOptions, + useAsyncSearch, +} from '../../../hooks/useAsyncSearch'; +import { onTabPress } from '../../../utils/keyboard'; +import { createEmoticonElement, moveCursor, replaceWithElement } from '../common'; +import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; +import { useRelevantImagePacks } from '../../../hooks/useImagePacks'; +import { IEmoji, emojis } from '../../../plugins/emoji'; +import { ExtendedPackImage, PackUsage } from '../../../plugins/custom-emoji'; +import { useKeyDown } from '../../../hooks/useKeyDown'; + +type EmoticonCompleteHandler = (key: string, shortcode: string) => void; + +type EmoticonSearchItem = ExtendedPackImage | IEmoji; + +type EmoticonAutocompleteProps = { + imagePackRooms: Room[]; + editor: Editor; + query: AutocompleteQuery; + requestClose: () => void; +}; + +const SEARCH_OPTIONS: UseAsyncSearchOptions = { + limit: 20, + matchOptions: { + contain: true, + }, +}; + +const getEmoticonStr: SearchItemStrGetter = (emoticon) => [ + `:${emoticon.shortcode}:`, +]; + +export function EmoticonAutocomplete({ + imagePackRooms, + editor, + query, + requestClose, +}: EmoticonAutocompleteProps) { + const mx = useMatrixClient(); + + const imagePacks = useRelevantImagePacks(mx, PackUsage.Emoticon, imagePackRooms); + const recentEmoji = useRecentEmoji(mx, 20); + + const searchList = useMemo(() => { + const list: Array = []; + return list.concat( + imagePacks.flatMap((pack) => pack.getImagesFor(PackUsage.Emoticon)), + emojis + ); + }, [imagePacks]); + + const [result, search] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS); + const autoCompleteEmoticon = result ? result.items : recentEmoji; + + useEffect(() => { + search(query.text); + }, [query.text, search]); + + const handleAutocomplete: EmoticonCompleteHandler = (key, shortcode) => { + const emoticonEl = createEmoticonElement(key, shortcode); + replaceWithElement(editor, query.range, emoticonEl); + moveCursor(editor, true); + requestClose(); + }; + + useKeyDown(window, (evt: KeyboardEvent) => { + onTabPress(evt, () => { + if (autoCompleteEmoticon.length === 0) return; + const emoticon = autoCompleteEmoticon[0]; + const key = 'url' in emoticon ? emoticon.url : emoticon.unicode; + handleAutocomplete(key, emoticon.shortcode); + }); + }); + + return autoCompleteEmoticon.length === 0 ? null : ( + Emojis} requestClose={requestClose}> + {autoCompleteEmoticon.map((emoticon) => { + const isCustomEmoji = 'url' in emoticon; + const key = isCustomEmoji ? emoticon.url : emoticon.unicode; + return ( + ) => + onTabPress(evt, () => handleAutocomplete(key, emoticon.shortcode)) + } + onClick={() => handleAutocomplete(key, emoticon.shortcode)} + before={ + isCustomEmoji ? ( + + ) : ( + + {key} + + ) + } + > + + :{emoticon.shortcode}: + + + ); + })} + + ); +} diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx new file mode 100644 index 0000000..2edfb8b --- /dev/null +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -0,0 +1,181 @@ +import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react'; +import { Editor } from 'slate'; +import { Avatar, AvatarFallback, AvatarImage, Icon, Icons, MenuItem, Text, color } from 'folds'; +import { MatrixClient } from 'matrix-js-sdk'; + +import { createMentionElement, moveCursor, replaceWithElement } from '../common'; +import { getRoomAvatarUrl, joinRuleToIconSrc } from '../../../utils/room'; +import { roomIdByActivity } from '../../../../util/sort'; +import initMatrix from '../../../../client/initMatrix'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { AutocompleteQuery } from './autocompleteQuery'; +import { AutocompleteMenu } from './AutocompleteMenu'; +import { getMxIdServer, validMxId } from '../../../utils/matrix'; +import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; +import { onTabPress } from '../../../utils/keyboard'; +import { useKeyDown } from '../../../hooks/useKeyDown'; + +type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void; + +const roomAliasFromQueryText = (mx: MatrixClient, text: string) => + validMxId(`#${text}`) + ? `#${text}` + : `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; + +function UnknownRoomMentionItem({ + query, + handleAutocomplete, +}: { + query: AutocompleteQuery; + handleAutocomplete: MentionAutoCompleteHandler; +}) { + const mx = useMatrixClient(); + const roomAlias: string = roomAliasFromQueryText(mx, query.text); + + return ( + ) => + onTabPress(evt, () => handleAutocomplete(roomAlias, roomAlias)) + } + onClick={() => handleAutocomplete(roomAlias, roomAlias)} + before={ + + + + } + > + + {roomAlias} + + + ); +} + +type RoomMentionAutocompleteProps = { + roomId: string; + editor: Editor; + query: AutocompleteQuery; + requestClose: () => void; +}; + +const SEARCH_OPTIONS: UseAsyncSearchOptions = { + limit: 20, + matchOptions: { + contain: true, + }, +}; + +export function RoomMentionAutocomplete({ + roomId, + editor, + query, + requestClose, +}: RoomMentionAutocompleteProps) { + const mx = useMatrixClient(); + const dms: Set = initMatrix.roomList?.directs ?? new Set(); + + const allRoomId: string[] = useMemo(() => { + const { spaces = [], rooms = [], directs = [] } = initMatrix.roomList ?? {}; + return [...spaces, ...rooms, ...directs].sort(roomIdByActivity); + }, []); + + const [result, search] = useAsyncSearch( + allRoomId, + useCallback( + (rId) => { + const r = mx.getRoom(rId); + if (!r) return 'Unknown Room'; + const alias = r.getCanonicalAlias(); + if (alias) return [r.name, alias]; + return r.name; + }, + [mx] + ), + SEARCH_OPTIONS + ); + + const autoCompleteRoomIds = result ? result.items : allRoomId.slice(0, 20); + + useEffect(() => { + search(query.text); + }, [query.text, search]); + + const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => { + const mentionEl = createMentionElement( + roomAliasOrId, + name.startsWith('#') ? name : `#${name}`, + roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId + ); + replaceWithElement(editor, query.range, mentionEl); + moveCursor(editor, true); + requestClose(); + }; + + useKeyDown(window, (evt: KeyboardEvent) => { + onTabPress(evt, () => { + if (autoCompleteRoomIds.length === 0) { + const alias = roomAliasFromQueryText(mx, query.text); + handleAutocomplete(alias, alias); + return; + } + const rId = autoCompleteRoomIds[0]; + const name = mx.getRoom(rId)?.name ?? rId; + handleAutocomplete(rId, name); + }); + }); + + return ( + Rooms} requestClose={requestClose}> + {autoCompleteRoomIds.length === 0 ? ( + + ) : ( + autoCompleteRoomIds.map((rId) => { + const room = mx.getRoom(rId); + if (!room) return null; + const dm = dms.has(room.roomId); + const avatarUrl = getRoomAvatarUrl(mx, room); + const iconSrc = !dm && joinRuleToIconSrc(Icons, room.getJoinRule(), room.isSpaceRoom()); + + return ( + ) => + onTabPress(evt, () => handleAutocomplete(rId, room.name)) + } + onClick={() => handleAutocomplete(rId, room.name)} + after={ + + {room.getCanonicalAlias() ?? ''} + + } + before={ + + {iconSrc && } + {avatarUrl && !iconSrc && } + {!avatarUrl && !iconSrc && ( + + {room.name[0]} + + )} + + } + > + + {room.name} + + + ); + }) + )} + + ); +} diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx new file mode 100644 index 0000000..10088ad --- /dev/null +++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx @@ -0,0 +1,191 @@ +import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { Editor } from 'slate'; +import { Avatar, AvatarFallback, AvatarImage, MenuItem, Text, color } from 'folds'; +import { MatrixClient, RoomMember } from 'matrix-js-sdk'; + +import { AutocompleteQuery } from './autocompleteQuery'; +import { AutocompleteMenu } from './AutocompleteMenu'; +import { useRoomMembers } from '../../../hooks/useRoomMembers'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { + SearchItemStrGetter, + UseAsyncSearchOptions, + useAsyncSearch, +} from '../../../hooks/useAsyncSearch'; +import { onTabPress } from '../../../utils/keyboard'; +import { createMentionElement, moveCursor, replaceWithElement } from '../common'; +import { useKeyDown } from '../../../hooks/useKeyDown'; +import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix'; + +type MentionAutoCompleteHandler = (userId: string, name: string) => void; + +const userIdFromQueryText = (mx: MatrixClient, text: string) => + validMxId(`@${text}`) + ? `@${text}` + : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`; + +function UnknownMentionItem({ + query, + userId, + name, + handleAutocomplete, +}: { + query: AutocompleteQuery; + userId: string; + name: string; + handleAutocomplete: MentionAutoCompleteHandler; +}) { + return ( + ) => + onTabPress(evt, () => handleAutocomplete(userId, name)) + } + onClick={() => handleAutocomplete(userId, name)} + before={ + + + {query.text[0]} + + + } + > + + {name} + + + ); +} + +type UserMentionAutocompleteProps = { + roomId: string; + editor: Editor; + query: AutocompleteQuery; + requestClose: () => void; +}; + +const SEARCH_OPTIONS: UseAsyncSearchOptions = { + limit: 20, + matchOptions: { + contain: true, + }, +}; + +const getRoomMemberStr: SearchItemStrGetter = (roomMember) => [ + roomMember.name, + getMxIdLocalPart(roomMember.userId) ?? roomMember.userId, + roomMember.userId, +]; + +export function UserMentionAutocomplete({ + roomId, + editor, + query, + requestClose, +}: UserMentionAutocompleteProps) { + const mx = useMatrixClient(); + const room = mx.getRoom(roomId); + const roomAliasOrId = room?.getCanonicalAlias() || roomId; + const members = useRoomMembers(mx, roomId); + + const [result, search] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS); + const autoCompleteMembers = result ? result.items : members.slice(0, 20); + + useEffect(() => { + search(query.text); + }, [query.text, search]); + + const handleAutocomplete: MentionAutoCompleteHandler = (uId, name) => { + const mentionEl = createMentionElement( + uId, + name.startsWith('@') ? name : `@${name}`, + mx.getUserId() === uId || roomAliasOrId === uId + ); + replaceWithElement(editor, query.range, mentionEl); + moveCursor(editor, true); + requestClose(); + }; + + useKeyDown(window, (evt: KeyboardEvent) => { + onTabPress(evt, () => { + if (query.text === 'room') { + handleAutocomplete(roomAliasOrId, '@room'); + return; + } + if (autoCompleteMembers.length === 0) { + const userId = userIdFromQueryText(mx, query.text); + handleAutocomplete(userId, userId); + return; + } + const roomMember = autoCompleteMembers[0]; + handleAutocomplete(roomMember.userId, roomMember.name); + }); + }); + + return ( + Mentions} requestClose={requestClose}> + {query.text === 'room' && ( + + )} + {autoCompleteMembers.length === 0 ? ( + + ) : ( + autoCompleteMembers.map((roomMember) => { + const avatarUrl = roomMember.getAvatarUrl(mx.baseUrl, 32, 32, 'crop', undefined, false); + return ( + ) => + onTabPress(evt, () => handleAutocomplete(roomMember.userId, roomMember.name)) + } + onClick={() => handleAutocomplete(roomMember.userId, roomMember.name)} + after={ + + {roomMember.userId} + + } + before={ + + {avatarUrl ? ( + + ) : ( + + {roomMember.name[0] || roomMember.userId[1]} + + )} + + } + > + + {roomMember.name} + + + ); + }) + )} + + ); +} diff --git a/src/app/components/editor/autocomplete/autocompleteQuery.ts b/src/app/components/editor/autocomplete/autocompleteQuery.ts new file mode 100644 index 0000000..348b446 --- /dev/null +++ b/src/app/components/editor/autocomplete/autocompleteQuery.ts @@ -0,0 +1,46 @@ +import { BaseRange, Editor } from 'slate'; + +export enum AutocompletePrefix { + RoomMention = '#', + UserMention = '@', + Emoticon = ':', +} +export const AUTOCOMPLETE_PREFIXES: readonly AutocompletePrefix[] = [ + AutocompletePrefix.RoomMention, + AutocompletePrefix.UserMention, + AutocompletePrefix.Emoticon, +]; + +export type AutocompleteQuery = { + range: BaseRange; + prefix: TPrefix; + text: string; +}; + +export const getAutocompletePrefix = ( + editor: Editor, + queryRange: BaseRange, + validPrefixes: readonly TPrefix[] +): TPrefix | undefined => { + const world = Editor.string(editor, queryRange); + const prefix = world[0] as TPrefix | undefined; + if (!prefix) return undefined; + return validPrefixes.includes(prefix) ? prefix : undefined; +}; + +export const getAutocompleteQueryText = (editor: Editor, queryRange: BaseRange): string => + Editor.string(editor, queryRange).slice(1); + +export const getAutocompleteQuery = ( + editor: Editor, + queryRange: BaseRange, + validPrefixes: readonly TPrefix[] +): AutocompleteQuery | undefined => { + const prefix = getAutocompletePrefix(editor, queryRange, validPrefixes); + if (!prefix) return undefined; + return { + range: queryRange, + prefix, + text: getAutocompleteQueryText(editor, queryRange), + }; +}; diff --git a/src/app/components/editor/autocomplete/index.ts b/src/app/components/editor/autocomplete/index.ts new file mode 100644 index 0000000..be6a744 --- /dev/null +++ b/src/app/components/editor/autocomplete/index.ts @@ -0,0 +1,5 @@ +export * from './AutocompleteMenu'; +export * from './autocompleteQuery'; +export * from './RoomMentionAutocomplete'; +export * from './UserMentionAutocomplete'; +export * from './EmoticonAutocomplete'; diff --git a/src/app/components/editor/common.ts b/src/app/components/editor/common.ts new file mode 100644 index 0000000..c9cf086 --- /dev/null +++ b/src/app/components/editor/common.ts @@ -0,0 +1,194 @@ +import { BasePoint, BaseRange, Editor, Element, Point, Range, Transforms } from 'slate'; +import { BlockType, MarkType } from './Elements'; +import { EmoticonElement, FormattedText, HeadingLevel, LinkElement, MentionElement } from './slate'; + +export const isMarkActive = (editor: Editor, format: MarkType) => { + const marks = Editor.marks(editor); + return marks ? marks[format] === true : false; +}; + +export const toggleMark = (editor: Editor, format: MarkType) => { + const isActive = isMarkActive(editor, format); + + if (isActive) { + Editor.removeMark(editor, format); + } else { + Editor.addMark(editor, format, true); + } +}; + +export const isBlockActive = (editor: Editor, format: BlockType) => { + const [match] = Editor.nodes(editor, { + match: (node) => Element.isElement(node) && node.type === format, + }); + + return !!match; +}; + +type BlockOption = { level: HeadingLevel }; +const NESTED_BLOCK = [ + BlockType.OrderedList, + BlockType.UnorderedList, + BlockType.BlockQuote, + BlockType.CodeBlock, +]; + +export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => { + const isActive = isBlockActive(editor, format); + + Transforms.unwrapNodes(editor, { + match: (node) => Element.isElement(node) && NESTED_BLOCK.includes(node.type), + split: true, + }); + + if (isActive) { + Transforms.setNodes(editor, { + type: BlockType.Paragraph, + }); + return; + } + + if (format === BlockType.OrderedList || format === BlockType.UnorderedList) { + Transforms.setNodes(editor, { + type: BlockType.ListItem, + }); + const block = { + type: format, + children: [], + }; + Transforms.wrapNodes(editor, block); + return; + } + if (format === BlockType.CodeBlock) { + Transforms.setNodes(editor, { + type: BlockType.CodeLine, + }); + const block = { + type: format, + children: [], + }; + Transforms.wrapNodes(editor, block); + return; + } + + if (format === BlockType.BlockQuote) { + Transforms.setNodes(editor, { + type: BlockType.QuoteLine, + }); + const block = { + type: format, + children: [], + }; + Transforms.wrapNodes(editor, block); + return; + } + + if (format === BlockType.Heading) { + Transforms.setNodes(editor, { + type: format, + level: option?.level ?? 1, + }); + } + + Transforms.setNodes(editor, { + type: format, + }); +}; + +export const resetEditor = (editor: Editor) => { + Transforms.delete(editor, { + at: { + anchor: Editor.start(editor, []), + focus: Editor.end(editor, []), + }, + }); + + toggleBlock(editor, BlockType.Paragraph); +}; + +export const createMentionElement = ( + id: string, + name: string, + highlight: boolean +): MentionElement => ({ + type: BlockType.Mention, + id, + highlight, + name, + children: [{ text: '' }], +}); + +export const createEmoticonElement = (key: string, shortcode: string): EmoticonElement => ({ + type: BlockType.Emoticon, + key, + shortcode, + children: [{ text: '' }], +}); + +export const createLinkElement = ( + href: string, + children: string | FormattedText[] +): LinkElement => ({ + type: BlockType.Link, + href, + children: typeof children === 'string' ? [{ text: children }] : children, +}); + +export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => { + Transforms.select(editor, selectRange); + Transforms.insertNodes(editor, element); +}; + +export const moveCursor = (editor: Editor, withSpace?: boolean) => { + // without timeout it works properly when we select autocomplete with Tab or Space + setTimeout(() => { + Transforms.move(editor); + if (withSpace) editor.insertText(' '); + }, 1); +}; + +interface PointUntilCharOptions { + match: (char: string) => boolean; + reverse?: boolean; +} +export const getPointUntilChar = ( + editor: Editor, + cursorPoint: BasePoint, + options: PointUntilCharOptions +): BasePoint | undefined => { + let targetPoint: BasePoint | undefined; + let prevPoint: BasePoint | undefined; + let char: string | undefined; + + const pointItr = Editor.positions(editor, { + at: { + anchor: Editor.start(editor, []), + focus: Editor.point(editor, cursorPoint, { edge: 'start' }), + }, + unit: 'character', + reverse: options.reverse, + }); + + // eslint-disable-next-line no-restricted-syntax + for (const point of pointItr) { + if (!Point.equals(point, cursorPoint) && prevPoint) { + char = Editor.string(editor, { anchor: point, focus: prevPoint }); + + if (options.match(char)) break; + targetPoint = point; + } + prevPoint = point; + } + return targetPoint; +}; + +export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => { + const { selection } = editor; + if (!selection || !Range.isCollapsed(selection)) return undefined; + const [cursorPoint] = Range.edges(selection); + const worldStartPoint = getPointUntilChar(editor, cursorPoint, { + reverse: true, + match: (char) => char === ' ', + }); + return worldStartPoint && Editor.range(editor, worldStartPoint, cursorPoint); +}; diff --git a/src/app/components/editor/index.ts b/src/app/components/editor/index.ts new file mode 100644 index 0000000..76ccf56 --- /dev/null +++ b/src/app/components/editor/index.ts @@ -0,0 +1,7 @@ +export * from './autocomplete'; +export * from './common'; +export * from './Editor'; +export * from './Elements'; +export * from './keyboard'; +export * from './output'; +export * from './Toolbar'; diff --git a/src/app/components/editor/keyboard.ts b/src/app/components/editor/keyboard.ts new file mode 100644 index 0000000..52217dd --- /dev/null +++ b/src/app/components/editor/keyboard.ts @@ -0,0 +1,40 @@ +import { isHotkey } from 'is-hotkey'; +import { KeyboardEvent } from 'react'; +import { Editor } from 'slate'; +import { isBlockActive, toggleBlock, toggleMark } from './common'; +import { BlockType, MarkType } from './Elements'; + +export const INLINE_HOTKEYS: Record = { + 'mod+b': MarkType.Bold, + 'mod+i': MarkType.Italic, + 'mod+u': MarkType.Underline, + 'mod+shift+u': MarkType.StrikeThrough, + 'mod+[': MarkType.Code, + 'mod+h': MarkType.Spoiler, +}; +const INLINE_KEYS = Object.keys(INLINE_HOTKEYS); + +export const BLOCK_HOTKEYS: Record = { + 'mod+shift+0': BlockType.OrderedList, + 'mod+shift+8': BlockType.UnorderedList, + "mod+shift+'": BlockType.BlockQuote, + 'mod+shift+;': BlockType.CodeBlock, +}; +const BLOCK_KEYS = Object.keys(BLOCK_HOTKEYS); + +export const toggleKeyboardShortcut = (editor: Editor, event: KeyboardEvent) => { + BLOCK_KEYS.forEach((hotkey) => { + if (isHotkey(hotkey, event)) { + event.preventDefault(); + toggleBlock(editor, BLOCK_HOTKEYS[hotkey]); + } + }); + + if (!isBlockActive(editor, BlockType.CodeBlock)) + INLINE_KEYS.forEach((hotkey) => { + if (isHotkey(hotkey, event)) { + event.preventDefault(); + toggleMark(editor, INLINE_HOTKEYS[hotkey]); + } + }); +}; diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts new file mode 100644 index 0000000..091dab7 --- /dev/null +++ b/src/app/components/editor/output.ts @@ -0,0 +1,95 @@ +import { Descendant, Text } from 'slate'; +import { sanitizeText } from '../../utils/sanitize'; +import { BlockType } from './Elements'; +import { CustomElement, FormattedText } from './slate'; + +const textToCustomHtml = (node: FormattedText): string => { + let string = sanitizeText(node.text); + if (node.bold) string = `${string}`; + if (node.italic) string = `${string}`; + if (node.underline) string = `${string}`; + if (node.strikeThrough) string = `${string}`; + if (node.code) string = `${string}`; + if (node.spoiler) string = `${string}`; + return string; +}; + +const elementToCustomHtml = (node: CustomElement, children: string): string => { + switch (node.type) { + case BlockType.Paragraph: + return `

${children}

`; + case BlockType.Heading: + return `${children}`; + case BlockType.CodeLine: + return `${children}\n`; + case BlockType.CodeBlock: + return `
${children}
`; + case BlockType.QuoteLine: + return `

${children}

`; + case BlockType.BlockQuote: + return `
${children}
`; + case BlockType.ListItem: + return `
  • ${children}

  • `; + case BlockType.OrderedList: + return `
      ${children}
    `; + case BlockType.UnorderedList: + return `
      ${children}
    `; + case BlockType.Mention: + return `${node.name}`; + case BlockType.Emoticon: + return node.key.startsWith('mxc://') + ? `${node.shortcode}` + : node.key; + case BlockType.Link: + return `${node.children}`; + default: + return children; + } +}; + +export const toMatrixCustomHTML = (node: Descendant | Descendant[]): string => { + if (Array.isArray(node)) return node.map((n) => toMatrixCustomHTML(n)).join(''); + if (Text.isText(node)) return textToCustomHtml(node); + + const children = node.children.map((n) => toMatrixCustomHTML(n)).join(''); + return elementToCustomHtml(node, children); +}; + +const elementToPlainText = (node: CustomElement, children: string): string => { + switch (node.type) { + case BlockType.Paragraph: + return `${children}\n`; + case BlockType.Heading: + return `${children}\n`; + case BlockType.CodeLine: + return `${children}\n`; + case BlockType.CodeBlock: + return `${children}\n`; + case BlockType.QuoteLine: + return `| ${children}\n`; + case BlockType.BlockQuote: + return `${children}\n`; + case BlockType.ListItem: + return `- ${children}\n`; + case BlockType.OrderedList: + return `${children}\n`; + case BlockType.UnorderedList: + return `${children}\n`; + case BlockType.Mention: + return node.id; + case BlockType.Emoticon: + return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key; + case BlockType.Link: + return `[${node.children}](${node.href})`; + default: + return children; + } +}; + +export const toPlainText = (node: Descendant | Descendant[]): string => { + if (Array.isArray(node)) return node.map((n) => toPlainText(n)).join(''); + if (Text.isText(node)) return sanitizeText(node.text); + + const children = node.children.map((n) => toPlainText(n)).join(''); + return elementToPlainText(node, children); +}; diff --git a/src/app/components/editor/slate.d.ts b/src/app/components/editor/slate.d.ts new file mode 100644 index 0000000..a321904 --- /dev/null +++ b/src/app/components/editor/slate.d.ts @@ -0,0 +1,107 @@ +import { BaseEditor } from 'slate'; +import { ReactEditor } from 'slate-react'; +import { BlockType } from './Elements'; + +export type HeadingLevel = 1 | 2 | 3; + +export type Editor = BaseEditor & ReactEditor; + +export type Text = { + text: string; +}; + +export type FormattedText = Text & { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikeThrough?: boolean; + code?: boolean; + spoiler?: boolean; +}; + +export type LinkElement = { + type: BlockType.Link; + href: string; + children: FormattedText[]; +}; +export type SpoilerElement = { + type: 'spoiler'; + alert?: string; + children: FormattedText[]; +}; +export type MentionElement = { + type: BlockType.Mention; + id: string; + highlight: boolean; + name: string; + children: Text[]; +}; +export type EmoticonElement = { + type: BlockType.Emoticon; + key: string; + shortcode: string; + children: Text[]; +}; + +export type ParagraphElement = { + type: BlockType.Paragraph; + children: FormattedText[]; +}; +export type HeadingElement = { + type: BlockType.Heading; + level: HeadingLevel; + children: FormattedText[]; +}; +export type CodeLineElement = { + type: BlockType.CodeLine; + children: Text[]; +}; +export type CodeBlockElement = { + type: BlockType.CodeBlock; + children: CodeLineElement[]; +}; +export type QuoteLineElement = { + type: BlockType.QuoteLine; + children: FormattedText[]; +}; +export type BlockQuoteElement = { + type: BlockType.BlockQuote; + children: QuoteLineElement[]; +}; +export type ListItemElement = { + type: BlockType.ListItem; + children: FormattedText[]; +}; +export type OrderedListElement = { + type: BlockType.OrderedList; + children: ListItemElement[]; +}; +export type UnorderedListElement = { + type: BlockType.UnorderedList; + children: ListItemElement[]; +}; + +export type CustomElement = + | LinkElement + // | SpoilerElement + | MentionElement + | EmoticonElement + | ParagraphElement + | HeadingElement + | CodeLineElement + | CodeBlockElement + | QuoteLineElement + | BlockQuoteElement + | ListItemElement + | OrderedListElement + | UnorderedListElement; + +export type CustomEditor = BaseEditor & ReactEditor; + +declare module 'slate' { + interface CustomTypes { + Editor: BaseEditor & ReactEditor; + Element: CustomElement; + Text: FormattedText & Text; + } +} diff --git a/src/app/components/emoji-board/EmojiBoard.css.tsx b/src/app/components/emoji-board/EmojiBoard.css.tsx new file mode 100644 index 0000000..0fefc5b --- /dev/null +++ b/src/app/components/emoji-board/EmojiBoard.css.tsx @@ -0,0 +1,134 @@ +import { style } from '@vanilla-extract/css'; +import { DefaultReset, FocusOutline, color, config, toRem } from 'folds'; + +export const Base = style({ + maxWidth: toRem(432), + width: `calc(100vw - 2 * ${config.space.S400})`, + height: toRem(450), + backgroundColor: color.Surface.Container, + color: color.Surface.OnContainer, + border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + borderRadius: config.radii.R400, + boxShadow: config.shadow.E200, + overflow: 'hidden', +}); + +export const Sidebar = style({ + width: toRem(54), + backgroundColor: color.Surface.Container, + color: color.Surface.OnContainer, + position: 'relative', +}); + +export const SidebarContent = style({ + padding: `${config.space.S200} 0`, +}); + +export const SidebarStack = style({ + width: '100%', + backgroundColor: color.Surface.Container, +}); + +export const NativeEmojiSidebarStack = style({ + position: 'sticky', + bottom: '-67%', + zIndex: 1, +}); + +export const SidebarDivider = style({ + width: toRem(18), +}); + +export const Header = style({ + padding: config.space.S300, + paddingBottom: 0, +}); + +export const EmojiBoardTab = style({ + cursor: 'pointer', +}); + +export const Footer = style({ + padding: config.space.S200, + margin: config.space.S300, + marginTop: 0, + minHeight: toRem(40), + + borderRadius: config.radii.R400, + backgroundColor: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, +}); + +export const EmojiGroup = style({ + padding: `${config.space.S300} 0`, +}); + +export const EmojiGroupLabel = style({ + position: 'sticky', + top: config.space.S200, + zIndex: 1, + + margin: 'auto', + padding: `${config.space.S100} ${config.space.S200}`, + borderRadius: config.radii.Pill, + backgroundColor: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, +}); + +export const EmojiGroupContent = style([ + DefaultReset, + { + padding: `0 ${config.space.S200}`, + }, +]); + +export const EmojiPreview = style([ + DefaultReset, + { + width: toRem(32), + height: toRem(32), + fontSize: toRem(32), + lineHeight: toRem(32), + }, +]); + +export const EmojiItem = style([ + DefaultReset, + FocusOutline, + { + width: toRem(48), + height: toRem(48), + fontSize: toRem(32), + lineHeight: toRem(32), + borderRadius: config.radii.R400, + cursor: 'pointer', + + ':hover': { + backgroundColor: color.Surface.ContainerHover, + }, + }, +]); + +export const StickerItem = style([ + EmojiItem, + { + width: toRem(112), + height: toRem(112), + }, +]); + +export const CustomEmojiImg = style([ + DefaultReset, + { + width: toRem(32), + height: toRem(32), + }, +]); + +export const StickerImg = style([ + DefaultReset, + { + width: toRem(96), + height: toRem(96), + }, +]); diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx new file mode 100644 index 0000000..c5f5038 --- /dev/null +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -0,0 +1,860 @@ +import React, { + ChangeEventHandler, + FocusEventHandler, + MouseEventHandler, + UIEventHandler, + ReactNode, + memo, + useCallback, + useEffect, + useMemo, + useRef, +} from 'react'; +import { + Badge, + Box, + Icon, + IconButton, + Icons, + Input, + Line, + Scroll, + Text, + Tooltip, + TooltipProvider, + as, + config, + toRem, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import isHotkey from 'is-hotkey'; +import classNames from 'classnames'; +import { MatrixClient, Room } from 'matrix-js-sdk'; +import { atom, useAtomValue, useSetAtom } from 'jotai'; + +import * as css from './EmojiBoard.css'; +import { EmojiGroupId, IEmoji, IEmojiGroup, emojiGroups, emojis } from '../../plugins/emoji'; +import { IEmojiGroupLabels, useEmojiGroupLabels } from './useEmojiGroupLabels'; +import { IEmojiGroupIcons, useEmojiGroupIcons } from './useEmojiGroupIcons'; +import { preventScrollWithArrowKey } from '../../utils/keyboard'; +import { useRelevantImagePacks } from '../../hooks/useImagePacks'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useRecentEmoji } from '../../hooks/useRecentEmoji'; +import { ExtendedPackImage, ImagePack, PackUsage } from '../../plugins/custom-emoji'; +import { isUserId } from '../../utils/matrix'; +import { editableActiveElement, inVisibleScrollArea, targetFromEvent } from '../../utils/dom'; +import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; +import { useDebounce } from '../../hooks/useDebounce'; +import { useThrottle } from '../../hooks/useThrottle'; + +const RECENT_GROUP_ID = 'recent_group'; +const SEARCH_GROUP_ID = 'search_group'; + +export enum EmojiBoardTab { + Emoji = 'Emoji', + Sticker = 'Sticker', +} + +enum EmojiType { + Emoji = 'emoji', + CustomEmoji = 'customEmoji', + Sticker = 'sticker', +} + +export type EmojiItemInfo = { + type: EmojiType; + data: string; + shortcode: string; +}; + +const getDOMGroupId = (id: string): string => `EmojiBoardGroup-${id}`; + +const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => { + const type = element.getAttribute('data-emoji-type') as EmojiType | undefined; + const data = element.getAttribute('data-emoji-data'); + const shortcode = element.getAttribute('data-emoji-shortcode'); + + if (type && data && shortcode) + return { + type, + data, + shortcode, + }; + return undefined; +}; + +const activeGroupIdAtom = atom(undefined); + +function Sidebar({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + ); +} + +const SidebarStack = as<'div'>(({ className, children, ...props }, ref) => ( + + {children} + +)); +function SidebarDivider() { + return ; +} + +function Header({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function Content({ children }: { children: ReactNode }) { + return {children}; +} + +function Footer({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +const EmojiBoardLayout = as< + 'div', + { + header: ReactNode; + sidebar?: ReactNode; + footer?: ReactNode; + children: ReactNode; + } +>(({ className, header, sidebar, footer, children, ...props }, ref) => ( + + + {header} + {children} + {footer} + + + {sidebar} + +)); + +function EmojiBoardTabs({ + tab, + onTabChange, +}: { + tab: EmojiBoardTab; + onTabChange: (tab: EmojiBoardTab) => void; +}) { + return ( + + onTabChange(EmojiBoardTab.Emoji)} + > + + Emoji + + + onTabChange(EmojiBoardTab.Sticker)} + > + + Sticker + + + + ); +} + +export function SidebarBtn({ + active, + label, + id, + onItemClick, + children, +}: { + active?: boolean; + label: string; + id: T; + onItemClick: (id: T) => void; + children: ReactNode; +}) { + return ( + + {label} + + } + > + {(ref) => ( + onItemClick(id)} + size="400" + radii="300" + variant="Surface" + > + {children} + + )} + + ); +} + +export const EmojiGroup = as< + 'div', + { + id: string; + label: string; + children: ReactNode; + } +>(({ className, id, label, children, ...props }, ref) => ( + + + {label} + +
    + + {children} + +
    +
    +)); + +export function EmojiItem({ + label, + type, + data, + shortcode, + children, +}: { + label: string; + type: EmojiType; + data: string; + shortcode: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function StickerItem({ + label, + type, + data, + shortcode, + children, +}: { + label: string; + type: EmojiType; + data: string; + shortcode: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function RecentEmojiSidebarStack({ onItemClick }: { onItemClick: (id: string) => void }) { + const activeGroupId = useAtomValue(activeGroupIdAtom); + + return ( + + onItemClick(RECENT_GROUP_ID)} + > + + + + ); +} + +function ImagePackSidebarStack({ + mx, + packs, + usage, + onItemClick, +}: { + mx: MatrixClient; + packs: ImagePack[]; + usage: PackUsage; + onItemClick: (id: string) => void; +}) { + const activeGroupId = useAtomValue(activeGroupIdAtom); + return ( + + {usage === PackUsage.Emoticon && } + {packs.map((pack) => { + let label = pack.displayName; + if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; + return ( + + {label + + ); + })} + + ); +} + +function NativeEmojiSidebarStack({ + groups, + icons, + labels, + onItemClick, +}: { + groups: IEmojiGroup[]; + icons: IEmojiGroupIcons; + labels: IEmojiGroupLabels; + onItemClick: (id: EmojiGroupId) => void; +}) { + const activeGroupId = useAtomValue(activeGroupIdAtom); + return ( + + + {groups.map((group) => ( + + + + ))} + + ); +} + +export function RecentEmojiGroup({ + label, + id, + emojis: recentEmojis, +}: { + label: string; + id: string; + emojis: IEmoji[]; +}) { + return ( + + {recentEmojis.map((emoji) => ( + + {emoji.unicode} + + ))} + + ); +} + +export function SearchEmojiGroup({ + mx, + tab, + label, + id, + emojis: searchResult, +}: { + mx: MatrixClient; + tab: EmojiBoardTab; + label: string; + id: string; + emojis: Array; +}) { + return ( + + {tab === EmojiBoardTab.Emoji + ? searchResult.map((emoji) => + 'unicode' in emoji ? ( + + {emoji.unicode} + + ) : ( + + {emoji.body + + ) + ) + : searchResult.map((emoji) => + 'unicode' in emoji ? null : ( + + {emoji.body + + ) + )} + + ); +} + +export const CustomEmojiGroups = memo( + ({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => ( + <> + {groups.map((pack) => ( + + {pack.getEmojis().map((image) => ( + + {image.body + + ))} + + ))} + + ) +); + +export const StickerGroups = memo(({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => ( + <> + {groups.length === 0 && ( + + + + No Sticker Packs! + + Add stickers from user, room or space settings. + + + + )} + {groups.map((pack) => ( + + {pack.getStickers().map((image) => ( + + {image.body + + ))} + + ))} + +)); + +export const NativeEmojiGroups = memo( + ({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => ( + <> + {groups.map((emojiGroup) => ( + + {emojiGroup.emojis.map((emoji) => ( + + {emoji.unicode} + + ))} + + ))} + + ) +); + +const getSearchListItemStr = (item: ExtendedPackImage | IEmoji) => `:${item.shortcode}:`; +const SEARCH_OPTIONS: UseAsyncSearchOptions = { + limit: 26, + matchOptions: { + contain: true, + }, +}; + +export function EmojiBoard({ + tab = EmojiBoardTab.Emoji, + onTabChange, + imagePackRooms, + requestClose, + returnFocusOnDeactivate, + onEmojiSelect, + onCustomEmojiSelect, + onStickerSelect, +}: { + tab?: EmojiBoardTab; + onTabChange?: (tab: EmojiBoardTab) => void; + imagePackRooms: Room[]; + requestClose: () => void; + returnFocusOnDeactivate?: boolean; + onEmojiSelect?: (unicode: string, shortcode: string) => void; + onCustomEmojiSelect?: (mxc: string, shortcode: string) => void; + onStickerSelect?: (mxc: string, shortcode: string) => void; +}) { + const emojiTab = tab === EmojiBoardTab.Emoji; + const stickerTab = tab === EmojiBoardTab.Sticker; + const usage = emojiTab ? PackUsage.Emoticon : PackUsage.Sticker; + + const setActiveGroupId = useSetAtom(activeGroupIdAtom); + const mx = useMatrixClient(); + const emojiGroupLabels = useEmojiGroupLabels(); + const emojiGroupIcons = useEmojiGroupIcons(); + const imagePacks = useRelevantImagePacks(mx, usage, imagePackRooms); + const recentEmojis = useRecentEmoji(mx, 21); + + const contentScrollRef = useRef(null); + const emojiPreviewRef = useRef(null); + const emojiPreviewTextRef = useRef(null); + + const searchList = useMemo(() => { + let list: Array = []; + list = list.concat(imagePacks.flatMap((pack) => pack.getImagesFor(usage))); + if (emojiTab) list = list.concat(emojis); + return list; + }, [emojiTab, usage, imagePacks]); + + const [result, search] = useAsyncSearch(searchList, getSearchListItemStr, SEARCH_OPTIONS); + + const handleOnChange: ChangeEventHandler = useDebounce( + useCallback( + (evt) => { + const term = evt.target.value; + search(term); + }, + [search] + ), + { wait: 200 } + ); + + const syncActiveGroupId = useCallback(() => { + const targetEl = contentScrollRef.current; + if (!targetEl) return; + const groupEls = [...targetEl.querySelectorAll('div[data-group-id]')] as HTMLElement[]; + const groupEl = groupEls.find((el) => inVisibleScrollArea(targetEl, el)); + const groupId = groupEl?.getAttribute('data-group-id') ?? undefined; + setActiveGroupId(groupId); + }, [setActiveGroupId]); + + const handleOnScroll: UIEventHandler = useThrottle(syncActiveGroupId, { + wait: 500, + }); + + const handleScrollToGroup = (groupId: string) => { + setActiveGroupId(groupId); + const groupElement = document.getElementById(getDOMGroupId(groupId)); + groupElement?.scrollIntoView(); + }; + + const handleEmojiClick: MouseEventHandler = (evt) => { + const targetEl = targetFromEvent(evt.nativeEvent, 'button'); + if (!targetEl) return; + const emojiInfo = getEmojiItemInfo(targetEl); + if (!emojiInfo) return; + if (emojiInfo.type === EmojiType.Emoji) { + onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); + if (!evt.altKey && !evt.shiftKey) requestClose(); + } + if (emojiInfo.type === EmojiType.CustomEmoji) { + onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); + if (!evt.altKey && !evt.shiftKey) requestClose(); + } + if (emojiInfo.type === EmojiType.Sticker) { + onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode); + if (!evt.altKey && !evt.shiftKey) requestClose(); + } + }; + + const handleEmojiPreview = useCallback( + (element: HTMLButtonElement) => { + const emojiInfo = getEmojiItemInfo(element); + if (!emojiInfo || !emojiPreviewTextRef.current) return; + if (emojiInfo.type === EmojiType.Emoji && emojiPreviewRef.current) { + emojiPreviewRef.current.textContent = emojiInfo.data; + } else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) { + const img = document.createElement('img'); + img.className = css.CustomEmojiImg; + img.setAttribute('src', mx.mxcUrlToHttp(emojiInfo.data) || emojiInfo.data); + img.setAttribute('alt', emojiInfo.shortcode); + emojiPreviewRef.current.textContent = ''; + emojiPreviewRef.current.appendChild(img); + } + emojiPreviewTextRef.current.textContent = `:${emojiInfo.shortcode}:`; + }, + [mx] + ); + + const throttleEmojiHover = useThrottle(handleEmojiPreview, { + wait: 200, + immediate: true, + }); + + const handleEmojiHover: MouseEventHandler = (evt) => { + const targetEl = targetFromEvent(evt.nativeEvent, 'button') as HTMLButtonElement | undefined; + if (!targetEl) return; + throttleEmojiHover(targetEl); + }; + + const handleEmojiFocus: FocusEventHandler = (evt) => { + const targetEl = evt.target as HTMLButtonElement; + handleEmojiPreview(targetEl); + }; + + // Reset scroll top on search and tab change + useEffect(() => { + syncActiveGroupId(); + contentScrollRef.current?.scrollTo({ + top: 0, + }); + }, [result, emojiTab, syncActiveGroupId]); + + return ( + + !editableActiveElement() && isHotkey(['arrowdown', 'arrowright'], evt), + isKeyBackward: (evt: KeyboardEvent) => + !editableActiveElement() && isHotkey(['arrowup', 'arrowleft'], evt), + }} + > + + + {onTabChange && } + } + onChange={handleOnChange} + autoFocus + /> + + + } + sidebar={ + + {emojiTab && recentEmojis.length > 0 && ( + + )} + {imagePacks.length > 0 && ( + + )} + {emojiTab && ( + + )} + + } + footer={ + emojiTab ? ( +
    + + 😃 + + + :smiley: + +
    + ) : ( + imagePacks.length > 0 && ( +
    + + :smiley: + +
    + ) + ) + } + > + + + + {result && ( + + )} + {emojiTab && recentEmojis.length > 0 && ( + + )} + {emojiTab && } + {stickerTab && } + {emojiTab && } + + + +
    +
    + ); +} diff --git a/src/app/components/emoji-board/index.ts b/src/app/components/emoji-board/index.ts new file mode 100644 index 0000000..430cec0 --- /dev/null +++ b/src/app/components/emoji-board/index.ts @@ -0,0 +1 @@ +export * from './EmojiBoard'; diff --git a/src/app/components/emoji-board/useEmojiGroupIcons.ts b/src/app/components/emoji-board/useEmojiGroupIcons.ts new file mode 100644 index 0000000..bef7374 --- /dev/null +++ b/src/app/components/emoji-board/useEmojiGroupIcons.ts @@ -0,0 +1,21 @@ +import { useMemo } from 'react'; +import { IconSrc, Icons } from 'folds'; + +import { EmojiGroupId } from '../../plugins/emoji'; + +export type IEmojiGroupIcons = Record; + +export const useEmojiGroupIcons = (): IEmojiGroupIcons => + useMemo( + () => ({ + [EmojiGroupId.People]: Icons.Smile, + [EmojiGroupId.Nature]: Icons.Leaf, + [EmojiGroupId.Food]: Icons.Cup, + [EmojiGroupId.Activity]: Icons.Ball, + [EmojiGroupId.Travel]: Icons.Photo, + [EmojiGroupId.Object]: Icons.Bulb, + [EmojiGroupId.Symbol]: Icons.Peace, + [EmojiGroupId.Flag]: Icons.Flag, + }), + [] + ); diff --git a/src/app/components/emoji-board/useEmojiGroupLabels.ts b/src/app/components/emoji-board/useEmojiGroupLabels.ts new file mode 100644 index 0000000..a1e5cf2 --- /dev/null +++ b/src/app/components/emoji-board/useEmojiGroupLabels.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'react'; +import { EmojiGroupId } from '../../plugins/emoji'; + +export type IEmojiGroupLabels = Record; + +export const useEmojiGroupLabels = (): IEmojiGroupLabels => + useMemo( + () => ({ + [EmojiGroupId.People]: 'Smileys & People', + [EmojiGroupId.Nature]: 'Animals & Nature', + [EmojiGroupId.Food]: 'Food & Drinks', + [EmojiGroupId.Activity]: 'Activity', + [EmojiGroupId.Travel]: 'Travel & Places', + [EmojiGroupId.Object]: 'Objects', + [EmojiGroupId.Symbol]: 'Symbols', + [EmojiGroupId.Flag]: 'Flags', + }), + [] + ); diff --git a/src/app/components/sidebar/Sidebar.css.ts b/src/app/components/sidebar/Sidebar.css.ts new file mode 100644 index 0000000..e62ed6f --- /dev/null +++ b/src/app/components/sidebar/Sidebar.css.ts @@ -0,0 +1,111 @@ +import { style } from '@vanilla-extract/css'; +import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; +import { color, config, DefaultReset, toRem } from 'folds'; + +export const Sidebar = style([ + DefaultReset, + { + width: toRem(66), + backgroundColor: color.Background.Container, + borderRight: `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`, + + display: 'flex', + flexDirection: 'column', + color: color.Background.OnContainer, + }, +]); + +export const SidebarStack = style([ + DefaultReset, + { + width: '100%', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + gap: config.space.S300, + padding: `${config.space.S300} 0`, + }, +]); + +const PUSH_X = 2; +export const SidebarAvatarBox = recipe({ + base: [ + DefaultReset, + { + display: 'flex', + alignItems: 'center', + position: 'relative', + transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)', + + selectors: { + '&:hover': { + transform: `translateX(${toRem(PUSH_X)})`, + }, + '&::before': { + content: '', + display: 'none', + position: 'absolute', + left: toRem(-11.5 - PUSH_X), + width: toRem(3 + PUSH_X), + height: toRem(16), + borderRadius: `0 ${toRem(4)} ${toRem(4)} 0`, + background: 'CurrentColor', + transition: 'height 200ms linear', + }, + '&:hover::before': { + display: 'block', + width: toRem(3), + }, + }, + }, + ], + variants: { + active: { + true: { + selectors: { + '&::before': { + display: 'block', + height: toRem(24), + }, + '&:hover::before': { + width: toRem(3 + PUSH_X), + }, + }, + }, + }, + }, +}); + +export type SidebarAvatarBoxVariants = RecipeVariants; + +export const SidebarBadgeBox = recipe({ + base: [ + DefaultReset, + { + position: 'absolute', + zIndex: 1, + }, + ], + variants: { + hasCount: { + true: { + top: toRem(-6), + right: toRem(-6), + }, + false: { + top: toRem(-2), + right: toRem(-2), + }, + }, + }, + defaultVariants: { + hasCount: false, + }, +}); + +export type SidebarBadgeBoxVariants = RecipeVariants; + +export const SidebarBadgeOutline = style({ + boxShadow: `0 0 0 ${config.borderWidth.B500} ${color.Background.Container}`, +}); diff --git a/src/app/components/sidebar/Sidebar.tsx b/src/app/components/sidebar/Sidebar.tsx new file mode 100644 index 0000000..7caf1b2 --- /dev/null +++ b/src/app/components/sidebar/Sidebar.tsx @@ -0,0 +1,8 @@ +import classNames from 'classnames'; +import { as } from 'folds'; +import React from 'react'; +import * as css from './Sidebar.css'; + +export const Sidebar = as<'div'>(({ as: AsSidebar = 'div', className, ...props }, ref) => ( + +)); diff --git a/src/app/components/sidebar/SidebarAvatar.tsx b/src/app/components/sidebar/SidebarAvatar.tsx new file mode 100644 index 0000000..86665f7 --- /dev/null +++ b/src/app/components/sidebar/SidebarAvatar.tsx @@ -0,0 +1,75 @@ +import classNames from 'classnames'; +import { as, Avatar, Box, color, config, Text, Tooltip, TooltipProvider } from 'folds'; +import React, { forwardRef, MouseEventHandler, ReactNode } from 'react'; +import * as css from './Sidebar.css'; + +const SidebarAvatarBox = as<'div', css.SidebarAvatarBoxVariants>( + ({ as: AsSidebarAvatarBox = 'div', className, active, ...props }, ref) => ( + + ) +); + +export const SidebarAvatar = forwardRef< + HTMLDivElement, + css.SidebarAvatarBoxVariants & + css.SidebarBadgeBoxVariants & { + outlined?: boolean; + avatarChildren: ReactNode; + tooltip: ReactNode | string; + notificationBadge?: (badgeClassName: string) => ReactNode; + onClick?: MouseEventHandler; + onContextMenu?: MouseEventHandler; + } +>( + ( + { + active, + hasCount, + outlined, + avatarChildren, + tooltip, + notificationBadge, + onClick, + onContextMenu, + }, + ref + ) => ( + + + {tooltip} + + } + > + {(avRef) => ( + + {avatarChildren} + + )} + + {notificationBadge && ( + + {notificationBadge(css.SidebarBadgeOutline)} + + )} + + ) +); diff --git a/src/app/components/sidebar/SidebarContent.tsx b/src/app/components/sidebar/SidebarContent.tsx new file mode 100644 index 0000000..4f40587 --- /dev/null +++ b/src/app/components/sidebar/SidebarContent.tsx @@ -0,0 +1,21 @@ +import React, { ReactNode } from 'react'; +import { Box, Scroll } from 'folds'; + +type SidebarContentProps = { + scrollable: ReactNode; + sticky: ReactNode; +}; +export function SidebarContent({ scrollable, sticky }: SidebarContentProps) { + return ( + <> + + + {scrollable} + + + + {sticky} + + + ); +} diff --git a/src/app/components/sidebar/SidebarStack.tsx b/src/app/components/sidebar/SidebarStack.tsx new file mode 100644 index 0000000..c0e976c --- /dev/null +++ b/src/app/components/sidebar/SidebarStack.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import classNames from 'classnames'; +import { as } from 'folds'; +import * as css from './Sidebar.css'; + +export const SidebarStack = as<'div'>( + ({ as: AsSidebarStack = 'div', className, ...props }, ref) => ( + + ) +); diff --git a/src/app/components/sidebar/SidebarStackSeparator.tsx b/src/app/components/sidebar/SidebarStackSeparator.tsx new file mode 100644 index 0000000..110341c --- /dev/null +++ b/src/app/components/sidebar/SidebarStackSeparator.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Line, toRem } from 'folds'; + +export function SidebarStackSeparator() { + return ( + + ); +} diff --git a/src/app/components/sidebar/index.ts b/src/app/components/sidebar/index.ts new file mode 100644 index 0000000..f744628 --- /dev/null +++ b/src/app/components/sidebar/index.ts @@ -0,0 +1,5 @@ +export * from './Sidebar'; +export * from './SidebarAvatar'; +export * from './SidebarContent'; +export * from './SidebarStack'; +export * from './SidebarStackSeparator'; diff --git a/src/app/components/upload-board/UploadBoard.css.ts b/src/app/components/upload-board/UploadBoard.css.ts new file mode 100644 index 0000000..80c1b26 --- /dev/null +++ b/src/app/components/upload-board/UploadBoard.css.ts @@ -0,0 +1,46 @@ +import { style } from '@vanilla-extract/css'; +import { DefaultReset, color, config, toRem } from 'folds'; + +export const UploadBoardBase = style([ + DefaultReset, + { + position: 'relative', + pointerEvents: 'none', + }, +]); + +export const UploadBoardContainer = style([ + DefaultReset, + { + position: 'absolute', + bottom: config.space.S200, + left: 0, + right: 0, + zIndex: config.zIndex.Max, + }, +]); + +export const UploadBoard = style({ + maxWidth: toRem(400), + width: '100%', + maxHeight: toRem(450), + height: '100%', + backgroundColor: color.Surface.Container, + color: color.Surface.OnContainer, + borderRadius: config.radii.R400, + boxShadow: config.shadow.E200, + border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + overflow: 'hidden', + pointerEvents: 'all', +}); + +export const UploadBoardHeaderContent = style({ + height: '100%', + padding: `0 ${config.space.S200}`, +}); + +export const UploadBoardContent = style({ + padding: config.space.S200, + paddingBottom: 0, + paddingRight: 0, +}); diff --git a/src/app/components/upload-board/UploadBoard.tsx b/src/app/components/upload-board/UploadBoard.tsx new file mode 100644 index 0000000..42f3899 --- /dev/null +++ b/src/app/components/upload-board/UploadBoard.tsx @@ -0,0 +1,145 @@ +import React, { MutableRefObject, ReactNode, useImperativeHandle, useRef } from 'react'; +import { Badge, Box, Chip, Header, Icon, Icons, Spinner, Text, as, percent } from 'folds'; +import classNames from 'classnames'; +import { useAtomValue } from 'jotai'; + +import * as css from './UploadBoard.css'; +import { TUploadFamilyObserverAtom, Upload, UploadStatus, UploadSuccess } from '../../state/upload'; + +type UploadBoardProps = { + header: ReactNode; +}; +export const UploadBoard = as<'div', UploadBoardProps>(({ header, children, ...props }, ref) => ( + + + + + {children} + + + {header} + + + + +)); + +export type UploadBoardImperativeHandlers = { handleSend: () => Promise }; + +type UploadBoardHeaderProps = { + open: boolean; + onToggle: () => void; + uploadFamilyObserverAtom: TUploadFamilyObserverAtom; + onCancel: (uploads: Upload[]) => void; + onSend: (uploads: UploadSuccess[]) => Promise; + imperativeHandlerRef: MutableRefObject; +}; + +export function UploadBoardHeader({ + open, + onToggle, + uploadFamilyObserverAtom, + onCancel, + onSend, + imperativeHandlerRef, +}: UploadBoardHeaderProps) { + const sendingRef = useRef(false); + const uploads = useAtomValue(uploadFamilyObserverAtom); + + const isSuccess = uploads.every((upload) => upload.status === UploadStatus.Success); + const isError = uploads.some((upload) => upload.status === UploadStatus.Error); + const progress = uploads.reduce( + (acc, upload) => { + acc.total += upload.file.size; + if (upload.status === UploadStatus.Loading) { + acc.loaded += upload.progress.loaded; + } + if (upload.status === UploadStatus.Success) { + acc.loaded += upload.file.size; + } + return acc; + }, + { loaded: 0, total: 0 } + ); + + const handleSend = async () => { + if (sendingRef.current) return; + sendingRef.current = true; + await onSend( + uploads.filter((upload) => upload.status === UploadStatus.Success) as UploadSuccess[] + ); + sendingRef.current = false; + }; + + useImperativeHandle(imperativeHandlerRef, () => ({ + handleSend, + })); + const handleCancel = () => onCancel(uploads); + + return ( +
    + + + Files + + + {isSuccess && ( + } + > + Send + + )} + {isError && !open && ( + + Upload Failed + + )} + {!isSuccess && !isError && !open && ( + <> + + {Math.round(percent(0, progress.total, progress.loaded))}% + + + + )} + {!isSuccess && open && ( + } + > + {uploads.length === 1 ? 'Remove' : 'Remove All'} + + )} + +
    + ); +} + +export const UploadBoardContent = as<'div'>(({ className, children, ...props }, ref) => ( + + {children} + +)); diff --git a/src/app/components/upload-board/index.ts b/src/app/components/upload-board/index.ts new file mode 100644 index 0000000..24ae780 --- /dev/null +++ b/src/app/components/upload-board/index.ts @@ -0,0 +1 @@ +export * from './UploadBoard'; diff --git a/src/app/components/upload-card/UploadCard.css.ts b/src/app/components/upload-card/UploadCard.css.ts new file mode 100644 index 0000000..20ac00e --- /dev/null +++ b/src/app/components/upload-card/UploadCard.css.ts @@ -0,0 +1,24 @@ +import { style } from '@vanilla-extract/css'; +import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; +import { RadiiVariant, color, config } from 'folds'; + +export const UploadCard = recipe({ + base: { + padding: config.space.S300, + backgroundColor: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, + }, + variants: { + radii: RadiiVariant, + }, + defaultVariants: { + radii: '400', + }, +}); + +export type UploadCardVariant = RecipeVariants; + +export const UploadCardError = style({ + padding: `0 ${config.space.S100}`, + color: color.Critical.Main, +}); diff --git a/src/app/components/upload-card/UploadCard.tsx b/src/app/components/upload-card/UploadCard.tsx new file mode 100644 index 0000000..ae7b71b --- /dev/null +++ b/src/app/components/upload-card/UploadCard.tsx @@ -0,0 +1,63 @@ +import { Badge, Box, Icon, Icons, ProgressBar, Text, percent } from 'folds'; +import React, { ReactNode, forwardRef } from 'react'; + +import * as css from './UploadCard.css'; +import { bytesToSize } from '../../utils/common'; + +type UploadCardProps = { + before?: ReactNode; + children: ReactNode; + after?: ReactNode; + bottom?: ReactNode; +}; + +export const UploadCard = forwardRef( + ({ before, after, children, bottom, radii }, ref) => ( + + + {before} + + {children} + + {after} + + {bottom} + + ) +); + +type UploadCardProgressProps = { + sentBytes: number; + totalBytes: number; +}; + +export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) { + return ( + + + + + {`${Math.round(percent(0, totalBytes, sentBytes))}%`} + + + + {bytesToSize(sentBytes)} / {bytesToSize(totalBytes)} + + + + + ); +} + +type UploadCardErrorProps = { + children: ReactNode; +}; + +export function UploadCardError({ children }: UploadCardErrorProps) { + return ( + + + {children} + + ); +} diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx new file mode 100644 index 0000000..949f5d6 --- /dev/null +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { Chip, Icon, IconButton, Icons, Text, color } from 'folds'; +import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard'; +import { TUploadAtom, UploadStatus, useBindUploadAtom } from '../../state/upload'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { TUploadContent } from '../../utils/matrix'; +import { getFileTypeIcon } from '../../utils/common'; + +type UploadCardRendererProps = { + file: TUploadContent; + isEncrypted?: boolean; + uploadAtom: TUploadAtom; + onRemove: (file: TUploadContent) => void; +}; +export function UploadCardRenderer({ + file, + isEncrypted, + uploadAtom, + onRemove, +}: UploadCardRendererProps) { + const mx = useMatrixClient(); + const { upload, startUpload, cancelUpload } = useBindUploadAtom( + mx, + file, + uploadAtom, + isEncrypted + ); + + if (upload.status === UploadStatus.Idle) startUpload(); + + const removeUpload = () => { + cancelUpload(); + onRemove(file); + }; + + return ( + } + after={ + <> + {upload.status === UploadStatus.Error && ( + + Retry + + )} + + + + + } + bottom={ + <> + {upload.status === UploadStatus.Idle && ( + + )} + {upload.status === UploadStatus.Loading && ( + + )} + {upload.status === UploadStatus.Error && ( + + {upload.error.message} + + )} + + } + > + + {file.name} + + {upload.status === UploadStatus.Success && ( + + )} + + ); +} diff --git a/src/app/components/upload-card/index.ts b/src/app/components/upload-card/index.ts new file mode 100644 index 0000000..3e7a5e3 --- /dev/null +++ b/src/app/components/upload-card/index.ts @@ -0,0 +1,2 @@ +export * from './UploadCard'; +export * from './UploadCardRenderer'; diff --git a/src/app/hooks/useAlive.ts b/src/app/hooks/useAlive.ts new file mode 100644 index 0000000..0bfbce8 --- /dev/null +++ b/src/app/hooks/useAlive.ts @@ -0,0 +1,15 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export const useAlive = (): (() => boolean) => { + const aliveRef = useRef(true); + + useEffect(() => { + aliveRef.current = true; + return () => { + aliveRef.current = false; + }; + }, []); + + const alive = useCallback(() => aliveRef.current, []); + return alive; +}; diff --git a/src/app/hooks/useAsyncCallback.ts b/src/app/hooks/useAsyncCallback.ts new file mode 100644 index 0000000..18b63ec --- /dev/null +++ b/src/app/hooks/useAsyncCallback.ts @@ -0,0 +1,70 @@ +import { useCallback, useState } from 'react'; +import { useAlive } from './useAlive'; + +export enum AsyncStatus { + Idle = 'idle', + Loading = 'loading', + Success = 'success', + Error = 'error', +} + +export type AsyncIdle = { + status: AsyncStatus.Idle; +}; + +export type AsyncLoading = { + status: AsyncStatus.Loading; +}; + +export type AsyncSuccess = { + status: AsyncStatus.Success; + data: T; +}; + +export type AsyncError = { + status: AsyncStatus.Error; + error: unknown; +}; + +export type AsyncState = AsyncIdle | AsyncLoading | AsyncSuccess | AsyncError; + +export type AsyncCallback = (...args: TArgs) => Promise; + +export const useAsyncCallback = ( + asyncCallback: AsyncCallback +): [AsyncState, AsyncCallback] => { + const [state, setState] = useState>({ + status: AsyncStatus.Idle, + }); + const alive = useAlive(); + + const callback: AsyncCallback = useCallback( + async (...args) => { + setState({ + status: AsyncStatus.Loading, + }); + + try { + const data = await asyncCallback(...args); + if (alive()) { + setState({ + status: AsyncStatus.Success, + data, + }); + } + return data; + } catch (e) { + if (alive()) { + setState({ + status: AsyncStatus.Error, + error: e, + }); + } + throw e; + } + }, + [asyncCallback, alive] + ); + + return [state, callback]; +}; diff --git a/src/app/hooks/useAsyncSearch.ts b/src/app/hooks/useAsyncSearch.ts new file mode 100644 index 0000000..b083a19 --- /dev/null +++ b/src/app/hooks/useAsyncSearch.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + MatchHandler, + AsyncSearch, + AsyncSearchHandler, + AsyncSearchOption, + MatchQueryOption, + NormalizeOption, + normalize, + matchQuery, + ResultHandler, +} from '../utils/AsyncSearch'; + +export type UseAsyncSearchOptions = AsyncSearchOption & { + matchOptions?: MatchQueryOption; + normalizeOptions?: NormalizeOption; +}; + +export type SearchItemStrGetter = ( + searchItem: TSearchItem +) => string | string[]; + +export type UseAsyncSearchResult = { + query: string; + items: TSearchItem[]; +}; + +export const useAsyncSearch = ( + list: TSearchItem[], + getItemStr: SearchItemStrGetter, + options?: UseAsyncSearchOptions +): [UseAsyncSearchResult | undefined, AsyncSearchHandler] => { + const [result, setResult] = useState>(); + + const [searchCallback, terminateSearch] = useMemo(() => { + setResult(undefined); + + const handleMatch: MatchHandler = (item, query) => { + const itemStr = getItemStr(item); + if (Array.isArray(itemStr)) + return !!itemStr.find((i) => + matchQuery(normalize(i, options?.normalizeOptions), query, options?.matchOptions) + ); + return matchQuery( + normalize(itemStr, options?.normalizeOptions), + query, + options?.matchOptions + ); + }; + + const handleResult: ResultHandler = (results, query) => + setResult({ + query, + items: results, + }); + + return AsyncSearch(list, handleMatch, handleResult, options); + }, [list, options, getItemStr]); + + const searchHandler: AsyncSearchHandler = useCallback( + (query) => { + const normalizedQuery = normalize(query, options?.normalizeOptions); + if (!normalizedQuery) { + setResult(undefined); + return; + } + searchCallback(normalizedQuery); + }, + [searchCallback, options?.normalizeOptions] + ); + + useEffect( + () => () => { + // terminate any ongoing search request on unmount. + terminateSearch(); + }, + [terminateSearch] + ); + + return [result, searchHandler]; +}; diff --git a/src/app/hooks/useDebounce.ts b/src/app/hooks/useDebounce.ts new file mode 100644 index 0000000..5f33976 --- /dev/null +++ b/src/app/hooks/useDebounce.ts @@ -0,0 +1,34 @@ +import { useCallback, useRef } from 'react'; + +export interface DebounceOptions { + wait?: number; + immediate?: boolean; +} +export type DebounceCallback = (...args: T) => void; + +export function useDebounce( + callback: DebounceCallback, + options?: DebounceOptions +): DebounceCallback { + const timeoutIdRef = useRef(); + const { wait, immediate } = options ?? {}; + + const debounceCallback = useCallback( + (...cbArgs: T) => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = undefined; + } else if (immediate) { + callback(...cbArgs); + } + + timeoutIdRef.current = window.setTimeout(() => { + callback(...cbArgs); + timeoutIdRef.current = undefined; + }, wait); + }, + [callback, wait, immediate] + ); + + return debounceCallback; +} diff --git a/src/app/hooks/useFileDrop.ts b/src/app/hooks/useFileDrop.ts new file mode 100644 index 0000000..bead203 --- /dev/null +++ b/src/app/hooks/useFileDrop.ts @@ -0,0 +1,66 @@ +import { useCallback, DragEventHandler, RefObject, useState, useEffect, useRef } from 'react'; +import { getDataTransferFiles } from '../utils/dom'; + +export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHandler => + useCallback( + (evt) => { + const files = getDataTransferFiles(evt.dataTransfer); + if (files) onDrop(files); + }, + [onDrop] + ); + +export const useFileDropZone = ( + zoneRef: RefObject, + onDrop: (file: File[]) => void +): boolean => { + const dragStateRef = useRef<'start' | 'leave' | 'over'>(); + const [active, setActive] = useState(false); + + useEffect(() => { + const target = zoneRef.current; + const handleDrop = (evt: DragEvent) => { + evt.preventDefault(); + dragStateRef.current = undefined; + setActive(false); + if (!evt.dataTransfer) return; + const files = getDataTransferFiles(evt.dataTransfer); + if (files) onDrop(files); + }; + + target?.addEventListener('drop', handleDrop); + return () => { + target?.removeEventListener('drop', handleDrop); + }; + }, [zoneRef, onDrop]); + + useEffect(() => { + const target = zoneRef.current; + const handleDragEnter = (evt: DragEvent) => { + if (evt.dataTransfer?.types.includes('Files')) { + dragStateRef.current = 'start'; + setActive(true); + } + }; + const handleDragLeave = () => { + if (dragStateRef.current !== 'over') return; + dragStateRef.current = 'leave'; + setActive(false); + }; + const handleDragOver = (evt: DragEvent) => { + evt.preventDefault(); + dragStateRef.current = 'over'; + }; + + target?.addEventListener('dragenter', handleDragEnter); + target?.addEventListener('dragleave', handleDragLeave); + target?.addEventListener('dragover', handleDragOver); + return () => { + target?.removeEventListener('dragenter', handleDragEnter); + target?.removeEventListener('dragleave', handleDragLeave); + target?.removeEventListener('dragover', handleDragOver); + }; + }, [zoneRef]); + + return active; +}; diff --git a/src/app/hooks/useFilePasteHandler.ts b/src/app/hooks/useFilePasteHandler.ts new file mode 100644 index 0000000..0f63b75 --- /dev/null +++ b/src/app/hooks/useFilePasteHandler.ts @@ -0,0 +1,11 @@ +import { useCallback, ClipboardEventHandler } from 'react'; +import { getDataTransferFiles } from '../utils/dom'; + +export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler => + useCallback( + (evt) => { + const files = getDataTransferFiles(evt.clipboardData); + if (files) onPaste(files); + }, + [onPaste] + ); diff --git a/src/app/hooks/useFilePicker.ts b/src/app/hooks/useFilePicker.ts new file mode 100644 index 0000000..e772d66 --- /dev/null +++ b/src/app/hooks/useFilePicker.ts @@ -0,0 +1,15 @@ +import { useCallback } from 'react'; +import { selectFile } from '../utils/dom'; + +export const useFilePicker = ( + onSelect: (file: M extends true ? File[] : File) => void, + multiple?: M +) => + useCallback( + async (accept: string) => { + const file = await selectFile(accept, multiple); + if (!file) return; + onSelect(file); + }, + [multiple, onSelect] + ); diff --git a/src/app/hooks/useForceUpdate.ts b/src/app/hooks/useForceUpdate.ts new file mode 100644 index 0000000..0691aa9 --- /dev/null +++ b/src/app/hooks/useForceUpdate.ts @@ -0,0 +1,9 @@ +import { useReducer } from 'react'; + +const reducer = (prevCount: number): number => prevCount + 1; + +export const useForceUpdate = (): [number, () => void] => { + const [state, dispatch] = useReducer(reducer, 0); + + return [state, dispatch]; +}; diff --git a/src/app/hooks/useImagePacks.ts b/src/app/hooks/useImagePacks.ts new file mode 100644 index 0000000..ab988e3 --- /dev/null +++ b/src/app/hooks/useImagePacks.ts @@ -0,0 +1,48 @@ +import { ClientEvent, MatrixClient, MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk'; +import { useEffect, useMemo } from 'react'; +import { getRelevantPacks, ImagePack, PackUsage } from '../plugins/custom-emoji'; +import { AccountDataEvent } from '../../types/matrix/accountData'; +import { StateEvent } from '../../types/matrix/room'; +import { useForceUpdate } from './useForceUpdate'; + +export const useRelevantImagePacks = ( + mx: MatrixClient, + usage: PackUsage, + rooms: Room[] +): ImagePack[] => { + const [forceCount, forceUpdate] = useForceUpdate(); + + const relevantPacks = useMemo( + () => getRelevantPacks(mx, rooms).filter((pack) => pack.getImagesFor(usage).length > 0), + // eslint-disable-next-line react-hooks/exhaustive-deps + [mx, usage, rooms, forceCount] + ); + + useEffect(() => { + const handleUpdate = (event: MatrixEvent) => { + if ( + event.getType() === AccountDataEvent.PoniesEmoteRooms || + event.getType() === AccountDataEvent.PoniesUserEmotes + ) { + forceUpdate(); + } + const eventRoomId = event.getRoomId(); + if ( + eventRoomId && + event.getType() === StateEvent.PoniesRoomEmotes && + rooms.find((room) => room.roomId === eventRoomId) + ) { + forceUpdate(); + } + }; + + mx.on(ClientEvent.AccountData, handleUpdate); + mx.on(RoomStateEvent.Events, handleUpdate); + return () => { + mx.removeListener(ClientEvent.AccountData, handleUpdate); + mx.removeListener(RoomStateEvent.Events, handleUpdate); + }; + }, [mx, rooms, forceUpdate]); + + return relevantPacks; +}; diff --git a/src/app/hooks/useKeyDown.ts b/src/app/hooks/useKeyDown.ts new file mode 100644 index 0000000..c754d88 --- /dev/null +++ b/src/app/hooks/useKeyDown.ts @@ -0,0 +1,10 @@ +import { useEffect } from 'react'; + +export const useKeyDown = (target: Window, callback: (evt: KeyboardEvent) => void) => { + useEffect(() => { + target.addEventListener('keydown', callback); + return () => { + target.removeEventListener('keydown', callback); + }; + }, [target, callback]); +}; diff --git a/src/app/hooks/useMatrixClient.ts b/src/app/hooks/useMatrixClient.ts new file mode 100644 index 0000000..5ff6d90 --- /dev/null +++ b/src/app/hooks/useMatrixClient.ts @@ -0,0 +1,12 @@ +import { createContext, useContext } from 'react'; +import { MatrixClient } from 'matrix-js-sdk'; + +const MatrixClientContext = createContext(null); + +export const MatrixClientProvider = MatrixClientContext.Provider; + +export function useMatrixClient(): MatrixClient { + const mx = useContext(MatrixClientContext); + if (!mx) throw new Error('MatrixClient not initialized!'); + return mx; +} diff --git a/src/app/hooks/usePowerLevels.ts b/src/app/hooks/usePowerLevels.ts new file mode 100644 index 0000000..8f999d4 --- /dev/null +++ b/src/app/hooks/usePowerLevels.ts @@ -0,0 +1,86 @@ +import { Room } from 'matrix-js-sdk'; +import { useCallback } from 'react'; +import { useStateEvent } from './useStateEvent'; +import { StateEvent } from '../../types/matrix/room'; + +enum DefaultPowerLevels { + usersDefault = 0, + stateDefault = 50, + eventsDefault = 0, + invite = 0, + redact = 50, + kick = 50, + ban = 50, + historical = 0, +} + +interface IPowerLevels { + users_default?: number; + state_default?: number; + events_default?: number; + historical?: number; + invite?: number; + redact?: number; + kick?: number; + ban?: number; + + events?: Record; + users?: Record; + notifications?: Record; +} + +export function usePowerLevels(room: Room) { + const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels); + const powerLevels: IPowerLevels = powerLevelsEvent?.getContent() ?? DefaultPowerLevels; + + const getPowerLevel = useCallback( + (userId: string) => { + const { users_default: usersDefault, users } = powerLevels; + if (users && typeof users[userId] === 'number') { + return users[userId]; + } + return usersDefault ?? DefaultPowerLevels.usersDefault; + }, + [powerLevels] + ); + + const canSendEvent = useCallback( + (eventType: string | undefined, powerLevel: number) => { + const { events, events_default: eventsDefault } = powerLevels; + if (events && eventType && typeof events[eventType] === 'string') { + return powerLevel >= events[eventType]; + } + return powerLevel >= (eventsDefault ?? DefaultPowerLevels.eventsDefault); + }, + [powerLevels] + ); + + const canSendStateEvent = useCallback( + (eventType: string | undefined, powerLevel: number) => { + const { events, state_default: stateDefault } = powerLevels; + if (events && eventType && typeof events[eventType] === 'number') { + return powerLevel >= events[eventType]; + } + return powerLevel >= (stateDefault ?? DefaultPowerLevels.stateDefault); + }, + [powerLevels] + ); + + const canDoAction = useCallback( + (action: 'invite' | 'redact' | 'kick' | 'ban' | 'historical', powerLevel: number) => { + const requiredPL = powerLevels[action]; + if (typeof requiredPL === 'number') { + return powerLevel >= requiredPL; + } + return powerLevel >= DefaultPowerLevels[action]; + }, + [powerLevels] + ); + + return { + getPowerLevel, + canSendEvent, + canSendStateEvent, + canDoAction, + }; +} diff --git a/src/app/hooks/useRecentEmoji.ts b/src/app/hooks/useRecentEmoji.ts new file mode 100644 index 0000000..926075f --- /dev/null +++ b/src/app/hooks/useRecentEmoji.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; +import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk'; +import { getRecentEmojis } from '../plugins/recent-emoji'; +import { AccountDataEvent } from '../../types/matrix/accountData'; +import { IEmoji } from '../plugins/emoji'; + +export const useRecentEmoji = (mx: MatrixClient, limit?: number): IEmoji[] => { + const [recentEmoji, setRecentEmoji] = useState(() => getRecentEmojis(mx, limit)); + + useEffect(() => { + const handleAccountData = (event: MatrixEvent) => { + if (event.getType() !== AccountDataEvent.ElementRecentEmoji) return; + setRecentEmoji(getRecentEmojis(mx, limit)); + }; + + mx.on(ClientEvent.AccountData, handleAccountData); + return () => { + mx.removeListener(ClientEvent.AccountData, handleAccountData); + }; + }, [mx, limit]); + + return recentEmoji; +}; diff --git a/src/app/hooks/useResizeObserver.ts b/src/app/hooks/useResizeObserver.ts new file mode 100644 index 0000000..69ec65d --- /dev/null +++ b/src/app/hooks/useResizeObserver.ts @@ -0,0 +1,24 @@ +import { useEffect, useMemo } from 'react'; + +export type OnResizeCallback = (entries: ResizeObserverEntry[]) => void; + +export const getResizeObserverEntry = ( + target: Element, + entries: ResizeObserverEntry[] +): ResizeObserverEntry | undefined => entries.find((entry) => entry.target === target); + +export const useResizeObserver = ( + element: Element | null, + onResizeCallback: OnResizeCallback +): ResizeObserver => { + const resizeObserver = useMemo(() => new ResizeObserver(onResizeCallback), [onResizeCallback]); + + useEffect(() => { + if (element) resizeObserver.observe(element); + return () => { + if (element) resizeObserver.unobserve(element); + }; + }, [resizeObserver, element]); + + return resizeObserver; +}; diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts new file mode 100644 index 0000000..544d97a --- /dev/null +++ b/src/app/hooks/useRoomMembers.ts @@ -0,0 +1,34 @@ +import { MatrixClient, MatrixEvent, RoomMember, RoomMemberEvent } from 'matrix-js-sdk'; +import { useEffect, useState } from 'react'; +import { useAlive } from './useAlive'; + +export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] => { + const [members, setMembers] = useState([]); + const alive = useAlive(); + + useEffect(() => { + const room = mx.getRoom(roomId); + + const updateMemberList = (event?: MatrixEvent) => { + if (!room || !alive || (event && event.getRoomId() !== roomId)) return; + setMembers(room.getMembers()); + }; + + if (room) { + updateMemberList(); + room.loadMembersIfNeeded().then(() => { + if (!alive) return; + updateMemberList(); + }); + } + + mx.on(RoomMemberEvent.Membership, updateMemberList); + mx.on(RoomMemberEvent.PowerLevel, updateMemberList); + return () => { + mx.removeListener(RoomMemberEvent.Membership, updateMemberList); + mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList); + }; + }, [mx, roomId, alive]); + + return members; +}; diff --git a/src/app/hooks/useStateEvent.ts b/src/app/hooks/useStateEvent.ts new file mode 100644 index 0000000..7bc66aa --- /dev/null +++ b/src/app/hooks/useStateEvent.ts @@ -0,0 +1,32 @@ +import { Room } from 'matrix-js-sdk'; +import { useCallback, useMemo } from 'react'; +import { useStateEventCallback } from './useStateEventCallback'; +import { useForceUpdate } from './useForceUpdate'; +import { getStateEvent } from '../utils/room'; +import { StateEvent } from '../../types/matrix/room'; + +export const useStateEvent = (room: Room, eventType: StateEvent, stateKey = '') => { + const [updateCount, forceUpdate] = useForceUpdate(); + + useStateEventCallback( + room.client, + useCallback( + (event) => { + if ( + event.getRoomId() === room.roomId && + event.getType() === eventType && + event.getStateKey() === stateKey + ) { + forceUpdate(); + } + }, + [room, eventType, stateKey, forceUpdate] + ) + ); + + return useMemo( + () => getStateEvent(room, eventType, stateKey), + // eslint-disable-next-line react-hooks/exhaustive-deps + [room, eventType, stateKey, updateCount] + ); +}; diff --git a/src/app/hooks/useStateEventCallback.ts b/src/app/hooks/useStateEventCallback.ts new file mode 100644 index 0000000..85be3ae --- /dev/null +++ b/src/app/hooks/useStateEventCallback.ts @@ -0,0 +1,17 @@ +import { MatrixClient, MatrixEvent, RoomState, RoomStateEvent } from 'matrix-js-sdk'; +import { useEffect } from 'react'; + +export type StateEventCallback = ( + event: MatrixEvent, + state: RoomState, + lastStateEvent: MatrixEvent | null +) => void; + +export const useStateEventCallback = (mx: MatrixClient, onStateEvent: StateEventCallback) => { + useEffect(() => { + mx.on(RoomStateEvent.Events, onStateEvent); + return () => { + mx.removeListener(RoomStateEvent.Events, onStateEvent); + }; + }, [mx, onStateEvent]); +}; diff --git a/src/app/hooks/useStateEvents.ts b/src/app/hooks/useStateEvents.ts new file mode 100644 index 0000000..dd08569 --- /dev/null +++ b/src/app/hooks/useStateEvents.ts @@ -0,0 +1,28 @@ +import { useCallback, useMemo } from 'react'; +import { Room } from 'matrix-js-sdk'; +import { StateEvent } from '../../types/matrix/room'; +import { useForceUpdate } from './useForceUpdate'; +import { useStateEventCallback } from './useStateEventCallback'; +import { getStateEvents } from '../utils/room'; + +export const useStateEvents = (room: Room, eventType: StateEvent) => { + const [updateCount, forceUpdate] = useForceUpdate(); + + useStateEventCallback( + room.client, + useCallback( + (event) => { + if (event.getRoomId() === room.roomId && event.getType() === eventType) { + forceUpdate(); + } + }, + [room, eventType, forceUpdate] + ) + ); + + return useMemo( + () => getStateEvents(room, eventType), + // eslint-disable-next-line react-hooks/exhaustive-deps + [room, eventType, updateCount] + ); +}; diff --git a/src/app/hooks/useThrottle.ts b/src/app/hooks/useThrottle.ts new file mode 100644 index 0000000..12b249f --- /dev/null +++ b/src/app/hooks/useThrottle.ts @@ -0,0 +1,41 @@ +import { useCallback, useRef } from 'react'; + +export interface ThrottleOptions { + wait?: number; + immediate?: boolean; +} + +export type ThrottleCallback = (...args: T) => void; + +export function useThrottle( + callback: ThrottleCallback, + options?: ThrottleOptions +): ThrottleCallback { + const timeoutIdRef = useRef(); + const argsRef = useRef(); + const { wait, immediate } = options ?? {}; + + const debounceCallback = useCallback( + (...cbArgs: T) => { + argsRef.current = cbArgs; + + if (timeoutIdRef.current) { + return; + } + if (immediate) { + callback(...cbArgs); + } + + timeoutIdRef.current = window.setTimeout(() => { + if (argsRef.current) { + callback(...argsRef.current); + } + argsRef.current = undefined; + timeoutIdRef.current = undefined; + }, wait); + }, + [callback, wait, immediate] + ); + + return debounceCallback; +} diff --git a/src/app/hooks/useTypingStatusUpdater.ts b/src/app/hooks/useTypingStatusUpdater.ts new file mode 100644 index 0000000..af76eae --- /dev/null +++ b/src/app/hooks/useTypingStatusUpdater.ts @@ -0,0 +1,42 @@ +import { MatrixClient } from 'matrix-js-sdk'; +import { useMemo, useRef } from 'react'; + +type TypingStatusUpdater = (typing: boolean) => void; + +const TYPING_TIMEOUT_MS = 5000; // 5 seconds + +export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): TypingStatusUpdater => { + const statusSentTsRef = useRef(0); + + const sendTypingStatus: TypingStatusUpdater = useMemo(() => { + statusSentTsRef.current = 0; + return (typing) => { + if (typing) { + if (Date.now() - statusSentTsRef.current < TYPING_TIMEOUT_MS) { + return; + } + + mx.sendTyping(roomId, true, TYPING_TIMEOUT_MS); + const sentTs = Date.now(); + statusSentTsRef.current = sentTs; + + // Don't believe server will timeout typing status; + // Clear typing status after timeout if already not; + setTimeout(() => { + if (statusSentTsRef.current === sentTs) { + mx.sendTyping(roomId, false, TYPING_TIMEOUT_MS); + statusSentTsRef.current = 0; + } + }, TYPING_TIMEOUT_MS); + return; + } + + if (Date.now() - statusSentTsRef.current < TYPING_TIMEOUT_MS) { + mx.sendTyping(roomId, false, TYPING_TIMEOUT_MS); + } + statusSentTsRef.current = 0; + }; + }, [mx, roomId]); + + return sendTypingStatus; +}; diff --git a/src/app/molecules/following-members/FollowingMembers.jsx b/src/app/molecules/following-members/FollowingMembers.jsx index 6529653..949dac7 100644 --- a/src/app/molecules/following-members/FollowingMembers.jsx +++ b/src/app/molecules/following-members/FollowingMembers.jsx @@ -17,38 +17,40 @@ function FollowingMembers({ roomTimeline }) { const [followingMembers, setFollowingMembers] = useState([]); const { roomId } = roomTimeline; const mx = initMatrix.matrixClient; - const { roomsInput } = initMatrix; const myUserId = mx.getUserId(); - const handleOnMessageSent = () => setFollowingMembers([]); - useEffect(() => { const updateFollowingMembers = () => { setFollowingMembers(roomTimeline.getLiveReaders()); }; + const updateOnEvent = (event, room) => { + if (room.roomId !== roomId) return; + setFollowingMembers(roomTimeline.getLiveReaders()); + }; updateFollowingMembers(); roomTimeline.on(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers); - roomsInput.on(cons.events.roomsInput.MESSAGE_SENT, handleOnMessageSent); + mx.on('Room.timeline', updateOnEvent); return () => { roomTimeline.removeListener(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers); - roomsInput.removeListener(cons.events.roomsInput.MESSAGE_SENT, handleOnMessageSent); + mx.removeListener('Room.timeline', updateOnEvent); }; - }, [roomTimeline]); + }, [roomTimeline, roomId]); const filteredM = followingMembers.filter((userId) => userId !== myUserId); - return filteredM.length !== 0 && ( - + return ( + filteredM.length !== 0 && ( + + ) ); } diff --git a/src/app/organisms/drag-drop/DragDrop.jsx b/src/app/organisms/drag-drop/DragDrop.jsx deleted file mode 100644 index e92f8a7..0000000 --- a/src/app/organisms/drag-drop/DragDrop.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import './DragDrop.scss'; - -import RawModal from '../../atoms/modal/RawModal'; -import Text from '../../atoms/text/Text'; - -function DragDrop({ isOpen }) { - return ( - - Drop file to upload - - ); -} - -DragDrop.propTypes = { - isOpen: PropTypes.bool.isRequired, -}; - -export default DragDrop; diff --git a/src/app/organisms/drag-drop/DragDrop.scss b/src/app/organisms/drag-drop/DragDrop.scss deleted file mode 100644 index 2e5b4f5..0000000 --- a/src/app/organisms/drag-drop/DragDrop.scss +++ /dev/null @@ -1,12 +0,0 @@ -.drag-drop__modal { - box-shadow: none; - text-align: center; - - .text { - color: white; - } -} - -.drag-drop__overlay { - background-color: var(--bg-overlay-low); -} diff --git a/src/app/organisms/navigation/SideBar.scss b/src/app/organisms/navigation/SideBar.scss index 6ff6628..401947a 100644 --- a/src/app/organisms/navigation/SideBar.scss +++ b/src/app/organisms/navigation/SideBar.scss @@ -7,10 +7,7 @@ width: var(--navigation-sidebar-width); height: 100%; background-color: var(--bg-surface-extra-low); - @include dir.side(border, - none, - 1px solid var(--bg-surface-border), - ); + @include dir.side(border, none, 1px solid var(--bg-surface-border)); &__scrollable, &__sticky { @@ -24,7 +21,7 @@ .scrollable-content { &::after { - content: ""; + content: ''; display: block; width: 100%; height: 8px; @@ -33,7 +30,8 @@ background-image: linear-gradient( to top, var(--bg-surface-extra-low), - var(--bg-surface-extra-low-transparent)); + var(--bg-surface-extra-low-transparent) + ); position: sticky; bottom: -1px; left: 0; @@ -44,7 +42,7 @@ .space-container, .sticky-container { @extend .cp-fx__column--c-c; - + padding: var(--sp-ultra-tight) 0; & > .sidebar-avatar, @@ -63,7 +61,7 @@ box-shadow: var(--bs-danger-border); animation-name: pushRight; animation-duration: 400ms; - animation-iteration-count: infinite; + animation-iteration-count: 30; animation-direction: alternate; } @@ -74,4 +72,4 @@ to { transform: translateX(0) scale(1); } -} \ No newline at end of file +} diff --git a/src/app/organisms/navigation/Sidebar1.tsx b/src/app/organisms/navigation/Sidebar1.tsx new file mode 100644 index 0000000..d9ee466 --- /dev/null +++ b/src/app/organisms/navigation/Sidebar1.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { Icon, Icons, Badge, AvatarFallback, Text } from 'folds'; +import { useAtom } from 'jotai'; + +import { + Sidebar, + SidebarContent, + SidebarStackSeparator, + SidebarStack, + SidebarAvatar, +} from '../../components/sidebar'; +import { selectedTabAtom, SidebarTab } from '../../state/selectedTab'; + +export function Sidebar1() { + const [selectedTab, setSelectedTab] = useAtom(selectedTabAtom); + + return ( + + + + } + onClick={() => setSelectedTab(SidebarTab.Home)} + /> + } + onClick={() => setSelectedTab(SidebarTab.People)} + /> + + + + ( + + )} + avatarChildren={ + + B + + } + /> + ( + + 64 + + )} + avatarChildren={ + + C + + } + /> + + + + } + /> + } + /> + + + } + sticky={ + <> + + + } + /> + + A + + } + /> + + + } + /> + + ); +} diff --git a/src/app/organisms/room/Room.jsx b/src/app/organisms/room/Room.jsx index 447686a..9d861c9 100644 --- a/src/app/organisms/room/Room.jsx +++ b/src/app/organisms/room/Room.jsx @@ -15,6 +15,7 @@ import PeopleDrawer from './PeopleDrawer'; function Room() { const [roomInfo, setRoomInfo] = useState({ + room: null, roomTimeline: null, eventId: null, }); @@ -25,14 +26,17 @@ function Room() { useEffect(() => { const handleRoomSelected = (rId, pRoomId, eId) => { roomInfo.roomTimeline?.removeInternalListeners(); - if (mx.getRoom(rId)) { + const r = mx.getRoom(rId); + if (r) { setRoomInfo({ + room: r, roomTimeline: new RoomTimeline(rId), eventId: eId ?? null, }); } else { // TODO: add ability to join room if roomId is invalid setRoomInfo({ + room: r, roomTimeline: null, eventId: null, }); @@ -43,7 +47,7 @@ function Room() { return () => { navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected); }; - }, [roomInfo]); + }, [roomInfo, mx]); useEffect(() => { const handleDrawerToggling = (visiblity) => setIsDrawer(visiblity); @@ -53,7 +57,7 @@ function Room() { }; }, []); - const { roomTimeline, eventId } = roomInfo; + const { room, roomTimeline, eventId } = roomInfo; if (roomTimeline === null) { setTimeout(() => openNavigation()); return ; @@ -63,7 +67,7 @@ function Room() {
    - +
    {isDrawer && }
    diff --git a/src/app/organisms/room/RoomInput.tsx b/src/app/organisms/room/RoomInput.tsx new file mode 100644 index 0000000..17830ad --- /dev/null +++ b/src/app/organisms/room/RoomInput.tsx @@ -0,0 +1,539 @@ +import React, { + KeyboardEventHandler, + RefObject, + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useAtom } from 'jotai'; +import isHotkey from 'is-hotkey'; +import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk'; +import { ReactEditor } from 'slate-react'; +import { Transforms, Range, Editor, Element } from 'slate'; +import { + Box, + Dialog, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + PopOut, + Scroll, + Text, + config, + toRem, +} from 'folds'; +import to from 'await-to-js'; + +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { + CustomEditor, + EditorChangeHandler, + useEditor, + Toolbar, + toMatrixCustomHTML, + toPlainText, + AUTOCOMPLETE_PREFIXES, + AutocompletePrefix, + AutocompleteQuery, + getAutocompleteQuery, + getPrevWorldRange, + resetEditor, + RoomMentionAutocomplete, + UserMentionAutocomplete, + EmoticonAutocomplete, + createEmoticonElement, + moveCursor, +} from '../../components/editor'; +import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; +import { UseStateProvider } from '../../components/UseStateProvider'; +import initMatrix from '../../../client/initMatrix'; +import { TUploadContent, encryptFile, getImageInfo } from '../../utils/matrix'; +import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater'; +import { useFilePicker } from '../../hooks/useFilePicker'; +import { useFilePasteHandler } from '../../hooks/useFilePasteHandler'; +import { useFileDropZone } from '../../hooks/useFileDrop'; +import { + TUploadItem, + roomIdToMsgDraftAtomFamily, + roomIdToReplyDraftAtomFamily, + roomIdToUploadItemsAtomFamily, + roomUploadAtomFamily, +} from '../../state/roomInputDrafts'; +import { UploadCardRenderer } from '../../components/upload-card'; +import { + UploadBoard, + UploadBoardContent, + UploadBoardHeader, + UploadBoardImperativeHandlers, +} from '../../components/upload-board'; +import { + Upload, + UploadStatus, + UploadSuccess, + createUploadFamilyObserverAtom, +} from '../../state/upload'; +import { getImageUrlBlob, loadImageElement } from '../../utils/dom'; +import { safeFile } from '../../utils/mimeTypes'; +import { fulfilledPromiseSettledResult } from '../../utils/common'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import { + getAudioMsgContent, + getFileMsgContent, + getImageMsgContent, + getVideoMsgContent, +} from './msgContent'; +import navigation from '../../../client/state/navigation'; +import cons from '../../../client/state/cons'; +import { MessageReply } from '../../molecules/message/Message'; +import colorMXID from '../../../util/colorMXID'; +import { parseReplyBody, parseReplyFormattedBody } from '../../utils/room'; +import { sanitizeText } from '../../utils/sanitize'; + +interface RoomInputProps { + roomViewRef: RefObject; + roomId: string; +} +export const RoomInput = forwardRef( + ({ roomViewRef, roomId }, ref) => { + const mx = useMatrixClient(); + const editor = useEditor(); + const room = mx.getRoom(roomId); + + const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId)); + const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId)); + const [uploadBoard, setUploadBoard] = useState(true); + const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId)); + const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( + roomUploadAtomFamily, + selectedFiles.map((f) => f.file) + ); + const uploadBoardHandlers = useRef(); + + const imagePackRooms: Room[] = useMemo(() => { + const allParentSpaces = [roomId, ...(initMatrix.roomList?.getAllParentSpaces(roomId) ?? [])]; + return allParentSpaces.reduce((list, rId) => { + const r = mx.getRoom(rId); + if (r) list.push(r); + return list; + }, []); + }, [mx, roomId]); + + const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); + const [autocompleteQuery, setAutocompleteQuery] = + useState>(); + + const sendTypingStatus = useTypingStatusUpdater(mx, roomId); + + const handleFiles = useCallback( + async (files: File[]) => { + setUploadBoard(true); + const safeFiles = files.map(safeFile); + const fileItems: TUploadItem[] = []; + + if (mx.isRoomEncrypted(roomId)) { + const encryptFiles = fulfilledPromiseSettledResult( + await Promise.allSettled(safeFiles.map((f) => encryptFile(f))) + ); + encryptFiles.forEach((ef) => fileItems.push(ef)); + } else { + safeFiles.forEach((f) => + fileItems.push({ file: f, originalFile: f, encInfo: undefined }) + ); + } + setSelectedFiles({ + type: 'PUT', + item: fileItems, + }); + }, + [setSelectedFiles, roomId, mx] + ); + const pickFile = useFilePicker(handleFiles, true); + const handlePaste = useFilePasteHandler(handleFiles); + const dropZoneVisible = useFileDropZone(roomViewRef, handleFiles); + + useEffect(() => { + Transforms.insertFragment(editor, msgDraft); + }, [editor, msgDraft]); + + useEffect(() => { + ReactEditor.focus(editor); + return () => { + const parsedDraft = JSON.parse(JSON.stringify(editor.children)); + setMsgDraft(parsedDraft); + resetEditor(editor); + }; + }, [roomId, editor, setMsgDraft]); + + useEffect(() => { + const handleReplyTo = ( + userId: string, + eventId: string, + body: string, + formattedBody: string + ) => { + setReplyDraft({ + userId, + eventId, + body, + formattedBody, + }); + }; + navigation.on(cons.events.navigation.REPLY_TO_CLICKED, handleReplyTo); + return () => { + navigation.removeListener(cons.events.navigation.REPLY_TO_CLICKED, handleReplyTo); + }; + }, [setReplyDraft]); + + const handleRemoveUpload = useCallback( + (upload: TUploadContent | TUploadContent[]) => { + const uploads = Array.isArray(upload) ? upload : [upload]; + setSelectedFiles({ + type: 'DELETE', + item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)), + }); + uploads.forEach((u) => roomUploadAtomFamily.remove(u)); + }, + [setSelectedFiles, selectedFiles] + ); + + const handleCancelUpload = (uploads: Upload[]) => { + uploads.forEach((upload) => { + if (upload.status === UploadStatus.Loading) { + mx.cancelUpload(upload.promise); + } + }); + handleRemoveUpload(uploads.map((upload) => upload.file)); + }; + + const handleSendUpload = async (uploads: UploadSuccess[]) => { + const sendPromises = uploads.map(async (upload) => { + const fileItem = selectedFiles.find((f) => f.file === upload.file); + if (fileItem && fileItem.file.type.startsWith('image')) { + const [imgError, imgContent] = await to(getImageMsgContent(fileItem, upload.mxc)); + if (imgError) console.warn(imgError); + if (imgContent) mx.sendMessage(roomId, imgContent); + return; + } + if (fileItem && fileItem.file.type.startsWith('video')) { + const [videoError, videoContent] = await to(getVideoMsgContent(mx, fileItem, upload.mxc)); + if (videoError) console.warn(videoError); + if (videoContent) mx.sendMessage(roomId, videoContent); + return; + } + if (fileItem && fileItem.file.type.startsWith('audio')) { + mx.sendMessage(roomId, getAudioMsgContent(fileItem, upload.mxc)); + return; + } + if (fileItem) { + mx.sendMessage(roomId, getFileMsgContent(fileItem, upload.mxc)); + } + }); + handleCancelUpload(uploads); + await Promise.allSettled(sendPromises); + }; + + const submit = useCallback(() => { + uploadBoardHandlers.current?.handleSend(); + + const plainText = toPlainText(editor.children).trim(); + const customHtml = toMatrixCustomHTML(editor.children); + + if (plainText === '') return; + + let body = plainText; + let formattedBody = customHtml; + if (replyDraft) { + body = parseReplyBody(replyDraft.userId, replyDraft.userId) + body; + formattedBody = + parseReplyFormattedBody( + roomId, + replyDraft.userId, + replyDraft.eventId, + replyDraft.formattedBody ?? sanitizeText(replyDraft.body) + ) + formattedBody; + } + + const content: IContent = { + msgtype: MsgType.Text, + body, + format: 'org.matrix.custom.html', + formatted_body: formattedBody, + }; + if (replyDraft) { + content['m.relates_to'] = { + 'm.in_reply_to': { + event_id: replyDraft.eventId, + }, + }; + } + mx.sendMessage(roomId, content); + resetEditor(editor); + setReplyDraft(); + sendTypingStatus(false); + }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft]); + + const handleKeyDown: KeyboardEventHandler = useCallback( + (evt) => { + const { selection } = editor; + if (isHotkey('enter', evt)) { + evt.preventDefault(); + submit(); + } + if (isHotkey('escape', evt)) { + evt.preventDefault(); + setReplyDraft(); + } + if (selection && Range.isCollapsed(selection)) { + if (isHotkey('arrowleft', evt)) { + evt.preventDefault(); + Transforms.move(editor, { unit: 'offset', reverse: true }); + } + if (isHotkey('arrowright', evt)) { + evt.preventDefault(); + Transforms.move(editor, { unit: 'offset' }); + } + } + }, + [submit, editor, setReplyDraft] + ); + + const handleChange: EditorChangeHandler = (value) => { + const prevWordRange = getPrevWorldRange(editor); + const query = prevWordRange + ? getAutocompleteQuery(editor, prevWordRange, AUTOCOMPLETE_PREFIXES) + : undefined; + + setAutocompleteQuery(query); + + const descendant = value[0]; + if (descendant && Element.isElement(descendant)) { + const isEmpty = value.length === 1 && Editor.isEmpty(editor, descendant); + sendTypingStatus(!isEmpty); + } + }; + + const handleEmoticonSelect = (key: string, shortcode: string) => { + editor.insertNode(createEmoticonElement(key, shortcode)); + moveCursor(editor); + }; + + const handleStickerSelect = async (mxc: string, shortcode: string) => { + const stickerUrl = mx.mxcUrlToHttp(mxc); + if (!stickerUrl) return; + + const info = await getImageInfo( + await loadImageElement(stickerUrl), + await getImageUrlBlob(stickerUrl) + ); + + mx.sendEvent(roomId, EventType.Sticker, { + body: shortcode, + url: mxc, + info, + }); + }; + + return ( +
    + {selectedFiles.length > 0 && ( + setUploadBoard(!uploadBoard)} + uploadFamilyObserverAtom={uploadFamilyObserverAtom} + onSend={handleSendUpload} + imperativeHandlerRef={uploadBoardHandlers} + onCancel={handleCancelUpload} + /> + } + > + {uploadBoard && ( + + + {Array.from(selectedFiles) + .reverse() + .map((fileItem, index) => ( + + ))} + + + )} + + )} + } + style={{ pointerEvents: 'none' }} + > + + + + + + {`Drop Files in "${room?.name || 'Room'}"`} + + Drag and drop files here or click for selection dialog + + + + + {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && ( + setAutocompleteQuery(undefined)} + /> + )} + {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && ( + setAutocompleteQuery(undefined)} + /> + )} + {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( + setAutocompleteQuery(undefined)} + /> + )} + + + setReplyDraft()} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + + +
    + ) + } + before={ + pickFile('*')} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + } + after={ + <> + setToolbar(!toolbar)} + > + + + + {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( + { + setEmojiBoardTab(undefined); + ReactEditor.focus(editor); + }} + /> + } + > + {(anchorRef) => ( + <> + setEmojiBoardTab(EmojiBoardTab.Sticker)} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + setEmojiBoardTab(EmojiBoardTab.Emoji)} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + + )} + + )} + + + + + + } + bottom={toolbar && } + /> + + ); + } +); diff --git a/src/app/organisms/room/RoomInputPlaceholder.css.ts b/src/app/organisms/room/RoomInputPlaceholder.css.ts new file mode 100644 index 0000000..d0873da --- /dev/null +++ b/src/app/organisms/room/RoomInputPlaceholder.css.ts @@ -0,0 +1,10 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const RoomInputPlaceholder = style({ + minHeight: toRem(48), + backgroundColor: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, + boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`, + borderRadius: config.radii.R400, +}); diff --git a/src/app/organisms/room/RoomInputPlaceholder.tsx b/src/app/organisms/room/RoomInputPlaceholder.tsx new file mode 100644 index 0000000..77c7ccf --- /dev/null +++ b/src/app/organisms/room/RoomInputPlaceholder.tsx @@ -0,0 +1,11 @@ +import React, { ComponentProps } from 'react'; +import { Box, as } from 'folds'; +import classNames from 'classnames'; + +import * as css from './RoomInputPlaceholder.css'; + +export const RoomInputPlaceholder = as<'div', ComponentProps>( + ({ className, ...props }, ref) => ( + + ) +); diff --git a/src/app/organisms/room/RoomTombstone.css.ts b/src/app/organisms/room/RoomTombstone.css.ts new file mode 100644 index 0000000..c4c0461 --- /dev/null +++ b/src/app/organisms/room/RoomTombstone.css.ts @@ -0,0 +1,7 @@ +import { style } from '@vanilla-extract/css'; +import { config } from 'folds'; + +export const RoomTombstone = style({ + padding: config.space.S200, + paddingLeft: config.space.S400, +}); diff --git a/src/app/organisms/room/RoomTombstone.tsx b/src/app/organisms/room/RoomTombstone.tsx new file mode 100644 index 0000000..39f0e63 --- /dev/null +++ b/src/app/organisms/room/RoomTombstone.tsx @@ -0,0 +1,67 @@ +import React, { useCallback } from 'react'; +import { Box, Button, Spinner, Text, color } from 'folds'; + +import { selectRoom } from '../../../client/action/navigation'; + +import * as css from './RoomTombstone.css'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { genRoomVia } from '../../../util/matrixUtil'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { Membership } from '../../../types/matrix/room'; +import { RoomInputPlaceholder } from './RoomInputPlaceholder'; + +type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string }; +export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) { + const mx = useMatrixClient(); + + const [joinState, handleJoin] = useAsyncCallback( + useCallback(() => { + const currentRoom = mx.getRoom(roomId); + const via = currentRoom ? genRoomVia(currentRoom) : []; + return mx.joinRoom(replacementRoomId, { + viaServers: via, + }); + }, [mx, roomId, replacementRoomId]) + ); + const replacementRoom = mx.getRoom(replacementRoomId); + + const handleOpen = () => { + if (replacementRoom) selectRoom(replacementRoom.roomId); + if (joinState.status === AsyncStatus.Success) selectRoom(joinState.data.roomId); + }; + + return ( + + + {body || 'This room has been replaced and is no longer active.'} + {joinState.status === AsyncStatus.Error && ( + + {(joinState.error as any)?.message ?? 'Failed to join replacement room!'} + + )} + + {replacementRoom?.getMyMembership() === Membership.Join || + joinState.status === AsyncStatus.Success ? ( + + ) : ( + + )} + + ); +} diff --git a/src/app/organisms/room/RoomView.jsx b/src/app/organisms/room/RoomView.jsx index b94c35c..591fcce 100644 --- a/src/app/organisms/room/RoomView.jsx +++ b/src/app/organisms/room/RoomView.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import './RoomView.scss'; +import { Text, config } from 'folds'; import EventEmitter from 'events'; @@ -10,16 +11,29 @@ import navigation from '../../../client/state/navigation'; import RoomViewHeader from './RoomViewHeader'; import RoomViewContent from './RoomViewContent'; import RoomViewFloating from './RoomViewFloating'; -import RoomViewInput from './RoomViewInput'; import RoomViewCmdBar from './RoomViewCmdBar'; +import { RoomInput } from './RoomInput'; +import { useStateEvent } from '../../hooks/useStateEvent'; +import { StateEvent } from '../../../types/matrix/room'; +import { RoomTombstone } from './RoomTombstone'; +import { usePowerLevels } from '../../hooks/usePowerLevels'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { RoomInputPlaceholder } from './RoomInputPlaceholder'; const viewEvent = new EventEmitter(); -function RoomView({ roomTimeline, eventId }) { +function RoomView({ room, roomTimeline, eventId }) { + const roomInputRef = useRef(null); const roomViewRef = useRef(null); // eslint-disable-next-line react/prop-types const { roomId } = roomTimeline; + const mx = useMatrixClient(); + const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone); + const { getPowerLevel, canSendEvent } = usePowerLevels(room); + const myUserId = mx.getUserId(); + const canMessage = myUserId ? canSendEvent(undefined, getPowerLevel(myUserId)) : false; + useEffect(() => { const settingsToggle = (isVisible) => { const roomView = roomViewRef.current; @@ -47,23 +61,36 @@ function RoomView({ roomTimeline, eventId }) { - +
    - - +
    + {tombstoneEvent ? ( + + ) : ( + <> + {canMessage && ( + + )} + {!canMessage && ( + + You do not have permission to post in this room + + )} + + )} +
    +
    @@ -74,6 +101,7 @@ RoomView.defaultProps = { eventId: null, }; RoomView.propTypes = { + room: PropTypes.shape({}).isRequired, roomTimeline: PropTypes.shape({}).isRequired, eventId: PropTypes.string, }; diff --git a/src/app/organisms/room/RoomView.scss b/src/app/organisms/room/RoomView.scss index 4f06bf2..c70c2b0 100644 --- a/src/app/organisms/room/RoomView.scss +++ b/src/app/organisms/room/RoomView.scss @@ -35,11 +35,12 @@ @extend .cp-fx__item-one; position: relative; } - + &__sticky { - min-height: 85px; position: relative; background: var(--bg-surface); - border-top: 1px solid var(--bg-surface-border); } -} \ No newline at end of file + &__editor { + padding: 0 var(--sp-normal); + } +} diff --git a/src/app/organisms/room/RoomViewContent.jsx b/src/app/organisms/room/RoomViewContent.jsx index 0a9256c..fe598bf 100644 --- a/src/app/organisms/room/RoomViewContent.jsx +++ b/src/app/organisms/room/RoomViewContent.jsx @@ -28,6 +28,7 @@ import { useForceUpdate } from '../../hooks/useForceUpdate'; import { parseTimelineChange } from './common'; import TimelineScroll from './TimelineScroll'; import EventLimit from './EventLimit'; +import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver'; const PAG_LIMIT = 30; const MAX_MSG_DIFF_MINUTES = 5; @@ -392,7 +393,7 @@ function useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, event let jumpToItemIndex = -1; -function RoomViewContent({ eventId, roomTimeline }) { +function RoomViewContent({ roomInputRef, eventId, roomTimeline }) { const [throttle] = useState(new Throttle()); const timelineSVRef = useRef(null); @@ -484,6 +485,21 @@ function RoomViewContent({ eventId, roomTimeline }) { } }, [newEvent]); + useResizeObserver( + roomInputRef.current, + useCallback((entries) => { + if (!roomInputRef.current) return; + const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries); + if (!editorBaseEntry) return; + + const timelineScroll = timelineScrollRef.current; + if (!roomTimeline.initialized) return; + if (timelineScroll.bottom < 40 && !roomTimeline.canPaginateForward() && document.visibilityState === 'visible') { + timelineScroll.scrollToBottom(); + } + }, [roomInputRef]) + ); + const listenKeyboard = useCallback((event) => { if (event.ctrlKey || event.altKey || event.metaKey) return; if (event.key !== 'ArrowUp') return; @@ -620,6 +636,9 @@ RoomViewContent.defaultProps = { RoomViewContent.propTypes = { eventId: PropTypes.string, roomTimeline: PropTypes.shape({}).isRequired, + roomInputRef: PropTypes.shape({ + current: PropTypes.shape({}) + }).isRequired }; export default RoomViewContent; diff --git a/src/app/organisms/room/msgContent.ts b/src/app/organisms/room/msgContent.ts new file mode 100644 index 0000000..2b0c50e --- /dev/null +++ b/src/app/organisms/room/msgContent.ts @@ -0,0 +1,148 @@ +import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk'; +import to from 'await-to-js'; +import { IThumbnailContent } from '../../../types/matrix/common'; +import { + getImageFileUrl, + getThumbnail, + getThumbnailDimensions, + getVideoFileUrl, + loadImageElement, + loadVideoElement, +} from '../../utils/dom'; +import { encryptFile, getImageInfo, getThumbnailContent, getVideoInfo } from '../../utils/matrix'; +import { TUploadItem } from '../../state/roomInputDrafts'; +import { MATRIX_BLUR_HASH_PROPERTY_NAME, encodeBlurHash } from '../../utils/blurHash'; + +const generateThumbnailContent = async ( + mx: MatrixClient, + img: HTMLImageElement | HTMLVideoElement, + dimensions: [number, number], + encrypt: boolean +): Promise => { + const thumbnail = await getThumbnail(img, ...dimensions); + if (!thumbnail) throw new Error('Can not create thumbnail!'); + const encThumbData = encrypt ? await encryptFile(thumbnail) : undefined; + const thumbnailFile = encThumbData?.file ?? thumbnail; + if (!thumbnailFile) throw new Error('Can not create thumbnail!'); + + const data = await mx.uploadContent(thumbnailFile); + const thumbMxc = data?.content_uri; + if (!thumbMxc) throw new Error('Failed when uploading thumbnail!'); + const thumbnailContent = getThumbnailContent({ + thumbnail: thumbnailFile, + encInfo: encThumbData?.encInfo, + mxc: thumbMxc, + width: dimensions[0], + height: dimensions[1], + }); + return thumbnailContent; +}; + +export const getImageMsgContent = async (item: TUploadItem, mxc: string): Promise => { + const { file, originalFile, encInfo } = item; + const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile))); + if (imgError) console.warn(imgError); + + const content: IContent = { + msgtype: MsgType.Image, + body: file.name, + }; + if (imgEl) { + content.info = { + ...getImageInfo(imgEl, file), + [MATRIX_BLUR_HASH_PROPERTY_NAME]: encodeBlurHash(imgEl), + }; + } + if (encInfo) { + content.file = { + ...encInfo, + url: mxc, + }; + } else { + content.url = mxc; + } + return content; +}; + +export const getVideoMsgContent = async ( + mx: MatrixClient, + item: TUploadItem, + mxc: string +): Promise => { + const { file, originalFile, encInfo } = item; + + const [videoError, videoEl] = await to(loadVideoElement(getVideoFileUrl(originalFile))); + if (videoError) console.warn(videoError); + + const content: IContent = { + msgtype: MsgType.Video, + body: file.name, + }; + if (videoEl) { + const [thumbError, thumbContent] = await to( + generateThumbnailContent( + mx, + videoEl, + getThumbnailDimensions(videoEl.videoWidth, videoEl.videoHeight), + !!encInfo + ) + ); + if (thumbError) console.warn(thumbError); + content.info = { + ...getVideoInfo(videoEl, file), + ...thumbContent, + }; + } + if (encInfo) { + content.file = { + ...encInfo, + url: mxc, + }; + } else { + content.url = mxc; + } + return content; +}; + +export const getAudioMsgContent = (item: TUploadItem, mxc: string): IContent => { + const { file, encInfo } = item; + const content: IContent = { + msgtype: MsgType.Audio, + body: file.name, + info: { + mimetype: file.type, + size: file.size, + }, + }; + if (encInfo) { + content.file = { + ...encInfo, + url: mxc, + }; + } else { + content.url = mxc; + } + return content; +}; + +export const getFileMsgContent = (item: TUploadItem, mxc: string): IContent => { + const { file, encInfo } = item; + const content: IContent = { + msgtype: MsgType.File, + body: file.name, + filename: file.name, + info: { + mimetype: file.type, + size: file.size, + }, + }; + if (encInfo) { + content.file = { + ...encInfo, + url: mxc, + }; + } else { + content.url = mxc; + } + return content; +}; diff --git a/src/app/pages/App.jsx b/src/app/pages/App.jsx index af7cc29..2828d7b 100644 --- a/src/app/pages/App.jsx +++ b/src/app/pages/App.jsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { StrictMode } from 'react'; +import { Provider } from 'jotai'; import { isAuthenticated } from '../../client/state/auth'; @@ -6,7 +7,11 @@ import Auth from '../templates/auth/Auth'; import Client from '../templates/client/Client'; function App() { - return isAuthenticated() ? : ; + return ( + + {isAuthenticated() ? : } + + ); } export default App; diff --git a/src/app/plugins/custom-emoji.ts b/src/app/plugins/custom-emoji.ts new file mode 100644 index 0000000..daceef4 --- /dev/null +++ b/src/app/plugins/custom-emoji.ts @@ -0,0 +1,293 @@ +import { IImageInfo, MatrixClient, Room } from 'matrix-js-sdk'; +import { AccountDataEvent } from '../../types/matrix/accountData'; +import { getAccountData, getStateEvents } from '../utils/room'; +import { StateEvent } from '../../types/matrix/room'; + +// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md + +export type PackEventIdToUnknown = Record; +export type EmoteRoomIdToPackEvents = Record; +export type EmoteRoomsContent = { + rooms?: EmoteRoomIdToPackEvents; +}; + +export enum PackUsage { + Emoticon = 'emoticon', + Sticker = 'sticker', +} + +export type PackImage = { + url: string; + body?: string; + usage?: PackUsage[]; + info?: IImageInfo; +}; + +export type PackImages = Record; + +export type PackMeta = { + display_name?: string; + avatar_url?: string; + attribution?: string; + usage?: PackUsage[]; +}; + +export type ExtendedPackImage = PackImage & { + shortcode: string; +}; + +export type PackContent = { + pack?: PackMeta; + images?: PackImages; +}; + +export class ImagePack { + public id: string; + + public content: PackContent; + + public displayName?: string; + + public avatarUrl?: string; + + public usage?: PackUsage[]; + + public attribution?: string; + + public images: Map; + + public emoticons: ExtendedPackImage[]; + + public stickers: ExtendedPackImage[]; + + static parsePack(eventId: string, packContent: PackContent) { + if (!eventId || typeof packContent?.images !== 'object') { + return undefined; + } + + return new ImagePack(eventId, packContent); + } + + constructor(eventId: string, content: PackContent) { + this.id = eventId; + this.content = JSON.parse(JSON.stringify(content)); + + this.images = new Map(); + this.emoticons = []; + this.stickers = []; + + this.applyPackMeta(content); + this.applyImages(content); + } + + applyPackMeta(content: PackContent) { + const pack = content.pack ?? {}; + + this.displayName = pack.display_name; + this.avatarUrl = pack.avatar_url; + this.usage = pack.usage ?? [PackUsage.Emoticon, PackUsage.Sticker]; + this.attribution = pack.attribution; + } + + applyImages(content: PackContent) { + this.images = new Map(); + this.emoticons = []; + this.stickers = []; + if (!content.images) return; + + Object.entries(content.images).forEach(([shortcode, data]) => { + const { url } = data; + const body = data.body ?? shortcode; + const usage = data.usage ?? this.usage; + const { info } = data; + + if (!url) return; + const image: ExtendedPackImage = { + shortcode, + url, + body, + usage, + info, + }; + + this.images.set(shortcode, image); + if (usage && usage.includes(PackUsage.Emoticon)) { + this.emoticons.push(image); + } + if (usage && usage.includes(PackUsage.Sticker)) { + this.stickers.push(image); + } + }); + } + + getImages() { + return this.images; + } + + getEmojis() { + return this.emoticons; + } + + getStickers() { + return this.stickers; + } + + getImagesFor(usage: PackUsage) { + if (usage === PackUsage.Emoticon) return this.getEmojis(); + if (usage === PackUsage.Sticker) return this.getStickers(); + return this.getEmojis(); + } + + getContent() { + return this.content; + } + + getPackAvatarUrl(usage: PackUsage): string | undefined { + return this.avatarUrl || this.getImagesFor(usage)[0].url; + } + + private updatePackProperty(property: K, value: PackMeta[K]) { + if (this.content.pack === undefined) { + this.content.pack = {}; + } + this.content.pack[property] = value; + this.applyPackMeta(this.content); + } + + setAvatarUrl(avatarUrl?: string) { + this.updatePackProperty('avatar_url', avatarUrl); + } + + setDisplayName(displayName?: string) { + this.updatePackProperty('display_name', displayName); + } + + setAttribution(attribution?: string) { + this.updatePackProperty('attribution', attribution); + } + + setUsage(usage?: PackUsage[]) { + this.updatePackProperty('usage', usage); + } + + addImage(key: string, imgContent: PackImage) { + this.content.images = { + [key]: imgContent, + ...this.content.images, + }; + this.applyImages(this.content); + } + + removeImage(key: string) { + if (!this.content.images) return; + if (this.content.images[key] === undefined) return; + delete this.content.images[key]; + this.applyImages(this.content); + } + + updateImageKey(key: string, newKey: string) { + const { images } = this.content; + if (!images) return; + if (images[key] === undefined) return; + const copyImages: PackImages = {}; + Object.keys(images).forEach((imgKey) => { + copyImages[imgKey === key ? newKey : imgKey] = images[imgKey]; + }); + this.content.images = copyImages; + this.applyImages(this.content); + } + + private updateImageProperty( + key: string, + property: K, + value: PackImage[K] + ) { + if (!this.content.images) return; + if (this.content.images[key] === undefined) return; + this.content.images[key][property] = value; + this.applyImages(this.content); + } + + setImageUrl(key: string, url: string) { + this.updateImageProperty(key, 'url', url); + } + + setImageBody(key: string, body?: string) { + this.updateImageProperty(key, 'body', body); + } + + setImageInfo(key: string, info?: IImageInfo) { + this.updateImageProperty(key, 'info', info); + } + + setImageUsage(key: string, usage?: PackUsage[]) { + this.updateImageProperty(key, 'usage', usage); + } +} + +export function getRoomImagePacks(room: Room): ImagePack[] { + const dataEvents = getStateEvents(room, StateEvent.PoniesRoomEmotes); + + return dataEvents.reduce((roomPacks, packEvent) => { + const packId = packEvent?.getId(); + const content = packEvent?.getContent() as PackContent | undefined; + if (!packId || !content) return roomPacks; + const pack = ImagePack.parsePack(packId, content); + if (pack) { + roomPacks.push(pack); + } + return roomPacks; + }, []); +} + +export function getGlobalImagePacks(mx: MatrixClient): ImagePack[] { + const emoteRoomsContent = getAccountData(mx, AccountDataEvent.PoniesEmoteRooms)?.getContent() as + | EmoteRoomsContent + | undefined; + if (typeof emoteRoomsContent !== 'object') return []; + + const { rooms } = emoteRoomsContent; + if (typeof rooms !== 'object') return []; + + const roomIds = Object.keys(rooms); + + const packs = roomIds.flatMap((roomId) => { + if (typeof rooms[roomId] !== 'object') return []; + const room = mx.getRoom(roomId); + if (!room) return []; + return getRoomImagePacks(room); + }); + + return packs; +} + +export function getUserImagePack(mx: MatrixClient): ImagePack | undefined { + const userPackContent = getAccountData(mx, AccountDataEvent.PoniesUserEmotes)?.getContent() as + | PackContent + | undefined; + const userId = mx.getUserId(); + if (!userPackContent || !userId) { + return undefined; + } + + const userImagePack = ImagePack.parsePack(userId, userPackContent); + return userImagePack; +} + +/** + * @param {MatrixClient} mx Provide if you want to include user personal/global pack + * @param {Room[]} rooms Provide rooms if you want to include rooms pack + * @returns {ImagePack[]} packs + */ +export function getRelevantPacks(mx?: MatrixClient, rooms?: Room[]): ImagePack[] { + const userPack = mx && getUserImagePack(mx); + const userPacks = userPack ? [userPack] : []; + const globalPacks = mx ? getGlobalImagePacks(mx) : []; + const globalPackIds = new Set(globalPacks.map((pack) => pack.id)); + const roomsPack = rooms?.flatMap(getRoomImagePacks) ?? []; + + return userPacks.concat( + globalPacks, + roomsPack.filter((pack) => !globalPackIds.has(pack.id)) + ); +} diff --git a/src/app/plugins/emoji.ts b/src/app/plugins/emoji.ts new file mode 100644 index 0000000..36bd044 --- /dev/null +++ b/src/app/plugins/emoji.ts @@ -0,0 +1,104 @@ +import { CompactEmoji } from 'emojibase'; +import emojisData from 'emojibase-data/en/compact.json'; +import joypixels from 'emojibase-data/en/shortcodes/joypixels.json'; +import emojibase from 'emojibase-data/en/shortcodes/emojibase.json'; + +export type IEmoji = CompactEmoji & { + shortcode: string; +}; + +export enum EmojiGroupId { + People = 'People', + Nature = 'Nature', + Food = 'Food', + Activity = 'Activity', + Travel = 'Travel', + Object = 'Object', + Symbol = 'Symbol', + Flag = 'Flag', +} + +export type IEmojiGroup = { + id: EmojiGroupId; + order: number; + emojis: IEmoji[]; +}; + +export const emojiGroups: IEmojiGroup[] = [ + { + id: EmojiGroupId.People, + order: 0, + emojis: [], + }, + { + id: EmojiGroupId.Nature, + order: 1, + emojis: [], + }, + { + id: EmojiGroupId.Food, + order: 2, + emojis: [], + }, + { + id: EmojiGroupId.Activity, + order: 3, + emojis: [], + }, + { + id: EmojiGroupId.Travel, + order: 4, + emojis: [], + }, + { + id: EmojiGroupId.Object, + order: 5, + emojis: [], + }, + { + id: EmojiGroupId.Symbol, + order: 6, + emojis: [], + }, + { + id: EmojiGroupId.Flag, + order: 7, + emojis: [], + }, +]; + +export const emojis: IEmoji[] = []; + +function addEmojiToGroup(groupIndex: number, emoji: IEmoji) { + emojiGroups[groupIndex].emojis.push(emoji); +} + +function getGroupIndex(emoji: IEmoji): number | undefined { + if (emoji.group === 0 || emoji.group === 1) return 0; + if (emoji.group === 3) return 1; + if (emoji.group === 4) return 2; + if (emoji.group === 6) return 3; + if (emoji.group === 5) return 4; + if (emoji.group === 7) return 5; + if (emoji.group === 8 || typeof emoji.group === 'undefined') return 6; + if (emoji.group === 9) return 7; + return undefined; +} + +emojisData.forEach((emoji) => { + const myShortCodes = joypixels[emoji.hexcode] || emojibase[emoji.hexcode]; + if (!myShortCodes) return; + if (Array.isArray(myShortCodes) && myShortCodes.length === 0) return; + + const em: IEmoji = { + ...emoji, + shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes, + shortcodes: Array.isArray(myShortCodes) ? myShortCodes : emoji.shortcodes, + }; + + const groupIndex = getGroupIndex(em); + if (groupIndex !== undefined) { + addEmojiToGroup(groupIndex, em); + emojis.push(em); + } +}); diff --git a/src/app/plugins/recent-emoji.ts b/src/app/plugins/recent-emoji.ts new file mode 100644 index 0000000..3634538 --- /dev/null +++ b/src/app/plugins/recent-emoji.ts @@ -0,0 +1,44 @@ +import { MatrixClient } from 'matrix-js-sdk'; +import { getAccountData } from '../utils/room'; +import { IEmoji, emojis } from './emoji'; +import { AccountDataEvent } from '../../types/matrix/accountData'; + +type EmojiUnicode = string; +type EmojiUsageCount = number; + +export type IRecentEmojiContent = { + recent_emoji?: [EmojiUnicode, EmojiUsageCount][]; +}; + +export const getRecentEmojis = (mx: MatrixClient, limit?: number): IEmoji[] => { + const recentEmojiEvent = getAccountData(mx, AccountDataEvent.ElementRecentEmoji); + const recentEmoji = recentEmojiEvent?.getContent().recent_emoji; + if (!Array.isArray(recentEmoji)) return []; + + return recentEmoji + .sort((e1, e2) => e2[1] - e1[1]) + .slice(0, limit) + .reduce((list, [unicode]) => { + const emoji = emojis.find((e) => e.unicode === unicode); + if (emoji) list.push(emoji); + return list; + }, []); +}; + +export function addRecentEmoji(mx: MatrixClient, unicode: string) { + const recentEmojiEvent = getAccountData(mx, AccountDataEvent.ElementRecentEmoji); + const recentEmoji = recentEmojiEvent?.getContent().recent_emoji ?? []; + + const emojiIndex = recentEmoji.findIndex(([u]) => u === unicode); + let entry: [EmojiUnicode, EmojiUsageCount]; + if (emojiIndex < 0) { + entry = [unicode, 1]; + } else { + [entry] = recentEmoji.splice(emojiIndex, 1); + entry[1] += 1; + } + recentEmoji.unshift(entry); + mx.setAccountData(AccountDataEvent.ElementRecentEmoji, { + recent_emoji: recentEmoji.slice(0, 100), + }); +} diff --git a/src/app/state/hooks/inviteList.ts b/src/app/state/hooks/inviteList.ts new file mode 100644 index 0000000..f8b7e05 --- /dev/null +++ b/src/app/state/hooks/inviteList.ts @@ -0,0 +1,63 @@ +import { useAtomValue, WritableAtom } from 'jotai'; +import { selectAtom } from 'jotai/utils'; +import { MatrixClient } from 'matrix-js-sdk'; +import { useCallback } from 'react'; +import { isDirectInvite, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room'; +import { compareRoomsEqual, RoomsAction } from '../utils'; +import { MDirectAction } from '../mDirectList'; + +export const useSpaceInvites = ( + mx: MatrixClient, + allInvitesAtom: WritableAtom +) => { + const selector = useCallback( + (rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))), + [mx] + ); + return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual)); +}; + +export const useRoomInvites = ( + mx: MatrixClient, + allInvitesAtom: WritableAtom, + mDirectAtom: WritableAtom, MDirectAction> +) => { + const mDirects = useAtomValue(mDirectAtom); + const selector = useCallback( + (rooms: string[]) => + rooms.filter( + (roomId) => + isRoom(mx.getRoom(roomId)) && + !(mDirects.has(roomId) || isDirectInvite(mx.getRoom(roomId), mx.getUserId())) + ), + [mx, mDirects] + ); + return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual)); +}; + +export const useDirectInvites = ( + mx: MatrixClient, + allInvitesAtom: WritableAtom, + mDirectAtom: WritableAtom, MDirectAction> +) => { + const mDirects = useAtomValue(mDirectAtom); + const selector = useCallback( + (rooms: string[]) => + rooms.filter( + (roomId) => mDirects.has(roomId) || isDirectInvite(mx.getRoom(roomId), mx.getUserId()) + ), + [mx, mDirects] + ); + return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual)); +}; + +export const useUnsupportedInvites = ( + mx: MatrixClient, + allInvitesAtom: WritableAtom +) => { + const selector = useCallback( + (rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))), + [mx] + ); + return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual)); +}; diff --git a/src/app/state/hooks/roomList.ts b/src/app/state/hooks/roomList.ts new file mode 100644 index 0000000..5d0890b --- /dev/null +++ b/src/app/state/hooks/roomList.ts @@ -0,0 +1,54 @@ +import { useAtomValue, WritableAtom } from 'jotai'; +import { selectAtom } from 'jotai/utils'; +import { MatrixClient } from 'matrix-js-sdk'; +import { useCallback } from 'react'; +import { isRoom, isSpace, isUnsupportedRoom } from '../../utils/room'; +import { compareRoomsEqual, RoomsAction } from '../utils'; +import { MDirectAction } from '../mDirectList'; + +export const useSpaces = (mx: MatrixClient, allRoomsAtom: WritableAtom) => { + const selector = useCallback( + (rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))), + [mx] + ); + return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual)); +}; + +export const useRooms = ( + mx: MatrixClient, + allRoomsAtom: WritableAtom, + mDirectAtom: WritableAtom, MDirectAction> +) => { + const mDirects = useAtomValue(mDirectAtom); + const selector = useCallback( + (rooms: string[]) => + rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId)), + [mx, mDirects] + ); + return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual)); +}; + +export const useDirects = ( + mx: MatrixClient, + allRoomsAtom: WritableAtom, + mDirectAtom: WritableAtom, MDirectAction> +) => { + const mDirects = useAtomValue(mDirectAtom); + const selector = useCallback( + (rooms: string[]) => + rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && mDirects.has(roomId)), + [mx, mDirects] + ); + return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual)); +}; + +export const useUnsupportedRooms = ( + mx: MatrixClient, + allRoomsAtom: WritableAtom +) => { + const selector = useCallback( + (rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))), + [mx] + ); + return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual)); +}; diff --git a/src/app/state/hooks/settings.ts b/src/app/state/hooks/settings.ts new file mode 100644 index 0000000..3f4dab6 --- /dev/null +++ b/src/app/state/hooks/settings.ts @@ -0,0 +1,34 @@ +import { atom, useAtomValue, useSetAtom, WritableAtom } from 'jotai'; +import { SetAtom } from 'jotai/core/atom'; +import { selectAtom } from 'jotai/utils'; +import { useMemo } from 'react'; +import { Settings } from '../settings'; + +export const useSetSetting = ( + settingsAtom: WritableAtom, + key: K +) => { + const setterAtom = useMemo( + () => + atom(null, (get, set, value) => { + const s = { ...get(settingsAtom) }; + s[key] = value; + set(settingsAtom, s); + }), + [settingsAtom, key] + ); + + return useSetAtom(setterAtom); +}; + +export const useSetting = ( + settingsAtom: WritableAtom, + key: K +): [Settings[K], SetAtom] => { + const selector = useMemo(() => (s: Settings) => s[key], [key]); + const setting = useAtomValue(selectAtom(settingsAtom, selector)); + + const setter = useSetSetting(settingsAtom, key); + + return [setting, setter]; +}; diff --git a/src/app/state/hooks/useBindAtoms.ts b/src/app/state/hooks/useBindAtoms.ts new file mode 100644 index 0000000..6dc2a3d --- /dev/null +++ b/src/app/state/hooks/useBindAtoms.ts @@ -0,0 +1,16 @@ +import { MatrixClient } from 'matrix-js-sdk'; +import { allInvitesAtom, useBindAllInvitesAtom } from '../inviteList'; +import { allRoomsAtom, useBindAllRoomsAtom } from '../roomList'; +import { mDirectAtom, useBindMDirectAtom } from '../mDirectList'; +import { muteChangesAtom, mutedRoomsAtom, useBindMutedRoomsAtom } from '../mutedRoomList'; +import { roomToUnreadAtom, useBindRoomToUnreadAtom } from '../roomToUnread'; +import { roomToParentsAtom, useBindRoomToParentsAtom } from '../roomToParents'; + +export const useBindAtoms = (mx: MatrixClient) => { + useBindMDirectAtom(mx, mDirectAtom); + useBindAllInvitesAtom(mx, allInvitesAtom); + useBindAllRoomsAtom(mx, allRoomsAtom); + useBindRoomToParentsAtom(mx, roomToParentsAtom); + useBindMutedRoomsAtom(mx, mutedRoomsAtom); + useBindRoomToUnreadAtom(mx, roomToUnreadAtom, muteChangesAtom); +}; diff --git a/src/app/state/inviteList.ts b/src/app/state/inviteList.ts new file mode 100644 index 0000000..463fd35 --- /dev/null +++ b/src/app/state/inviteList.ts @@ -0,0 +1,32 @@ +import { atom, WritableAtom } from 'jotai'; +import { MatrixClient } from 'matrix-js-sdk'; +import { useMemo } from 'react'; +import { Membership } from '../../types/matrix/room'; +import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils'; + +const baseRoomsAtom = atom([]); +export const allInvitesAtom = atom( + (get) => get(baseRoomsAtom), + (get, set, action) => { + if (action.type === 'INITIALIZE') { + set(baseRoomsAtom, action.rooms); + return; + } + set(baseRoomsAtom, (ids) => { + const newIds = ids.filter((id) => id !== action.roomId); + if (action.type === 'PUT') newIds.push(action.roomId); + return newIds; + }); + } +); + +export const useBindAllInvitesAtom = ( + mx: MatrixClient, + allRooms: WritableAtom +) => { + useBindRoomsWithMembershipsAtom( + mx, + allRooms, + useMemo(() => [Membership.Invite], []) + ); +}; diff --git a/src/app/state/list.ts b/src/app/state/list.ts new file mode 100644 index 0000000..4f5a619 --- /dev/null +++ b/src/app/state/list.ts @@ -0,0 +1,33 @@ +import { atom } from 'jotai'; + +export type ListAction = + | { + type: 'PUT'; + item: T | T[]; + } + | { + type: 'DELETE'; + item: T | T[]; + }; + +export const createListAtom = () => { + const baseListAtom = atom([]); + return atom>( + (get) => get(baseListAtom), + (get, set, action) => { + const items = get(baseListAtom); + const newItems = Array.isArray(action.item) ? action.item : [action.item]; + if (action.type === 'DELETE') { + set( + baseListAtom, + items.filter((item) => !newItems.includes(item)) + ); + return; + } + if (action.type === 'PUT') { + set(baseListAtom, [...items, ...newItems]); + } + } + ); +}; +export type TListAtom = ReturnType>; diff --git a/src/app/state/mDirectList.ts b/src/app/state/mDirectList.ts new file mode 100644 index 0000000..96e2f0d --- /dev/null +++ b/src/app/state/mDirectList.ts @@ -0,0 +1,47 @@ +import { atom, useSetAtom, WritableAtom } from 'jotai'; +import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk'; +import { useEffect } from 'react'; +import { AccountDataEvent } from '../../types/matrix/accountData'; +import { getAccountData, getMDirects } from '../utils/room'; + +export type MDirectAction = { + type: 'INITIALIZE' | 'UPDATE'; + rooms: Set; +}; + +const baseMDirectAtom = atom(new Set()); +export const mDirectAtom = atom, MDirectAction>( + (get) => get(baseMDirectAtom), + (get, set, action) => { + set(baseMDirectAtom, action.rooms); + } +); + +export const useBindMDirectAtom = ( + mx: MatrixClient, + mDirect: WritableAtom, MDirectAction> +) => { + const setMDirect = useSetAtom(mDirect); + + useEffect(() => { + const mDirectEvent = getAccountData(mx, AccountDataEvent.Direct); + if (mDirectEvent) { + setMDirect({ + type: 'INITIALIZE', + rooms: getMDirects(mDirectEvent), + }); + } + + const handleAccountData = (event: MatrixEvent) => { + setMDirect({ + type: 'UPDATE', + rooms: getMDirects(event), + }); + }; + + mx.on(ClientEvent.AccountData, handleAccountData); + return () => { + mx.removeListener(ClientEvent.AccountData, handleAccountData); + }; + }, [mx, setMDirect]); +}; diff --git a/src/app/state/mutedRoomList.ts b/src/app/state/mutedRoomList.ts new file mode 100644 index 0000000..d456f85 --- /dev/null +++ b/src/app/state/mutedRoomList.ts @@ -0,0 +1,101 @@ +import { atom, WritableAtom, useSetAtom } from 'jotai'; +import { ClientEvent, IPushRule, IPushRules, MatrixClient, MatrixEvent } from 'matrix-js-sdk'; +import { useEffect } from 'react'; +import { MuteChanges } from '../../types/matrix/room'; +import { findMutedRule, isMutedRule } from '../utils/room'; + +export type MutedRoomsUpdate = + | { + type: 'INITIALIZE'; + addRooms: string[]; + } + | { + type: 'UPDATE'; + addRooms: string[]; + removeRooms: string[]; + }; + +export const muteChangesAtom = atom({ + added: [], + removed: [], +}); + +const baseMutedRoomsAtom = atom(new Set()); +export const mutedRoomsAtom = atom, MutedRoomsUpdate>( + (get) => get(baseMutedRoomsAtom), + (get, set, action) => { + const mutedRooms = new Set([...get(mutedRoomsAtom)]); + if (action.type === 'INITIALIZE') { + set(baseMutedRoomsAtom, new Set([...action.addRooms])); + set(muteChangesAtom, { + added: [...action.addRooms], + removed: [], + }); + return; + } + if (action.type === 'UPDATE') { + action.removeRooms.forEach((roomId) => mutedRooms.delete(roomId)); + action.addRooms.forEach((roomId) => mutedRooms.add(roomId)); + set(baseMutedRoomsAtom, mutedRooms); + set(muteChangesAtom, { + added: [...action.addRooms], + removed: [...action.removeRooms], + }); + } + } +); + +export const useBindMutedRoomsAtom = ( + mx: MatrixClient, + mutedAtom: WritableAtom, MutedRoomsUpdate> +) => { + const setMuted = useSetAtom(mutedAtom); + + useEffect(() => { + const overrideRules = mx.getAccountData('m.push_rules')?.getContent() + ?.global?.override; + if (overrideRules) { + const mutedRooms = overrideRules.reduce((rooms, rule) => { + if (isMutedRule(rule)) rooms.push(rule.rule_id); + return rooms; + }, []); + setMuted({ + type: 'INITIALIZE', + addRooms: mutedRooms, + }); + } + }, [mx, setMuted]); + + useEffect(() => { + const handlePushRules = (mEvent: MatrixEvent, oldMEvent?: MatrixEvent) => { + if (mEvent.getType() === 'm.push_rules') { + const override = mEvent?.getContent()?.global?.override as IPushRule[] | undefined; + const oldOverride = oldMEvent?.getContent()?.global?.override as IPushRule[] | undefined; + if (!override || !oldOverride) return; + + const isMuteToggled = (rule: IPushRule, otherOverride: IPushRule[]) => { + const roomId = rule.rule_id; + + const isMuted = isMutedRule(rule); + if (!isMuted) return false; + const isOtherMuted = findMutedRule(otherOverride, roomId); + if (isOtherMuted) return false; + return true; + }; + + const mutedRules = override.filter((rule) => isMuteToggled(rule, oldOverride)); + const unMutedRules = oldOverride.filter((rule) => isMuteToggled(rule, override)); + + setMuted({ + type: 'UPDATE', + addRooms: mutedRules.map((rule) => rule.rule_id), + removeRooms: unMutedRules.map((rule) => rule.rule_id), + }); + } + }; + mx.on(ClientEvent.AccountData, handlePushRules); + return () => { + mx.removeListener(ClientEvent.AccountData, handlePushRules); + }; + }, [mx, setMuted]); +}; diff --git a/src/app/state/roomInputDrafts.ts b/src/app/state/roomInputDrafts.ts new file mode 100644 index 0000000..2708b8b --- /dev/null +++ b/src/app/state/roomInputDrafts.ts @@ -0,0 +1,48 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; +import { Descendant } from 'slate'; +import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; +import { TListAtom, createListAtom } from './list'; +import { createUploadAtomFamily } from './upload'; +import { TUploadContent } from '../utils/matrix'; + +export const roomUploadAtomFamily = createUploadAtomFamily(); + +export type TUploadItem = { + file: TUploadContent; + originalFile: TUploadContent; + encInfo: EncryptedAttachmentInfo | undefined; +}; + +export const roomIdToUploadItemsAtomFamily = atomFamily>( + createListAtom +); + +export type RoomIdToMsgAction = + | { + type: 'PUT'; + roomId: string; + msg: Descendant[]; + } + | { + type: 'DELETE'; + roomId: string; + }; + +const createMsgDraftAtom = () => atom([]); +export type TMsgDraftAtom = ReturnType; +export const roomIdToMsgDraftAtomFamily = atomFamily(() => + createMsgDraftAtom() +); + +export type IReplyDraft = { + userId: string; + eventId: string; + body: string; + formattedBody?: string; +}; +const createReplyDraftAtom = () => atom(undefined); +export type TReplyDraftAtom = ReturnType; +export const roomIdToReplyDraftAtomFamily = atomFamily(() => + createReplyDraftAtom() +); diff --git a/src/app/state/roomList.ts b/src/app/state/roomList.ts new file mode 100644 index 0000000..7a793d8 --- /dev/null +++ b/src/app/state/roomList.ts @@ -0,0 +1,31 @@ +import { atom, WritableAtom } from 'jotai'; +import { MatrixClient } from 'matrix-js-sdk'; +import { useMemo } from 'react'; +import { Membership } from '../../types/matrix/room'; +import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils'; + +const baseRoomsAtom = atom([]); +export const allRoomsAtom = atom( + (get) => get(baseRoomsAtom), + (get, set, action) => { + if (action.type === 'INITIALIZE') { + set(baseRoomsAtom, action.rooms); + return; + } + set(baseRoomsAtom, (ids) => { + const newIds = ids.filter((id) => id !== action.roomId); + if (action.type === 'PUT') newIds.push(action.roomId); + return newIds; + }); + } +); +export const useBindAllRoomsAtom = ( + mx: MatrixClient, + allRooms: WritableAtom +) => { + useBindRoomsWithMembershipsAtom( + mx, + allRooms, + useMemo(() => [Membership.Join], []) + ); +}; diff --git a/src/app/state/roomToParents.ts b/src/app/state/roomToParents.ts new file mode 100644 index 0000000..374ddd5 --- /dev/null +++ b/src/app/state/roomToParents.ts @@ -0,0 +1,120 @@ +import produce from 'immer'; +import { atom, useSetAtom, WritableAtom } from 'jotai'; +import { + ClientEvent, + MatrixClient, + MatrixEvent, + Room, + RoomEvent, + RoomStateEvent, +} from 'matrix-js-sdk'; +import { useEffect } from 'react'; +import { Membership, RoomToParents, StateEvent } from '../../types/matrix/room'; +import { + getRoomToParents, + getSpaceChildren, + isSpace, + isValidChild, + mapParentWithChildren, +} from '../utils/room'; + +export type RoomToParentsAction = + | { + type: 'INITIALIZE'; + roomToParents: RoomToParents; + } + | { + type: 'PUT'; + parent: string; + children: string[]; + } + | { + type: 'DELETE'; + roomId: string; + }; + +const baseRoomToParents = atom(new Map()); +export const roomToParentsAtom = atom( + (get) => get(baseRoomToParents), + (get, set, action) => { + if (action.type === 'INITIALIZE') { + set(baseRoomToParents, action.roomToParents); + return; + } + if (action.type === 'PUT') { + set( + baseRoomToParents, + produce(get(baseRoomToParents), (draftRoomToParents) => { + mapParentWithChildren(draftRoomToParents, action.parent, action.children); + }) + ); + return; + } + if (action.type === 'DELETE') { + set( + baseRoomToParents, + produce(get(baseRoomToParents), (draftRoomToParents) => { + const noParentRooms: string[] = []; + draftRoomToParents.delete(action.roomId); + draftRoomToParents.forEach((parents, child) => { + parents.delete(action.roomId); + if (parents.size === 0) noParentRooms.push(child); + }); + noParentRooms.forEach((room) => draftRoomToParents.delete(room)); + }) + ); + } + } +); + +export const useBindRoomToParentsAtom = ( + mx: MatrixClient, + roomToParents: WritableAtom +) => { + const setRoomToParents = useSetAtom(roomToParents); + + useEffect(() => { + setRoomToParents({ type: 'INITIALIZE', roomToParents: getRoomToParents(mx) }); + + const handleAddRoom = (room: Room) => { + if (isSpace(room) && room.getMyMembership() !== Membership.Invite) { + setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) }); + } + }; + + const handleMembershipChange = (room: Room, membership: string) => { + if (isSpace(room) && membership === Membership.Join) { + setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) }); + } + }; + + const handleStateChange = (mEvent: MatrixEvent) => { + if (mEvent.getType() === StateEvent.SpaceChild) { + const childId = mEvent.getStateKey(); + const roomId = mEvent.getRoomId(); + if (childId && roomId) { + if (isValidChild(mEvent)) { + setRoomToParents({ type: 'PUT', parent: roomId, children: [childId] }); + } else { + setRoomToParents({ type: 'DELETE', roomId: childId }); + } + } + } + }; + + const handleDeleteRoom = (roomId: string) => { + setRoomToParents({ type: 'DELETE', roomId }); + }; + + mx.on(ClientEvent.Room, handleAddRoom); + mx.on(RoomEvent.MyMembership, handleMembershipChange); + mx.on(RoomStateEvent.Events, handleStateChange); + mx.on(ClientEvent.DeleteRoom, handleDeleteRoom); + return () => { + mx.removeListener(ClientEvent.Room, handleAddRoom); + mx.removeListener(RoomEvent.MyMembership, handleMembershipChange); + mx.removeListener(RoomStateEvent.Events, handleStateChange); + mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom); + }; + }, [mx, setRoomToParents]); +}; diff --git a/src/app/state/roomToUnread.ts b/src/app/state/roomToUnread.ts new file mode 100644 index 0000000..0c7b6bd --- /dev/null +++ b/src/app/state/roomToUnread.ts @@ -0,0 +1,219 @@ +import produce from 'immer'; +import { atom, useSetAtom, PrimitiveAtom, WritableAtom, useAtomValue } from 'jotai'; +import { IRoomTimelineData, MatrixClient, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk'; +import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts'; +import { useEffect } from 'react'; +import { + MuteChanges, + Membership, + NotificationType, + RoomToUnread, + UnreadInfo, +} from '../../types/matrix/room'; +import { + getAllParents, + getNotificationType, + getUnreadInfo, + getUnreadInfos, + isNotificationEvent, + roomHaveUnread, +} from '../utils/room'; +import { roomToParentsAtom } from './roomToParents'; + +export type RoomToUnreadAction = + | { + type: 'RESET'; + unreadInfos: UnreadInfo[]; + } + | { + type: 'PUT'; + unreadInfo: UnreadInfo; + } + | { + type: 'DELETE'; + roomId: string; + }; + +const putUnreadInfo = ( + roomToUnread: RoomToUnread, + allParents: Set, + unreadInfo: UnreadInfo +) => { + const oldUnread = roomToUnread.get(unreadInfo.roomId) ?? { highlight: 0, total: 0, from: null }; + roomToUnread.set(unreadInfo.roomId, { + highlight: unreadInfo.highlight, + total: unreadInfo.total, + from: null, + }); + + const newH = unreadInfo.highlight - oldUnread.highlight; + const newT = unreadInfo.total - oldUnread.total; + + allParents.forEach((parentId) => { + const oldParentUnread = roomToUnread.get(parentId) ?? { highlight: 0, total: 0, from: null }; + roomToUnread.set(parentId, { + highlight: (oldParentUnread.highlight += newH), + total: (oldParentUnread.total += newT), + from: new Set([...(oldParentUnread.from ?? []), unreadInfo.roomId]), + }); + }); +}; + +const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set, roomId: string) => { + const oldUnread = roomToUnread.get(roomId); + if (!oldUnread) return; + roomToUnread.delete(roomId); + + allParents.forEach((parentId) => { + const oldParentUnread = roomToUnread.get(parentId); + if (!oldParentUnread) return; + const newFrom = new Set([...(oldParentUnread.from ?? roomId)]); + newFrom.delete(roomId); + if (newFrom.size === 0) { + roomToUnread.delete(parentId); + return; + } + roomToUnread.set(parentId, { + highlight: oldParentUnread.highlight - oldUnread.highlight, + total: oldParentUnread.total - oldUnread.total, + from: newFrom, + }); + }); +}; + +const baseRoomToUnread = atom(new Map()); +export const roomToUnreadAtom = atom( + (get) => get(baseRoomToUnread), + (get, set, action) => { + if (action.type === 'RESET') { + const draftRoomToUnread: RoomToUnread = new Map(); + action.unreadInfos.forEach((unreadInfo) => { + putUnreadInfo( + draftRoomToUnread, + getAllParents(get(roomToParentsAtom), unreadInfo.roomId), + unreadInfo + ); + }); + set(baseRoomToUnread, draftRoomToUnread); + return; + } + if (action.type === 'PUT') { + set( + baseRoomToUnread, + produce(get(baseRoomToUnread), (draftRoomToUnread) => + putUnreadInfo( + draftRoomToUnread, + getAllParents(get(roomToParentsAtom), action.unreadInfo.roomId), + action.unreadInfo + ) + ) + ); + return; + } + if (action.type === 'DELETE' && get(baseRoomToUnread).has(action.roomId)) { + set( + baseRoomToUnread, + produce(get(baseRoomToUnread), (draftRoomToUnread) => + deleteUnreadInfo( + draftRoomToUnread, + getAllParents(get(roomToParentsAtom), action.roomId), + action.roomId + ) + ) + ); + } + } +); + +export const useBindRoomToUnreadAtom = ( + mx: MatrixClient, + unreadAtom: WritableAtom, + muteChangesAtom: PrimitiveAtom +) => { + const setUnreadAtom = useSetAtom(unreadAtom); + const muteChanges = useAtomValue(muteChangesAtom); + + useEffect(() => { + setUnreadAtom({ + type: 'RESET', + unreadInfos: getUnreadInfos(mx), + }); + }, [mx, setUnreadAtom]); + + useEffect(() => { + const handleTimelineEvent = ( + mEvent: MatrixEvent, + room: Room | undefined, + toStartOfTimeline: boolean | undefined, + removed: boolean, + data: IRoomTimelineData + ) => { + if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return; + if (getNotificationType(mx, room.roomId) === NotificationType.Mute) { + setUnreadAtom({ + type: 'DELETE', + roomId: room.roomId, + }); + return; + } + + if (mEvent.getSender() === mx.getUserId()) return; + setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) }); + }; + mx.on(RoomEvent.Timeline, handleTimelineEvent); + return () => { + mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); + }; + }, [mx, setUnreadAtom]); + + useEffect(() => { + const handleReceipt = (mEvent: MatrixEvent, room: Room) => { + if (mEvent.getType() === 'm.receipt') { + const myUserId = mx.getUserId(); + if (!myUserId) return; + if (room.isSpaceRoom()) return; + const content = mEvent.getContent(); + + const isMyReceipt = Object.keys(content).find((eventId) => + (Object.keys(content[eventId]) as ReceiptType[]).find( + (receiptType) => content[eventId][receiptType][myUserId] + ) + ); + if (isMyReceipt) { + setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); + } + } + }; + mx.on(RoomEvent.Receipt, handleReceipt); + return () => { + mx.removeListener(RoomEvent.Receipt, handleReceipt); + }; + }, [mx, setUnreadAtom]); + + useEffect(() => { + muteChanges.removed.forEach((roomId) => { + const room = mx.getRoom(roomId); + if (!room) return; + if (!roomHaveUnread(mx, room)) return; + setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) }); + }); + muteChanges.added.forEach((roomId) => { + setUnreadAtom({ type: 'DELETE', roomId }); + }); + }, [mx, setUnreadAtom, muteChanges]); + + useEffect(() => { + const handleMembershipChange = (room: Room, membership: string) => { + if (membership !== Membership.Join) { + setUnreadAtom({ + type: 'DELETE', + roomId: room.roomId, + }); + } + }; + mx.on(RoomEvent.MyMembership, handleMembershipChange); + return () => { + mx.removeListener(RoomEvent.MyMembership, handleMembershipChange); + }; + }, [mx, setUnreadAtom]); +}; diff --git a/src/app/state/selectedRoom.ts b/src/app/state/selectedRoom.ts new file mode 100644 index 0000000..1ef04de --- /dev/null +++ b/src/app/state/selectedRoom.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const selectedRoomAtom = atom(undefined); diff --git a/src/app/state/selectedTab.ts b/src/app/state/selectedTab.ts new file mode 100644 index 0000000..e680ae6 --- /dev/null +++ b/src/app/state/selectedTab.ts @@ -0,0 +1,8 @@ +import { atom } from 'jotai'; + +export enum SidebarTab { + Home = 'Home', + People = 'People', +} + +export const selectedTabAtom = atom(SidebarTab.Home); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts new file mode 100644 index 0000000..7739c58 --- /dev/null +++ b/src/app/state/settings.ts @@ -0,0 +1,49 @@ +import { atom } from 'jotai'; + +const STORAGE_KEY = 'settings'; +export interface Settings { + themeIndex: number; + useSystemTheme: boolean; + isMarkdown: boolean; + editorToolbar: boolean; + isPeopleDrawer: boolean; + + hideMembershipEvents: boolean; + hideNickAvatarEvents: boolean; + + showNotifications: boolean; + isNotificationSounds: boolean; +} + +const defaultSettings: Settings = { + themeIndex: 0, + useSystemTheme: true, + isMarkdown: true, + editorToolbar: false, + isPeopleDrawer: true, + + hideMembershipEvents: false, + hideNickAvatarEvents: true, + + showNotifications: true, + isNotificationSounds: true, +}; + +export const getSettings = () => { + const settings = localStorage.getItem(STORAGE_KEY); + if (settings === null) return defaultSettings; + return JSON.parse(settings) as Settings; +}; + +export const setSettings = (settings: Settings) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); +}; + +const baseSettings = atom(getSettings()); +export const settingsAtom = atom( + (get) => get(baseSettings), + (get, set, update) => { + set(baseSettings, update); + setSettings(update); + } +); diff --git a/src/app/state/tabToRoom.ts b/src/app/state/tabToRoom.ts new file mode 100644 index 0000000..2f4ee92 --- /dev/null +++ b/src/app/state/tabToRoom.ts @@ -0,0 +1,34 @@ +import produce from 'immer'; +import { atom } from 'jotai'; +import { MatrixClient } from 'matrix-js-sdk'; + +type RoomInfo = { + roomId: string; + timestamp: number; +}; +type TabToRoom = Map; + +type TabToRoomAction = { + type: 'PUT'; + tabInfo: { tabId: string; roomInfo: RoomInfo }; +}; + +const baseTabToRoom = atom(new Map()); +export const tabToRoomAtom = atom( + (get) => get(baseTabToRoom), + (get, set, action) => { + if (action.type === 'PUT') { + set( + baseTabToRoom, + produce(get(baseTabToRoom), (draft) => { + draft.set(action.tabInfo.tabId, action.tabInfo.roomInfo); + }) + ); + } + } +); + +export const useBindTabToRoomAtom = (mx: MatrixClient) => { + console.log(mx); + // TODO: +}; diff --git a/src/app/state/upload.ts b/src/app/state/upload.ts new file mode 100644 index 0000000..d92b93d --- /dev/null +++ b/src/app/state/upload.ts @@ -0,0 +1,146 @@ +import { atom, useAtom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; +import { MatrixClient, UploadResponse, UploadProgress, MatrixError } from 'matrix-js-sdk'; +import { useCallback } from 'react'; +import { useThrottle } from '../hooks/useThrottle'; +import { uploadContent, TUploadContent } from '../utils/matrix'; + +export enum UploadStatus { + Idle = 'idle', + Loading = 'loading', + Success = 'success', + Error = 'error', +} + +export type UploadIdle = { + file: TUploadContent; + status: UploadStatus.Idle; +}; + +export type UploadLoading = { + file: TUploadContent; + status: UploadStatus.Loading; + promise: Promise; + progress: UploadProgress; +}; + +export type UploadSuccess = { + file: TUploadContent; + status: UploadStatus.Success; + mxc: string; +}; + +export type UploadError = { + file: TUploadContent; + status: UploadStatus.Error; + error: MatrixError; +}; + +export type Upload = UploadIdle | UploadLoading | UploadSuccess | UploadError; + +export type UploadAtomAction = + | { + promise: Promise; + } + | { + progress: UploadProgress; + } + | { + mxc: string; + } + | { + error: MatrixError; + }; + +export const createUploadAtom = (file: TUploadContent) => { + const baseUploadAtom = atom({ + file, + status: UploadStatus.Idle, + }); + return atom( + (get) => get(baseUploadAtom), + (get, set, update) => { + const uploadState = get(baseUploadAtom); + if ('promise' in update) { + set(baseUploadAtom, { + status: UploadStatus.Loading, + file, + promise: update.promise, + progress: { loaded: 0, total: file.size }, + }); + return; + } + if ('progress' in update && uploadState.status === UploadStatus.Loading) { + set(baseUploadAtom, { + ...uploadState, + progress: update.progress, + }); + return; + } + if ('mxc' in update) { + set(baseUploadAtom, { + status: UploadStatus.Success, + file, + mxc: update.mxc, + }); + return; + } + if ('error' in update) { + set(baseUploadAtom, { + status: UploadStatus.Error, + file, + error: update.error, + }); + } + } + ); +}; +export type TUploadAtom = ReturnType; + +export const useBindUploadAtom = ( + mx: MatrixClient, + file: TUploadContent, + uploadAtom: TUploadAtom, + hideFilename?: boolean +) => { + const [upload, setUpload] = useAtom(uploadAtom); + + const handleProgress = useThrottle( + useCallback((progress: UploadProgress) => setUpload({ progress }), [setUpload]), + { immediate: true, wait: 200 } + ); + + const startUpload = useCallback( + () => + uploadContent(mx, file, { + hideFilename, + onPromise: (promise: Promise) => setUpload({ promise }), + onProgress: handleProgress, + onSuccess: (mxc) => setUpload({ mxc }), + onError: (error) => setUpload({ error }), + }), + [mx, file, hideFilename, setUpload, handleProgress] + ); + + const cancelUpload = useCallback(async () => { + if (upload.status === UploadStatus.Loading) { + await mx.cancelUpload(upload.promise); + } + }, [mx, upload]); + + return { + upload, + startUpload, + cancelUpload, + }; +}; + +export const createUploadAtomFamily = () => + atomFamily(createUploadAtom); +export type TUploadAtomFamily = ReturnType; + +export const createUploadFamilyObserverAtom = ( + uploadFamily: TUploadAtomFamily, + uploads: TUploadContent[] +) => atom((get) => uploads.map((upload) => get(uploadFamily(upload)))); +export type TUploadFamilyObserverAtom = ReturnType; diff --git a/src/app/state/utils.ts b/src/app/state/utils.ts new file mode 100644 index 0000000..355c941 --- /dev/null +++ b/src/app/state/utils.ts @@ -0,0 +1,64 @@ +import { useSetAtom, WritableAtom } from 'jotai'; +import { ClientEvent, MatrixClient, Room, RoomEvent } from 'matrix-js-sdk'; +import { useEffect } from 'react'; +import { Membership } from '../../types/matrix/room'; + +export type RoomsAction = + | { + type: 'INITIALIZE'; + rooms: string[]; + } + | { + type: 'PUT' | 'DELETE'; + roomId: string; + }; + +export const useBindRoomsWithMembershipsAtom = ( + mx: MatrixClient, + roomsAtom: WritableAtom, + memberships: Membership[] +) => { + const setRoomsAtom = useSetAtom(roomsAtom); + + useEffect(() => { + const satisfyMembership = (room: Room): boolean => + !!memberships.find((membership) => membership === room.getMyMembership()); + setRoomsAtom({ + type: 'INITIALIZE', + rooms: mx + .getRooms() + .filter(satisfyMembership) + .map((room) => room.roomId), + }); + + const handleAddRoom = (room: Room) => { + if (satisfyMembership(room)) { + setRoomsAtom({ type: 'PUT', roomId: room.roomId }); + } + }; + + const handleMembershipChange = (room: Room) => { + if (!satisfyMembership(room)) { + setRoomsAtom({ type: 'DELETE', roomId: room.roomId }); + } + }; + + const handleDeleteRoom = (roomId: string) => { + setRoomsAtom({ type: 'DELETE', roomId }); + }; + + mx.on(ClientEvent.Room, handleAddRoom); + mx.on(RoomEvent.MyMembership, handleMembershipChange); + mx.on(ClientEvent.DeleteRoom, handleDeleteRoom); + return () => { + mx.removeListener(ClientEvent.Room, handleAddRoom); + mx.removeListener(RoomEvent.MyMembership, handleMembershipChange); + mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom); + }; + }, [mx, memberships, setRoomsAtom]); +}; + +export const compareRoomsEqual = (a: string[], b: string[]) => { + if (a.length !== b.length) return false; + return a.every((roomId, roomIdIndex) => roomId === b[roomIdIndex]); +}; diff --git a/src/app/templates/client/Client.jsx b/src/app/templates/client/Client.jsx index d83845b..cc9d88f 100644 --- a/src/app/templates/client/Client.jsx +++ b/src/app/templates/client/Client.jsx @@ -18,14 +18,13 @@ import EmojiBoardOpener from '../../organisms/emoji-board/EmojiBoardOpener'; import initMatrix from '../../../client/initMatrix'; import navigation from '../../../client/state/navigation'; import cons from '../../../client/state/cons'; -import DragDrop from '../../organisms/drag-drop/DragDrop'; import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg'; +import { MatrixClientProvider } from '../../hooks/useMatrixClient'; function Client() { const [isLoading, changeLoading] = useState(true); const [loadingMsg, setLoadingMsg] = useState('Heating up'); - const [dragCounter, setDragCounter] = useState(0); const classNameHidden = 'client__item-hidden'; const navWrapperRef = useRef(null); @@ -44,19 +43,17 @@ function Client() { navigation.on(cons.events.navigation.ROOM_SELECTED, onRoomSelected); navigation.on(cons.events.navigation.NAVIGATION_OPENED, onNavigationSelected); - return (() => { + return () => { navigation.removeListener(cons.events.navigation.ROOM_SELECTED, onRoomSelected); navigation.removeListener(cons.events.navigation.NAVIGATION_OPENED, onNavigationSelected); - }); + }; }, []); useEffect(() => { + changeLoading(true); let counter = 0; const iId = setInterval(() => { - const msgList = [ - 'Almost there...', - 'Looks like you have a lot of stuff to heat up!', - ]; + const msgList = ['Almost there...', 'Looks like you have a lot of stuff to heat up!']; if (counter === msgList.length - 1) { setLoadingMsg(msgList[msgList.length - 1]); clearInterval(iId); @@ -80,103 +77,48 @@ function Client() {
    initMatrix.clearCacheAndReload()}> Clear cache & reload initMatrix.logout()}>Logout + } + render={(toggle) => ( + )} - render={(toggle) => } />
    - {loadingMsg} + + {loadingMsg} +
    - Cinny + + Cinny +
    ); } - function dragContainsFiles(e) { - if (!e.dataTransfer.types) return false; - - for (let i = 0; i < e.dataTransfer.types.length; i += 1) { - if (e.dataTransfer.types[i] === 'Files') return true; - } - return false; - } - - function modalOpen() { - return navigation.isRawModalVisible && dragCounter <= 0; - } - - function handleDragOver(e) { - if (!dragContainsFiles(e)) return; - - e.preventDefault(); - - if (!navigation.selectedRoomId || modalOpen()) { - e.dataTransfer.dropEffect = 'none'; - } - } - - function handleDragEnter(e) { - e.preventDefault(); - - if (navigation.selectedRoomId && !modalOpen() && dragContainsFiles(e)) { - setDragCounter(dragCounter + 1); - } - } - - function handleDragLeave(e) { - e.preventDefault(); - - if (navigation.selectedRoomId && !modalOpen() && dragContainsFiles(e)) { - setDragCounter(dragCounter - 1); - } - } - - function handleDrop(e) { - e.preventDefault(); - - setDragCounter(0); - - if (modalOpen()) return; - - const roomId = navigation.selectedRoomId; - if (!roomId) return; - - const { files } = e.dataTransfer; - if (!files?.length) return; - const file = files[0]; - initMatrix.roomsInput.setAttachment(roomId, file); - initMatrix.roomsInput.emit(cons.events.roomsInput.ATTACHMENT_SET, file); - } - return ( -
    -
    - + +
    +
    + +
    +
    + +
    + + + +
    -
    - -
    - - - - - -
    + ); } diff --git a/src/app/utils/AsyncSearch.ts b/src/app/utils/AsyncSearch.ts new file mode 100644 index 0000000..4baacf0 --- /dev/null +++ b/src/app/utils/AsyncSearch.ts @@ -0,0 +1,102 @@ +export type NormalizeOption = { + caseSensitive?: boolean; + normalizeUnicode?: boolean; + ignoreWhitespace?: boolean; +}; + +export type MatchQueryOption = { + contain?: boolean; +}; + +export type AsyncSearchOption = { + limit?: number; +}; + +export type MatchHandler = ( + item: TSearchItem, + query: string +) => boolean; +export type ResultHandler = ( + results: TSearchItem[], + query: string +) => void; + +export type AsyncSearchHandler = (query: string) => void; +export type TerminateAsyncSearch = () => void; + +export const normalize = (str: string, options?: NormalizeOption) => { + let nStr = str.normalize(options?.normalizeUnicode ?? true ? 'NFKC' : 'NFC'); + if (!options?.caseSensitive) nStr = nStr.toLocaleLowerCase(); + if (options?.ignoreWhitespace ?? true) nStr = nStr.replace(/\s/g, ''); + return nStr; +}; + +export const matchQuery = (item: string, query: string, options?: MatchQueryOption): boolean => { + if (options?.contain) return item.indexOf(query) !== -1; + return item.startsWith(query); +}; + +export const AsyncSearch = ( + list: TSearchItem[], + match: MatchHandler, + onResult: ResultHandler, + options?: AsyncSearchOption +): [AsyncSearchHandler, TerminateAsyncSearch] => { + let resultList: TSearchItem[] = []; + + let searchIndex = 0; + let sessionStartTimestamp = 0; + let sessionScheduleId: number | undefined; + + const terminateSearch: TerminateAsyncSearch = () => { + resultList = []; + searchIndex = 0; + sessionStartTimestamp = 0; + if (sessionScheduleId) clearTimeout(sessionScheduleId); + sessionScheduleId = undefined; + }; + + const find = (query: string, sessionTimestamp: number) => { + const findingCount = resultList.length; + sessionScheduleId = undefined; + // return if find session got reset + if (sessionTimestamp !== sessionStartTimestamp) return; + + sessionStartTimestamp = window.performance.now(); + for (; searchIndex < list.length; searchIndex += 1) { + if (match(list[searchIndex], query)) { + resultList.push(list[searchIndex]); + if (typeof options?.limit === 'number' && resultList.length >= options.limit) { + break; + } + } + + const matchFinishTime = window.performance.now(); + if (matchFinishTime - sessionStartTimestamp > 8) { + const currentFindingCount = resultList.length; + const thisSessionTimestamp = sessionStartTimestamp; + if (findingCount !== currentFindingCount) onResult(resultList, query); + + searchIndex += 1; + sessionScheduleId = window.setTimeout(() => find(query, thisSessionTimestamp), 1); + return; + } + } + + if (findingCount !== resultList.length || findingCount === 0) { + onResult(resultList, query); + } + terminateSearch(); + }; + + const search: AsyncSearchHandler = (query: string) => { + terminateSearch(); + if (query === '') { + onResult(resultList, query); + return; + } + find(query, sessionStartTimestamp); + }; + + return [search, terminateSearch]; +}; diff --git a/src/app/utils/blurHash.ts b/src/app/utils/blurHash.ts new file mode 100644 index 0000000..0de5a92 --- /dev/null +++ b/src/app/utils/blurHash.ts @@ -0,0 +1,19 @@ +import { encode } from 'blurhash'; + +export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash'; + +export const encodeBlurHash = ( + img: HTMLImageElement | HTMLVideoElement, + width?: number, + height?: number +): string | undefined => { + const canvas = document.createElement('canvas'); + canvas.width = width || img.width; + canvas.height = height || img.height; + const context = canvas.getContext('2d'); + + if (!context) return undefined; + context.drawImage(img, 0, 0, canvas.width, canvas.height); + const data = context.getImageData(0, 0, canvas.width, canvas.height); + return encode(data.data, data.width, data.height, 4, 4); +}; diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts new file mode 100644 index 0000000..d3804ae --- /dev/null +++ b/src/app/utils/common.ts @@ -0,0 +1,32 @@ +import { IconName, IconSrc } from 'folds'; + +export const bytesToSize = (bytes: number): string => { + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + if (bytes === 0) return '0KB'; + + let sizeIndex = Math.floor(Math.log(bytes) / Math.log(1000)); + + if (sizeIndex === 0) sizeIndex = 1; + + return `${(bytes / 1000 ** sizeIndex).toFixed(1)} ${sizes[sizeIndex]}`; +}; + +export const getFileTypeIcon = (icons: Record, fileType: string): IconSrc => { + const type = fileType.toLowerCase(); + if (type.startsWith('audio')) { + return icons.Play; + } + if (type.startsWith('video')) { + return icons.Vlc; + } + if (type.startsWith('image')) { + return icons.Photo; + } + return icons.File; +}; + +export const fulfilledPromiseSettledResult = (prs: PromiseSettledResult[]): T[] => + prs.reduce((values, pr) => { + if (pr.status === 'fulfilled') values.push(pr.value); + return values; + }, []); diff --git a/src/app/utils/disposable.ts b/src/app/utils/disposable.ts new file mode 100644 index 0000000..7840fe4 --- /dev/null +++ b/src/app/utils/disposable.ts @@ -0,0 +1,8 @@ +export type DisposeCallback = (...args: Q) => R; +export type DisposableContext

    = ( + ...args: P +) => DisposeCallback; + +export const disposable =

    ( + context: DisposableContext +) => context; diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts new file mode 100644 index 0000000..d717adf --- /dev/null +++ b/src/app/utils/dom.ts @@ -0,0 +1,133 @@ +export const targetFromEvent = (evt: Event, selector: string): Element | undefined => { + const targets = evt.composedPath() as Element[]; + return targets.find((target) => target.matches?.(selector)); +}; + +export const editableActiveElement = (): boolean => + !!document.activeElement && + /^(input)|(textarea)$/.test(document.activeElement.nodeName.toLowerCase()); + +export const inVisibleScrollArea = ( + scrollElement: HTMLElement, + childElement: HTMLElement +): boolean => { + const scrollTop = scrollElement.offsetTop + scrollElement.scrollTop; + const scrollBottom = scrollTop + scrollElement.offsetHeight; + + const childTop = childElement.offsetTop; + const childBottom = childTop + childElement.clientHeight; + + if (childTop >= scrollTop && childTop < scrollBottom) return true; + if (childTop < scrollTop && childBottom > scrollTop) return true; + return false; +}; + +export type FilesOrFile = T extends true ? File[] : File; + +export const selectFile = ( + accept: string, + multiple?: M +): Promise | undefined> => + new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + if (accept) input.accept = accept; + if (multiple) input.multiple = true; + + const changeHandler = () => { + const fileList = input.files; + if (!fileList) { + resolve(undefined); + } else { + const files: File[] = [...fileList].filter((file) => file); + resolve((multiple ? files : files[0]) as FilesOrFile); + } + input.removeEventListener('change', changeHandler); + }; + + input.addEventListener('change', changeHandler); + input.click(); + }); + +export const getDataTransferFiles = (dataTransfer: DataTransfer): File[] | undefined => { + const fileList = dataTransfer.files; + const files = [...fileList].filter((file) => file); + if (files.length === 0) return undefined; + return files; +}; + +export const getImageUrlBlob = async (url: string) => { + const res = await fetch(url); + const blob = await res.blob(); + return blob; +}; + +export const getImageFileUrl = (fileOrBlob: File | Blob) => URL.createObjectURL(fileOrBlob); + +export const getVideoFileUrl = (fileOrBlob: File | Blob) => URL.createObjectURL(fileOrBlob); + +export const loadImageElement = (url: string): Promise => + new Promise((resolve, reject) => { + const img = document.createElement('img'); + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + img.src = url; + }); + +export const loadVideoElement = (url: string): Promise => + new Promise((resolve, reject) => { + const video = document.createElement('video'); + video.preload = 'metadata'; + video.playsInline = true; + video.muted = true; + + video.onloadeddata = () => { + resolve(video); + video.pause(); + }; + video.onerror = (e) => { + reject(e); + }; + + video.src = url; + video.load(); + video.play(); + }); + +export const getThumbnailDimensions = (width: number, height: number): [number, number] => { + const MAX_WIDTH = 400; + const MAX_HEIGHT = 300; + let targetWidth = width; + let targetHeight = height; + if (targetHeight > MAX_HEIGHT) { + targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); + targetHeight = MAX_HEIGHT; + } + if (targetWidth > MAX_WIDTH) { + targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); + targetWidth = MAX_WIDTH; + } + return [targetWidth, targetHeight]; +}; + +export const getThumbnail = ( + img: HTMLImageElement | SVGImageElement | HTMLVideoElement, + width: number, + height: number, + thumbnailMimeType?: string +): Promise => + new Promise((resolve) => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) { + resolve(undefined); + return; + } + context.drawImage(img, 0, 0, width, height); + + canvas.toBlob((thumbnail) => { + resolve(thumbnail ?? undefined); + }, thumbnailMimeType ?? 'image/jpeg'); + }); diff --git a/src/app/utils/key-symbol.ts b/src/app/utils/key-symbol.ts new file mode 100644 index 0000000..7e758fd --- /dev/null +++ b/src/app/utils/key-symbol.ts @@ -0,0 +1,6 @@ +export enum KeySymbol { + Command = '⌘', + Shift = '⇧', + Option = '⌥', + Control = '⌃', +} diff --git a/src/app/utils/keyboard.ts b/src/app/utils/keyboard.ts new file mode 100644 index 0000000..56eeb9f --- /dev/null +++ b/src/app/utils/keyboard.ts @@ -0,0 +1,25 @@ +import isHotkey from 'is-hotkey'; +import { KeyboardEventHandler } from 'react'; + +export interface KeyboardEventLike { + key: string; + which: number; + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; + preventDefault(): void; +} + +export const onTabPress = (evt: KeyboardEventLike, callback: () => void) => { + if (isHotkey('tab', evt)) { + evt.preventDefault(); + callback(); + } +}; + +export const preventScrollWithArrowKey: KeyboardEventHandler = (evt) => { + if (isHotkey(['arrowup', 'arrowright', 'arrowdown', 'arrowleft'], evt)) { + evt.preventDefault(); + } +}; diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts new file mode 100644 index 0000000..7f2fc0f --- /dev/null +++ b/src/app/utils/matrix.ts @@ -0,0 +1,118 @@ +import { EncryptedAttachmentInfo, encryptAttachment } from 'browser-encrypt-attachment'; +import { MatrixClient, MatrixError, UploadProgress, UploadResponse } from 'matrix-js-sdk'; +import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; + +export const matchMxId = (id: string): RegExpMatchArray | null => + id.match(/^([@!$+#])(\S+):(\S+)$/); + +export const validMxId = (id: string): boolean => !!matchMxId(id); + +export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3]; + +export const getMxIdLocalPart = (userId: string): string | undefined => matchMxId(userId)?.[2]; + +export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@'); + +export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => { + const info: IImageInfo = {}; + info.w = img.width; + info.h = img.height; + info.mimetype = fileOrBlob.type; + info.size = fileOrBlob.size; + return info; +}; + +export const getVideoInfo = (video: HTMLVideoElement, fileOrBlob: File | Blob): IVideoInfo => { + const info: IVideoInfo = {}; + info.duration = Number.isNaN(video.duration) ? undefined : video.duration; + info.w = video.videoWidth; + info.h = video.videoHeight; + info.mimetype = fileOrBlob.type; + info.size = fileOrBlob.size; + return info; +}; + +export const getThumbnailContent = (thumbnailInfo: { + thumbnail: File | Blob; + encInfo: EncryptedAttachmentInfo | undefined; + mxc: string; + width: number; + height: number; +}): IThumbnailContent => { + const { thumbnail, encInfo, mxc, width, height } = thumbnailInfo; + + const content: IThumbnailContent = { + thumbnail_info: { + mimetype: thumbnail.type, + size: thumbnail.size, + w: width, + h: height, + }, + }; + if (encInfo) { + content.thumbnail_file = { + ...encInfo, + url: mxc, + }; + } else { + content.thumbnail_url = mxc; + } + return content; +}; + +export const encryptFile = async ( + file: File | Blob +): Promise<{ + encInfo: EncryptedAttachmentInfo; + file: File; + originalFile: File | Blob; +}> => { + const dataBuffer = await file.arrayBuffer(); + const encryptedAttachment = await encryptAttachment(dataBuffer); + const encFile = new File([encryptedAttachment.data], file.name, { + type: file.type, + }); + return { + encInfo: encryptedAttachment.info, + file: encFile, + originalFile: file, + }; +}; + +export type TUploadContent = File | Blob; + +export type ContentUploadOptions = { + name?: string; + fileType?: string; + hideFilename?: boolean; + onPromise?: (promise: Promise) => void; + onProgress?: (progress: UploadProgress) => void; + onSuccess: (mxc: string) => void; + onError: (error: MatrixError) => void; +}; + +export const uploadContent = async ( + mx: MatrixClient, + file: TUploadContent, + options: ContentUploadOptions +) => { + const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; + + const uploadPromise = mx.uploadContent(file, { + name, + type: fileType, + includeFilename: !hideFilename, + progressHandler: onProgress, + }); + onPromise?.(uploadPromise); + try { + const data = await uploadPromise; + const mxc = data.content_uri; + if (mxc) onSuccess(mxc); + else onError(new MatrixError(data)); + } catch (e: any) { + const error = typeof e?.message === 'string' ? e.message : undefined; + const errcode = typeof e?.name === 'string' ? e.message : undefined; + onError(new MatrixError({ error, errcode })); + } +}; diff --git a/src/app/utils/mimeTypes.ts b/src/app/utils/mimeTypes.ts new file mode 100644 index 0000000..c432bdc --- /dev/null +++ b/src/app/utils/mimeTypes.ts @@ -0,0 +1,47 @@ +// https://github.com/matrix-org/matrix-react-sdk/blob/cd15e08fc285da42134817cce50de8011809cd53/src/utils/blobs.ts +export const ALLOWED_BLOB_MIMETYPES = [ + 'image/jpeg', + 'image/gif', + 'image/png', + 'image/apng', + 'image/webp', + 'image/avif', + + 'video/mp4', + 'video/webm', + 'video/ogg', + 'video/quicktime', + + 'audio/mp4', + 'audio/webm', + 'audio/aac', + 'audio/mpeg', + 'audio/ogg', + 'audio/wave', + 'audio/wav', + 'audio/x-wav', + 'audio/x-pn-wav', + 'audio/flac', + 'audio/x-flac', +]; + +export const getBlobSafeMimeType = (mimeType: string) => { + if (typeof mimeType !== 'string') return 'application/octet-stream'; + const [type] = mimeType.split(';'); + if (!ALLOWED_BLOB_MIMETYPES.includes(type)) { + return 'application/octet-stream'; + } + // Required for Chromium browsers + if (type === 'video/quicktime') { + return 'video/mp4'; + } + return type; +}; + +export const safeFile = (f: File) => { + const safeType = getBlobSafeMimeType(f.type); + if (safeType !== f.type) { + return new File([f], f.name, { type: safeType }); + } + return f; +}; diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts new file mode 100644 index 0000000..daf9560 --- /dev/null +++ b/src/app/utils/room.ts @@ -0,0 +1,265 @@ +import { IconName, IconSrc } from 'folds'; + +import { + IPushRule, + IPushRules, + JoinRule, + MatrixClient, + MatrixEvent, + NotificationCountType, + Room, +} from 'matrix-js-sdk'; +import { AccountDataEvent } from '../../types/matrix/accountData'; +import { + NotificationType, + RoomToParents, + RoomType, + StateEvent, + UnreadInfo, +} from '../../types/matrix/room'; + +export const getStateEvent = ( + room: Room, + eventType: StateEvent, + stateKey = '' +): MatrixEvent | undefined => room.currentState.getStateEvents(eventType, stateKey) ?? undefined; + +export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[] => + room.currentState.getStateEvents(eventType); + +export const getAccountData = ( + mx: MatrixClient, + eventType: AccountDataEvent +): MatrixEvent | undefined => mx.getAccountData(eventType); + +export const getMDirects = (mDirectEvent: MatrixEvent): Set => { + const roomIds = new Set(); + const userIdToDirects = mDirectEvent?.getContent(); + + if (userIdToDirects === undefined) return roomIds; + + Object.keys(userIdToDirects).forEach((userId) => { + const directs = userIdToDirects[userId]; + if (Array.isArray(directs)) { + directs.forEach((id) => { + if (typeof id === 'string') roomIds.add(id); + }); + } + }); + + return roomIds; +}; + +export const isDirectInvite = (room: Room | null, myUserId: string | null): boolean => { + if (!room || !myUserId) return false; + const me = room.getMember(myUserId); + const memberEvent = me?.events?.member; + const content = memberEvent?.getContent(); + return content?.is_direct === true; +}; + +export const isSpace = (room: Room | null): boolean => { + if (!room) return false; + const event = getStateEvent(room, StateEvent.RoomCreate); + if (!event) return false; + return event.getContent().type === RoomType.Space; +}; + +export const isRoom = (room: Room | null): boolean => { + if (!room) return false; + const event = getStateEvent(room, StateEvent.RoomCreate); + if (!event) return false; + return event.getContent().type === undefined; +}; + +export const isUnsupportedRoom = (room: Room | null): boolean => { + if (!room) return false; + const event = getStateEvent(room, StateEvent.RoomCreate); + if (!event) return true; // Consider room unsupported if m.room.create event doesn't exist + return event.getContent().type !== undefined && event.getContent().type !== RoomType.Space; +}; + +export function isValidChild(mEvent: MatrixEvent): boolean { + return mEvent.getType() === StateEvent.SpaceChild && Object.keys(mEvent.getContent()).length > 0; +} + +export const getAllParents = (roomToParents: RoomToParents, roomId: string): Set => { + const allParents = new Set(); + + const addAllParentIds = (rId: string) => { + if (allParents.has(rId)) return; + allParents.add(rId); + + const parents = roomToParents.get(rId); + parents?.forEach((id) => addAllParentIds(id)); + }; + addAllParentIds(roomId); + allParents.delete(roomId); + return allParents; +}; + +export const getSpaceChildren = (room: Room) => + getStateEvents(room, StateEvent.SpaceChild).reduce((filtered, mEvent) => { + const stateKey = mEvent.getStateKey(); + if (isValidChild(mEvent) && stateKey) { + filtered.push(stateKey); + } + return filtered; + }, []); + +export const mapParentWithChildren = ( + roomToParents: RoomToParents, + roomId: string, + children: string[] +) => { + const allParents = getAllParents(roomToParents, roomId); + children.forEach((childId) => { + if (allParents.has(childId)) { + // Space cycle detected. + return; + } + const parents = roomToParents.get(childId) ?? new Set(); + parents.add(roomId); + roomToParents.set(childId, parents); + }); +}; + +export const getRoomToParents = (mx: MatrixClient): RoomToParents => { + const map: RoomToParents = new Map(); + mx.getRooms() + .filter((room) => isSpace(room)) + .forEach((room) => mapParentWithChildren(map, room.roomId, getSpaceChildren(room))); + + return map; +}; + +export const isMutedRule = (rule: IPushRule) => + rule.actions[0] === 'dont_notify' && rule.conditions?.[0]?.kind === 'event_match'; + +export const findMutedRule = (overrideRules: IPushRule[], roomId: string) => + overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule)); + +export const getNotificationType = (mx: MatrixClient, roomId: string): NotificationType => { + let roomPushRule: IPushRule | undefined; + try { + roomPushRule = mx.getRoomPushRule('global', roomId); + } catch { + roomPushRule = undefined; + } + + if (!roomPushRule) { + const overrideRules = mx.getAccountData('m.push_rules')?.getContent() + ?.global?.override; + if (!overrideRules) return NotificationType.Default; + + return findMutedRule(overrideRules, roomId) ? NotificationType.Mute : NotificationType.Default; + } + + if (roomPushRule.actions[0] === 'notify') return NotificationType.AllMessages; + return NotificationType.MentionsAndKeywords; +}; + +export const isNotificationEvent = (mEvent: MatrixEvent) => { + const eType = mEvent.getType(); + if ( + ['m.room.create', 'm.room.message', 'm.room.encrypted', 'm.room.member', 'm.sticker'].find( + (type) => type === eType + ) + ) + return false; + if (eType === 'm.room.member') return false; + + if (mEvent.isRedacted()) return false; + if (mEvent.getRelation()?.rel_type === 'm.replace') return false; + + return true; +}; + +export const roomHaveUnread = (mx: MatrixClient, room: Room) => { + const userId = mx.getUserId(); + if (!userId) return false; + const readUpToId = room.getEventReadUpTo(userId); + const liveEvents = room.getLiveTimeline().getEvents(); + + if (liveEvents[liveEvents.length - 1]?.getSender() === userId) { + return false; + } + + for (let i = liveEvents.length - 1; i >= 0; i -= 1) { + const event = liveEvents[i]; + if (!event) return false; + if (event.getId() === readUpToId) return false; + if (isNotificationEvent(event)) return true; + } + return true; +}; + +export const getUnreadInfo = (room: Room): UnreadInfo => { + const total = room.getUnreadNotificationCount(NotificationCountType.Total); + const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight); + return { + roomId: room.roomId, + highlight, + total: highlight > total ? highlight : total, + }; +}; + +export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => { + const unreadInfos = mx.getRooms().reduce((unread, room) => { + if (room.isSpaceRoom()) return unread; + if (room.getMyMembership() !== 'join') return unread; + if (getNotificationType(mx, room.roomId) === NotificationType.Mute) return unread; + + if (roomHaveUnread(mx, room)) { + unread.push(getUnreadInfo(room)); + } + + return unread; + }, []); + return unreadInfos; +}; + +export const joinRuleToIconSrc = ( + icons: Record, + joinRule: JoinRule, + space: boolean +): IconSrc | undefined => { + if (joinRule === JoinRule.Restricted) { + return space ? icons.Space : icons.Hash; + } + if (joinRule === JoinRule.Knock) { + return space ? icons.SpaceLock : icons.HashLock; + } + if (joinRule === JoinRule.Invite) { + return space ? icons.SpaceLock : icons.HashLock; + } + if (joinRule === JoinRule.Public) { + return space ? icons.SpaceGlobe : icons.HashGlobe; + } + return undefined; +}; + +export const getRoomAvatarUrl = (mx: MatrixClient, room: Room): string | undefined => { + const url = + room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 32, 32, 'crop', undefined, false) ?? + undefined; + if (url) return url; + return room.getAvatarUrl(mx.baseUrl, 32, 32, 'crop') ?? undefined; +}; + +export const parseReplyBody = (userId: string, body: string) => + `> <${userId}> ${body.replace(/\n/g, '\n> ')}\n\n`; + +export const parseReplyFormattedBody = ( + roomId: string, + userId: string, + eventId: string, + formattedBody: string +): string => { + const replyToLink = `In reply to`; + const userLink = `${userId}`; + + return `

    ${replyToLink}${userLink}
    ${formattedBody}
    `; +}; diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts new file mode 100644 index 0000000..555089d --- /dev/null +++ b/src/app/utils/sanitize.ts @@ -0,0 +1,10 @@ +export const sanitizeText = (body: string) => { + const tagsToReplace: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return body.replace(/[&<>'"]/g, (tag) => tagsToReplace[tag] || tag); +}; diff --git a/src/app/utils/user-agent.ts b/src/app/utils/user-agent.ts new file mode 100644 index 0000000..61a903f --- /dev/null +++ b/src/app/utils/user-agent.ts @@ -0,0 +1,5 @@ +import { UAParser } from 'ua-parser-js'; + +export const ua = () => UAParser(window.navigator.userAgent); + +export const isMacOS = () => ua().os.name === 'Mac OS'; diff --git a/src/client/initMatrix.js b/src/client/initMatrix.js index 420f315..9b8d1d8 100644 --- a/src/client/initMatrix.js +++ b/src/client/initMatrix.js @@ -23,6 +23,11 @@ class InitMatrix extends EventEmitter { } async init() { + if (this.matrixClient) { + console.warn('Client is already initialized!') + return; + } + await this.startClient(); this.setupSync(); this.listenEvents(); diff --git a/src/client/mx.ts b/src/client/mx.ts new file mode 100644 index 0000000..3090945 --- /dev/null +++ b/src/client/mx.ts @@ -0,0 +1,7 @@ +import { MatrixClient } from 'matrix-js-sdk'; +import initMatrix from './initMatrix'; + +export const mx = (): MatrixClient => { + if (!initMatrix.matrixClient) console.error('Matrix client is used before initialization!'); + return initMatrix.matrixClient!; +}; diff --git a/src/client/state/RoomList.js b/src/client/state/RoomList.js index a157048..fc137ae 100644 --- a/src/client/state/RoomList.js +++ b/src/client/state/RoomList.js @@ -220,12 +220,6 @@ class RoomList extends EventEmitter { this.inviteRooms.clear(); this.matrixClient.getRooms().forEach((room) => { const { roomId } = room; - const tombstone = room.currentState.events.get('m.room.tombstone'); - if (tombstone?.get('') !== undefined) { - const repRoomId = tombstone.get('').getContent().replacement_room; - const repRoomMembership = this.matrixClient.getRoom(repRoomId)?.getMyMembership(); - if (repRoomMembership === 'join') return; - } if (room.getMyMembership() === 'invite') { if (this._isDMInvite(room)) this.inviteDirects.add(roomId); diff --git a/src/client/state/settings.js b/src/client/state/settings.js index 32f55fc..af2e279 100644 --- a/src/client/state/settings.js +++ b/src/client/state/settings.js @@ -1,7 +1,9 @@ +import { lightTheme } from 'folds'; import EventEmitter from 'events'; import appDispatcher from '../dispatcher'; import cons from './cons'; +import { darkTheme, butterTheme, silverTheme } from '../../colors.css'; function getSettings() { const settings = localStorage.getItem('settings'); @@ -20,6 +22,7 @@ class Settings extends EventEmitter { constructor() { super(); + this.themeClasses = [lightTheme, silverTheme, darkTheme, butterTheme]; this.themes = ['', 'silver-theme', 'dark-theme', 'butter-theme']; this.themeIndex = this.getThemeIndex(); @@ -31,6 +34,10 @@ class Settings extends EventEmitter { this._showNotifications = this.getShowNotifications(); this.isNotificationSounds = this.getIsNotificationSounds(); + this.darkModeQueryList = window.matchMedia('(prefers-color-scheme: dark)'); + + this.darkModeQueryList.addEventListener('change', () => this.applyTheme()) + this.isTouchScreenDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0); } @@ -49,20 +56,19 @@ class Settings extends EventEmitter { } _clearTheme() { - document.body.classList.remove('system-theme'); - this.themes.forEach((themeName) => { - if (themeName === '') return; - document.body.classList.remove(themeName); + this.themes.forEach((themeName, index) => { + if (themeName !== '') document.body.classList.remove(themeName); + document.body.classList.remove(this.themeClasses[index]); }); } applyTheme() { this._clearTheme(); - if (this.useSystemTheme) { - document.body.classList.add('system-theme'); - } else if (this.themes[this.themeIndex]) { - document.body.classList.add(this.themes[this.themeIndex]); - } + const autoThemeIndex = this.darkModeQueryList.matches ? 2 : 0; + const themeIndex = this.useSystemTheme ? autoThemeIndex : this.themeIndex; + if (this.themes[themeIndex] === undefined) return + if (this.themes[themeIndex]) document.body.classList.add(this.themes[themeIndex]); + document.body.classList.add(this.themeClasses[themeIndex]); } setTheme(themeIndex) { diff --git a/src/colors.css.ts b/src/colors.css.ts new file mode 100644 index 0000000..9b854be --- /dev/null +++ b/src/colors.css.ts @@ -0,0 +1,238 @@ +import { createTheme } from '@vanilla-extract/css'; +import { color } from 'folds'; + +export const silverTheme = createTheme(color, { + Background: { + Container: '#E6E6E6', + ContainerHover: '#DADADA', + ContainerActive: '#CECECE', + ContainerLine: '#C2C2C2', + OnContainer: '#000000', + }, + + Surface: { + Container: '#F2F2F2', + ContainerHover: '#E6E6E6', + ContainerActive: '#DADADA', + ContainerLine: '#CECECE', + OnContainer: '#000000', + }, + + SurfaceVariant: { + Container: '#E6E6E6', + ContainerHover: '#DADADA', + ContainerActive: '#CECECE', + ContainerLine: '#C2C2C2', + OnContainer: '#000000', + }, + + Primary: { + Main: '#1858D5', + MainHover: '#164FC0', + MainActive: '#144BB5', + MainLine: '#1346AA', + OnMain: '#FFFFFF', + Container: '#E8EEFB', + ContainerHover: '#DCE6F9', + ContainerActive: '#D1DEF7', + ContainerLine: '#C5D5F5', + OnContainer: '#113E95', + }, + + Secondary: { + Main: '#000000', + MainHover: '#0C0C0C', + MainActive: '#181818', + MainLine: '#303030', + OnMain: '#F2F2F2', + Container: '#CECECE', + ContainerHover: '#C2C2C2', + ContainerActive: '#B5B5B5', + ContainerLine: '#A9A9A9', + OnContainer: '#0C0C0C', + }, + + Success: { + Main: '#00844C', + MainHover: '#007744', + MainActive: '#007041', + MainLine: '#006A3D', + OnMain: '#FFFFFF', + Container: '#E5F3ED', + ContainerHover: '#D9EDE4', + ContainerActive: '#CCE6DB', + ContainerLine: '#BFE0D2', + OnContainer: '#005C35', + }, + + Warning: { + Main: '#A85400', + MainHover: '#974C00', + MainActive: '#8F4700', + MainLine: '#864300', + OnMain: '#FFFFFF', + Container: '#F6EEE5', + ContainerHover: '#F2E5D9', + ContainerActive: '#EEDDCC', + ContainerLine: '#E9D4BF', + OnContainer: '#763B00', + }, + + Critical: { + Main: '#C40E0E', + MainHover: '#AC0909', + MainActive: '#A60C0C', + MainLine: '#9C0B0B', + OnMain: '#FFFFFF', + Container: '#F9E7E7', + ContainerHover: '#F6DBDB', + ContainerActive: '#F3CFCF', + ContainerLine: '#F0C3C3', + OnContainer: '#890A0A', + }, + + Other: { + FocusRing: 'rgba(0 0 0 / 50%)', + Shadow: 'rgba(0 0 0 / 20%)', + Overlay: 'rgba(0 0 0 / 50%)', + }, +}); + +const darkThemeData = { + Background: { + Container: '#15171A', + ContainerHover: '#1F2326', + ContainerActive: '#2A2E33', + ContainerLine: '#343A40', + OnContainer: '#ffffff', + }, + + Surface: { + Container: '#1F2326', + ContainerHover: '#2A2E33', + ContainerActive: '#343A40', + ContainerLine: '#3F464D', + OnContainer: '#ffffff', + }, + + SurfaceVariant: { + Container: '#2A2E33', + ContainerHover: '#343A40', + ContainerActive: '#3F464D', + ContainerLine: '#495159', + OnContainer: '#ffffff', + }, + + Primary: { + Main: '#BDB6EC', + MainHover: '#B2AAE9', + MainActive: '#ADA3E8', + MainLine: '#A79DE6', + OnMain: '#2C2843', + Container: '#413C65', + ContainerHover: '#494370', + ContainerActive: '#50497B', + ContainerLine: '#575086', + OnContainer: '#E3E1F7', + }, + + Secondary: { + Main: '#D1E8FF', + MainHover: '#BCD1E5', + MainActive: '#B2C5D9', + MainLine: '#A7BACC', + OnMain: '#15171A', + Container: '#343A40', + ContainerHover: '#3F464D', + ContainerActive: '#495159', + ContainerLine: '#545D66', + OnContainer: '#C7DCF2', + }, + + Success: { + Main: '#85E0BA', + MainHover: '#70DBAF', + MainActive: '#66D9A9', + MainLine: '#5CD6A3', + OnMain: '#0F3D2A', + Container: '#175C3F', + ContainerHover: '#1A6646', + ContainerActive: '#1C704D', + ContainerLine: '#1F7A54', + OnContainer: '#CCF2E2', + }, + + Warning: { + Main: '#E3BA91', + MainHover: '#DFAF7E', + MainActive: '#DDA975', + MainLine: '#DAA36C', + OnMain: '#3F2A15', + Container: '#5E3F20', + ContainerHover: '#694624', + ContainerActive: '#734D27', + ContainerLine: '#7D542B', + OnContainer: '#F3E2D1', + }, + + Critical: { + Main: '#E69D9D', + MainHover: '#E28D8D', + MainActive: '#E08585', + MainLine: '#DE7D7D', + OnMain: '#401C1C', + Container: '#602929', + ContainerHover: '#6B2E2E', + ContainerActive: '#763333', + ContainerLine: '#803737', + OnContainer: '#F5D6D6', + }, + + Other: { + FocusRing: 'rgba(255, 255, 255, 0.5)', + Shadow: 'rgba(0, 0, 0, 1)', + Overlay: 'rgba(0, 0, 0, 0.6)', + }, +}; + +export const darkTheme = createTheme(color, darkThemeData); + +export const butterTheme = createTheme(color, { + ...darkThemeData, + Background: { + Container: '#1A1916', + ContainerHover: '#262621', + ContainerActive: '#33322C', + ContainerLine: '#403F38', + OnContainer: '#FFFBDE', + }, + + Surface: { + Container: '#262621', + ContainerHover: '#33322C', + ContainerActive: '#403F38', + ContainerLine: '#4D4B43', + OnContainer: '#FFFBDE', + }, + + SurfaceVariant: { + Container: '#33322C', + ContainerHover: '#403F38', + ContainerActive: '#4D4B43', + ContainerLine: '#59584E', + OnContainer: '#FFFBDE', + }, + + Secondary: { + Main: '#FFFBDE', + MainHover: '#E5E2C8', + MainActive: '#D9D5BD', + MainLine: '#CCC9B2', + OnMain: '#1A1916', + Container: '#403F38', + ContainerHover: '#4D4B43', + ContainerActive: '#59584E', + ContainerLine: '#666459', + OnContainer: '#F2EED3', + }, +}); diff --git a/src/ext.d.ts b/src/ext.d.ts new file mode 100644 index 0000000..55f5932 --- /dev/null +++ b/src/ext.d.ts @@ -0,0 +1,23 @@ +declare module 'browser-encrypt-attachment' { + export interface EncryptedAttachmentInfo { + v: string; + key: { + alg: string; + key_ops: string[]; + kty: string; + k: string; + ext: boolean; + }; + iv: string; + hashes: { + [alg: string]: string; + }; + } + + export interface EncryptedAttachment { + data: ArrayBuffer; + info: EncryptedAttachmentInfo; + } + + export function encryptAttachment(dataBuffer: ArrayBuffer): Promise; +} diff --git a/src/index.jsx b/src/index.jsx index a252f6f..e7256e2 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,5 +1,13 @@ +/* eslint-disable import/first */ import React from 'react'; import ReactDom from 'react-dom'; +import { enableMapSet } from 'immer'; +import '@fontsource/inter/variable.css'; +import 'folds/dist/style.css'; +import { configClass, varsClass } from 'folds'; + +enableMapSet(); + import './font'; import './index.scss'; @@ -7,6 +15,8 @@ import settings from './client/state/settings'; import App from './app/pages/App'; +document.body.classList.add(configClass, varsClass); + settings.applyTheme(); ReactDom.render(, document.getElementById('root')); diff --git a/src/index.scss b/src/index.scss index 39d0612..93443fe 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,14 +1,20 @@ @use './app/partials/screen'; -:root { +@font-face { + font-family: Twemoji; + src: url('../public/font/Twemoji.Mozilla.v.7.0.woff2'), + url('../public/font/Twemoji.Mozilla.v0.7.0.ttf'); + font-display: swap; +} +:root { /* background color | --bg-[background type]: value */ - --bg-surface: #FFFFFF; - --bg-surface-transparent: #FFFFFF00; - --bg-surface-low: #F6F6F6; - --bg-surface-low-transparent: #F6F6F600; - --bg-surface-extra-low: #F6F6F6; - --bg-surface-extra-low-transparent: #F6F6F600; + --bg-surface: #ffffff; + --bg-surface-transparent: #ffffff00; + --bg-surface-low: #f6f6f6; + --bg-surface-low-transparent: #f6f6f600; + --bg-surface-extra-low: #f6f6f6; + --bg-surface-extra-low-transparent: #f6f6f600; --bg-surface-hover: rgba(0, 0, 0, 3%); --bg-surface-active: rgba(0, 0, 0, 5%); --bg-surface-border: rgba(0, 0, 0, 6%); @@ -22,7 +28,7 @@ --bg-positive-hover: rgba(69, 184, 59, 8%); --bg-positive-active: rgba(69, 184, 59, 15%); --bg-positive-border: rgba(69, 184, 59, 40%); - + --bg-caution: rgb(255, 179, 0); --bg-caution-hover: rgba(255, 179, 0, 8%); --bg-caution-active: rgba(255, 179, 0, 15%); @@ -37,18 +43,18 @@ --bg-badge: #989898; --bg-ping: hsla(137deg, 100%, 68%, 40%); --bg-ping-hover: hsla(137deg, 100%, 68%, 50%); - --bg-divider: hsla(0, 0%, 0%, .1); + --bg-divider: hsla(0, 0%, 0%, 0.1); /* text color | --tc-[background type]-[priority]: value */ --tc-surface-high: #000000; --tc-surface-normal: rgba(0, 0, 0, 78%); --tc-surface-normal-low: rgba(0, 0, 0, 60%); --tc-surface-low: rgba(0, 0, 0, 48%); - + --tc-primary-high: #ffffff; --tc-primary-normal: rgba(255, 255, 255, 68%); --tc-primary-low: rgba(255, 255, 255, 40%); - + --tc-positive-high: var(--bg-positive); --tc-positive-normal: rgb(69, 184, 59, 80%); --tc-positive-low: rgb(69, 184, 59, 60%); @@ -56,7 +62,7 @@ --tc-caution-high: var(--bg-caution); --tc-caution-normal: rgb(255, 179, 0, 80%); --tc-caution-low: rgb(255, 179, 0, 60%); - + --tc-danger-high: var(--bg-danger); --tc-danger-normal: rgba(240, 71, 71, 88%); --tc-danger-low: rgba(240, 71, 71, 60%); @@ -66,7 +72,6 @@ --tc-tooltip: white; --tc-badge: white; - /* system icons | --ic-[background type]-[priority]: value */ --ic-surface-high: #272727; --ic-surface-normal: #626262; @@ -102,7 +107,6 @@ --av-small: 36px; --av-extra-small: 24px; - /* shadow and overlay */ --bg-overlay: rgba(0, 0, 0, 20%); --bg-overlay-low: rgba(0, 0, 0, 50%); @@ -124,11 +128,9 @@ --bs-danger-border: inset 0 0 0 1px var(--bg-danger-border); --bs-danger-outline: 0 0 0 2px var(--bg-danger-border); - /* border */ --bo-radius: 8px; - /* font styles: font-size, letter-spacing, line-hight */ --fs-h1: 36px; --ls-h1: -1.5px; @@ -160,7 +162,6 @@ --fw-medium: 500; --fw-bold: 700; - /* spacing | --sp-[space]: value */ --sp-none: 0px; --sp-ultra-tight: 4px; @@ -170,7 +171,6 @@ --sp-loose: 20px; --sp-extra-loose: 32px; - /* other */ --border-width: 1px; --header-height: 54px; @@ -180,7 +180,7 @@ --people-drawer-width: calc(268px - var(--border-width)); --popup-window-drawer-width: 280px; - + @include screen.smallerThan(tabletBreakpoint) { --navigation-drawer-width: calc(240px + var(--border-width)); --people-drawer-width: calc(256px - var(--border-width)); @@ -191,11 +191,11 @@ --fluid-push: cubic-bezier(0, 0.8, 0.67, 0.97); --fluid-slide-down: cubic-bezier(0.02, 0.82, 0.4, 0.96); --fluid-slide-up: cubic-bezier(0.13, 0.56, 0.25, 0.99); - - --font-primary: 'Roboto', sans-serif; - --font-secondary: 'Roboto', sans-serif; -} + --font-emoji: 'Twemoji'; + --font-primary: 'Roboto', var(--font-emoji), sans-serif; + --font-secondary: 'Roboto', var(--font-emoji), sans-serif; +} .silver-theme { /* background color | --bg-[background type]: value */ @@ -207,7 +207,8 @@ --bg-surface-extra-low-transparent: hsla(0, 0%, 91%, 0); } -@mixin dark-mode() { +.dark-theme, +.butter-theme { /* background color | --bg-[background type]: value */ --bg-surface: hsl(208, 8%, 20%); --bg-surface-transparent: hsla(208, 8%, 20%, 0); @@ -228,15 +229,14 @@ --bg-badge: hsl(0, 0%, 75%); --bg-ping: hsla(137deg, 100%, 38%, 40%); --bg-ping-hover: hsla(137deg, 100%, 38%, 50%); - --bg-divider: hsla(0, 0%, 100%, .1); - + --bg-divider: hsla(0, 0%, 100%, 0.1); /* text color | --tc-[background type]-[priority]: value */ --tc-surface-high: rgba(255, 255, 255, 98%); --tc-surface-normal: rgba(255, 255, 255, 94%); --tc-surface-normal-low: rgba(255, 255, 255, 60%); --tc-surface-low: rgba(255, 255, 255, 58%); - + --tc-primary-high: #ffffff; --tc-primary-normal: rgba(255, 255, 255, 0.68); --tc-primary-low: rgba(255, 255, 255, 0.4); @@ -262,7 +262,7 @@ --mx-uc-7: hsl(243, 100%, 74%); --mx-uc-8: hsl(94, 66%, 50%); } - + /* shadow and overlay */ --bg-overlay: rgba(0, 0, 0, 60%); --bg-overlay-low: rgba(0, 0, 0, 80%); @@ -274,7 +274,7 @@ --bs-primary-border: inset 0 0 0 1px var(--bg-primary-border); --bs-primary-outline: 0 0 0 2px var(--bg-primary-border); - + /* font styles: font-size, letter-spacing, line-hight */ --fs-h1: 35.6px; @@ -292,18 +292,7 @@ /* override normal font weight for dark mode */ --fw-normal: 350; - --font-secondary: 'InterVariable', 'Roboto', sans-serif; -} - -.dark-theme, -.butter-theme { - @include dark-mode(); -} - -@media (prefers-color-scheme: dark) { - .system-theme { - @include dark-mode(); - } + --font-secondary: 'InterVariable', 'Roboto', var(--font-emoji), sans-serif; } .butter-theme { @@ -317,14 +306,12 @@ --bg-badge: #c4c1ab; - /* text color | --tc-[background type]-[priority]: value */ --tc-surface-high: rgb(255, 251, 222, 94%); --tc-surface-normal: rgba(255, 251, 222, 94%); - --tc-surface-normal-low: rgba(255, 251, 222, 60%); + --tc-surface-normal-low: rgba(255, 251, 222, 60%); --tc-surface-low: rgba(255, 251, 222, 58%); - /* system icons | --ic-[background type]-[priority]: value */ --ic-surface-high: rgb(255, 251, 222); --ic-surface-normal: rgba(255, 251, 222, 84%); @@ -387,9 +374,11 @@ body { height: 100%; } -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; - -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -webkit-tap-highlight-color: transparent; } a { @@ -428,16 +417,16 @@ button { textarea, input, input[type], -input[type=text], -input[type=username], -input[type=password], -input[type=email], -input[type=checkbox] { +input[type='text'], +input[type='username'], +input[type='password'], +input[type='email'], +input[type='checkbox'] { -webkit-appearance: none; -moz-appearance: none; appearance: none; } -input[type=checkbox] { +input[type='checkbox'] { margin: 0; padding: 0; width: 20px; @@ -451,7 +440,7 @@ input[type=checkbox] { &:checked { background-color: var(--bg-primary); &::before { - content: ""; + content: ''; display: inline-block; width: 12px; height: 6px; @@ -468,11 +457,11 @@ textarea { } .noselect { -webkit-touch-callout: none; /* iOS Safari */ - -webkit-user-select: none; /* Safari */ - -khtml-user-select: none; /* Konqueror HTML */ - -moz-user-select: none; /* Old versions of Firefox */ - -ms-user-select: none; /* Internet Explorer/Edge */ - user-select: none; /* Non-prefixed version, currently + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Old versions of Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */ } @@ -484,4 +473,4 @@ audio:not([controls]) { display: flex; justify-content: center; align-items: center; -} \ No newline at end of file +} diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts new file mode 100644 index 0000000..1078cb3 --- /dev/null +++ b/src/types/matrix/accountData.ts @@ -0,0 +1,12 @@ +export enum AccountDataEvent { + PushRules = 'm.push_rules', + Direct = 'm.direct', + IgnoredUserList = 'm.ignored_user_list', + + CinnySpaces = 'in.cinny.spaces', + + ElementRecentEmoji = 'io.element.recent_emoji', + + PoniesUserEmotes = 'im.ponies.user_emotes', + PoniesEmoteRooms = 'im.ponies.emote_rooms', +} diff --git a/src/types/matrix/common.ts b/src/types/matrix/common.ts new file mode 100644 index 0000000..94a46a9 --- /dev/null +++ b/src/types/matrix/common.ts @@ -0,0 +1,22 @@ +import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; + +export type IImageInfo = { + w?: number; + h?: number; + mimetype?: string; + size?: number; +}; + +export type IVideoInfo = IImageInfo & { + duration?: number; +}; + +export type IEncryptedFile = EncryptedAttachmentInfo & { + url: string; +}; + +export type IThumbnailContent = { + thumbnail_info?: IImageInfo; + thumbnail_file?: IEncryptedFile; + thumbnail_url?: string; +}; diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts new file mode 100644 index 0000000..93e8761 --- /dev/null +++ b/src/types/matrix/room.ts @@ -0,0 +1,61 @@ +export enum Membership { + Invite = 'invite', + Knock = 'knock', + Join = 'join', + Leave = 'leave', + Ban = 'ban', +} + +export enum StateEvent { + RoomCanonicalAlias = 'm.room.canonical_alias', + RoomCreate = 'm.room.create', + RoomJoinRules = 'm.room.join_rules', + RoomMember = 'm.room.member', + RoomThirdPartyInvite = 'm.room.third_party_invite', + RoomPowerLevels = 'm.room.power_levels', + RoomName = 'm.room.name', + RoomTopic = 'm.room.topic', + RoomAvatar = 'm.room.avatar', + RoomPinnedEvents = 'm.room.pinned_events', + RoomEncryption = 'm.room.encryption', + RoomHistoryVisibility = 'm.room.history_visibility', + RoomGuestAccess = 'm.room.guest_access', + RoomServerAcl = 'm.room.server_acl', + RoomTombstone = 'm.room.tombstone', + + SpaceChild = 'm.space.child', + SpaceParent = 'm.space.parent', + + PoniesRoomEmotes = 'im.ponies.room_emotes', +} + +export enum RoomType { + Space = 'm.space', +} + +export enum NotificationType { + Default = 'default', + AllMessages = 'all_messages', + MentionsAndKeywords = 'mentions_and_keywords', + Mute = 'mute', +} + +export type RoomToParents = Map>; +export type RoomToUnread = Map< + string, + { + total: number; + highlight: number; + from: Set | null; + } +>; +export type UnreadInfo = { + roomId: string; + total: number; + highlight: number; +}; + +export type MuteChanges = { + added: string[]; + removed: string[]; +}; diff --git a/src/util/sanitize.js b/src/util/sanitize.js index 79cc041..3723a11 100644 --- a/src/util/sanitize.js +++ b/src/util/sanitize.js @@ -6,7 +6,7 @@ let mx = null; const permittedHtmlTags = [ 'font', 'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', - 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', + 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 's', 'code', 'hr', 'br', 'div', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'caption', 'pre', 'span', 'img', 'details', 'summary', ]; diff --git a/tsconfig.json b/tsconfig.json index e109a97..02eb184 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,9 @@ "compilerOptions": { "sourceMap": true, "jsx": "react", - "target": "ES6", + "target": "ES2016", "allowJs": true, + "strict": true, "esModuleInterop": true, "moduleResolution": "Node", "outDir": "dist", diff --git a/vite.config.js b/vite.config.js index 979e9aa..6a44316 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,6 +2,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { wasm } from '@rollup/plugin-wasm'; import { viteStaticCopy } from 'vite-plugin-static-copy'; +import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill'; import inject from '@rollup/plugin-inject'; import { svgLoader } from './viteSvgLoader'; @@ -37,6 +38,7 @@ export default defineConfig({ }, plugins: [ viteStaticCopy(copyFiles), + vanillaExtractPlugin(), svgLoader(), wasm(), react(),