rules: {
'linebreak-style': 0,
'no-underscore-dangle': 0,
+ "no-shadow": "off",
"import/prefer-default-export": "off",
"import/extensions": "off",
"react-hooks/exhaustive-deps": "error",
"@typescript-eslint/no-unused-vars": "error",
+ "@typescript-eslint/no-shadow": "error"
},
};
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
- <link rel="manifest" href="./manifest.json" />
+ <link rel="manifest" href="./public/manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Cinny" />
<meta name="apple-mobile-web-app-title" content="Cinny" />
"@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",
"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",
"@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",
"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": {
"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"
"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"
},
}
},
"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": {
}
},
"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"
},
"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",
}
},
"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": {
"@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"
}
"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"
"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"
},
"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"
},
}
},
"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"
"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"
}
"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"
},
"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"
},
"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"
}
"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"
}
"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"
"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",
}
},
"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"
},
"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",
"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"
},
}
},
"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",
"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",
"cpu": [
"arm"
],
- "dev": true,
"optional": true,
"os": [
"android"
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"android"
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"android"
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"darwin"
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"darwin"
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"freebsd"
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"freebsd"
"cpu": [
"arm"
],
- "dev": true,
"optional": true,
"os": [
"linux"
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
"cpu": [
"ia32"
],
- "dev": true,
"optional": true,
"os": [
"linux"
"cpu": [
"loong64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
"cpu": [
"mips64el"
],
- "dev": true,
"optional": true,
"os": [
"linux"
"cpu": [
"ppc64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
"cpu": [
"riscv64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
"cpu": [
"s390x"
],
- "dev": true,
"optional": true,
"os": [
"linux"
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"netbsd"
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"openbsd"
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"sunos"
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"win32"
"cpu": [
"ia32"
],
- "dev": true,
"optional": true,
"os": [
"win32"
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"win32"
"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"
"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"
}
"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"
}
"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",
"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",
"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",
"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",
"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",
"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",
"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"
},
"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",
"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",
}
},
"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",
"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",
"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"
}
"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",
"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",
"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",
"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"
},
"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",
"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",
"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",
"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",
"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"
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
- "dev": true,
"engines": {
"node": ">=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"
}
"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",
"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",
"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"
"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",
"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"
}
"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"
}
"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"
}
"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",
"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",
"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",
"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"
},
"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"
},
"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",
"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"
},
"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",
"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",
}
},
"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",
"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"
},
"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"
},
"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"
}
"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",
"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",
"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",
"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"
}
"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",
"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"
},
"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",
"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"
}
}
},
"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",
"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",
}
},
"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",
"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"
},
"@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",
"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",
"@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",
"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"
}
}
--- /dev/null
+import { Dispatch, ReactElement, SetStateAction, useState } from 'react';
+
+type UseStateProviderProps<T> = {
+ initial: T | (() => T);
+ children: (value: T, setter: Dispatch<SetStateAction<T>>) => ReactElement;
+};
+export function UseStateProvider<T>({ initial, children }: UseStateProviderProps<T>) {
+ return children(...useState(initial));
+}
--- /dev/null
+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,
+ },
+]);
--- /dev/null
+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 (
+ <>
+ <IconButton variant="SurfaceVariant" onClick={() => setOpen(!open)}>
+ <Icon src={Icons.BlockQuote} />
+ </IconButton>
+ <Overlay open={open} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setOpen(false),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal size="500">
+ <div style={{ padding: config.space.S400 }}>
+ <CustomEditor
+ editor={editor}
+ placeholder="Send a message..."
+ before={
+ <IconButton variant="SurfaceVariant" size="300" radii="300">
+ <Icon src={Icons.PlusCircle} />
+ </IconButton>
+ }
+ after={
+ <>
+ <IconButton
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ onClick={() => setToolbar(!toolbar)}
+ aria-pressed={toolbar}
+ >
+ <Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
+ </IconButton>
+ <IconButton variant="SurfaceVariant" size="300" radii="300">
+ <Icon src={Icons.Smile} />
+ </IconButton>
+ <IconButton variant="SurfaceVariant" size="300" radii="300">
+ <Icon src={Icons.Send} />
+ </IconButton>
+ </>
+ }
+ bottom={
+ toolbar && (
+ <div>
+ <Line variant="SurfaceVariant" size="300" />
+ <Toolbar />
+ </div>
+ )
+ }
+ />
+ </div>
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ </>
+ );
+}
--- /dev/null
+/* 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<HTMLDivElement, CustomEditorProps>(
+ (
+ {
+ top,
+ bottom,
+ before,
+ after,
+ maxHeight = '50vh',
+ editor,
+ placeholder,
+ onKeyDown,
+ onChange,
+ onPaste,
+ },
+ ref
+ ) => {
+ const renderElement = useCallback(
+ (props: RenderElementProps) => <RenderElement {...props} />,
+ []
+ );
+
+ const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
+
+ 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 (
+ <Text as="span" {...props} className={css.EditorPlaceholder} contentEditable={false}>
+ {children}
+ </Text>
+ );
+ }, []);
+
+ return (
+ <div className={css.Editor} ref={ref}>
+ <Slate editor={editor} value={initialValue} onChange={onChange}>
+ {top}
+ <Box alignItems="Start">
+ {before && (
+ <Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
+ {before}
+ </Box>
+ )}
+ <Scroll
+ className={css.EditorTextareaScroll}
+ variant="SurfaceVariant"
+ style={{ maxHeight }}
+ size="300"
+ visibility="Hover"
+ hideTrack
+ >
+ <Editable
+ className={css.EditorTextarea}
+ placeholder={placeholder}
+ renderPlaceholder={renderPlaceholder}
+ renderElement={renderElement}
+ renderLeaf={renderLeaf}
+ onKeyDown={handleKeydown}
+ onPaste={onPaste}
+ />
+ </Scroll>
+ {after && (
+ <Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
+ {after}
+ </Box>
+ )}
+ </Box>
+ {bottom}
+ </Slate>
+ </div>
+ );
+ }
+);
--- /dev/null
+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',
+ },
+]);
--- /dev/null
+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 (
+ <span className={css.InlineChromiumBugfix} contentEditable={false}>
+ {String.fromCodePoint(160) /* Non-breaking space */}
+ </span>
+ );
+}
+
+function RenderMentionElement({
+ attributes,
+ element,
+ children,
+}: { element: MentionElement } & RenderElementProps) {
+ const selected = useSelected();
+ const focused = useFocused();
+
+ return (
+ <span
+ {...attributes}
+ className={css.Mention({
+ highlight: element.highlight,
+ focus: selected && focused,
+ })}
+ contentEditable={false}
+ >
+ {element.name}
+ {children}
+ </span>
+ );
+}
+
+function RenderEmoticonElement({
+ attributes,
+ element,
+ children,
+}: { element: EmoticonElement } & RenderElementProps) {
+ const mx = useMatrixClient();
+ const selected = useSelected();
+ const focused = useFocused();
+
+ return (
+ <span className={css.EmoticonBase} {...attributes}>
+ <span
+ className={css.Emoticon({
+ focus: selected && focused,
+ })}
+ contentEditable={false}
+ >
+ {element.key.startsWith('mxc://') ? (
+ <img
+ className={css.EmoticonImg}
+ src={mx.mxcUrlToHttp(element.key) ?? element.key}
+ alt={element.shortcode}
+ />
+ ) : (
+ element.key
+ )}
+ {children}
+ </span>
+ </span>
+ );
+}
+
+function RenderLinkElement({
+ attributes,
+ element,
+ children,
+}: { element: LinkElement } & RenderElementProps) {
+ return (
+ <a href={element.href} {...attributes}>
+ <InlineChromiumBugfix />
+ {children}
+ </a>
+ );
+}
+
+export function RenderElement({ attributes, element, children }: RenderElementProps) {
+ switch (element.type) {
+ case BlockType.Paragraph:
+ return (
+ <Text {...attributes} className={css.Paragraph}>
+ {children}
+ </Text>
+ );
+ case BlockType.Heading:
+ if (element.level === 1)
+ return (
+ <Text className={css.Heading} as="h2" size="H2" {...attributes}>
+ {children}
+ </Text>
+ );
+ if (element.level === 2)
+ return (
+ <Text className={css.Heading} as="h3" size="H3" {...attributes}>
+ {children}
+ </Text>
+ );
+ if (element.level === 3)
+ return (
+ <Text className={css.Heading} as="h4" size="H4" {...attributes}>
+ {children}
+ </Text>
+ );
+ return (
+ <Text className={css.Heading} as="h3" size="H3" {...attributes}>
+ {children}
+ </Text>
+ );
+ case BlockType.CodeLine:
+ return <div {...attributes}>{children}</div>;
+ case BlockType.CodeBlock:
+ return (
+ <Text as="pre" className={css.CodeBlock} {...attributes}>
+ <Scroll direction="Horizontal" variant="Warning" size="300" visibility="Hover" hideTrack>
+ <div className={css.CodeBlockInternal}>{children}</div>
+ </Scroll>
+ </Text>
+ );
+ case BlockType.QuoteLine:
+ return <div {...attributes}>{children}</div>;
+ case BlockType.BlockQuote:
+ return (
+ <Text as="blockquote" className={css.BlockQuote} {...attributes}>
+ {children}
+ </Text>
+ );
+ case BlockType.ListItem:
+ return (
+ <Text as="li" {...attributes}>
+ {children}
+ </Text>
+ );
+ case BlockType.OrderedList:
+ return (
+ <ol className={css.List} {...attributes}>
+ {children}
+ </ol>
+ );
+ case BlockType.UnorderedList:
+ return (
+ <ul className={css.List} {...attributes}>
+ {children}
+ </ul>
+ );
+ case BlockType.Mention:
+ return (
+ <RenderMentionElement attributes={attributes} element={element}>
+ {children}
+ </RenderMentionElement>
+ );
+ case BlockType.Emoticon:
+ return (
+ <RenderEmoticonElement attributes={attributes} element={element}>
+ {children}
+ </RenderEmoticonElement>
+ );
+ case BlockType.Link:
+ return (
+ <RenderLinkElement attributes={attributes} element={element}>
+ {children}
+ </RenderLinkElement>
+ );
+ default:
+ return (
+ <Text className={css.Paragraph} {...attributes}>
+ {children}
+ </Text>
+ );
+ }
+}
+
+export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) {
+ let child = children;
+ if (leaf.bold)
+ child = (
+ <strong {...attributes}>
+ <InlineChromiumBugfix />
+ {child}
+ </strong>
+ );
+ if (leaf.italic)
+ child = (
+ <i {...attributes}>
+ <InlineChromiumBugfix />
+ {child}
+ </i>
+ );
+ if (leaf.underline)
+ child = (
+ <u {...attributes}>
+ <InlineChromiumBugfix />
+ {child}
+ </u>
+ );
+ if (leaf.strikeThrough)
+ child = (
+ <s {...attributes}>
+ <InlineChromiumBugfix />
+ {child}
+ </s>
+ );
+ if (leaf.code)
+ child = (
+ <code className={css.Code} {...attributes}>
+ <InlineChromiumBugfix />
+ {child}
+ </code>
+ );
+ if (leaf.spoiler)
+ child = (
+ <span className={css.Spoiler} {...attributes}>
+ <InlineChromiumBugfix />
+ {child}
+ </span>
+ );
+
+ if (child !== children) return child;
+
+ return <span {...attributes}>{child}</span>;
+}
--- /dev/null
+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 (
+ <Tooltip style={{ padding: config.space.S300 }}>
+ <Box gap="200" direction="Column" alignItems="Center">
+ <Text align="Center">{text}</Text>
+ {shortCode && (
+ <Badge as="kbd" radii="300" size="500">
+ <Text size="T200" align="Center">
+ {shortCode}
+ </Text>
+ </Badge>
+ )}
+ </Box>
+ </Tooltip>
+ );
+}
+
+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 (
+ <TooltipProvider tooltip={tooltip} delay={500}>
+ {(triggerRef) => (
+ <IconButton
+ ref={triggerRef}
+ variant="SurfaceVariant"
+ onClick={handleClick}
+ aria-pressed={isMarkActive(editor, format)}
+ size="300"
+ radii="300"
+ >
+ <Icon size="50" src={icon} />
+ </IconButton>
+ )}
+ </TooltipProvider>
+ );
+}
+
+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 (
+ <TooltipProvider tooltip={tooltip} delay={500}>
+ {(triggerRef) => (
+ <IconButton
+ ref={triggerRef}
+ variant="SurfaceVariant"
+ onClick={handleClick}
+ aria-pressed={isBlockActive(editor, format)}
+ size="300"
+ radii="300"
+ >
+ <Icon size="50" src={icon} />
+ </IconButton>
+ )}
+ </TooltipProvider>
+ );
+}
+
+export function HeadingBlockButton() {
+ const editor = useSlate();
+ const [level, setLevel] = useState<HeadingLevel>(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 (
+ <PopOut
+ open={open}
+ align="Start"
+ position="Top"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setOpen(false),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
+ }}
+ >
+ <Menu style={{ padding: config.space.S100 }}>
+ <Box gap="100">
+ <IconButton onClick={() => handleMenuSelect(1)} size="300" radii="300">
+ <Icon size="100" src={Icons.Heading1} />
+ </IconButton>
+ <IconButton onClick={() => handleMenuSelect(2)} size="300" radii="300">
+ <Icon size="100" src={Icons.Heading2} />
+ </IconButton>
+ <IconButton onClick={() => handleMenuSelect(3)} size="300" radii="300">
+ <Icon size="100" src={Icons.Heading3} />
+ </IconButton>
+ </Box>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ {(ref) => (
+ <IconButton
+ style={{ width: 'unset' }}
+ ref={ref}
+ variant="SurfaceVariant"
+ onClick={() => (isActive ? toggleBlock(editor, BlockType.Heading) : setOpen(!open))}
+ aria-pressed={isActive}
+ size="300"
+ radii="300"
+ >
+ <Icon size="50" src={Icons[`Heading${level}`]} />
+ <Icon size="50" src={isActive ? Icons.Cross : Icons.ChevronBottom} />
+ </IconButton>
+ )}
+ </PopOut>
+ );
+}
+
+export function Toolbar() {
+ const editor = useSlate();
+ const allowInline = !isBlockActive(editor, BlockType.CodeBlock);
+ const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
+
+ return (
+ <Box className={css.EditorToolbar} alignItems="Center" gap="300">
+ <Box gap="100">
+ <HeadingBlockButton />
+ <BlockButton
+ format={BlockType.OrderedList}
+ icon={Icons.OrderList}
+ tooltip={
+ <BtnTooltip text="Ordered List" shortCode={`${modKey} + ${KeySymbol.Shift} + 0`} />
+ }
+ />
+ <BlockButton
+ format={BlockType.UnorderedList}
+ icon={Icons.UnorderList}
+ tooltip={
+ <BtnTooltip text="Unordered List" shortCode={`${modKey} + ${KeySymbol.Shift} + 8`} />
+ }
+ />
+ <BlockButton
+ format={BlockType.BlockQuote}
+ icon={Icons.BlockQuote}
+ tooltip={
+ <BtnTooltip text="Block Quote" shortCode={`${modKey} + ${KeySymbol.Shift} + '`} />
+ }
+ />
+ <BlockButton
+ format={BlockType.CodeBlock}
+ icon={Icons.BlockCode}
+ tooltip={
+ <BtnTooltip text="Block Code" shortCode={`${modKey} + ${KeySymbol.Shift} + ;`} />
+ }
+ />
+ </Box>
+ {allowInline && (
+ <>
+ <Line variant="SurfaceVariant" direction="Vertical" style={{ height: toRem(12) }} />
+ <Box gap="100">
+ <MarkButton
+ format={MarkType.Bold}
+ icon={Icons.Bold}
+ tooltip={<BtnTooltip text="Bold" shortCode={`${modKey} + B`} />}
+ />
+ <MarkButton
+ format={MarkType.Italic}
+ icon={Icons.Italic}
+ tooltip={<BtnTooltip text="Italic" shortCode={`${modKey} + I`} />}
+ />
+ <MarkButton
+ format={MarkType.Underline}
+ icon={Icons.Underline}
+ tooltip={<BtnTooltip text="Underline" shortCode={`${modKey} + U`} />}
+ />
+ <MarkButton
+ format={MarkType.StrikeThrough}
+ icon={Icons.Strike}
+ tooltip={
+ <BtnTooltip
+ text="Strike Through"
+ shortCode={`${modKey} + ${KeySymbol.Shift} + U`}
+ />
+ }
+ />
+ <MarkButton
+ format={MarkType.Code}
+ icon={Icons.Code}
+ tooltip={<BtnTooltip text="Inline Code" shortCode={`${modKey} + [`} />}
+ />
+ <MarkButton
+ format={MarkType.Spoiler}
+ icon={Icons.EyeBlind}
+ tooltip={<BtnTooltip text="Spoiler" shortCode={`${modKey} + H`} />}
+ />
+ </Box>
+ </>
+ )}
+ </Box>
+ );
+}
--- /dev/null
+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 },
+]);
--- /dev/null
+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 (
+ <div className={css.AutocompleteMenuBase}>
+ <div className={css.AutocompleteMenuContainer}>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => requestClose(),
+ clickOutsideDeactivates: true,
+ allowOutsideClick: true,
+ isKeyForward: (evt: KeyboardEvent) => isHotkey('arrowdown', evt),
+ isKeyBackward: (evt: KeyboardEvent) => isHotkey('arrowup', evt),
+ }}
+ >
+ <Menu className={css.AutocompleteMenu}>
+ <Header className={css.AutocompleteMenuHeader} size="400">
+ {headerContent}
+ </Header>
+ <Scroll style={{ flexGrow: 1 }} onKeyDown={preventScrollWithArrowKey}>
+ <div style={{ padding: config.space.S200 }}>{children}</div>
+ </Scroll>
+ </Menu>
+ </FocusTrap>
+ </div>
+ </div>
+ );
+}
--- /dev/null
+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<string>;
+ requestClose: () => void;
+};
+
+const SEARCH_OPTIONS: UseAsyncSearchOptions = {
+ limit: 20,
+ matchOptions: {
+ contain: true,
+ },
+};
+
+const getEmoticonStr: SearchItemStrGetter<EmoticonSearchItem> = (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<EmoticonSearchItem> = [];
+ 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 : (
+ <AutocompleteMenu headerContent={<Text size="L400">Emojis</Text>} requestClose={requestClose}>
+ {autoCompleteEmoticon.map((emoticon) => {
+ const isCustomEmoji = 'url' in emoticon;
+ const key = isCustomEmoji ? emoticon.url : emoticon.unicode;
+ return (
+ <MenuItem
+ key={emoticon.shortcode + key}
+ as="button"
+ radii="300"
+ onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
+ onTabPress(evt, () => handleAutocomplete(key, emoticon.shortcode))
+ }
+ onClick={() => handleAutocomplete(key, emoticon.shortcode)}
+ before={
+ isCustomEmoji ? (
+ <Box
+ shrink="No"
+ as="img"
+ src={mx.mxcUrlToHttp(key) || key}
+ alt={emoticon.shortcode}
+ style={{ width: toRem(24), height: toRem(24) }}
+ />
+ ) : (
+ <Box
+ shrink="No"
+ as="span"
+ display="InlineFlex"
+ style={{ fontSize: toRem(24), lineHeight: toRem(24) }}
+ >
+ {key}
+ </Box>
+ )
+ }
+ >
+ <Text style={{ flexGrow: 1 }} size="B400" truncate>
+ :{emoticon.shortcode}:
+ </Text>
+ </MenuItem>
+ );
+ })}
+ </AutocompleteMenu>
+ );
+}
--- /dev/null
+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<string>;
+ handleAutocomplete: MentionAutoCompleteHandler;
+}) {
+ const mx = useMatrixClient();
+ const roomAlias: string = roomAliasFromQueryText(mx, query.text);
+
+ return (
+ <MenuItem
+ as="button"
+ radii="300"
+ onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
+ onTabPress(evt, () => handleAutocomplete(roomAlias, roomAlias))
+ }
+ onClick={() => handleAutocomplete(roomAlias, roomAlias)}
+ before={
+ <Avatar size="200">
+ <Icon src={Icons.Hash} size="100" />
+ </Avatar>
+ }
+ >
+ <Text style={{ flexGrow: 1 }} size="B400">
+ {roomAlias}
+ </Text>
+ </MenuItem>
+ );
+}
+
+type RoomMentionAutocompleteProps = {
+ roomId: string;
+ editor: Editor;
+ query: AutocompleteQuery<string>;
+ 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<string> = 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 (
+ <AutocompleteMenu headerContent={<Text size="L400">Rooms</Text>} requestClose={requestClose}>
+ {autoCompleteRoomIds.length === 0 ? (
+ <UnknownRoomMentionItem query={query} handleAutocomplete={handleAutocomplete} />
+ ) : (
+ 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 (
+ <MenuItem
+ key={rId}
+ as="button"
+ radii="300"
+ onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
+ onTabPress(evt, () => handleAutocomplete(rId, room.name))
+ }
+ onClick={() => handleAutocomplete(rId, room.name)}
+ after={
+ <Text size="T200" priority="300" truncate>
+ {room.getCanonicalAlias() ?? ''}
+ </Text>
+ }
+ before={
+ <Avatar size="200">
+ {iconSrc && <Icon src={iconSrc} size="100" />}
+ {avatarUrl && !iconSrc && <AvatarImage src={avatarUrl} alt={room.name} />}
+ {!avatarUrl && !iconSrc && (
+ <AvatarFallback
+ style={{
+ backgroundColor: color.Secondary.Container,
+ color: color.Secondary.OnContainer,
+ }}
+ >
+ <Text size="H6">{room.name[0]}</Text>
+ </AvatarFallback>
+ )}
+ </Avatar>
+ }
+ >
+ <Text style={{ flexGrow: 1 }} size="B400" truncate>
+ {room.name}
+ </Text>
+ </MenuItem>
+ );
+ })
+ )}
+ </AutocompleteMenu>
+ );
+}
--- /dev/null
+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<string>;
+ userId: string;
+ name: string;
+ handleAutocomplete: MentionAutoCompleteHandler;
+}) {
+ return (
+ <MenuItem
+ as="button"
+ radii="300"
+ onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
+ onTabPress(evt, () => handleAutocomplete(userId, name))
+ }
+ onClick={() => handleAutocomplete(userId, name)}
+ before={
+ <Avatar size="200">
+ <AvatarFallback
+ style={{
+ backgroundColor: color.Secondary.Container,
+ color: color.Secondary.OnContainer,
+ }}
+ >
+ <Text size="H6">{query.text[0]}</Text>
+ </AvatarFallback>
+ </Avatar>
+ }
+ >
+ <Text style={{ flexGrow: 1 }} size="B400">
+ {name}
+ </Text>
+ </MenuItem>
+ );
+}
+
+type UserMentionAutocompleteProps = {
+ roomId: string;
+ editor: Editor;
+ query: AutocompleteQuery<string>;
+ requestClose: () => void;
+};
+
+const SEARCH_OPTIONS: UseAsyncSearchOptions = {
+ limit: 20,
+ matchOptions: {
+ contain: true,
+ },
+};
+
+const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (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 (
+ <AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
+ {query.text === 'room' && (
+ <UnknownMentionItem
+ query={query}
+ userId={roomAliasOrId}
+ name="@room"
+ handleAutocomplete={handleAutocomplete}
+ />
+ )}
+ {autoCompleteMembers.length === 0 ? (
+ <UnknownMentionItem
+ query={query}
+ userId={userIdFromQueryText(mx, query.text)}
+ name={userIdFromQueryText(mx, query.text)}
+ handleAutocomplete={handleAutocomplete}
+ />
+ ) : (
+ autoCompleteMembers.map((roomMember) => {
+ const avatarUrl = roomMember.getAvatarUrl(mx.baseUrl, 32, 32, 'crop', undefined, false);
+ return (
+ <MenuItem
+ key={roomMember.userId}
+ as="button"
+ radii="300"
+ onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
+ onTabPress(evt, () => handleAutocomplete(roomMember.userId, roomMember.name))
+ }
+ onClick={() => handleAutocomplete(roomMember.userId, roomMember.name)}
+ after={
+ <Text size="T200" priority="300" truncate>
+ {roomMember.userId}
+ </Text>
+ }
+ before={
+ <Avatar size="200">
+ {avatarUrl ? (
+ <AvatarImage src={avatarUrl} alt={roomMember.userId} />
+ ) : (
+ <AvatarFallback
+ style={{
+ backgroundColor: color.Secondary.Container,
+ color: color.Secondary.OnContainer,
+ }}
+ >
+ <Text size="H6">{roomMember.name[0] || roomMember.userId[1]}</Text>
+ </AvatarFallback>
+ )}
+ </Avatar>
+ }
+ >
+ <Text style={{ flexGrow: 1 }} size="B400" truncate>
+ {roomMember.name}
+ </Text>
+ </MenuItem>
+ );
+ })
+ )}
+ </AutocompleteMenu>
+ );
+}
--- /dev/null
+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<TPrefix extends string> = {
+ range: BaseRange;
+ prefix: TPrefix;
+ text: string;
+};
+
+export const getAutocompletePrefix = <TPrefix extends string>(
+ 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 = <TPrefix extends string>(
+ editor: Editor,
+ queryRange: BaseRange,
+ validPrefixes: readonly TPrefix[]
+): AutocompleteQuery<TPrefix> | undefined => {
+ const prefix = getAutocompletePrefix(editor, queryRange, validPrefixes);
+ if (!prefix) return undefined;
+ return {
+ range: queryRange,
+ prefix,
+ text: getAutocompleteQueryText(editor, queryRange),
+ };
+};
--- /dev/null
+export * from './AutocompleteMenu';
+export * from './autocompleteQuery';
+export * from './RoomMentionAutocomplete';
+export * from './UserMentionAutocomplete';
+export * from './EmoticonAutocomplete';
--- /dev/null
+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);
+};
--- /dev/null
+export * from './autocomplete';
+export * from './common';
+export * from './Editor';
+export * from './Elements';
+export * from './keyboard';
+export * from './output';
+export * from './Toolbar';
--- /dev/null
+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<string, MarkType> = {
+ '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<string, BlockType> = {
+ '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<Element>) => {
+ 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]);
+ }
+ });
+};
--- /dev/null
+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 = `<strong>${string}</strong>`;
+ if (node.italic) string = `<i>${string}</i>`;
+ if (node.underline) string = `<u>${string}</u>`;
+ if (node.strikeThrough) string = `<s>${string}</s>`;
+ if (node.code) string = `<code>${string}</code>`;
+ if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
+ return string;
+};
+
+const elementToCustomHtml = (node: CustomElement, children: string): string => {
+ switch (node.type) {
+ case BlockType.Paragraph:
+ return `<p>${children}</p>`;
+ case BlockType.Heading:
+ return `<h${node.level}>${children}</h${node.level}>`;
+ case BlockType.CodeLine:
+ return `${children}\n`;
+ case BlockType.CodeBlock:
+ return `<pre><code>${children}</code></pre>`;
+ case BlockType.QuoteLine:
+ return `<p>${children}</p>`;
+ case BlockType.BlockQuote:
+ return `<blockquote>${children}</blockquote>`;
+ case BlockType.ListItem:
+ return `<li><p>${children}</p></li>`;
+ case BlockType.OrderedList:
+ return `<ol>${children}</ol>`;
+ case BlockType.UnorderedList:
+ return `<ul>${children}</ul>`;
+ case BlockType.Mention:
+ return `<a href="https://matrix.to/#/${node.id}">${node.name}</a>`;
+ case BlockType.Emoticon:
+ return node.key.startsWith('mxc://')
+ ? `<img data-mx-emoticon src="${node.key}" alt="${node.shortcode}" title="${node.shortcode}" height="32">`
+ : node.key;
+ case BlockType.Link:
+ return `<a href="${node.href}">${node.children}</a>`;
+ 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);
+};
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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),
+ },
+]);
--- /dev/null
+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<string | undefined>(undefined);
+
+function Sidebar({ children }: { children: ReactNode }) {
+ return (
+ <Box className={css.Sidebar} shrink="No">
+ <Scroll size="0">
+ <Box className={css.SidebarContent} direction="Column" alignItems="Center" gap="100">
+ {children}
+ </Box>
+ </Scroll>
+ </Box>
+ );
+}
+
+const SidebarStack = as<'div'>(({ className, children, ...props }, ref) => (
+ <Box
+ className={classNames(css.SidebarStack, className)}
+ direction="Column"
+ alignItems="Center"
+ gap="100"
+ {...props}
+ ref={ref}
+ >
+ {children}
+ </Box>
+));
+function SidebarDivider() {
+ return <Line className={css.SidebarDivider} size="300" variant="Surface" />;
+}
+
+function Header({ children }: { children: ReactNode }) {
+ return (
+ <Box className={css.Header} direction="Column" shrink="No">
+ {children}
+ </Box>
+ );
+}
+
+function Content({ children }: { children: ReactNode }) {
+ return <Box grow="Yes">{children}</Box>;
+}
+
+function Footer({ children }: { children: ReactNode }) {
+ return (
+ <Box shrink="No" className={css.Footer} gap="300" alignItems="Center">
+ {children}
+ </Box>
+ );
+}
+
+const EmojiBoardLayout = as<
+ 'div',
+ {
+ header: ReactNode;
+ sidebar?: ReactNode;
+ footer?: ReactNode;
+ children: ReactNode;
+ }
+>(({ className, header, sidebar, footer, children, ...props }, ref) => (
+ <Box
+ display="InlineFlex"
+ className={classNames(css.Base, className)}
+ direction="Row"
+ {...props}
+ ref={ref}
+ >
+ <Box direction="Column" grow="Yes">
+ {header}
+ {children}
+ {footer}
+ </Box>
+ <Line size="300" direction="Vertical" />
+ {sidebar}
+ </Box>
+));
+
+function EmojiBoardTabs({
+ tab,
+ onTabChange,
+}: {
+ tab: EmojiBoardTab;
+ onTabChange: (tab: EmojiBoardTab) => void;
+}) {
+ return (
+ <Box gap="100">
+ <Badge
+ className={css.EmojiBoardTab}
+ as="button"
+ variant="Secondary"
+ fill={tab === EmojiBoardTab.Emoji ? 'Solid' : 'None'}
+ size="500"
+ onClick={() => onTabChange(EmojiBoardTab.Emoji)}
+ >
+ <Text as="span" size="L400">
+ Emoji
+ </Text>
+ </Badge>
+ <Badge
+ className={css.EmojiBoardTab}
+ as="button"
+ variant="Secondary"
+ fill={tab === EmojiBoardTab.Sticker ? 'Solid' : 'None'}
+ size="500"
+ onClick={() => onTabChange(EmojiBoardTab.Sticker)}
+ >
+ <Text as="span" size="L400">
+ Sticker
+ </Text>
+ </Badge>
+ </Box>
+ );
+}
+
+export function SidebarBtn<T extends string>({
+ active,
+ label,
+ id,
+ onItemClick,
+ children,
+}: {
+ active?: boolean;
+ label: string;
+ id: T;
+ onItemClick: (id: T) => void;
+ children: ReactNode;
+}) {
+ return (
+ <TooltipProvider
+ delay={500}
+ position="Left"
+ tooltip={
+ <Tooltip id={`SidebarStackItem-${id}-label`}>
+ <Text size="T300">{label}</Text>
+ </Tooltip>
+ }
+ >
+ {(ref) => (
+ <IconButton
+ aria-pressed={active}
+ aria-labelledby={`SidebarStackItem-${id}-label`}
+ ref={ref}
+ onClick={() => onItemClick(id)}
+ size="400"
+ radii="300"
+ variant="Surface"
+ >
+ {children}
+ </IconButton>
+ )}
+ </TooltipProvider>
+ );
+}
+
+export const EmojiGroup = as<
+ 'div',
+ {
+ id: string;
+ label: string;
+ children: ReactNode;
+ }
+>(({ className, id, label, children, ...props }, ref) => (
+ <Box
+ id={getDOMGroupId(id)}
+ data-group-id={id}
+ className={classNames(css.EmojiGroup, className)}
+ direction="Column"
+ gap="200"
+ {...props}
+ ref={ref}
+ >
+ <Text id={`EmojiGroup-${id}-label`} as="label" className={css.EmojiGroupLabel} size="O400">
+ {label}
+ </Text>
+ <div aria-labelledby={`EmojiGroup-${id}-label`} className={css.EmojiGroupContent}>
+ <Box wrap="Wrap" justifyContent="Center">
+ {children}
+ </Box>
+ </div>
+ </Box>
+));
+
+export function EmojiItem({
+ label,
+ type,
+ data,
+ shortcode,
+ children,
+}: {
+ label: string;
+ type: EmojiType;
+ data: string;
+ shortcode: string;
+ children: ReactNode;
+}) {
+ return (
+ <Box
+ as="button"
+ className={css.EmojiItem}
+ type="button"
+ alignItems="Center"
+ justifyContent="Center"
+ title={label}
+ aria-label={`${label} emoji`}
+ data-emoji-type={type}
+ data-emoji-data={data}
+ data-emoji-shortcode={shortcode}
+ >
+ {children}
+ </Box>
+ );
+}
+
+export function StickerItem({
+ label,
+ type,
+ data,
+ shortcode,
+ children,
+}: {
+ label: string;
+ type: EmojiType;
+ data: string;
+ shortcode: string;
+ children: ReactNode;
+}) {
+ return (
+ <Box
+ as="button"
+ className={css.StickerItem}
+ type="button"
+ alignItems="Center"
+ justifyContent="Center"
+ title={label}
+ aria-label={`${label} sticker`}
+ data-emoji-type={type}
+ data-emoji-data={data}
+ data-emoji-shortcode={shortcode}
+ >
+ {children}
+ </Box>
+ );
+}
+
+function RecentEmojiSidebarStack({ onItemClick }: { onItemClick: (id: string) => void }) {
+ const activeGroupId = useAtomValue(activeGroupIdAtom);
+
+ return (
+ <SidebarStack>
+ <SidebarBtn
+ active={activeGroupId === RECENT_GROUP_ID}
+ id={RECENT_GROUP_ID}
+ label="Recent"
+ onItemClick={() => onItemClick(RECENT_GROUP_ID)}
+ >
+ <Icon src={Icons.RecentClock} filled={activeGroupId === RECENT_GROUP_ID} />
+ </SidebarBtn>
+ </SidebarStack>
+ );
+}
+
+function ImagePackSidebarStack({
+ mx,
+ packs,
+ usage,
+ onItemClick,
+}: {
+ mx: MatrixClient;
+ packs: ImagePack[];
+ usage: PackUsage;
+ onItemClick: (id: string) => void;
+}) {
+ const activeGroupId = useAtomValue(activeGroupIdAtom);
+ return (
+ <SidebarStack>
+ {usage === PackUsage.Emoticon && <SidebarDivider />}
+ {packs.map((pack) => {
+ let label = pack.displayName;
+ if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
+ return (
+ <SidebarBtn
+ active={activeGroupId === pack.id}
+ key={pack.id}
+ id={pack.id}
+ label={label || 'Unknown Pack'}
+ onItemClick={onItemClick}
+ >
+ <img
+ style={{
+ width: toRem(24),
+ height: toRem(24),
+ }}
+ src={mx.mxcUrlToHttp(pack.getPackAvatarUrl(usage) ?? '') || pack.avatarUrl}
+ alt={label || 'Unknown Pack'}
+ />
+ </SidebarBtn>
+ );
+ })}
+ </SidebarStack>
+ );
+}
+
+function NativeEmojiSidebarStack({
+ groups,
+ icons,
+ labels,
+ onItemClick,
+}: {
+ groups: IEmojiGroup[];
+ icons: IEmojiGroupIcons;
+ labels: IEmojiGroupLabels;
+ onItemClick: (id: EmojiGroupId) => void;
+}) {
+ const activeGroupId = useAtomValue(activeGroupIdAtom);
+ return (
+ <SidebarStack className={css.NativeEmojiSidebarStack}>
+ <SidebarDivider />
+ {groups.map((group) => (
+ <SidebarBtn
+ key={group.id}
+ active={activeGroupId === group.id}
+ id={group.id}
+ label={labels[group.id]}
+ onItemClick={onItemClick}
+ >
+ <Icon src={icons[group.id]} filled={activeGroupId === group.id} />
+ </SidebarBtn>
+ ))}
+ </SidebarStack>
+ );
+}
+
+export function RecentEmojiGroup({
+ label,
+ id,
+ emojis: recentEmojis,
+}: {
+ label: string;
+ id: string;
+ emojis: IEmoji[];
+}) {
+ return (
+ <EmojiGroup key={id} id={id} label={label}>
+ {recentEmojis.map((emoji) => (
+ <EmojiItem
+ key={emoji.unicode}
+ label={emoji.label}
+ type={EmojiType.Emoji}
+ data={emoji.unicode}
+ shortcode={emoji.shortcode}
+ >
+ {emoji.unicode}
+ </EmojiItem>
+ ))}
+ </EmojiGroup>
+ );
+}
+
+export function SearchEmojiGroup({
+ mx,
+ tab,
+ label,
+ id,
+ emojis: searchResult,
+}: {
+ mx: MatrixClient;
+ tab: EmojiBoardTab;
+ label: string;
+ id: string;
+ emojis: Array<ExtendedPackImage | IEmoji>;
+}) {
+ return (
+ <EmojiGroup key={id} id={id} label={label}>
+ {tab === EmojiBoardTab.Emoji
+ ? searchResult.map((emoji) =>
+ 'unicode' in emoji ? (
+ <EmojiItem
+ key={emoji.unicode}
+ label={emoji.label}
+ type={EmojiType.Emoji}
+ data={emoji.unicode}
+ shortcode={emoji.shortcode}
+ >
+ {emoji.unicode}
+ </EmojiItem>
+ ) : (
+ <EmojiItem
+ key={emoji.shortcode}
+ label={emoji.body || emoji.shortcode}
+ type={EmojiType.CustomEmoji}
+ data={emoji.url}
+ shortcode={emoji.shortcode}
+ >
+ <img
+ loading="lazy"
+ className={css.CustomEmojiImg}
+ alt={emoji.body || emoji.shortcode}
+ src={mx.mxcUrlToHttp(emoji.url) ?? emoji.url}
+ />
+ </EmojiItem>
+ )
+ )
+ : searchResult.map((emoji) =>
+ 'unicode' in emoji ? null : (
+ <StickerItem
+ key={emoji.shortcode}
+ label={emoji.body || emoji.shortcode}
+ type={EmojiType.Sticker}
+ data={emoji.url}
+ shortcode={emoji.shortcode}
+ >
+ <img
+ loading="lazy"
+ className={css.StickerImg}
+ alt={emoji.body || emoji.shortcode}
+ src={mx.mxcUrlToHttp(emoji.url) ?? emoji.url}
+ />
+ </StickerItem>
+ )
+ )}
+ </EmojiGroup>
+ );
+}
+
+export const CustomEmojiGroups = memo(
+ ({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => (
+ <>
+ {groups.map((pack) => (
+ <EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
+ {pack.getEmojis().map((image) => (
+ <EmojiItem
+ key={image.shortcode}
+ label={image.body || image.shortcode}
+ type={EmojiType.CustomEmoji}
+ data={image.url}
+ shortcode={image.shortcode}
+ >
+ <img
+ loading="lazy"
+ className={css.CustomEmojiImg}
+ alt={image.body || image.shortcode}
+ src={mx.mxcUrlToHttp(image.url) ?? image.url}
+ />
+ </EmojiItem>
+ ))}
+ </EmojiGroup>
+ ))}
+ </>
+ )
+);
+
+export const StickerGroups = memo(({ mx, groups }: { mx: MatrixClient; groups: ImagePack[] }) => (
+ <>
+ {groups.length === 0 && (
+ <Box
+ style={{ padding: `${toRem(60)} ${config.space.S500}` }}
+ alignItems="Center"
+ justifyContent="Center"
+ direction="Column"
+ gap="300"
+ >
+ <Icon size="600" src={Icons.Sticker} />
+ <Box direction="Inherit">
+ <Text align="Center">No Sticker Packs!</Text>
+ <Text priority="300" align="Center" size="T200">
+ Add stickers from user, room or space settings.
+ </Text>
+ </Box>
+ </Box>
+ )}
+ {groups.map((pack) => (
+ <EmojiGroup key={pack.id} id={pack.id} label={pack.displayName || 'Unknown'}>
+ {pack.getStickers().map((image) => (
+ <StickerItem
+ key={image.shortcode}
+ label={image.body || image.shortcode}
+ type={EmojiType.Sticker}
+ data={image.url}
+ shortcode={image.shortcode}
+ >
+ <img
+ loading="lazy"
+ className={css.StickerImg}
+ alt={image.body || image.shortcode}
+ src={mx.mxcUrlToHttp(image.url) ?? image.url}
+ />
+ </StickerItem>
+ ))}
+ </EmojiGroup>
+ ))}
+ </>
+));
+
+export const NativeEmojiGroups = memo(
+ ({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => (
+ <>
+ {groups.map((emojiGroup) => (
+ <EmojiGroup key={emojiGroup.id} id={emojiGroup.id} label={labels[emojiGroup.id]}>
+ {emojiGroup.emojis.map((emoji) => (
+ <EmojiItem
+ key={emoji.unicode}
+ label={emoji.label}
+ type={EmojiType.Emoji}
+ data={emoji.unicode}
+ shortcode={emoji.shortcode}
+ >
+ {emoji.unicode}
+ </EmojiItem>
+ ))}
+ </EmojiGroup>
+ ))}
+ </>
+ )
+);
+
+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<HTMLDivElement>(null);
+ const emojiPreviewRef = useRef<HTMLDivElement>(null);
+ const emojiPreviewTextRef = useRef<HTMLParagraphElement>(null);
+
+ const searchList = useMemo(() => {
+ let list: Array<ExtendedPackImage | IEmoji> = [];
+ 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<HTMLInputElement> = 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<HTMLDivElement> = 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 (
+ <FocusTrap
+ focusTrapOptions={{
+ returnFocusOnDeactivate,
+ initialFocus: false,
+ onDeactivate: requestClose,
+ clickOutsideDeactivates: true,
+ allowOutsideClick: true,
+ isKeyForward: (evt: KeyboardEvent) =>
+ !editableActiveElement() && isHotkey(['arrowdown', 'arrowright'], evt),
+ isKeyBackward: (evt: KeyboardEvent) =>
+ !editableActiveElement() && isHotkey(['arrowup', 'arrowleft'], evt),
+ }}
+ >
+ <EmojiBoardLayout
+ header={
+ <Header>
+ <Box direction="Column" gap="200">
+ {onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
+ <Input
+ variant="SurfaceVariant"
+ size="400"
+ placeholder="Search"
+ maxLength={50}
+ after={<Icon src={Icons.Search} size="50" />}
+ onChange={handleOnChange}
+ autoFocus
+ />
+ </Box>
+ </Header>
+ }
+ sidebar={
+ <Sidebar>
+ {emojiTab && recentEmojis.length > 0 && (
+ <RecentEmojiSidebarStack onItemClick={handleScrollToGroup} />
+ )}
+ {imagePacks.length > 0 && (
+ <ImagePackSidebarStack
+ mx={mx}
+ usage={usage}
+ packs={imagePacks}
+ onItemClick={handleScrollToGroup}
+ />
+ )}
+ {emojiTab && (
+ <NativeEmojiSidebarStack
+ groups={emojiGroups}
+ icons={emojiGroupIcons}
+ labels={emojiGroupLabels}
+ onItemClick={handleScrollToGroup}
+ />
+ )}
+ </Sidebar>
+ }
+ footer={
+ emojiTab ? (
+ <Footer>
+ <Box
+ display="InlineFlex"
+ ref={emojiPreviewRef}
+ className={css.EmojiPreview}
+ alignItems="Center"
+ justifyContent="Center"
+ >
+ 😃
+ </Box>
+ <Text ref={emojiPreviewTextRef} size="H5" truncate>
+ :smiley:
+ </Text>
+ </Footer>
+ ) : (
+ imagePacks.length > 0 && (
+ <Footer>
+ <Text ref={emojiPreviewTextRef} size="H5" truncate>
+ :smiley:
+ </Text>
+ </Footer>
+ )
+ )
+ }
+ >
+ <Content>
+ <Scroll
+ ref={contentScrollRef}
+ size="400"
+ onScroll={handleOnScroll}
+ onKeyDown={preventScrollWithArrowKey}
+ hideTrack
+ >
+ <Box
+ onClick={handleEmojiClick}
+ onMouseMove={handleEmojiHover}
+ onFocus={handleEmojiFocus}
+ direction="Column"
+ gap="200"
+ >
+ {result && (
+ <SearchEmojiGroup
+ mx={mx}
+ tab={tab}
+ id={SEARCH_GROUP_ID}
+ label={result.items.length ? 'Search Results' : 'No Results found'}
+ emojis={result.items}
+ />
+ )}
+ {emojiTab && recentEmojis.length > 0 && (
+ <RecentEmojiGroup id={RECENT_GROUP_ID} label="Recent" emojis={recentEmojis} />
+ )}
+ {emojiTab && <CustomEmojiGroups mx={mx} groups={imagePacks} />}
+ {stickerTab && <StickerGroups mx={mx} groups={imagePacks} />}
+ {emojiTab && <NativeEmojiGroups groups={emojiGroups} labels={emojiGroupLabels} />}
+ </Box>
+ </Scroll>
+ </Content>
+ </EmojiBoardLayout>
+ </FocusTrap>
+ );
+}
--- /dev/null
+export * from './EmojiBoard';
--- /dev/null
+import { useMemo } from 'react';
+import { IconSrc, Icons } from 'folds';
+
+import { EmojiGroupId } from '../../plugins/emoji';
+
+export type IEmojiGroupIcons = Record<EmojiGroupId, IconSrc>;
+
+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,
+ }),
+ []
+ );
--- /dev/null
+import { useMemo } from 'react';
+import { EmojiGroupId } from '../../plugins/emoji';
+
+export type IEmojiGroupLabels = Record<EmojiGroupId, string>;
+
+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',
+ }),
+ []
+ );
--- /dev/null
+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<typeof SidebarAvatarBox>;
+
+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<typeof SidebarBadgeBox>;
+
+export const SidebarBadgeOutline = style({
+ boxShadow: `0 0 0 ${config.borderWidth.B500} ${color.Background.Container}`,
+});
--- /dev/null
+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) => (
+ <AsSidebar className={classNames(css.Sidebar, className)} {...props} ref={ref} />
+));
--- /dev/null
+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) => (
+ <AsSidebarAvatarBox
+ className={classNames(css.SidebarAvatarBox({ active }), className)}
+ {...props}
+ ref={ref}
+ />
+ )
+);
+
+export const SidebarAvatar = forwardRef<
+ HTMLDivElement,
+ css.SidebarAvatarBoxVariants &
+ css.SidebarBadgeBoxVariants & {
+ outlined?: boolean;
+ avatarChildren: ReactNode;
+ tooltip: ReactNode | string;
+ notificationBadge?: (badgeClassName: string) => ReactNode;
+ onClick?: MouseEventHandler<HTMLButtonElement>;
+ onContextMenu?: MouseEventHandler<HTMLButtonElement>;
+ }
+>(
+ (
+ {
+ active,
+ hasCount,
+ outlined,
+ avatarChildren,
+ tooltip,
+ notificationBadge,
+ onClick,
+ onContextMenu,
+ },
+ ref
+ ) => (
+ <SidebarAvatarBox active={active} ref={ref}>
+ <TooltipProvider
+ delay={0}
+ position="Right"
+ tooltip={
+ <Tooltip>
+ <Text size="T300">{tooltip}</Text>
+ </Tooltip>
+ }
+ >
+ {(avRef) => (
+ <Avatar
+ ref={avRef}
+ as="button"
+ onClick={onClick}
+ onContextMenu={onContextMenu}
+ style={{
+ border: outlined
+ ? `${config.borderWidth.B300} solid ${color.Background.ContainerLine}`
+ : undefined,
+ cursor: 'pointer',
+ }}
+ >
+ {avatarChildren}
+ </Avatar>
+ )}
+ </TooltipProvider>
+ {notificationBadge && (
+ <Box className={css.SidebarBadgeBox({ hasCount })}>
+ {notificationBadge(css.SidebarBadgeOutline)}
+ </Box>
+ )}
+ </SidebarAvatarBox>
+ )
+);
--- /dev/null
+import React, { ReactNode } from 'react';
+import { Box, Scroll } from 'folds';
+
+type SidebarContentProps = {
+ scrollable: ReactNode;
+ sticky: ReactNode;
+};
+export function SidebarContent({ scrollable, sticky }: SidebarContentProps) {
+ return (
+ <>
+ <Box direction="Column" grow="Yes">
+ <Scroll variant="Background" size="0">
+ {scrollable}
+ </Scroll>
+ </Box>
+ <Box direction="Column" shrink="No">
+ {sticky}
+ </Box>
+ </>
+ );
+}
--- /dev/null
+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) => (
+ <AsSidebarStack className={classNames(css.SidebarStack, className)} {...props} ref={ref} />
+ )
+);
--- /dev/null
+import React from 'react';
+import { Line, toRem } from 'folds';
+
+export function SidebarStackSeparator() {
+ return (
+ <Line
+ role="separator"
+ style={{ width: toRem(24), margin: '0 auto' }}
+ variant="Background"
+ size="300"
+ />
+ );
+}
--- /dev/null
+export * from './Sidebar';
+export * from './SidebarAvatar';
+export * from './SidebarContent';
+export * from './SidebarStack';
+export * from './SidebarStackSeparator';
--- /dev/null
+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,
+});
--- /dev/null
+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) => (
+ <Box className={css.UploadBoardBase} {...props} ref={ref}>
+ <Box className={css.UploadBoardContainer} justifyContent="End">
+ <Box className={classNames(css.UploadBoard)} direction="Column">
+ <Box grow="Yes" direction="Column">
+ {children}
+ </Box>
+ <Box direction="Column" shrink="No">
+ {header}
+ </Box>
+ </Box>
+ </Box>
+ </Box>
+));
+
+export type UploadBoardImperativeHandlers = { handleSend: () => Promise<void> };
+
+type UploadBoardHeaderProps = {
+ open: boolean;
+ onToggle: () => void;
+ uploadFamilyObserverAtom: TUploadFamilyObserverAtom;
+ onCancel: (uploads: Upload[]) => void;
+ onSend: (uploads: UploadSuccess[]) => Promise<void>;
+ imperativeHandlerRef: MutableRefObject<UploadBoardImperativeHandlers | undefined>;
+};
+
+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 (
+ <Header size="400">
+ <Box
+ as="button"
+ style={{ cursor: 'pointer' }}
+ onClick={onToggle}
+ className={css.UploadBoardHeaderContent}
+ alignItems="Center"
+ grow="Yes"
+ gap="100"
+ >
+ <Icon src={open ? Icons.ChevronTop : Icons.ChevronRight} size="50" />
+ <Text size="H6">Files</Text>
+ </Box>
+ <Box className={css.UploadBoardHeaderContent} alignItems="Center" gap="100">
+ {isSuccess && (
+ <Chip
+ as="button"
+ onClick={handleSend}
+ variant="Primary"
+ radii="Pill"
+ outlined
+ after={<Icon src={Icons.Send} size="50" filled />}
+ >
+ <Text size="B300">Send</Text>
+ </Chip>
+ )}
+ {isError && !open && (
+ <Badge variant="Critical" fill="Solid" radii="300">
+ <Text size="L400">Upload Failed</Text>
+ </Badge>
+ )}
+ {!isSuccess && !isError && !open && (
+ <>
+ <Badge variant="Secondary" fill="Solid" radii="Pill">
+ <Text size="L400">{Math.round(percent(0, progress.total, progress.loaded))}%</Text>
+ </Badge>
+ <Spinner variant="Secondary" size="200" />
+ </>
+ )}
+ {!isSuccess && open && (
+ <Chip
+ as="button"
+ onClick={handleCancel}
+ variant="SurfaceVariant"
+ radii="Pill"
+ after={<Icon src={Icons.Cross} size="50" />}
+ >
+ <Text size="B300">{uploads.length === 1 ? 'Remove' : 'Remove All'}</Text>
+ </Chip>
+ )}
+ </Box>
+ </Header>
+ );
+}
+
+export const UploadBoardContent = as<'div'>(({ className, children, ...props }, ref) => (
+ <Box
+ className={classNames(css.UploadBoardContent, className)}
+ direction="Column"
+ gap="200"
+ {...props}
+ ref={ref}
+ >
+ {children}
+ </Box>
+));
--- /dev/null
+export * from './UploadBoard';
--- /dev/null
+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<typeof UploadCard>;
+
+export const UploadCardError = style({
+ padding: `0 ${config.space.S100}`,
+ color: color.Critical.Main,
+});
--- /dev/null
+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<HTMLDivElement, UploadCardProps & css.UploadCardVariant>(
+ ({ before, after, children, bottom, radii }, ref) => (
+ <Box className={css.UploadCard({ radii })} direction="Column" gap="200" ref={ref}>
+ <Box alignItems="Center" gap="200">
+ {before}
+ <Box alignItems="Center" grow="Yes" gap="200">
+ {children}
+ </Box>
+ {after}
+ </Box>
+ {bottom}
+ </Box>
+ )
+);
+
+type UploadCardProgressProps = {
+ sentBytes: number;
+ totalBytes: number;
+};
+
+export function UploadCardProgress({ sentBytes, totalBytes }: UploadCardProgressProps) {
+ return (
+ <Box direction="Column" gap="200">
+ <ProgressBar variant="Secondary" size="300" min={0} max={totalBytes} value={sentBytes} />
+ <Box alignItems="Center" justifyContent="SpaceBetween">
+ <Badge variant="Secondary" fill="Solid" radii="Pill">
+ <Text size="L400">{`${Math.round(percent(0, totalBytes, sentBytes))}%`}</Text>
+ </Badge>
+ <Badge variant="Secondary" fill="Soft" radii="Pill">
+ <Text size="L400">
+ {bytesToSize(sentBytes)} / {bytesToSize(totalBytes)}
+ </Text>
+ </Badge>
+ </Box>
+ </Box>
+ );
+}
+
+type UploadCardErrorProps = {
+ children: ReactNode;
+};
+
+export function UploadCardError({ children }: UploadCardErrorProps) {
+ return (
+ <Box className={css.UploadCardError} alignItems="Center" gap="300">
+ <Icon src={Icons.Warning} size="50" />
+ {children}
+ </Box>
+ );
+}
--- /dev/null
+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 (
+ <UploadCard
+ radii="300"
+ before={<Icon src={getFileTypeIcon(Icons, file.type)} />}
+ after={
+ <>
+ {upload.status === UploadStatus.Error && (
+ <Chip
+ as="button"
+ onClick={startUpload}
+ aria-label="Retry Upload"
+ variant="Critical"
+ radii="Pill"
+ outlined
+ >
+ <Text size="B300">Retry</Text>
+ </Chip>
+ )}
+ <IconButton
+ onClick={removeUpload}
+ aria-label="Cancel Upload"
+ variant="SurfaceVariant"
+ radii="Pill"
+ size="300"
+ >
+ <Icon src={Icons.Cross} size="200" />
+ </IconButton>
+ </>
+ }
+ bottom={
+ <>
+ {upload.status === UploadStatus.Idle && (
+ <UploadCardProgress sentBytes={0} totalBytes={file.size} />
+ )}
+ {upload.status === UploadStatus.Loading && (
+ <UploadCardProgress sentBytes={upload.progress.loaded} totalBytes={file.size} />
+ )}
+ {upload.status === UploadStatus.Error && (
+ <UploadCardError>
+ <Text size="T200">{upload.error.message}</Text>
+ </UploadCardError>
+ )}
+ </>
+ }
+ >
+ <Text size="H6" truncate>
+ {file.name}
+ </Text>
+ {upload.status === UploadStatus.Success && (
+ <Icon style={{ color: color.Success.Main }} src={Icons.Check} size="100" />
+ )}
+ </UploadCard>
+ );
+}
--- /dev/null
+export * from './UploadCard';
+export * from './UploadCardRenderer';
--- /dev/null
+import { useCallback, useEffect, useRef } from 'react';
+
+export const useAlive = (): (() => boolean) => {
+ const aliveRef = useRef<boolean>(true);
+
+ useEffect(() => {
+ aliveRef.current = true;
+ return () => {
+ aliveRef.current = false;
+ };
+ }, []);
+
+ const alive = useCallback(() => aliveRef.current, []);
+ return alive;
+};
--- /dev/null
+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<T> = {
+ status: AsyncStatus.Success;
+ data: T;
+};
+
+export type AsyncError = {
+ status: AsyncStatus.Error;
+ error: unknown;
+};
+
+export type AsyncState<T> = AsyncIdle | AsyncLoading | AsyncSuccess<T> | AsyncError;
+
+export type AsyncCallback<TArgs extends unknown[], TData> = (...args: TArgs) => Promise<TData>;
+
+export const useAsyncCallback = <TArgs extends unknown[], TData>(
+ asyncCallback: AsyncCallback<TArgs, TData>
+): [AsyncState<TData>, AsyncCallback<TArgs, TData>] => {
+ const [state, setState] = useState<AsyncState<TData>>({
+ status: AsyncStatus.Idle,
+ });
+ const alive = useAlive();
+
+ const callback: AsyncCallback<TArgs, TData> = 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];
+};
--- /dev/null
+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<TSearchItem extends object | string | number> = (
+ searchItem: TSearchItem
+) => string | string[];
+
+export type UseAsyncSearchResult<TSearchItem extends object | string | number> = {
+ query: string;
+ items: TSearchItem[];
+};
+
+export const useAsyncSearch = <TSearchItem extends object | string | number>(
+ list: TSearchItem[],
+ getItemStr: SearchItemStrGetter<TSearchItem>,
+ options?: UseAsyncSearchOptions
+): [UseAsyncSearchResult<TSearchItem> | undefined, AsyncSearchHandler] => {
+ const [result, setResult] = useState<UseAsyncSearchResult<TSearchItem>>();
+
+ const [searchCallback, terminateSearch] = useMemo(() => {
+ setResult(undefined);
+
+ const handleMatch: MatchHandler<TSearchItem> = (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<TSearchItem> = (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];
+};
--- /dev/null
+import { useCallback, useRef } from 'react';
+
+export interface DebounceOptions {
+ wait?: number;
+ immediate?: boolean;
+}
+export type DebounceCallback<T extends unknown[]> = (...args: T) => void;
+
+export function useDebounce<T extends unknown[]>(
+ callback: DebounceCallback<T>,
+ options?: DebounceOptions
+): DebounceCallback<T> {
+ const timeoutIdRef = useRef<number>();
+ 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;
+}
--- /dev/null
+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<HTMLElement>,
+ 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;
+};
--- /dev/null
+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]
+ );
--- /dev/null
+import { useCallback } from 'react';
+import { selectFile } from '../utils/dom';
+
+export const useFilePicker = <M extends boolean | undefined = undefined>(
+ 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]
+ );
--- /dev/null
+import { useReducer } from 'react';
+
+const reducer = (prevCount: number): number => prevCount + 1;
+
+export const useForceUpdate = (): [number, () => void] => {
+ const [state, dispatch] = useReducer<typeof reducer>(reducer, 0);
+
+ return [state, dispatch];
+};
--- /dev/null
+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;
+};
--- /dev/null
+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]);
+};
--- /dev/null
+import { createContext, useContext } from 'react';
+import { MatrixClient } from 'matrix-js-sdk';
+
+const MatrixClientContext = createContext<MatrixClient | null>(null);
+
+export const MatrixClientProvider = MatrixClientContext.Provider;
+
+export function useMatrixClient(): MatrixClient {
+ const mx = useContext(MatrixClientContext);
+ if (!mx) throw new Error('MatrixClient not initialized!');
+ return mx;
+}
--- /dev/null
+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<string, number>;
+ users?: Record<string, number>;
+ notifications?: Record<string, number>;
+}
+
+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,
+ };
+}
--- /dev/null
+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;
+};
--- /dev/null
+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;
+};
--- /dev/null
+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<RoomMember[]>([]);
+ 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;
+};
--- /dev/null
+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]
+ );
+};
--- /dev/null
+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]);
+};
--- /dev/null
+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]
+ );
+};
--- /dev/null
+import { useCallback, useRef } from 'react';
+
+export interface ThrottleOptions {
+ wait?: number;
+ immediate?: boolean;
+}
+
+export type ThrottleCallback<T extends unknown[]> = (...args: T) => void;
+
+export function useThrottle<T extends unknown[]>(
+ callback: ThrottleCallback<T>,
+ options?: ThrottleOptions
+): ThrottleCallback<T> {
+ const timeoutIdRef = useRef<number>();
+ const argsRef = useRef<T>();
+ 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;
+}
--- /dev/null
+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<number>(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;
+};
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 && (
- <button
- className="following-members"
- onClick={() => openReadReceipts(roomId, followingMembers)}
- type="button"
- >
- <RawIcon
- size="extra-small"
- src={TickMarkIC}
- />
- <Text variant="b2">{getUsersActionJsx(roomId, filteredM, 'following the conversation.')}</Text>
- </button>
+ return (
+ filteredM.length !== 0 && (
+ <button
+ className="following-members"
+ onClick={() => openReadReceipts(roomId, followingMembers)}
+ type="button"
+ >
+ <RawIcon size="extra-small" src={TickMarkIC} />
+ <Text variant="b2">
+ {getUsersActionJsx(roomId, filteredM, 'following the conversation.')}
+ </Text>
+ </button>
+ )
);
}
+++ /dev/null
-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 (
- <RawModal
- className="drag-drop__modal"
- overlayClassName="drag-drop__overlay"
- isOpen={isOpen}
- >
- <Text variant="h2" weight="medium">Drop file to upload</Text>
- </RawModal>
- );
-}
-
-DragDrop.propTypes = {
- isOpen: PropTypes.bool.isRequired,
-};
-
-export default DragDrop;
+++ /dev/null
-.drag-drop__modal {
- box-shadow: none;
- text-align: center;
-
- .text {
- color: white;
- }
-}
-
-.drag-drop__overlay {
- background-color: var(--bg-overlay-low);
-}
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 {
.scrollable-content {
&::after {
- content: "";
+ content: '';
display: block;
width: 100%;
height: 8px;
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;
.space-container,
.sticky-container {
@extend .cp-fx__column--c-c;
-
+
padding: var(--sp-ultra-tight) 0;
& > .sidebar-avatar,
box-shadow: var(--bs-danger-border);
animation-name: pushRight;
animation-duration: 400ms;
- animation-iteration-count: infinite;
+ animation-iteration-count: 30;
animation-direction: alternate;
}
to {
transform: translateX(0) scale(1);
}
-}
\ No newline at end of file
+}
--- /dev/null
+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 (
+ <Sidebar>
+ <SidebarContent
+ scrollable={
+ <>
+ <SidebarStack>
+ <SidebarAvatar
+ active={selectedTab === SidebarTab.Home}
+ outlined
+ tooltip="Home"
+ avatarChildren={<Icon src={Icons.Home} filled />}
+ onClick={() => setSelectedTab(SidebarTab.Home)}
+ />
+ <SidebarAvatar
+ active={selectedTab === SidebarTab.People}
+ outlined
+ tooltip="People"
+ avatarChildren={<Icon src={Icons.User} />}
+ onClick={() => setSelectedTab(SidebarTab.People)}
+ />
+ </SidebarStack>
+ <SidebarStackSeparator />
+ <SidebarStack>
+ <SidebarAvatar
+ tooltip="Space A"
+ notificationBadge={(badgeClassName) => (
+ <Badge
+ className={badgeClassName}
+ size="200"
+ variant="Secondary"
+ fill="Solid"
+ radii="Pill"
+ />
+ )}
+ avatarChildren={
+ <AvatarFallback
+ style={{
+ backgroundColor: 'red',
+ color: 'white',
+ }}
+ >
+ <Text size="T500">B</Text>
+ </AvatarFallback>
+ }
+ />
+ <SidebarAvatar
+ tooltip="Space B"
+ hasCount
+ notificationBadge={(badgeClassName) => (
+ <Badge className={badgeClassName} radii="Pill" fill="Solid" variant="Secondary">
+ <Text size="L400">64</Text>
+ </Badge>
+ )}
+ avatarChildren={
+ <AvatarFallback
+ style={{
+ backgroundColor: 'green',
+ color: 'white',
+ }}
+ >
+ <Text size="T500">C</Text>
+ </AvatarFallback>
+ }
+ />
+ </SidebarStack>
+ <SidebarStackSeparator />
+ <SidebarStack>
+ <SidebarAvatar
+ outlined
+ tooltip="Explore Community"
+ avatarChildren={<Icon src={Icons.Explore} />}
+ />
+ <SidebarAvatar
+ outlined
+ tooltip="Create Space"
+ avatarChildren={<Icon src={Icons.Plus} />}
+ />
+ </SidebarStack>
+ </>
+ }
+ sticky={
+ <>
+ <SidebarStackSeparator />
+ <SidebarStack>
+ <SidebarAvatar
+ outlined
+ tooltip="Search"
+ avatarChildren={<Icon src={Icons.Search} />}
+ />
+ <SidebarAvatar
+ tooltip="User Settings"
+ avatarChildren={
+ <AvatarFallback
+ style={{
+ backgroundColor: 'blue',
+ color: 'white',
+ }}
+ >
+ <Text size="T500">A</Text>
+ </AvatarFallback>
+ }
+ />
+ </SidebarStack>
+ </>
+ }
+ />
+ </Sidebar>
+ );
+}
function Room() {
const [roomInfo, setRoomInfo] = useState({
+ room: null,
roomTimeline: null,
eventId: null,
});
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,
});
return () => {
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
};
- }, [roomInfo]);
+ }, [roomInfo, mx]);
useEffect(() => {
const handleDrawerToggling = (visiblity) => setIsDrawer(visiblity);
};
}, []);
- const { roomTimeline, eventId } = roomInfo;
+ const { room, roomTimeline, eventId } = roomInfo;
if (roomTimeline === null) {
setTimeout(() => openNavigation());
return <Welcome />;
<div className="room">
<div className="room__content">
<RoomSettings roomId={roomTimeline.roomId} />
- <RoomView roomTimeline={roomTimeline} eventId={eventId} />
+ <RoomView room={room} roomTimeline={roomTimeline} eventId={eventId} />
</div>
{isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
</div>
--- /dev/null
+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<HTMLElement>;
+ roomId: string;
+}
+export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
+ ({ 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<UploadBoardImperativeHandlers>();
+
+ const imagePackRooms: Room[] = useMemo(() => {
+ const allParentSpaces = [roomId, ...(initMatrix.roomList?.getAllParentSpaces(roomId) ?? [])];
+ return allParentSpaces.reduce<Room[]>((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<AutocompleteQuery<AutocompletePrefix>>();
+
+ 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<AutocompletePrefix>(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 (
+ <div ref={ref}>
+ {selectedFiles.length > 0 && (
+ <UploadBoard
+ header={
+ <UploadBoardHeader
+ open={uploadBoard}
+ onToggle={() => setUploadBoard(!uploadBoard)}
+ uploadFamilyObserverAtom={uploadFamilyObserverAtom}
+ onSend={handleSendUpload}
+ imperativeHandlerRef={uploadBoardHandlers}
+ onCancel={handleCancelUpload}
+ />
+ }
+ >
+ {uploadBoard && (
+ <Scroll size="300" hideTrack visibility="Hover">
+ <UploadBoardContent>
+ {Array.from(selectedFiles)
+ .reverse()
+ .map((fileItem, index) => (
+ <UploadCardRenderer
+ // eslint-disable-next-line react/no-array-index-key
+ key={index}
+ file={fileItem.file}
+ isEncrypted={!!fileItem.encInfo}
+ uploadAtom={roomUploadAtomFamily(fileItem.file)}
+ onRemove={handleRemoveUpload}
+ />
+ ))}
+ </UploadBoardContent>
+ </Scroll>
+ )}
+ </UploadBoard>
+ )}
+ <Overlay
+ open={dropZoneVisible}
+ backdrop={<OverlayBackdrop />}
+ style={{ pointerEvents: 'none' }}
+ >
+ <OverlayCenter>
+ <Dialog variant="Primary">
+ <Box
+ direction="Column"
+ justifyContent="Center"
+ alignItems="Center"
+ gap="500"
+ style={{ padding: toRem(60) }}
+ >
+ <Icon size="600" src={Icons.File} />
+ <Text size="H4" align="Center">
+ {`Drop Files in "${room?.name || 'Room'}"`}
+ </Text>
+ <Text align="Center">Drag and drop files here or click for selection dialog</Text>
+ </Box>
+ </Dialog>
+ </OverlayCenter>
+ </Overlay>
+ {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
+ <RoomMentionAutocomplete
+ roomId={roomId}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={() => setAutocompleteQuery(undefined)}
+ />
+ )}
+ {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
+ <UserMentionAutocomplete
+ roomId={roomId}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={() => setAutocompleteQuery(undefined)}
+ />
+ )}
+ {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
+ <EmoticonAutocomplete
+ imagePackRooms={imagePackRooms}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={() => setAutocompleteQuery(undefined)}
+ />
+ )}
+ <CustomEditor
+ editor={editor}
+ placeholder="Send a message..."
+ onKeyDown={handleKeyDown}
+ onChange={handleChange}
+ onPaste={handlePaste}
+ top={
+ replyDraft && (
+ <div>
+ <Box
+ alignItems="Center"
+ gap="300"
+ style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
+ >
+ <IconButton
+ onClick={() => setReplyDraft()}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ >
+ <Icon src={Icons.Cross} size="50" />
+ </IconButton>
+ <MessageReply
+ color={colorMXID(replyDraft.userId)}
+ name={room?.getMember(replyDraft.userId)?.name ?? replyDraft.userId}
+ body={replyDraft.body}
+ />
+ </Box>
+ </div>
+ )
+ }
+ before={
+ <IconButton
+ onClick={() => pickFile('*')}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ >
+ <Icon src={Icons.PlusCircle} />
+ </IconButton>
+ }
+ after={
+ <>
+ <IconButton
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ onClick={() => setToolbar(!toolbar)}
+ >
+ <Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
+ </IconButton>
+ <UseStateProvider initial={undefined}>
+ {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
+ <PopOut
+ offset={16}
+ alignOffset={-44}
+ position="Top"
+ align="End"
+ open={!!emojiBoardTab}
+ content={
+ <EmojiBoard
+ tab={emojiBoardTab}
+ onTabChange={setEmojiBoardTab}
+ imagePackRooms={imagePackRooms}
+ returnFocusOnDeactivate={false}
+ onEmojiSelect={handleEmoticonSelect}
+ onCustomEmojiSelect={handleEmoticonSelect}
+ onStickerSelect={handleStickerSelect}
+ requestClose={() => {
+ setEmojiBoardTab(undefined);
+ ReactEditor.focus(editor);
+ }}
+ />
+ }
+ >
+ {(anchorRef) => (
+ <>
+ <IconButton
+ aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
+ onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ >
+ <Icon
+ src={Icons.Sticker}
+ filled={emojiBoardTab === EmojiBoardTab.Sticker}
+ />
+ </IconButton>
+ <IconButton
+ ref={anchorRef}
+ aria-pressed={emojiBoardTab === EmojiBoardTab.Emoji}
+ onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ >
+ <Icon src={Icons.Smile} filled={emojiBoardTab === EmojiBoardTab.Emoji} />
+ </IconButton>
+ </>
+ )}
+ </PopOut>
+ )}
+ </UseStateProvider>
+ <IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
+ <Icon src={Icons.Send} />
+ </IconButton>
+ </>
+ }
+ bottom={toolbar && <Toolbar />}
+ />
+ </div>
+ );
+ }
+);
--- /dev/null
+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,
+});
--- /dev/null
+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<typeof Box>>(
+ ({ className, ...props }, ref) => (
+ <Box className={classNames(css.RoomInputPlaceholder, className)} {...props} ref={ref} />
+ )
+);
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+export const RoomTombstone = style({
+ padding: config.space.S200,
+ paddingLeft: config.space.S400,
+});
--- /dev/null
+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 (
+ <RoomInputPlaceholder alignItems="Center" gap="600" className={css.RoomTombstone}>
+ <Box direction="Column" grow="Yes">
+ <Text size="T400">{body || 'This room has been replaced and is no longer active.'}</Text>
+ {joinState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T200">
+ {(joinState.error as any)?.message ?? 'Failed to join replacement room!'}
+ </Text>
+ )}
+ </Box>
+ {replacementRoom?.getMyMembership() === Membership.Join ||
+ joinState.status === AsyncStatus.Success ? (
+ <Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
+ <Text size="B300">Open New Room</Text>
+ </Button>
+ ) : (
+ <Button
+ onClick={handleJoin}
+ size="300"
+ variant="Primary"
+ fill="Solid"
+ radii="300"
+ before={
+ joinState.status === AsyncStatus.Loading && (
+ <Spinner size="100" variant="Primary" fill="Solid" />
+ )
+ }
+ disabled={joinState.status === AsyncStatus.Loading}
+ >
+ <Text size="B300">Join New Room</Text>
+ </Button>
+ )}
+ </RoomInputPlaceholder>
+ );
+}
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './RoomView.scss';
+import { Text, config } from 'folds';
import EventEmitter from 'events';
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;
<RoomViewContent
eventId={eventId}
roomTimeline={roomTimeline}
+ roomInputRef={roomInputRef}
/>
- <RoomViewFloating
- roomId={roomId}
- roomTimeline={roomTimeline}
- />
+ <RoomViewFloating roomId={roomId} roomTimeline={roomTimeline} />
</div>
<div className="room-view__sticky">
- <RoomViewInput
- roomId={roomId}
- roomTimeline={roomTimeline}
- viewEvent={viewEvent}
- />
- <RoomViewCmdBar
- roomId={roomId}
- roomTimeline={roomTimeline}
- viewEvent={viewEvent}
- />
+ <div className="room-view__editor">
+ {tombstoneEvent ? (
+ <RoomTombstone
+ roomId={roomId}
+ body={tombstoneEvent.getContent().body}
+ replacementRoomId={tombstoneEvent.getContent().replacement_room}
+ />
+ ) : (
+ <>
+ {canMessage && (
+ <RoomInput roomId={roomId} roomViewRef={roomViewRef} ref={roomInputRef} />
+ )}
+ {!canMessage && (
+ <RoomInputPlaceholder
+ style={{ padding: config.space.S200 }}
+ alignItems="Center"
+ justifyContent="Center"
+ >
+ <Text align="Center">You do not have permission to post in this room</Text>
+ </RoomInputPlaceholder>
+ )}
+ </>
+ )}
+ </div>
+ <RoomViewCmdBar roomId={roomId} roomTimeline={roomTimeline} viewEvent={viewEvent} />
</div>
</div>
</div>
eventId: null,
};
RoomView.propTypes = {
+ room: PropTypes.shape({}).isRequired,
roomTimeline: PropTypes.shape({}).isRequired,
eventId: PropTypes.string,
};
@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);
+ }
+}
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;
let jumpToItemIndex = -1;
-function RoomViewContent({ eventId, roomTimeline }) {
+function RoomViewContent({ roomInputRef, eventId, roomTimeline }) {
const [throttle] = useState(new Throttle());
const timelineSVRef = useRef(null);
}
}, [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;
RoomViewContent.propTypes = {
eventId: PropTypes.string,
roomTimeline: PropTypes.shape({}).isRequired,
+ roomInputRef: PropTypes.shape({
+ current: PropTypes.shape({})
+ }).isRequired
};
export default RoomViewContent;
--- /dev/null
+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<IThumbnailContent> => {
+ 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<IContent> => {
+ 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<IContent> => {
+ 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;
+};
-import React from 'react';
+import React, { StrictMode } from 'react';
+import { Provider } from 'jotai';
import { isAuthenticated } from '../../client/state/auth';
import Client from '../templates/client/Client';
function App() {
- return isAuthenticated() ? <Client /> : <Auth />;
+ return (
+ <StrictMode>
+ <Provider>{isAuthenticated() ? <Client /> : <Auth />}</Provider>
+ </StrictMode>
+ );
}
export default App;
--- /dev/null
+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<string, unknown>;
+export type EmoteRoomIdToPackEvents = Record<string, PackEventIdToUnknown>;
+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<string, PackImage>;
+
+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<string, ExtendedPackImage>;
+
+ 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<K extends keyof PackMeta>(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<K extends keyof PackImage>(
+ 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<ImagePack[]>((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))
+ );
+}
--- /dev/null
+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);
+ }
+});
--- /dev/null
+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<IRecentEmojiContent>().recent_emoji;
+ if (!Array.isArray(recentEmoji)) return [];
+
+ return recentEmoji
+ .sort((e1, e2) => e2[1] - e1[1])
+ .slice(0, limit)
+ .reduce<IEmoji[]>((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<IRecentEmojiContent>().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),
+ });
+}
--- /dev/null
+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<string[], RoomsAction>
+) => {
+ 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<string[], RoomsAction>,
+ mDirectAtom: WritableAtom<Set<string>, 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<string[], RoomsAction>,
+ mDirectAtom: WritableAtom<Set<string>, 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<string[], RoomsAction>
+) => {
+ const selector = useCallback(
+ (rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
+ [mx]
+ );
+ return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
+};
--- /dev/null
+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<string[], RoomsAction>) => {
+ 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<string[], RoomsAction>,
+ mDirectAtom: WritableAtom<Set<string>, 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<string[], RoomsAction>,
+ mDirectAtom: WritableAtom<Set<string>, 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<string[], RoomsAction>
+) => {
+ const selector = useCallback(
+ (rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
+ [mx]
+ );
+ return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
+};
--- /dev/null
+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 = <K extends keyof Settings>(
+ settingsAtom: WritableAtom<Settings, Settings>,
+ key: K
+) => {
+ const setterAtom = useMemo(
+ () =>
+ atom<null, Settings[K]>(null, (get, set, value) => {
+ const s = { ...get(settingsAtom) };
+ s[key] = value;
+ set(settingsAtom, s);
+ }),
+ [settingsAtom, key]
+ );
+
+ return useSetAtom(setterAtom);
+};
+
+export const useSetting = <K extends keyof Settings>(
+ settingsAtom: WritableAtom<Settings, Settings>,
+ key: K
+): [Settings[K], SetAtom<Settings[K], void>] => {
+ const selector = useMemo(() => (s: Settings) => s[key], [key]);
+ const setting = useAtomValue(selectAtom(settingsAtom, selector));
+
+ const setter = useSetSetting(settingsAtom, key);
+
+ return [setting, setter];
+};
--- /dev/null
+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);
+};
--- /dev/null
+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<string[]>([]);
+export const allInvitesAtom = atom<string[], RoomsAction>(
+ (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<string[], RoomsAction>
+) => {
+ useBindRoomsWithMembershipsAtom(
+ mx,
+ allRooms,
+ useMemo(() => [Membership.Invite], [])
+ );
+};
--- /dev/null
+import { atom } from 'jotai';
+
+export type ListAction<T> =
+ | {
+ type: 'PUT';
+ item: T | T[];
+ }
+ | {
+ type: 'DELETE';
+ item: T | T[];
+ };
+
+export const createListAtom = <T>() => {
+ const baseListAtom = atom<T[]>([]);
+ return atom<T[], ListAction<T>>(
+ (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<T> = ReturnType<typeof createListAtom<T>>;
--- /dev/null
+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<string>;
+};
+
+const baseMDirectAtom = atom(new Set<string>());
+export const mDirectAtom = atom<Set<string>, MDirectAction>(
+ (get) => get(baseMDirectAtom),
+ (get, set, action) => {
+ set(baseMDirectAtom, action.rooms);
+ }
+);
+
+export const useBindMDirectAtom = (
+ mx: MatrixClient,
+ mDirect: WritableAtom<Set<string>, 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]);
+};
--- /dev/null
+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<MuteChanges>({
+ added: [],
+ removed: [],
+});
+
+const baseMutedRoomsAtom = atom(new Set<string>());
+export const mutedRoomsAtom = atom<Set<string>, 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<Set<string>, MutedRoomsUpdate>
+) => {
+ const setMuted = useSetAtom(mutedAtom);
+
+ useEffect(() => {
+ const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
+ ?.global?.override;
+ if (overrideRules) {
+ const mutedRooms = overrideRules.reduce<string[]>((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]);
+};
--- /dev/null
+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<string, TListAtom<TUploadItem>>(
+ createListAtom
+);
+
+export type RoomIdToMsgAction =
+ | {
+ type: 'PUT';
+ roomId: string;
+ msg: Descendant[];
+ }
+ | {
+ type: 'DELETE';
+ roomId: string;
+ };
+
+const createMsgDraftAtom = () => atom<Descendant[]>([]);
+export type TMsgDraftAtom = ReturnType<typeof createMsgDraftAtom>;
+export const roomIdToMsgDraftAtomFamily = atomFamily<string, TMsgDraftAtom>(() =>
+ createMsgDraftAtom()
+);
+
+export type IReplyDraft = {
+ userId: string;
+ eventId: string;
+ body: string;
+ formattedBody?: string;
+};
+const createReplyDraftAtom = () => atom<IReplyDraft | undefined>(undefined);
+export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>;
+export const roomIdToReplyDraftAtomFamily = atomFamily<string, TReplyDraftAtom>(() =>
+ createReplyDraftAtom()
+);
--- /dev/null
+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<string[]>([]);
+export const allRoomsAtom = atom<string[], RoomsAction>(
+ (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<string[], RoomsAction>
+) => {
+ useBindRoomsWithMembershipsAtom(
+ mx,
+ allRooms,
+ useMemo(() => [Membership.Join], [])
+ );
+};
--- /dev/null
+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<RoomToParents>(new Map());
+export const roomToParentsAtom = atom<RoomToParents, RoomToParentsAction>(
+ (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<RoomToParents, RoomToParentsAction>
+) => {
+ 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]);
+};
--- /dev/null
+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<string>,
+ 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<string>, 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<RoomToUnread>(new Map());
+export const roomToUnreadAtom = atom<RoomToUnread, RoomToUnreadAction>(
+ (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<RoomToUnread, RoomToUnreadAction>,
+ muteChangesAtom: PrimitiveAtom<MuteChanges>
+) => {
+ 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<ReceiptContent>();
+
+ 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]);
+};
--- /dev/null
+import { atom } from 'jotai';
+
+export const selectedRoomAtom = atom<string | undefined>(undefined);
--- /dev/null
+import { atom } from 'jotai';
+
+export enum SidebarTab {
+ Home = 'Home',
+ People = 'People',
+}
+
+export const selectedTabAtom = atom<SidebarTab | string>(SidebarTab.Home);
--- /dev/null
+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<Settings>(getSettings());
+export const settingsAtom = atom<Settings, Settings>(
+ (get) => get(baseSettings),
+ (get, set, update) => {
+ set(baseSettings, update);
+ setSettings(update);
+ }
+);
--- /dev/null
+import produce from 'immer';
+import { atom } from 'jotai';
+import { MatrixClient } from 'matrix-js-sdk';
+
+type RoomInfo = {
+ roomId: string;
+ timestamp: number;
+};
+type TabToRoom = Map<string, RoomInfo>;
+
+type TabToRoomAction = {
+ type: 'PUT';
+ tabInfo: { tabId: string; roomInfo: RoomInfo };
+};
+
+const baseTabToRoom = atom<TabToRoom>(new Map());
+export const tabToRoomAtom = atom<TabToRoom, TabToRoomAction>(
+ (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:
+};
--- /dev/null
+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<UploadResponse>;
+ 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<UploadResponse>;
+ }
+ | {
+ progress: UploadProgress;
+ }
+ | {
+ mxc: string;
+ }
+ | {
+ error: MatrixError;
+ };
+
+export const createUploadAtom = (file: TUploadContent) => {
+ const baseUploadAtom = atom<Upload>({
+ file,
+ status: UploadStatus.Idle,
+ });
+ return atom<Upload, UploadAtomAction>(
+ (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<typeof createUploadAtom>;
+
+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<UploadResponse>) => 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<TUploadContent, TUploadAtom>(createUploadAtom);
+export type TUploadAtomFamily = ReturnType<typeof createUploadAtomFamily>;
+
+export const createUploadFamilyObserverAtom = (
+ uploadFamily: TUploadAtomFamily,
+ uploads: TUploadContent[]
+) => atom<Upload[]>((get) => uploads.map((upload) => get(uploadFamily(upload))));
+export type TUploadFamilyObserverAtom = ReturnType<typeof createUploadFamilyObserverAtom>;
--- /dev/null
+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<string[], RoomsAction>,
+ 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]);
+};
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);
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);
<div className="loading__menu">
<ContextMenu
placement="bottom"
- content={(
+ content={
<>
<MenuItem onClick={() => initMatrix.clearCacheAndReload()}>
Clear cache & reload
</MenuItem>
<MenuItem onClick={() => initMatrix.logout()}>Logout</MenuItem>
</>
+ }
+ render={(toggle) => (
+ <IconButton size="extra-small" onClick={toggle} src={VerticalMenuIC} />
)}
- render={(toggle) => <IconButton size="extra-small" onClick={toggle} src={VerticalMenuIC} />}
/>
</div>
<Spinner />
- <Text className="loading__message" variant="b2">{loadingMsg}</Text>
+ <Text className="loading__message" variant="b2">
+ {loadingMsg}
+ </Text>
<div className="loading__appname">
- <Text variant="h2" weight="medium">Cinny</Text>
+ <Text variant="h2" weight="medium">
+ Cinny
+ </Text>
</div>
</div>
);
}
- 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 (
- <div
- className="client-container"
- onDragOver={handleDragOver}
- onDragEnter={handleDragEnter}
- onDragLeave={handleDragLeave}
- onDrop={handleDrop}
- >
- <div className="navigation__wrapper" ref={navWrapperRef}>
- <Navigation />
- </div>
- <div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
- <Room />
+ <MatrixClientProvider value={initMatrix.matrixClient}>
+ <div className="client-container">
+ <div className="navigation__wrapper" ref={navWrapperRef}>
+ <Navigation />
+ </div>
+ <div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
+ <Room />
+ </div>
+ <Windows />
+ <Dialogs />
+ <EmojiBoardOpener />
+ <ReusableContextMenu />
</div>
- <Windows />
- <Dialogs />
- <EmojiBoardOpener />
- <ReusableContextMenu />
- <DragDrop isOpen={dragCounter !== 0} />
- </div>
+ </MatrixClientProvider>
);
}
--- /dev/null
+export type NormalizeOption = {
+ caseSensitive?: boolean;
+ normalizeUnicode?: boolean;
+ ignoreWhitespace?: boolean;
+};
+
+export type MatchQueryOption = {
+ contain?: boolean;
+};
+
+export type AsyncSearchOption = {
+ limit?: number;
+};
+
+export type MatchHandler<TSearchItem extends object | string | number> = (
+ item: TSearchItem,
+ query: string
+) => boolean;
+export type ResultHandler<TSearchItem extends object | string | number> = (
+ 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 = <TSearchItem extends object | string | number>(
+ list: TSearchItem[],
+ match: MatchHandler<TSearchItem>,
+ onResult: ResultHandler<TSearchItem>,
+ 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];
+};
--- /dev/null
+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);
+};
--- /dev/null
+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<IconName, IconSrc>, 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 = <T>(prs: PromiseSettledResult<T>[]): T[] =>
+ prs.reduce<T[]>((values, pr) => {
+ if (pr.status === 'fulfilled') values.push(pr.value);
+ return values;
+ }, []);
--- /dev/null
+export type DisposeCallback<Q extends unknown[] = [], R = void> = (...args: Q) => R;
+export type DisposableContext<P extends unknown[] = [], Q extends unknown[] = [], R = void> = (
+ ...args: P
+) => DisposeCallback<Q, R>;
+
+export const disposable = <P extends unknown[], Q extends unknown[] = [], R = void>(
+ context: DisposableContext<P, Q, R>
+) => context;
--- /dev/null
+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 boolean | undefined = undefined> = T extends true ? File[] : File;
+
+export const selectFile = <M extends boolean | undefined = undefined>(
+ accept: string,
+ multiple?: M
+): Promise<FilesOrFile<M> | 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<M>);
+ }
+ 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<HTMLImageElement> =>
+ 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<HTMLVideoElement> =>
+ 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<Blob | undefined> =>
+ 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');
+ });
--- /dev/null
+export enum KeySymbol {
+ Command = '⌘',
+ Shift = '⇧',
+ Option = '⌥',
+ Control = '⌃',
+}
--- /dev/null
+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();
+ }
+};
--- /dev/null
+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<UploadResponse>) => 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 }));
+ }
+};
--- /dev/null
+// 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;
+};
--- /dev/null
+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<string> => {
+ const roomIds = new Set<string>();
+ 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<string> => {
+ const allParents = new Set<string>();
+
+ 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<string[]>((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<string>();
+ 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<IPushRules>()
+ ?.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<UnreadInfo[]>((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<IconName, IconSrc>,
+ 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 = `<a href="https://matrix.to/#/${encodeURIComponent(
+ roomId
+ )}/${encodeURIComponent(eventId)}">In reply to</a>`;
+ const userLink = `<a href="https://matrix.to/#/${encodeURIComponent(userId)}">${userId}</a>`;
+
+ return `<mx-reply><blockquote>${replyToLink}${userLink}<br />${formattedBody}</blockquote></mx-reply>`;
+};
--- /dev/null
+export const sanitizeText = (body: string) => {
+ const tagsToReplace: Record<string, string> = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ };
+ return body.replace(/[&<>'"]/g, (tag) => tagsToReplace[tag] || tag);
+};
--- /dev/null
+import { UAParser } from 'ua-parser-js';
+
+export const ua = () => UAParser(window.navigator.userAgent);
+
+export const isMacOS = () => ua().os.name === 'Mac OS';
}
async init() {
+ if (this.matrixClient) {
+ console.warn('Client is already initialized!')
+ return;
+ }
+
await this.startClient();
this.setupSync();
this.listenEvents();
--- /dev/null
+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!;
+};
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);
+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');
constructor() {
super();
+ this.themeClasses = [lightTheme, silverTheme, darkTheme, butterTheme];
this.themes = ['', 'silver-theme', 'dark-theme', 'butter-theme'];
this.themeIndex = this.getThemeIndex();
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);
}
}
_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) {
--- /dev/null
+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',
+ },
+});
--- /dev/null
+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<EncryptedAttachment>;
+}
+/* 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';
import App from './app/pages/App';
+document.body.classList.add(configClass, varsClass);
+
settings.applyTheme();
ReactDom.render(<App />, document.getElementById('root'));
@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%);
--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%);
--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%);
--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%);
--tc-tooltip: white;
--tc-badge: white;
-
/* system icons | --ic-[background type]-[priority]: value */
--ic-surface-high: #272727;
--ic-surface-normal: #626262;
--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%);
--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;
--fw-medium: 500;
--fw-bold: 700;
-
/* spacing | --sp-[space]: value */
--sp-none: 0px;
--sp-ultra-tight: 4px;
--sp-loose: 20px;
--sp-extra-loose: 32px;
-
/* other */
--border-width: 1px;
--header-height: 54px;
--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));
--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 */
--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);
--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);
--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%);
--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;
/* 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 {
--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%);
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 {
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;
&:checked {
background-color: var(--bg-primary);
&::before {
- content: "";
+ content: '';
display: inline-block;
width: 12px;
height: 6px;
}
.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 */
}
display: flex;
justify-content: center;
align-items: center;
-}
\ No newline at end of file
+}
--- /dev/null
+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',
+}
--- /dev/null
+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;
+};
--- /dev/null
+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<string, Set<string>>;
+export type RoomToUnread = Map<
+ string,
+ {
+ total: number;
+ highlight: number;
+ from: Set<string> | null;
+ }
+>;
+export type UnreadInfo = {
+ roomId: string;
+ total: number;
+ highlight: number;
+};
+
+export type MuteChanges = {
+ added: string[];
+ removed: string[];
+};
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',
];
"compilerOptions": {
"sourceMap": true,
"jsx": "react",
- "target": "ES6",
+ "target": "ES2016",
"allowJs": true,
+ "strict": true,
"esModuleInterop": true,
"moduleResolution": "Node",
"outDir": "dist",
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';
},
plugins: [
viteStaticCopy(copyFiles),
+ vanillaExtractPlugin(),
svgLoader(),
wasm(),
react(),